Repository: narwhals-dev/narwhals (python)

Issue:
[Bug]: `is_numpy_scalar` reeturns `True` for non-numpy scalars
### Describe the bug

The function signature of `narwhals.dependencies.is_numpy_scalar` says that it narrows the type to `np.generic`m but it is not necessarily the case, as `numpy.is_scalar` also returns `True` for Python scalars. A potential fix would be to just do an explicit `isinstance(..., np.generic)` check instead of using `is_scalar`.

Also, a related question: is `is_numpy_scalar` considered to be a part of the public API? I don't think it's in the docs, but I think it could be useful.

### Steps or code to reproduce the bug

```python
import numpy as np
from narwhals.dependencies import is_numpy_scalar

isinstance(1, np.generic)  # False
is_numpy_scalar(1)  # True, but should be False
```

### Expected results

`is_numpy_scalar(x) == True` should imply `isinstance(x, np.generic) == True`.

### Actual results

It does not for Python scalars (see example).

### Please run narwhals.show_version() and enter the output below.

```shell
System:
    python: 3.13.3 | packaged by conda-forge | (main, Apr 14 2025, 20:44:30) [Clang 18.1.8 ]
executable: /Users/stanmart/Library/Caches/rattler/cache/cached-envs-v0/ipython-c454637721dc3b0a/bin/python3.13
   machine: macOS-15.4-arm64-arm-64bit-Mach-O

Python dependencies:
     narwhals: 1.35.0
       pandas:
       polars:
         cudf:
        modin:
      pyarrow:
        numpy: 2.2.4
```

### Relevant log output

```shell

```

Pull request:
fix: Use `np.generic` in `is_numpy_scalar`
Closes #2394

## What type of PR is this? (check all applicable)

- [ ] 💾 Refactor
- [ ] ✨ Feature
- [x] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📝 Documentation
- [x] ✅ Test
- [ ] 🐳 Other

## Related issues

- Closes #2394

## Checklist

- [x] Code follows style guide (ruff)
- [x] Tests added
- [ ] Documented the changes

## If you have comments or can explain your changes, please do so below
- Still keeping this utility internal
- We didn't see an issue, as it was always used on the same branch as an `int` guard

Patch:
diff --git a/narwhals/dependencies.py b/narwhals/dependencies.py
index 1f49ef96..fb815468 100644
--- a/narwhals/dependencies.py
+++ b/narwhals/dependencies.py
@@ -13,7 +13,6 @@ if TYPE_CHECKING:
     import duckdb
     import ibis
     import modin.pandas as mpd
-    import numpy as np
     import pandas as pd
     import polars as pl
     import pyarrow as pa
@@ -32,6 +31,7 @@ if TYPE_CHECKING:
     from narwhals.typing import _1DArray
     from narwhals.typing import _2DArray
     from narwhals.typing import _NDArray
+    from narwhals.typing import _NumpyScalar
     from narwhals.typing import _ShapeT
 
 # We silently allow these but - given that they claim
@@ -254,12 +254,12 @@ def is_numpy_array_2d(arr: Any) -> TypeIs[_2DArray]:
     return is_numpy_array(arr) and arr.ndim == 2
 
 
-def is_numpy_scalar(scalar: Any) -> TypeGuard[np.generic]:
+def is_numpy_scalar(scalar: Any) -> TypeGuard[_NumpyScalar]:
     """Check whether `scalar` is a NumPy Scalar without importing NumPy."""
     # NOTE: Needs to stay as `TypeGuard`
     # - Used in `Series.__getitem__`, but not annotated
     # - `TypeGuard` is *hiding* that the check introduces an intersection
-    return (np := get_numpy()) is not None and np.isscalar(scalar)
+    return (np := get_numpy()) is not None and isinstance(scalar, np.generic)
 
 
 def is_pandas_like_dataframe(df: Any) -> bool:


Test patch:
diff --git a/tests/dependencies/is_numpy_scalar_test.py b/tests/dependencies/is_numpy_scalar_test.py
new file mode 100644
index 00000000..3a0d3499
--- /dev/null
+++ b/tests/dependencies/is_numpy_scalar_test.py
@@ -0,0 +1,44 @@
+from __future__ import annotations
+
+import datetime as dt
+from typing import TYPE_CHECKING
+from typing import Any
+
+import pytest
+
+from narwhals.dependencies import is_numpy_scalar
+
+pytest.importorskip("numpy")
+
+import numpy as np
+
+if TYPE_CHECKING:
+    from narwhals.typing import _NumpyScalar
+
+
+@pytest.mark.parametrize(
+    "data",
+    [
+        np.int64(-70),
+        np.uint16(12),
+        np.float64(94.999),
+        np.str_("word"),
+        np.datetime64(dt.date(2000, 1, 1)),
+    ],
+)
+def test_is_numpy_scalar_valid(data: _NumpyScalar) -> None:
+    assert is_numpy_scalar(data)
+
+
+@pytest.mark.parametrize(
+    "data",
+    [
+        -70,
+        12,
+        94.999,
+        "word",
+        dt.date(2000, 1, 1),
+    ],
+)
+def test_is_numpy_scalar_invalid(data: Any) -> None:
+    assert not is_numpy_scalar(data)