Repository: networkx/networkx

Issue:
Heads up.... numpy random integer generators cant handle large integers
It looks like numpy random number generators cant handle large integers (large being > `2**64`, i.e. not held in `int64`). They raise an error saying that the values can't be held in `int64` (which of course is correct).  And if you try to specify that they use python integers (which don't have that limit)  by using `dtype=np.object_`, it raises stating that np.object_ is not of integer type. 
We probably use large integers more than most scientific libraries (20**20 is the number of possible edges with 21 nodes). So, we may run into this more often than some. 

Workaround: instead of `seed = np.random.default_rng(); seed.integers(0, 20**20)`, we could convert it to a floating point computation and discretize later:
```python
seed = np.random.default.rng()
def randint(a, b, seed):
    return a + int(seed.random() * (b - a)) 

randint(0, 20**20, seed)
```
This could/should be added to the `utils.misc.py` class `PythonRandomInterface` method `randint`.

### Numpy fix
Since numpy is creating the value, it seems like they don't need to raise an error for `dtype=np.object_` in `np.random.Generator.integers`. It could be made an integer Python object and be perfectly good output.
We could/should look into making a PR to numpy to allow `integers` to return Python integers when `dtype=object_`

I think this is the first time we've noticed a feature of `random` that isn't provided by `numpy.random`, but there may be others.


Discussion:
I created an issue on numpy numpy/numpy#24458 and a thread on the numpy discussion list.
I think it looks straight-forward to add support for dtype('O') in the `integers` function. But straight-foward does not always mean easy. :}

The example where this arose is the `_select_k` and `_select_jd_trees` parts of #6758

Resolving patch:
diff --git a/networkx/utils/decorators.py b/networkx/utils/decorators.py
--- a/networkx/utils/decorators.py
+++ b/networkx/utils/decorators.py
@@ -261,14 +261,15 @@ def _nodes_or_number(n):
 
 
 def np_random_state(random_state_argument):
-    """Decorator to generate a `numpy.random.RandomState` instance.
+    """Decorator to generate a numpy RandomState or Generator instance.
 
     The decorator processes the argument indicated by `random_state_argument`
     using :func:`nx.utils.create_random_state`.
     The argument value can be a seed (integer), or a `numpy.random.RandomState`
-    instance or (`None` or `numpy.random`). The latter options use the glocal
-    random number generator used by `numpy.random`.
-    The result is a `numpy.random.RandomState` instance.
+    or `numpy.random.RandomState` instance or (`None` or `numpy.random`).
+    The latter two options use the global random number generator for `numpy.random`.
+
+    The returned instance is a `numpy.random.RandomState` or `numpy.random.Generator`.
 
     Parameters
     ----------
@@ -307,19 +308,24 @@ def random_array(dims, random_state=1):
 def py_random_state(random_state_argument):
     """Decorator to generate a random.Random instance (or equiv).
 
-    The decorator processes the argument indicated by `random_state_argument`
-    using :func:`nx.utils.create_py_random_state`.
-    The argument value can be a seed (integer), or a random number generator::
+    This decorator processes `random_state_argument` using
+    :func:`nx.utils.create_py_random_state`.
+    The input value can be a seed (integer), or a random number generator::
 
         If int, return a random.Random instance set with seed=int.
         If random.Random instance, return it.
         If None or the `random` package, return the global random number
         generator used by `random`.
-        If np.random package, return the global numpy random number
-        generator wrapped in a PythonRandomInterface class.
-        If np.random.RandomState instance, return it wrapped in
-        PythonRandomInterface
-        If a PythonRandomInterface instance, return it
+        If np.random package, or the default numpy RandomState instance,
+        return the default numpy random number generator wrapped in a
+        `PythonRandomViaNumpyBits`  class.
+        If np.random.Generator instance, return it wrapped in a
+        `PythonRandomViaNumpyBits`  class.
+
+        # Legacy options
+        If np.random.RandomState instance, return it wrapped in a
+        `PythonRandomInterface` class.
+        If a `PythonRandomInterface` instance, return it
 
     Parameters
     ----------
diff --git a/networkx/utils/misc.py b/networkx/utils/misc.py
--- a/networkx/utils/misc.py
+++ b/networkx/utils/misc.py
@@ -11,6 +11,7 @@
 1
 """
 
+import random
 import sys
 import uuid
 import warnings
@@ -30,6 +31,7 @@
     "create_random_state",
     "create_py_random_state",
     "PythonRandomInterface",
+    "PythonRandomViaNumpyBits",
     "nodes_equal",
     "edges_equal",
     "graphs_equal",
