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

Issue:
TST/DOC Increase documentation and test coverage of random seed function
## Summary of issue
We have a function `serpentTools.seed.seedFiles` that is entirely untested and undocumented. This was included in our attempt to provide a command line interface with #82. We should include documentation and testing of these features

https://github.com/CORE-GATECH-GROUP/serpent-tools/blob/b1d86b1fc0cfd6388ab2fa133bee7cfe298cd27a/serpentTools/seed.py#L54-L55

## Code for reproducing the issue
```
$ git checkout develop
$ git pull
$ python setup.py install
$ coverage run setup.py test
$ coverage report
```

## Expected outcome

Get this number up to >70% with extra internet points for 90-100%. 

## Versions

* Version  from ``serpentTools.__version__`` `0.5.2+15.gb1d86b1` :arrow_left: b1d86b1fc0cfd6388ab2fa133bee7cfe298cd27a

* Python version - ``python --version`` `3.5.2`

Pull request:
[DOC/TST] Document and test random seed files and CLI
Closes #225 by

1. Adding unittest for `serpentTools.seed` module
1. Documenting the API provided by this module
1. Documenting our minor command line interface

- [x] PR fits the [project scope](http://serpent-tools.readthedocs.io/en/latest/develop/contributing.html#project-scope)
- [x] Code to be committed is [DRY](https://en.wikipedia.org/wiki/Don%27t_repeat_yourself) and matches the [code style](http://serpent-tools.readthedocs.io/en/latest/develop/codestyle.html#code-style) of the project
- [x] New and/or changed features are [well documented](http://serpent-tools.readthedocs.io/en/latest/develop/documentation.html#documentation) and have adequate testing

## TODO
- [ ] Add this PR to changelog

Patch:
diff --git a/docs/api/index.rst b/docs/api/index.rst
index a05b237..a7a3329 100644
--- a/docs/api/index.rst
+++ b/docs/api/index.rst
@@ -25,3 +25,4 @@ API
    xsplot.rst
    microxs.rst
    exceptions.rst
+   seed.rst
diff --git a/docs/api/seed.rst b/docs/api/seed.rst
new file mode 100644
index 0000000..56bab2b
--- /dev/null
+++ b/docs/api/seed.rst
@@ -0,0 +1,1 @@
+.. automodule:: serpentTools.seed
diff --git a/docs/changelog.rst b/docs/changelog.rst
index 77817a2..dba3651 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -12,6 +12,9 @@ Next
 ====
 
 * :pull:`221` - Expanded ``utils`` module to better assist developers
+* :pull:`227` - Better documentation of our :ref:`cli`.
+  Better documentation and testing of functions for generating input
+  files with unique random seeds - :mod:`serpentTools.seed`
 
 .. _v0.5.2:
 
diff --git a/docs/command-line.rst b/docs/command-line.rst
new file mode 100644
index 0000000..e40dcfe
--- /dev/null
+++ b/docs/command-line.rst
@@ -0,0 +1,70 @@
+.. _cli:
+
+======================
+Command Line Interface
+======================
+
+``serpentTools`` includes minor command line functionality. 
+Provided you have installed according to :ref:`install`, you can
+access this interface with::
+
+    $ python -m serpentTools
+
+.. note:
+    
+    Outputs here are accurate up to version 0.5.2+14.
+    Please alert the developers if any major differences
+    are found, or a method to automatically update this
+    can be found
+
+Display the available options by passing the ``-h`` flag::
+
+    $ python -m serpentTools -h
+    usage: serpentTools [-h] [--version] [-v | -q] [-c CONFIG_FILE]
+                        {seed,list} ...
+
+    optional arguments:
+      -h, --help            show this help message and exit
+      --version             show program's version number and exit
+      -v                    increase verbosity v: info, vv: debug
+      -q                    suppress warnings and errors q: error, qq: critical
+      -c CONFIG_FILE, --config-file CONFIG_FILE
+                            path to settings file
+
+    sub-commands:
+      {seed,list}           sub-command help
+        seed                copy an input file with unique random number seeds
+        list                show the default settings
+
+Random Seed Generation
+======================
+
+The ``seed`` subcommand utilizes the :mod:`serpentTools.seed` module to
+produced repeated input files with unique random seeds
+This is helpful for running repeated analyses to reduce the stochastic nature of
+Monte Carlo calculations. Options for forcing the number of digits,
+utilizing the ``SERPENT`` 
+`include <http://serpent.vtt.fi/mediawiki/index.php/Input_syntax_manual#include_.28read_another_input_file.29>`_
+command rather than fully copying the file,
+and new output directories are afforded to the user.
+
+.. code::
+
+    $ python -m serpentTools seed -h
+    usage: serpentTools seed [-h] [--seed SEED] [-l LENGTH]
+                             [--output-dir OUTPUT_DIR] [--link]
+                             file N
+
+    positional arguments:
+      file                  input file to copy
+      N                     integer number of files to create
+
+    optional arguments:
+      -h, --help            show this help message and exit
+      --seed SEED           Seed to start with the builtin random generator
+      -l LENGTH, --length LENGTH
+                            Number of digits in random seeds
+      --output-dir OUTPUT_DIR
+                            Copy files into this directory
+      --link                Reference input file with include statement, rather
+                            than copying the whole file
diff --git a/docs/index.rst b/docs/index.rst
index 89d624e..23daa78 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -50,6 +50,7 @@ using :term:`SERPENT`.
    settingsTop
    variableGroupsTop
    api/index
+   command-line
    develop/index
    license
    contributors
diff --git a/serpentTools/__init__.py b/serpentTools/__init__.py
index 52d1017..c27b59c 100644
--- a/serpentTools/__init__.py
+++ b/serpentTools/__init__.py
@@ -2,6 +2,7 @@
 from serpentTools.parsers import *
 from serpentTools.data import *
 from serpentTools.samplers import *
+from serpentTools.seed import *
 from ._version import get_versions
 __version__ = get_versions()['version']
 del get_versions
diff --git a/serpentTools/__main__.py b/serpentTools/__main__.py
index 32ebdf5..d071c17 100644
--- a/serpentTools/__main__.py
+++ b/serpentTools/__main__.py
@@ -54,7 +54,7 @@ def __buildParser():
                             help='Seed to start with the builtin random '
                                  'generator')
     seedParser.add_argument('-l', '--length', type=int, default=10,
-                            help='Average number of digits in random seeds')
+                            help='Number of digits in random seeds')
     seedParser.add_argument('--output-dir', type=str, default=None,
                             help='Copy files into this directory')
     seedParser.add_argument('--link', action='store_true',
diff --git a/serpentTools/seed.py b/serpentTools/seed.py
index 0848b0c..ad73f7b 100644
--- a/serpentTools/seed.py
+++ b/serpentTools/seed.py
@@ -1,7 +1,8 @@
 """
-Function to copy an input file N times with N randomly generated seeds
+Functions to copy an input file ``N`` times with ``N`` randomly generated seeds
 """
 
+from math import log10
 import os
 from os import path
 from shutil import copy
@@ -9,18 +10,61 @@ import random
 
 from six.moves import range
 
-from serpentTools.messages import error, debug
+from serpentTools.messages import info
 
-__all__ = ['seedFiles']
-SLOPE = 0.3010142116935483
-OFFSET = 0.0701126088709696
+__all__ = ['seedFiles', 'generateSeed']
 
 
 def _writeSeed(stream, bits, length):
+    stream.write('\nset seed {}\n'.format(_seedFromBits(bits, length)))
+
+
+def _getBitsForLength(length):
+    """
+    Return the number of bits required to obtain a random digit
+    of a given length.
+    """
+    SLOPE = 0.3010142116935483
+    OFFSET = 0.0701126088709696
+    return int((length - OFFSET) / SLOPE)
+
+
+def generateSeed(length):
+    """
+    Generate a random seed with ``length`` digits
+
+    Parameters
+    ----------
+    length: int
+        Positive number of digits in seed
+
+    Returns
+    -------
+    int:
+        Randomly generated seed with
+        ``length`` digits
+
+    Raises
+    ------
+    ValueError:
+        If the value of ``length`` is non-positive
+    TypeError:
+        If ``length`` is not an integer, nor can be
+        coerced into one with :func:`int`
+    """
+    if not isinstance(length, int):
+        length = int(length)
+    if length <= 0:
+        raise ValueError("length must be positive, not {}".format(length))
+    bits = _getBitsForLength(length)
+    return _seedFromBits(bits, length)
+
+
+def _seedFromBits(bits, length):
     seed = random.getrandbits(bits)
-    while len(str(seed)) != length:
+    while int(log10(seed)) + 1 != length:
         seed = random.getrandbits(bits)
-    stream.write('\nset seed {}'.format(seed))
+    return seed
 
 
 def _makeFileFmt(inputFile):
@@ -36,23 +80,31 @@ def _makeFileFmt(inputFile):
 
 
 def _include(inputFile, numSeeds, fileFmt, bits, length):
+    """Write input files utilizing the ``include`` directive."""
+    files = []
     for N in range(numSeeds):
         name = fileFmt.format(N)
+        files.append(name)
         with open(name, 'w') as stream:
             stream.write('include \"{}\"\n'.format(inputFile))
             _writeSeed(stream, bits, length)
+    return files
 
 
 def _copy(inputFile, numSeeds, fileFmt, bits, length):
+    """Write input files while copying the entire base input file."""
+    files = []
     for N in range(numSeeds):
         name = fileFmt.format(N)
         copy(inputFile, name)
+        files.append(name)
         with open(name, 'a') as stream:
             _writeSeed(stream, bits, length)
+    return files
 
 
 def seedFiles(inputFile, numSeeds, seed=None, outputDir=None, link=False,
-              digits=10):
+              length=10):
     """
     Copy input file multiple times with unique seeds.
 
@@ -71,31 +123,50 @@ def seedFiles(inputFile, numSeeds, seed=None, outputDir=None, link=False,
         relative to directory that contains the input file
     link: bool
         If True, do not copy the full file. Instead, create a new file
-        with 'include <inputFile>' and the new seed declaration.
-    digits: int
-        Average number of digits for random seeds
+        with ``'include <inputFile>'`` and the new seed declaration.
+    length: int
+        Number of digits for random seeds
+
+    Returns
+    -------
+    list:
+        List of the names of all files created
+
+    Raises
+    ------
+    OSError:
+        If the requested input file could not be found
+        and ``link`` does not evaluate to true.
+    ValueError:
+        If the number of requested seeds is not a positive
+        integer, nor can be converted to one, or if the length
+        of the random seeds cannot be converted to a positive
+        integer.
+    TypeError:
+        Raised if the values passed to ``length`` or ``nseeds``
+        cannot be converted to integers with :func:`int`
 
     See Also
     --------
-    :py:mod:`random`
-    :py:func:`random.seed()`
-    :py:func:`random.getrandbits()`
+    :func:`generateSeed`
+    :mod:`random`
+    :func:`random.seed()`
+    :func:`random.getrandbits()`
 
     """
     if '~' in inputFile:
         inputFile = os.path.expanduser(inputFile)
 
     if not path.exists(inputFile):
-        error('Input file {} does not exist'.format(inputFile))
-        return
+        raise OSError('Input file {} does not exist'.format(inputFile))
+
+    if not isinstance(numSeeds, int):
+        numSeeds = int(numSeeds)
 
     if numSeeds < 1:
-        error('Require positive number of files to create')
-        return
+        raise ValueError('Require positive number of files to create')
 
-    if digits < 1:
-        error('Require positive number of digits in random seeds')
-    bits = int((digits - OFFSET) / SLOPE)
+    bits = _getBitsForLength(length)
 
     random.seed(seed)
 
@@ -105,7 +176,7 @@ def seedFiles(inputFile, numSeeds, seed=None, outputDir=None, link=False,
     if outputDir is not None:
         fPrefix = path.abspath(path.join(inputRoot, outputDir))
         if not path.isdir(fPrefix):
-            debug('Creating directory at {}'.format(fPrefix))
+            info('Creating directory at {}'.format(fPrefix))
             os.mkdir(fPrefix)
     else:
         fPrefix = inputRoot
@@ -113,6 +184,4 @@ def seedFiles(inputFile, numSeeds, seed=None, outputDir=None, link=False,
     fileFmt = path.join(fPrefix, _makeFileFmt(inputFile))
 
     writeFunc = _include if link else _copy
-    writeFunc(inputPath, numSeeds, fileFmt, bits, digits)
-
-    return
+    return writeFunc(inputPath, numSeeds, fileFmt, bits, length)


Test patch:
diff --git a/serpentTools/tests/test_seed.py b/serpentTools/tests/test_seed.py
new file mode 100644
index 0000000..d783d18
--- /dev/null
+++ b/serpentTools/tests/test_seed.py
@@ -0,0 +1,186 @@
+"""
+Test the seedFiles function
+"""
+
+from math import log10
+import re
+import random
+from tempfile import mkdtemp
+from unittest import TestCase
+from os import remove, listdir, rmdir
+
+from serpentTools import seedFiles, generateSeed
+
+BASE_INPUT_FILE = "base-seed.sss"
+BASE_CONTENTS = "% EMPTY FILE"
+LENGTH_SEEDS = 10
+NUM_FILES = 5
+
+SEED_REGEX = re.compile(r'set seed (\d+)')
+# Match the value used to set the serpent SEED
+INCLUDE_REGEX = re.compile(r'include ".*{}"'.format(BASE_INPUT_FILE))
+
+
+def setUpModule():
+    """Initialize the base input file"""
+    with open(BASE_INPUT_FILE, 'w') as out:
+        out.write(BASE_CONTENTS)
+
+
+def tearDownModule():
+    """Remove the base input file."""
+    remove(BASE_INPUT_FILE)
+
+
+class SeedInspector(TestCase):
+    """Class that just ensures that seeds are the correct length"""
+
+    def examineSeed(self, seed):
+        self.assertEqual(int(log10(seed)) + 1, LENGTH_SEEDS, msg=seed)
+
+
+class SeedGeneratorTester(SeedInspector):
+    """
+    Test the capabilities of the generateSeed function
+    """
+
+    def test_badLength(self):
+        """Try a variety of bad lengths and check for failure."""
+        self.assertRaises(ValueError, generateSeed, -1)
+        self.assertRaises(TypeError, generateSeed, {'fail': True})
+        self.assertRaises(ValueError, generateSeed, 0)
+        self.assertRaises(ValueError, generateSeed, '-1')
+
+    def test_goodLength(self):
+        """Check the length of random seeds given a variety of input types"""
+        for typeFunc in [int, float, str]:
+            actual = generateSeed(typeFunc(LENGTH_SEEDS))
+            self.examineSeed(actual)
+
+
+class SeedFileHelper(SeedInspector):
+
+    @property
+    def SEED(self):
+        raise NotImplementedError
+
+    @property
+    def EXPECTED_SEEDS(self):
+        raise NotImplementedError
+
+    def _examineSeed(self, seed, index):
+        SeedInspector.examineSeed(self, seed)
+        if self.EXPECTED_SEEDS is not None:
+            self.assertEqual(seed, self.EXPECTED_SEEDS[index])
+
+    def _examineSeedsInFiles(self, files):
+        self.assertEqual(NUM_FILES, len(files), msg="Number of files written.")
+        for fileIndex, fileP in enumerate(files):
+            self._examineSeedInFile(fileP, fileIndex)
+            remove(fileP)
+
+    def _examineSeedInFile(self, fileP, fileIndex):
+        with open(fileP) as stream:
+            lines = stream.read()
+        match = SEED_REGEX.search(lines)
+        self.assertTrue(match is not None,
+                        msg="No seed setting found in {}".format(fileP))
+
+        groups = match.groups()
+        self.assertEqual(1, len(groups),
+                         msg=("Did not find a solitary seed declaration "
+                              "in {}".format(fileP)))
+        actualSeed = int(groups[0])
+        self._examineSeed(actualSeed, fileIndex)
+
+    def _examineLinksInFiles(self, files):
+        for fileP in files:
+            with open(fileP) as stream:
+                includeMatch = INCLUDE_REGEX.search(stream.read())
+                self.assertTrue(includeMatch is not None, msg=fileP)
+
+    def test_noOutdir_noLink(self):
+        """
+        Write the files and verify contents with no output dir nor linking.
+        """
+        files = seedFiles(BASE_INPUT_FILE, NUM_FILES, self.SEED,
+                          outputDir=None, link=False)
+        self._examineSeedsInFiles(files)
+
+    def test_noOutdir_withLink(self):
+        """
+        Write the files and verify contents with no output dir.
+        """
+        files = seedFiles(BASE_INPUT_FILE, NUM_FILES, self.SEED,
+                          outputDir=None, link=True)
+        self._examineLinksInFiles(files)
+        self._examineSeedsInFiles(files)
+
+    def _setupAndTestWithDirectories(self, dirName):
+        files = seedFiles(BASE_INPUT_FILE, NUM_FILES, self.SEED,
+                          outputDir=dirName, link=False)
+        itemsInDir = listdir(dirName)
+        self.assertEqual(len(itemsInDir), NUM_FILES, msg=itemsInDir)
+        self._examineSeedsInFiles(files)
+        rmdir(dirName)
+
+    def test_outDir(self):
+        """Verify the file writer can write into existing directories."""
+        tempDir = mkdtemp()
+        self._setupAndTestWithDirectories(tempDir)
+
+    def test_outDir_makeNew(self):
+        """Verify the file writer can create new directories."""
+        outDir = 'rm-seed-d'
+        self._setupAndTestWithDirectories(outDir)
+
+
+class SeedlessFileWriter(SeedFileHelper):
+    """
+    Test the writer without passing a value of ``seed`` to the function
+    """
+
+    EXPECTED_SEEDS = SEED = None
+
+
+class SeededFileWriter(SeedFileHelper):
+    """
+    Test the reproducability of the file writer by explictely passing a seed.
+    """
+
+    @classmethod
+    def setUpClass(cls):
+        """
+        Generate expected seeds before resetting the random state
+        """
+        cls.SEED = 123456789
+        state = random.getstate()
+        random.seed(cls.SEED)
+        cls.EXPECTED_SEEDS = []
+        for _n in range(NUM_FILES):
+            candidate = generateSeed(LENGTH_SEEDS)
+            cls.EXPECTED_SEEDS.append(candidate)
+        random.setstate(state)
+
+
+class SeedWriterErrorChecker(TestCase):
+    """Check the error conditions for seedFiles."""
+
+    def test_missingInput(self):
+        """Verify an error is raised if the input does not exist"""
+        with self.assertRaises(OSError):
+            seedFiles("THIS SHOULD FAIL", NUM_FILES)
+
+    def test_nonPosNumber(self):
+        """Verify an error is raised if the number of files is non-positive"""
+        badInputs = [0, -1, '0']
+        for badN in badInputs:
+            self.assertRaises(ValueError, seedFiles, BASE_INPUT_FILE, badN)
+
+
+del SeedFileHelper
+
+
+if __name__ == '__main__':
+    from unittest import main
+    main()