Repository: scipy/scipy

Issue:
Licensing concern
Hi scipy folk,

Digging around into scipy 1.11.0  on PyPI, I wanted to flag that the tar.gz contains a GPL-2.0 file, which as I can't find it previously reported may be unintended given scipy's BSD licensing.

The file comes through into scipy through the unuran git submodule; namely this file:

    https://github.com/scipy/unuran/blob/a63d39160e5ecc4402e7ed0e8417f4c3ff9634cb/unuran/src/specfunct/log1p.c


Discussion:
C99 should have log1p available in math.h
Hi @hyandell, thank you for reporting.

SciPy got an authorisation from the original copyright and license holders to relicense UNU.RAN to our BSD-3-license. See https://github.com/scipy/unuran/blob/main/license.txt

I am not sure if we should edit this file. I guess the intent here was to just have a plain copy of the source as they are given to us. (waiting for a decision there and we can close the issue.)
This one is actually a problem as it was not originally authored by the UNURAN authors but [copied from the GPLed GSL codebase](https://github.com/scipy/unuran/blob/main/unuran/src/specfunct/log1p.c#L7-L13). [We've encountered this before.](https://github.com/scipy/unuran/pull/5#issuecomment-1531662829)
Right thanks for the link, I thought I remembered a discussion on this file. Do you want to do the change?
Hmm, ISTR seeing similar code before. I wouldn't be surprised if the GSL folks also copied it from somewhere.
> Do you want to do the change?

I don't have the bandwidth to do it in short order.

> I wouldn't be surprised if the GSL folks also copied it from somewhere.

Possibly, but we have lots of `log1p` implementations with definitely-clean provenance laying about.
Anyone wants to take this one up?
Can we clarify the actions that are needed?

1. Is it sufficient to release `1.11.1` reasonably soon that rectifies the situation? Or is leaving "any trace" of the accidental/potential licensing issue in our PyPI release problematic in a practical sense?
2. What does rectification mean here, exactly? We should replace the `unuran` C source file in question with a license-compatible equivalent, and that's it? Or we pull the `log1p` from one of our own internal implementations (`scipy.special.log1p` under the hood? or the NumPy one somehow?)
Hmm, this is a problem. It would be great if someone had turned the initial discussion of this into an issue and marked it as a release blocker.

> 1. Is it sufficient to release `1.11.1` reasonably soon that rectifies the situation?

We should do that asap, and then yank `1.11.0` from PyPI (Cc @tylerjereddy). That way, no one will pull it in by accident (only if they explicitly install with `== 1.11.0`) and we have a very visible marker of there being a problem with that release.

> 2. What does rectification mean here, exactly? We should replace the `unuran` C source file in question with a license-compatible equivalent, and that's it? Or we pull the `log1p` from one of our own internal implementations (`scipy.special.log1p` under the hood? or the NumPy one somehow?)

That's the same, whatever is the easiest thing to do. 

Resolving patch:
diff --git a/scipy/linalg/_basic.py b/scipy/linalg/_basic.py
--- a/scipy/linalg/_basic.py
+++ b/scipy/linalg/_basic.py
@@ -1001,7 +1001,8 @@ def det(a, overwrite_a=False, check_finite=True):
     det : (...) float or complex
         Determinant of `a`. For stacked arrays, a scalar is returned for each
         (m, m) slice in the last two dimensions of the input. For example, an
-        input of shape (p, q, m, m) will produce a result of shape (p, q).
+        input of shape (p, q, m, m) will produce a result of shape (p, q). If
+        all dimensions are 1 a scalar is returned regardless of ndim.
 
     Notes
     -----
@@ -1066,11 +1067,17 @@ def det(a, overwrite_a=False, check_finite=True):
 
     # Scalar case
     if a1.shape[-2:] == (1, 1):
-        if a1.dtype.char in 'dD':
-            return np.squeeze(a1)
+        # Either ndarray with spurious singletons or a single element
+        if max(*a1.shape) > 1:
+            temp = np.squeeze(a1)
+            if a1.dtype.char in 'dD':
+                return temp
+            else:
+                return (temp.astype('d') if a1.dtype.char == 'f' else
+                        temp.astype('D'))
         else:
-            return (np.squeeze(a1).astype('d') if a1.dtype.char == 'f' else
-                    np.squeeze(a1).astype('D'))
+            return (np.float64(a1.item()) if a1.dtype.char in 'fd' else
+                    np.complex128(a1.item()))
 
     # Then check overwrite permission
     if not _datacopied(a1, a):  # "a"  still alive through "a1"
diff --git a/scipy/odr/_odrpack.py b/scipy/odr/_odrpack.py
--- a/scipy/odr/_odrpack.py
+++ b/scipy/odr/_odrpack.py
@@ -899,10 +899,16 @@ def _gen_work(self):
         elif len(self.data.we.shape) == 3:
             ld2we, ldwe = self.data.we.shape[1:]
         else:
-            # Okay, this isn't precisely right, but for this calculation,
-            # it's fine
+            we = self.data.we
             ldwe = 1
-            ld2we = self.data.we.shape[1]
+            ld2we = 1
+            if we.ndim == 1 and q == 1:
+                ldwe = n
+            elif we.ndim == 2:
+                if we.shape == (q, q):
+                    ld2we = q
+                elif we.shape == (q, n):
+                    ldwe = n
 
         if self.job % 10 < 2:
             # ODR not OLS
diff --git a/scipy/special/_basic.py b/scipy/special/_basic.py
--- a/scipy/special/_basic.py
+++ b/scipy/special/_basic.py
@@ -2930,7 +2930,11 @@ def factorial(n, exact=False):
         return _exact_factorialx_array(n)
     # we do not raise for non-integers with exact=True due to
     # historical reasons, though deprecation would be possible
-    return _ufuncs._factorial(n)
+    res = _ufuncs._factorial(n)
+    if isinstance(n, np.ndarray):
+        # _ufuncs._factorial does not maintain 0-dim arrays
+        return np.array(res)
+    return res
 
 
 def factorial2(n, exact=False):


Test patch:
diff --git a/scipy/linalg/tests/test_basic.py b/scipy/linalg/tests/test_basic.py
--- a/scipy/linalg/tests/test_basic.py
+++ b/scipy/linalg/tests/test_basic.py
@@ -932,6 +932,23 @@ class TestDet:
     def setup_method(self):
         self.rng = np.random.default_rng(1680305949878959)
 
+    def test_1x1_all_singleton_dims(self):
+        a = np.array([[1]])
+        deta = det(a)
+        assert deta.dtype.char == 'd'
+        assert np.isscalar(deta)
+        assert deta == 1.
+        a = np.array([[[[1]]]], dtype='f')
+        deta = det(a)
+        assert deta.dtype.char == 'd'
+        assert np.isscalar(deta)
+        assert deta == 1.
+        a = np.array([[[1 + 3.j]]], dtype=np.complex64)
+        deta = det(a)
+        assert deta.dtype.char == 'D'
+        assert np.isscalar(deta)
+        assert deta == 1.+3.j
+
     def test_1by1_stacked_input_output(self):
         a = self.rng.random([4, 5, 1, 1], dtype=np.float32)
         deta = det(a)
diff --git a/scipy/odr/tests/test_odr.py b/scipy/odr/tests/test_odr.py
--- a/scipy/odr/tests/test_odr.py
+++ b/scipy/odr/tests/test_odr.py
@@ -531,3 +531,32 @@ def func(b, x):
         p = Model(func)
         p.set_meta(name='Sample Model Meta', ref='ODRPACK')
         assert_equal(p.meta, {'name': 'Sample Model Meta', 'ref': 'ODRPACK'})
+
+    def test_work_array_del_init(self):
+        """
+        Verify fix for gh-18739 where del_init=1 fails.
+        """
+        def func(b, x):
+            return b[0] + b[1] * x
+
+        # generate some data
+        n_data = 4
+        x = np.arange(n_data)
+        y = np.where(x % 2, x + 0.1, x - 0.1)
+        x_err = np.full(n_data, 0.1)
+        y_err = np.full(n_data, 0.1)
+
+        linear_model = Model(func)
+        # Try various shapes of the `we` array from various `sy` and `covy`
+        rd0 = RealData(x, y, sx=x_err, sy=y_err)
+        rd1 = RealData(x, y, sx=x_err, sy=0.1)
+        rd2 = RealData(x, y, sx=x_err, sy=[0.1])
+        rd3 = RealData(x, y, sx=x_err, sy=np.full((1, n_data), 0.1))
+        rd4 = RealData(x, y, sx=x_err, covy=[[0.01]])
+        rd5 = RealData(x, y, sx=x_err, covy=np.full((1, 1, n_data), 0.01))
+        for rd in [rd0, rd1, rd2, rd3, rd4, rd5]:
+            odr_obj = ODR(rd, linear_model, beta0=[0.4, 0.4],
+                          delta0=np.full(n_data, -0.1))
+            odr_obj.set_job(fit_type=0, del_init=1)
+            # Just make sure that it runs without raising an exception.
+            odr_obj.run()
diff --git a/scipy/special/tests/test_basic.py b/scipy/special/tests/test_basic.py
--- a/scipy/special/tests/test_basic.py
+++ b/scipy/special/tests/test_basic.py
@@ -2129,6 +2129,8 @@ def _check(n, expected):
     def test_factorial_array_corner_cases(self, content, dim, exact, dtype):
         if dtype == np.int64 and any(np.isnan(x) for x in content):
             pytest.skip("impossible combination")
+        # np.array(x, ndim=0) will not be 0-dim. unless x is too
+        content = content if (dim > 0 or len(content) != 1) else content[0]
         n = np.array(content, ndmin=dim, dtype=dtype)
         result = None
         if not content:
@@ -2151,14 +2153,22 @@ def test_factorial_array_corner_cases(self, content, dim, exact, dtype):
             # no error
             result = special.factorial(n, exact=exact)
 
+        # assert_equal does not distinguish scalars and 0-dim arrays of the same value, see
+        # https://github.com/numpy/numpy/issues/24050
+        def assert_really_equal(x, y):
+            assert type(x) == type(y), f"types not equal: {type(x)}, {type(y)}"
+            assert_equal(x, y)
+
         if result is not None:
             # expected result is empty if and only if n is empty,
             # and has the same dtype & dimension as n
             with suppress_warnings() as sup:
                 sup.filter(DeprecationWarning)
-                r = special.factorial(n.ravel(), exact=exact) if n.size else []
+                # keep 0-dim.; otherwise n.ravel().ndim==1, even if n.ndim==0
+                n_flat = n.ravel() if n.ndim else n
+                r = special.factorial(n_flat, exact=exact) if n.size else []
             expected = np.array(r, ndmin=dim, dtype=dtype)
-            assert_equal(result, expected)
+            assert_really_equal(result, expected)
 
     @pytest.mark.parametrize("exact", [True, False])
     @pytest.mark.parametrize("n", [1, 1.1, 2 + 2j, np.nan, None],
@@ -2211,6 +2221,8 @@ def test_factorial2_int_reference(self, n):
     def test_factorial2_array_corner_cases(self, content, dim, exact, dtype):
         if dtype == np.int64 and any(np.isnan(x) for x in content):
             pytest.skip("impossible combination")
+        # np.array(x, ndim=0) will not be 0-dim. unless x is too
+        content = content if (dim > 0 or len(content) != 1) else content[0]
         n = np.array(content, ndmin=dim, dtype=dtype)
         if np.issubdtype(n.dtype, np.integer) or (not content):
             # no error
@@ -2262,6 +2274,8 @@ def test_factorialk_int_reference(self, n, k):
     def test_factorialk_array_corner_cases(self, content, dim, dtype):
         if dtype == np.int64 and any(np.isnan(x) for x in content):
             pytest.skip("impossible combination")
+        # np.array(x, ndim=0) will not be 0-dim. unless x is too
+        content = content if (dim > 0 or len(content) != 1) else content[0]
         n = np.array(content, ndmin=dim, dtype=dtype)
         if np.issubdtype(n.dtype, np.integer) or (not content):
             # no error; expected result is identical to n