@@ -271,7 +273,68 @@ def create_random_state(random_state=None):
     raise ValueError(msg)
 
 
+class PythonRandomViaNumpyBits(random.Random):
+    """Provide the random.random algorithms using a Numpy.random bit generator
+
+    The intent is to allow people to contribute code that uses Python's random
+    library, but still allow users to provide a single easily controlled random
+    bit-stream for all work with NetworkX. This implementation is based on helpful
+    comments and code from Robert Kern on NumPy's GitHub Issue #24458.
+
+    This implementation supercedes that of `PythonRandomInterface` which rewrote
+    methods to account for subtle differences in API between `random` and
+    `numpy.random`. Instead this subclasses `random.Random` and overwrites
+    the methods `random`, `getrandbits`, `getstate`, `setstate` and `seed`.
+    It makes them use the rng values from an input numpy `RandomState` or `Generator`.
+    Those few methods allow the rest of the `random.Random` methods to provide
+    the API interface of `random.random` whlie using randomness generated by
+    a numpy generator.
+    """
+
+    def __init__(self, rng=None):
+        try:
+            import numpy as np
+        except ImportError:
+            msg = "numpy not found, only random.random available."
+            warnings.warn(msg, ImportWarning)
+
+        if rng is None:
+            self._rng = np.random.mtrand._rand
+        else:
+            self._rng = rng
+
+        # Not necessary, given our overriding of gauss() below, but it's
+        # in the superclass and nominally public, so initialize it here.
+        self.gauss_next = None
+
+    def random(self):
+        """Get the next random number in the range 0.0 <= X < 1.0."""
+        return self._rng.random()
+
+    def getrandbits(self, k):
+        """getrandbits(k) -> x.  Generates an int with k random bits."""
+        if k < 0:
+            raise ValueError("number of bits must be non-negative")
+        numbytes = (k + 7) // 8  # bits / 8 and rounded up
+        x = int.from_bytes(self._rng.bytes(numbytes), "big")
+        return x >> (numbytes * 8 - k)  # trim excess bits
+
+    def getstate(self):
+        return self._rng.__getstate__()
+
+    def setstate(self, state):
+        self._rng.__setstate__(state)
+
+    def seed(self, *args, **kwds):
+        "Do nothing override method."
+
+
+##################################################################
 class PythonRandomInterface:
+    """PythonRandomInterface is included for backward compatibility
+    New code should use PythonRandomViaNumpyBits instead.
+    """
+
     def __init__(self, rng=None):
         try:
             import numpy as np
@@ -293,6 +356,12 @@ def uniform(self, a, b):
     def randrange(self, a, b=None):
         import numpy as np
 
+        if b is None:
+            a, b = 0, a
+        if b > 9223372036854775807:  # from np.iinfo(np.int64).max
+            tmp_rng = PythonRandomViaNumpyBits(self._rng)
+            return tmp_rng.randrange(a, b)
+
         if isinstance(self._rng, np.random.Generator):
             return self._rng.integers(a, b)
         return self._rng.randint(a, b)
@@ -323,6 +392,10 @@ def sample(self, seq, k):
     def randint(self, a, b):
         import numpy as np
 
+        if b > 9223372036854775807:  # from np.iinfo(np.int64).max
+            tmp_rng = PythonRandomViaNumpyBits(self._rng)
+            return tmp_rng.randint(a, b)
+
         if isinstance(self._rng, np.random.Generator):
             return self._rng.integers(a, b + 1)
         return self._rng.randint(a, b + 1)
@@ -357,32 +430,50 @@ def create_py_random_state(random_state=None):
         if random.Random instance, return it.
         if None or the `random` package, return the global random number
         generator used by `random`.
-        if np.random package, return the global numpy random number
-        generator wrapped in a PythonRandomInterface class.
-        if np.random.RandomState or np.random.Generator instance, return it
-        wrapped in PythonRandomInterface
+        if an np.random.Generator instance, or the np.random package, or
+        the global numpy random number generator, then return it
+        wrapped in a PythonRandomViaNumpyBits class.
+        if a PythonRandomViaNumpyBits instance, return it
+
+        # Provided for backward bit-stream matching with legacy code
+        if a np.randomRandomState instance and not the global numpy default,
+        return it wrapped in PythonRandomInterface
         if a PythonRandomInterface instance, return it
