Repository: scikit-hep/awkward

Issue:
`array['x'] = 1` raises an `AttributeError`
### Version of Awkward Array

main

### Description and code to reproduce

#### Replacement with scalar
Replacing with an array with a scalar throws an `AttributeError`

```python
array = ak.Array({
    "x": [1, 2, 3]
})
array['x'] = 4
```

It seems that this also fails in `1.10.0`.

This should be an easy fix, and I think it should be next release as it is fairly fundamental.

#### Replacement with array
Replacing with an array doesn't broadcast, but adding a new field with an array does.

```python
array = ak.Array({
    "x": [1, 2, 3]
})
array['x'] = [1]
array["x"]
```

I'm currently working on docs, so I'm filing the issues I find here in the mean time.


Discussion:
Relatedly, setting `array['x'] = [1]` does _not_ broadcast against the existing value of the array: 
```python
>>> array['x'] = [1]
>>> array.show()
[{x: 1}]
```

This doesn't match the behavior for a bare NumPy array:
```python
>>> x = np.arange(3)
>>> x[:] = 1
>>> x.tolist()
[1, 1, 1]
```

I'm not actually sure whether it's clear cut that this is wrong, though; the in-place assignment `x[:]` is not the same as the direct assignment. So, perhaps this is just policy. 

That said, if we have multiple fields, then the result does broadcast, so I'm inclined to say that it should always broadcast (and not change length):
```python
>>> array['y'] = array.x
>>> array['x'] = [1]
>>> array.show()
[{y: 1, x: 1},
 {y: 2, x: 1},
 {y: 3, x: 1}]
```
Yes, this only applies to assigning a new field whose name is the same as an existing field. If different names are used,

```python
>>> array = ak.Array({
...     "x": [1, 2, 3]
... })

>>> array["y"] = 4
>>> array.show()
[{x: 1, y: 4},
 {x: 2, y: 4},
 {x: 3, y: 4}]

>>> array["z"] = [1]
>>> array.show()
[{x: 1, y: 4, z: 1},
 {x: 2, y: 4, z: 1},
 {x: 3, y: 4, z: 1}]
```

I think the proper behavior for setting a new column with the same name as an old column is to broadcast (as above) but replace the old column. So, it would work like this:

```python
>>> array = ak.Array({
...     "x": [1, 2, 3]
... })

>>> array["x"] = 4
>>> array.show()
[{x: 4},
 {x: 4},
 {x: 4}]
```

What's preventing that from happening is probably just the order of operations in the implementation. We want to first use the original to broadcast, and then remove the old column, and then add the new column. (Maybe those last two steps are a single assignment.)

But that makes this a straightforward bug and not an API-changing issue. (The old behavior is "wrong.")
> I think the proper behavior for setting a new column with the same name as an old column is to broadcast (as above) but replace the old column. So, it would work like this:

Agreed, this is also my feeling.

So, we have _two_ bugs here (although both would be solved with broadcasting, so likely the same cause). I'll update the issue

Resolving patch:
diff --git a/src/awkward/operations/ak_with_field.py b/src/awkward/operations/ak_with_field.py
--- a/src/awkward/operations/ak_with_field.py
+++ b/src/awkward/operations/ak_with_field.py
@@ -1,7 +1,7 @@
 # BSD 3-Clause License; see https://github.com/scikit-hep/awkward-1.0/blob/main/LICENSE
 
 import copy
-from collections.abc import Iterable
+from collections.abc import Sequence
 
 import awkward as ak
 
