Repository: mdickinson/pcgrandom

Issue:
Revisit seeding
- While we definitely don't want to use Python hashes for seeding, it would still be nice to allow bytestrings and Unicode strings.

- It's possible that not all platforms will have `os.urandom`; we may need some kind of fallback.

Comments:

Patch:
diff --git a/.travis.yml b/.travis.yml
index 5eb0243..11f1ed1 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -9,7 +9,7 @@ install:
   - pip install -r requirements.txt
   - pip install -e .
 script:
-  - coverage run -m unittest discover -v
+  - coverage run --branch -m unittest discover -v
 after_success:
   - codecov
 notifications:
diff --git a/pcgrandom/pcg_common.py b/pcgrandom/pcg_common.py
index def0378..228a3a6 100644
--- a/pcgrandom/pcg_common.py
+++ b/pcgrandom/pcg_common.py
@@ -19,14 +19,86 @@ from __future__ import division
 
 import bisect as _bisect
 import collections as _collections
+import hashlib as _hashlib
 import operator as _operator
 import os as _os
 
 from builtins import int as _int, range as _range
+from past.builtins import unicode as _unicode
 
 from pcgrandom.distributions import Distributions
 
 
+def seed_from_system_entropy(bits):
+    """
+    Create a new integer seed from whatever entropy we can find.
+
+    Parameters
+    ----------
+    bits : nonnegative integer
+        Number of bits we need.
+
+    Returns
+    -------
+    seed : integer
+        Integer seed in the range 0 <= seed < 2**bits.
+    """
+    numbytes, excess = -(-bits // 8), -bits % 8
+    seed = _int.from_bytes(_os.urandom(numbytes), byteorder="big")
+    return seed >> excess
+
+
+def seed_from_object(obj, bits):
+    """
+    Create a new integer seed from the given Python object, in
+    a reproducible manner.
+
+    Parameters
+    ----------
+    obj : Object
+        The object to use to create the seed.
+    bits : nonnegative integer.
+        Number of bits needed for the seed. This function can produce
+        a maximum of 512 bits from a Unicode or string object.
+
+    Returns
+    -------
+    seed : integer
+        Integer seed in the range 0 <= seed < 2**bits.
+    """
+    # From an integer-like.
+    try:
+        obj_as_integer = _operator.index(obj)
+    except TypeError:
+        pass
+    else:
+        seed_mask = ~(~0 << bits)
+        seed = obj_as_integer & seed_mask
+        return seed
+
+    # For a Unicode or byte string.
+    if isinstance(obj, _unicode):
+        obj = obj.encode('utf8')
+
+    if isinstance(obj, bytes):
+        obj_hash = _hashlib.sha512(obj).digest()
+        numbytes, excess = -(-bits // 8), -bits % 8
+
+        if numbytes > len(obj_hash):
+            raise ValueError(
+                "Cannot provide more than {} bits of seed.".format(
+                    8 * len(obj_hash)))
+
+        seed = _int.from_bytes(obj_hash[:numbytes], byteorder="big") >> excess
+        return seed
+
+    raise TypeError(
+        "Unable to create seed from object of type {}. "
+        "Please use an integer, bytestring or Unicode string.".format(
+            type(obj))
+    )
+
+
 class PCGCommon(Distributions):
     """
     Common base class for the PCG random generators.
@@ -61,12 +133,11 @@ class PCGCommon(Distributions):
         """Initialize internal state from hashable object.
         """
         if seed is None:
-            nbytes = self._state_bits // 8
-            seed = _int.from_bytes(_os.urandom(nbytes), byteorder="little")
+            integer_seed = seed_from_system_entropy(self._state_bits)
         else:
-            seed = _operator.index(seed)
+            integer_seed = seed_from_object(seed, self._state_bits)
 
-        self._set_state_from_seed(seed)
+        self._set_state_from_seed(integer_seed)
         self.gauss_next = None
 
     def getstate(self):


Test patch:
diff --git a/pcgrandom/test/test_pcg_common.py b/pcgrandom/test/test_pcg_common.py
index c3f76ad..f28ffd1 100644
--- a/pcgrandom/test/test_pcg_common.py
+++ b/pcgrandom/test/test_pcg_common.py
@@ -21,6 +21,10 @@ import collections
 import itertools
 import math
 import pickle
+import unittest
+
+from pcgrandom.pcg_common import seed_from_system_entropy, seed_from_object
+
 
 # 99% values of the chi-squared statistic used in the goodness-of-fit tests
 # below, indexed by degrees of freedom. Values calculated using
@@ -34,6 +38,25 @@ chisq_99percentile = {
 }
 
 
+class TestSeedingFunctions(unittest.TestCase):
+    def test_seed_from_system_entropy_different(self):
+        seeds = [seed_from_system_entropy(bits=64) for _ in range(10)]
+        for seed in seeds:
+            self.assertEqual(seed % 2**64, seed)
+        self.assertEqual(len(seeds), len(set(seeds)))
+
+    def test_seed_from_object_large_bits(self):
+        with self.assertRaises(ValueError):
+            seed_from_object("some string or other", 513)
+        seed = seed_from_object("some string or other", 512)
+        self.assertGreater(seed.bit_length(), 500)
+        self.assertLessEqual(seed.bit_length(), 512)
+
+    def test_seed_from_object_bad_object_type(self):
+        with self.assertRaises(TypeError):
+            seed_from_object(3.4, 32)
+
+
 class TestPCGCommon(object):
     """
     Mixin class providing tests common to all generators in the
@@ -58,6 +81,18 @@ class TestPCGCommon(object):
         self.assertNotEqual(gen1.getstate(), gen2.getstate())
         self.assertEqual(gen1.getrandbits(64), gen3.getrandbits(64))
 
+    def test_seed_from_integer(self):
+        gen1 = self.gen_class(seed=17289)
+        gen2 = self.gen_class(seed=17289 + 2**self.gen_class._state_bits)
+        gen3 = self.gen_class(seed=17289 - 2**self.gen_class._state_bits)
+        self.assertEqual(gen1.getstate(), gen2.getstate())
+        self.assertEqual(gen1.getstate(), gen3.getstate())
+
+    def test_seed_from_bytes_and_unicode(self):
+        gen1 = self.gen_class(seed=b"your mother was a hamster")
+        gen2 = self.gen_class(seed=u"your mother was a hamster")
+        self.assertEqual(gen1.getstate(), gen2.getstate())
+
     def test_sequence_default(self):
         gen = self.gen_class(seed=12345)
         self.assertEqual(gen._increment, gen._default_increment)