-    """
-    import random
-
-    try:
-        import numpy as np
-
-        if random_state is np.random:
-            return PythonRandomInterface(np.random.mtrand._rand)
-        if isinstance(random_state, np.random.RandomState | np.random.Generator):
-            return PythonRandomInterface(random_state)
-        if isinstance(random_state, PythonRandomInterface):
-            return random_state
-    except ImportError:
-        pass
 
+    Note: Conversion from older PythonRandomInterface to PythonRandomViaNumpyBits
+    is handled here to allow users of Legacy `numpy.random.RandomState` to exactly
+    match the legacy values produced. We assume that if a user cares about legacy
+    values, they are using a np.RandomState instance that is not the numpy default.
+    The default instance has state reset for each Python session. The Generator
+    class does not guarantee to maintain bit stream across versions. We wrap any
+    RandomState instance other than the default with `PythonRandomInterface`.
+    All other numpy random inputs are wrapped with `PythonRandomViaNumpyBits`.
+    """
     if random_state is None or random_state is random:
         return random._inst
     if isinstance(random_state, random.Random):
         return random_state
     if isinstance(random_state, int):
         return random.Random(random_state)
+
+    try:
+        import numpy as np
+    except ImportError:
+        pass
+    else:
+        if isinstance(random_state, PythonRandomInterface | PythonRandomViaNumpyBits):
+            return random_state
+        if isinstance(random_state, np.random.Generator):
+            return PythonRandomViaNumpyBits(random_state)
+        if random_state is np.random:
+            return PythonRandomViaNumpyBits(np.random.mtrand._rand)
+
+        if isinstance(random_state, np.random.RandomState):
+            if random_state is np.random.mtrand._rand:
+                return PythonRandomViaNumpyBits(random_state)
+            # Only need older interface if specially constructed RandomState used
+            return PythonRandomInterface(random_state)
+
     msg = f"{random_state} cannot be used to generate a random.Random instance"
     raise ValueError(msg)
 


Test patch:
diff --git a/networkx/utils/tests/test_decorators.py b/networkx/utils/tests/test_decorators.py
--- a/networkx/utils/tests/test_decorators.py
+++ b/networkx/utils/tests/test_decorators.py
@@ -13,7 +13,7 @@
     open_file,
     py_random_state,
 )
-from networkx.utils.misc import PythonRandomInterface
+from networkx.utils.misc import PythonRandomInterface, PythonRandomViaNumpyBits
 
 
 def test_not_implemented_decorator():
@@ -212,17 +212,19 @@ def setup_class(cls):
 
     @np_random_state(1)
     def instantiate_np_random_state(self, random_state):
-        assert isinstance(random_state, np.random.RandomState)
-        return random_state.random_sample()
+        allowed = (np.random.RandomState, np.random.Generator)
+        assert isinstance(random_state, allowed)
+        return random_state.random()
 
     @py_random_state(1)
     def instantiate_py_random_state(self, random_state):
-        assert isinstance(random_state, random.Random | PythonRandomInterface)
+        allowed = (random.Random, PythonRandomInterface, PythonRandomViaNumpyBits)
+        assert isinstance(random_state, allowed)
         return random_state.random()
 
     def test_random_state_None(self):
         np.random.seed(42)
-        rv = np.random.random_sample()
+        rv = np.random.random()
         np.random.seed(42)
         assert rv == self.instantiate_np_random_state(None)
 
@@ -233,7 +235,7 @@ def test_random_state_None(self):
 
     def test_random_state_np_random(self):
         np.random.seed(42)
-        rv = np.random.random_sample()
+        rv = np.random.random()
         np.random.seed(42)
         assert rv == self.instantiate_np_random_state(np.random)
         np.random.seed(42)
@@ -241,7 +243,7 @@ def test_random_state_np_random(self):
 
     def test_random_state_int(self):
         np.random.seed(42)
-        np_rv = np.random.random_sample()
+        np_rv = np.random.random()
         random.seed(42)
         py_rv = random.random()
 
@@ -249,39 +251,56 @@ def test_random_state_int(self):
         seed = 1
         rval = self.instantiate_np_random_state(seed)
         rval_expected = np.random.RandomState(seed).rand()
-        assert rval, rval_expected
+        assert rval == rval_expected
         # test that global seed wasn't changed in function
-        assert np_rv == np.random.random_sample()
+        assert np_rv == np.random.random()
 
         random.seed(42)
         rval = self.instantiate_py_random_state(seed)
         rval_expected = random.Random(seed).random()
-        assert rval, rval_expected
+        assert rval == rval_expected
         # test that global seed wasn't changed in function
         assert py_rv == random.random()
 
-    def test_random_state_np_random_RandomState(self):
+    def test_random_state_np_random_Generator(self):
         np.random.seed(42)