@@ -13,9 +13,9 @@ def with_field(array, what, where=None, *, highlevel=True, behavior=None):
     Args:
         array: Array-like data (anything #ak.to_layout recognizes).
         what: Array-like data (anything #ak.to_layout recognizes) to add as a new field.
-        where (None or str or non-empy iterable of str): If None, the new field
+        where (None or str or non-empy sequence of str): If None, the new field
             has no name (can be accessed as an integer slot number in a
-            string); If str, the name of the new field. If iterable, it is
+            string); If str, the name of the new field. If a sequence, it is
             interpreted as a path where to add the field in a nested record.
         highlevel (bool): If True, return an #ak.Array; otherwise, return
             a low-level #ak.contents.Content subclass.
@@ -44,7 +44,11 @@ def _impl(base, what, where, highlevel, behavior):
     if not (
         where is None
         or isinstance(where, str)
-        or (isinstance(where, Iterable) and all(isinstance(x, str) for x in where))
+        or (
+            ak._util.is_non_string_iterable(where)
+            and isinstance(where, Sequence)
+            and all(isinstance(x, str) for x in where)
+        )
     ):
         raise ak._errors.wrap_error(
             TypeError(
@@ -53,15 +57,7 @@ def _impl(base, what, where, highlevel, behavior):
             )
         )
 
-    if not isinstance(where, str) and isinstance(where, Iterable):
-        where = list(where)
-
-    if (
-        not isinstance(where, str)
-        and isinstance(where, Iterable)
-        and all(isinstance(x, str) for x in where)
-        and len(where) > 1
-    ):
+    if ak._util.is_non_string_iterable(where) and len(where) > 1:
         return _impl(
             base,
             _impl(
@@ -76,8 +72,8 @@ def _impl(base, what, where, highlevel, behavior):
             behavior,
         )
     else:
-
-        if not (isinstance(where, str) or where is None):
+        # If we have an iterable here, pull out the only ti
+        if ak._util.is_non_string_iterable(where):
             where = where[0]
 
         behavior = ak._util.behavior_of(base, what, behavior=behavior)
@@ -94,54 +90,50 @@ def _impl(base, what, where, highlevel, behavior):
         if where in base.fields:
             keys.remove(where)
 
-        if len(keys) == 0:
-            # the only key was removed, so just create new Record
-            out = (
-                ak.contents.RecordArray([what], [where], parameters=base.parameters),
-            )
-
-        else:
-
-            def action(inputs, **kwargs):
-                base, what = inputs
-                backend = base.backend
-
-                if isinstance(base, ak.contents.RecordArray):
-                    if what is None:
-                        what = ak.contents.IndexedOptionArray(
-                            ak.index.Index64(
-                                backend.index_nplike.full(len(base), -1, np.int64),
-                                nplike=backend.index_nplike,
-                            ),
-                            ak.contents.EmptyArray(),
-                        )
-                    elif not isinstance(what, ak.contents.Content):
-                        what = ak.contents.NumpyArray(
-                            backend.nplike.repeat(what, len(base))
-                        )
-                    if base.is_tuple and where is None:
+        def action(inputs, **kwargs):
+            base, what = inputs
+            backend = base.backend
+
+            if isinstance(base, ak.contents.RecordArray):
+                if what is None:
+                    what = ak.contents.IndexedOptionArray(
+                        ak.index.Index64(
+                            backend.index_nplike.full(len(base), -1, np.int64),
+                            nplike=backend.index_nplike,
+                        ),
+                        ak.contents.EmptyArray(),
+                    )
+                elif not isinstance(what, ak.contents.Content):
+                    what = ak.contents.NumpyArray(
+                        backend.nplike.repeat(what, len(base))
+                    )
+                if base.is_tuple:
+                    # Preserve tuple-ness
+                    if where is None:
                         fields = None
-                    elif base.is_tuple:
-                        fields = keys + [where]
-                    elif where is None:
-                        fields = keys + [str(len(keys))]
+                    # Otherwise the tuple becomes a record
                     else:
                         fields = keys + [where]
-                    out = ak.contents.RecordArray(
-                        [base[k] for k in keys] + [what],
-                        fields,
-                        parameters=base.parameters,
-                    )
-                    return (out,)
+                # Records with `where=None` will create a tuple-like key
+                elif where is None:
+                    fields = keys + [str(len(keys))]
                 else:
-                    return None
-
-            out = ak._broadcasting.broadcast_and_apply(
-                [base, what],
-                action,
-                behavior,
-                right_broadcast=False,
-            )
+                    fields = keys + [where]
+                out = ak.contents.RecordArray(
+                    [base[k] for k in keys] + [what],
+                    fields,
+                    parameters=base.parameters,
+                )
+                return (out,)
+            else:
+                return None
+
+        out = ak._broadcasting.broadcast_and_apply(
+            [base, what],
+            action,
+            behavior,
+            right_broadcast=False,
+        )
 
         assert isinstance(out, tuple) and len(out) == 1
 


Test patch:
diff --git a/tests/test_1936-with-field-broadcasting.py b/tests/test_1936-with-field-broadcasting.py
new file mode 100644
--- /dev/null
+++ b/tests/test_1936-with-field-broadcasting.py
@@ -0,0 +1,26 @@
+# BSD 3-Clause License; see https://github.com/scikit-hep/awkward-1.0/blob/main/LICENSE
+
+import pytest  # noqa: F401
+
+import awkward as ak
+
+
+def test_scalar():
+    array = ak.Array({"x": [1, 2, 3]})
+    array["x"] = 4
+    assert array.to_list() == [{"x": 4}, {"x": 4}, {"x": 4}]
+
+
+def test_array():
+    array = ak.Array({"x": [1, 2, 3]})
+    array["x"] = [4]
+    assert array.to_list() == [{"x": 4}, {"x": 4}, {"x": 4}]
+
+
+def test_where_non_sequence():
+    array = ak.Array({"x": [{"y": 1}, {"y": 2}, {"y": 3}]})
+    result = ak.with_field(array, 4, ("x", "y"))
+    assert result.to_list() == [{"x": {"y": 4}}, {"x": {"y": 4}}, {"x": {"y": 4}}]
+
+    with pytest.raises(TypeError, match=r"New fields may only be assigned "):
+        result = ak.with_field(array, 4, iter(("x", "y")))