Repository: pandas-dev/pandas

Issue:
BUG/Internals: maybe_upcast_putmask
In the context of #23192 (and #23604 / #23606), I want to use `pandas.core.dtypes.cast.maybe_upcast_putmask`,  because it solves exactly the problem I need it to solve.

Unfortunately, it does not work as advertised (and I already found the culprit).
The docstring says: 
```
def maybe_upcast_putmask(result, mask, other):
    """
    A safe version of putmask that potentially upcasts the result

    Parameters
    ----------
    result : ndarray
        The destination array. This will be mutated in-place if no upcasting is
        necessary.
    mask : boolean ndarray
    other : ndarray or scalar
        The source array or value
```
in other words, it expects `result` and `other` to be ndarrays. Curiously enough, in some branches, it only works for `Series` and produces wrong results for `ndarray`, e.g.

```
>>> import pandas as pd
>>> import numpy as np
>>> s = pd.Series([10, 11, 12])
>>> t = pd.Series([np.nan, 61, np.nan])
>>> from pandas.core.dtypes.cast import maybe_upcast_putmask
>>> result, _ = maybe_upcast_putmask(s, np.array([False, True, False]), t)
>>> result  # correct
0    10
1    61
2    12
dtype: int64
>>> result, _ = maybe_upcast_putmask(s.values, np.array([False, True, False]), t.values)
>>> result  # incorrect
array([10., nan, 12.])
```

This is because the code does 
```
try:
    [...]
    new_result = result.values.copy()
    [...]
    return [...]
except: 
    # do something else
```

which actually expects a `Series` (since `.values` won't ever work on  an ndarray).
     


Discussion:
How odd!  Some investigation and cleaning up would be great.

Resolving patch:
diff --git a/pandas/core/dtypes/cast.py b/pandas/core/dtypes/cast.py
--- a/pandas/core/dtypes/cast.py
+++ b/pandas/core/dtypes/cast.py
@@ -169,7 +169,10 @@ def trans(x):  # noqa
 
 def maybe_upcast_putmask(result, mask, other):
     """
-    A safe version of putmask that potentially upcasts the result
+    A safe version of putmask that potentially upcasts the result.
+    The result is replaced with the first N elements of other,
+    where N is the number of True values in mask.
+    If the length of other is shorter than N, other will be repeated.
 
     Parameters
     ----------
@@ -185,8 +188,18 @@ def maybe_upcast_putmask(result, mask, other):
     result : ndarray
     changed : boolean
         Set to true if the result array was upcasted
+
+    Examples
+    --------
+    >>> result, _ = maybe_upcast_putmask(np.arange(1,6),
+    np.array([False, True, False, True, True]), np.arange(21,23))
+    >>> result
+    array([1, 21, 3, 22, 21])
     """
 
+    if not isinstance(result, np.ndarray):
+        raise ValueError("The result input must be a ndarray.")
+
     if mask.any():
         # Two conversions for date-like dtypes that can't be done automatically
         # in np.place:
@@ -241,7 +254,7 @@ def changeit():
             # we have an ndarray and the masking has nans in it
             else:
 
-                if isna(other[mask]).any():
+                if isna(other).any():
                     return changeit()
 
         try:


Test patch:
diff --git a/pandas/tests/dtypes/cast/test_upcast.py b/pandas/tests/dtypes/cast/test_upcast.py
new file mode 100644
--- /dev/null
+++ b/pandas/tests/dtypes/cast/test_upcast.py
@@ -0,0 +1,66 @@
+# -*- coding: utf-8 -*-
+
+import numpy as np
+import pytest
+
+from pandas.core.dtypes.cast import maybe_upcast_putmask
+
+from pandas import Series
+from pandas.util import testing as tm
+
+
+@pytest.mark.parametrize("result", [
+    Series([10, 11, 12]),
+    [10, 11, 12],
+    (10, 11, 12)
+])
+def test_upcast_error(result):
+    # GH23823
+    mask = np.array([False, True, False])
+    other = np.array([61, 62, 63])
+    with pytest.raises(ValueError):
+        result, _ = maybe_upcast_putmask(result, mask, other)
+
+
+@pytest.mark.parametrize("arr, other, exp_changed, expected", [
+    (np.arange(1, 6), np.array([61, 62, 63]),
+     False, np.array([1, 61, 3, 62, 63])),
+    (np.arange(1, 6), np.array([61.1, 62.2, 63.3]),
+     True, np.array([1, 61.1, 3, 62.2, 63.3])),
+    (np.arange(1, 6), np.nan,
+     True, np.array([1, np.nan, 3, np.nan, np.nan])),
+    (np.arange(10, 15), np.array([61, 62]),
+     False, np.array([10, 61, 12, 62, 61])),
+    (np.arange(10, 15), np.array([61, np.nan]),
+     True, np.array([10, 61, 12, np.nan, 61]))
+])
+def test_upcast(arr, other, exp_changed, expected):
+    # GH23823
+    mask = np.array([False, True, False, True, True])
+    result, changed = maybe_upcast_putmask(arr, mask, other)
+
+    assert changed == exp_changed
+    tm.assert_numpy_array_equal(result, expected)
+
+
+@pytest.mark.parametrize("arr, other, exp_changed, expected", [
+    (np.arange('2019-01-01', '2019-01-06', dtype='datetime64[D]'),
+     np.arange('2018-01-01', '2018-01-04', dtype='datetime64[D]'),
+     False, np.array(['2019-01-01', '2018-01-01', '2019-01-03',
+                      '2018-01-02', '2018-01-03'], dtype='datetime64[D]')),
+    (np.arange('2019-01-01', '2019-01-06', dtype='datetime64[D]'), np.nan,
+     False, np.array(['2019-01-01', np.datetime64('NaT'),
+                      '2019-01-03', np.datetime64('NaT'),
+                      np.datetime64('NaT')], dtype='datetime64[D]')),
+    (np.arange('2019-01-01', '2019-01-06', dtype='datetime64[D]'),
+     np.arange('2018-01-01', '2018-01-03', dtype='datetime64[D]'),
+     False, np.array(['2019-01-01', '2018-01-01', '2019-01-03',
+                      '2018-01-02', '2018-01-01'], dtype='datetime64[D]'))
+])
+def test_upcast_datetime(arr, other, exp_changed, expected):
+    # GH23823
+    mask = np.array([False, True, False, True, True])
+    result, changed = maybe_upcast_putmask(arr, mask, other)
+
+    assert changed == exp_changed
+    tm.assert_numpy_array_equal(result, expected)