-        np_rv = np.random.random_sample()
+        np_rv = np.random.random()
+        np.random.seed(42)
+        seed = 1
+
+        rng = np.random.default_rng(seed)
+        rval = self.instantiate_np_random_state(rng)
+        rval_expected = np.random.default_rng(seed).random()
+        assert rval == rval_expected
+
+        rval = self.instantiate_py_random_state(rng)
+        rval_expected = np.random.default_rng(seed).random(size=2)[1]
+        assert rval == rval_expected
+        # test that global seed wasn't changed in function
+        assert np_rv == np.random.random()
 
+    def test_random_state_np_random_RandomState(self):
+        np.random.seed(42)
+        np_rv = np.random.random()
         np.random.seed(42)
         seed = 1
+
         rng = np.random.RandomState(seed)
-        rval = self.instantiate_np_random_state(seed)
-        rval_expected = np.random.RandomState(seed).rand()
-        assert rval, rval_expected
+        rval = self.instantiate_np_random_state(rng)
+        rval_expected = np.random.RandomState(seed).random()
+        assert rval == rval_expected
 
-        rval = self.instantiate_py_random_state(seed)
-        rval_expected = np.random.RandomState(seed).rand()
-        assert rval, rval_expected
+        rval = self.instantiate_py_random_state(rng)
+        rval_expected = np.random.RandomState(seed).random(size=2)[1]
+        assert rval == rval_expected
         # test that global seed wasn't changed in function
-        assert np_rv == np.random.random_sample()
+        assert np_rv == np.random.random()
 
     def test_random_state_py_random(self):
         seed = 1
         rng = random.Random(seed)
         rv = self.instantiate_py_random_state(rng)
-        assert rv, random.Random(seed).random()
+        assert rv == random.Random(seed).random()
 
         pytest.raises(ValueError, self.instantiate_np_random_state, rng)
 
diff --git a/networkx/utils/tests/test_misc.py b/networkx/utils/tests/test_misc.py
--- a/networkx/utils/tests/test_misc.py
+++ b/networkx/utils/tests/test_misc.py
@@ -6,6 +6,7 @@
 import networkx as nx
 from networkx.utils import (
     PythonRandomInterface,
+    PythonRandomViaNumpyBits,
     arbitrary_element,
     create_py_random_state,
     create_random_state,
@@ -184,21 +185,31 @@ def test_create_py_random_state():
     rs = np.random.RandomState
     rng = np.random.default_rng(1000)
     rng_explicit = np.random.Generator(np.random.SFC64())
-    nprs = PythonRandomInterface
+    old_nprs = PythonRandomInterface
+    nprs = PythonRandomViaNumpyBits
     assert isinstance(create_py_random_state(np.random), nprs)
-    assert isinstance(create_py_random_state(rs(1)), nprs)
+    assert isinstance(create_py_random_state(rs(1)), old_nprs)
     assert isinstance(create_py_random_state(rng), nprs)
     assert isinstance(create_py_random_state(rng_explicit), nprs)
     # test default rng input
-    assert isinstance(PythonRandomInterface(), nprs)
+    assert isinstance(PythonRandomInterface(), old_nprs)
+    assert isinstance(PythonRandomViaNumpyBits(), nprs)
+
+    # VeryLargeIntegers Smoke test (they raise error for np.random)
+    int64max = 9223372036854775807  # from np.iinfo(np.int64).max
+    for r in (rng, rs(1)):
+        prs = create_py_random_state(r)
+        prs.randrange(3, int64max + 5)
+        prs.randint(3, int64max + 5)
 
 
 def test_PythonRandomInterface_RandomState():
     np = pytest.importorskip("numpy")
 
+    seed = 42
     rs = np.random.RandomState
-    rng = PythonRandomInterface(rs(42))
-    rs42 = rs(42)
+    rng = PythonRandomInterface(rs(seed))
+    rs42 = rs(seed)
 
     # make sure these functions are same as expected outcome
     assert rng.randrange(3, 5) == rs42.randint(3, 5)
@@ -219,8 +230,9 @@ def test_PythonRandomInterface_RandomState():
 def test_PythonRandomInterface_Generator():
     np = pytest.importorskip("numpy")
 
-    rng = np.random.default_rng(42)
-    pri = PythonRandomInterface(np.random.default_rng(42))
+    seed = 42
+    rng = np.random.default_rng(seed)
+    pri = PythonRandomInterface(np.random.default_rng(seed))
 
     # make sure these functions are same as expected outcome
     assert pri.randrange(3, 5) == rng.integers(3, 5)