Repository: scikit-hep/awkward

Issue:
`ak.without_field`
### Description of new feature

It is not possible to remove a field from an `RecordArray` without rebuilding the layout manually e.g. with `ak.zip`. 

I think a natural extension of `with_field` and `__setitem__` would be `without_field` and `__delitem__`. Unlike `with_parameter`, where `None` has special meaning, `None` values are supported as array values, so we need a distinct function


Discussion:

Resolving patch:
diff --git a/src/awkward/highlevel.py b/src/awkward/highlevel.py
--- a/src/awkward/highlevel.py
+++ b/src/awkward/highlevel.py
@@ -1063,20 +1063,34 @@ def __setitem__(self, where, what):
     def __delitem__(self, where):
         """
         Args:
-            where (str): Field name to remove from records in the array.
+            where (str or tuple of str): Field name to remove from the array.
 
-        Removes a field from records in the array.
+        For example:
+
+            >>> array = ak.Array([{"x": 3.3, "y": {"this": 10, "that": 20}}])
+            >>> del array["y", "that"]
+            >>> array.show()
+            [{x: 3.3, y: {this: 10}}]
+
+        See #ak.without_field for a variant that does not change the #ak.Array
+        in-place. (Internally, this method uses #ak.without_field, so performance
+        is not a factor in choosing one over the other.)
         """
         with ak._errors.OperationErrorContext(
             "ak.Array.__delitem__",
             dict(self=self, field_name=where),
         ):
-            names = ak.operations.fields(self._layout)
-            if where not in names:
+            if not (
+                isinstance(where, str)
+                or (isinstance(where, tuple) and all(isinstance(x, str) for x in where))
+            ):
                 raise ak._errors.wrap_error(
-                    TypeError(f"array fields do not contain {where!r}")
+                    TypeError("only fields may be removed in-place (by field name)")
                 )
-            self._layout = self._layout[[x for x in names if x != where]]
+
+            self._layout = ak.operations.ak_without_field._impl(
+                self._layout, where, highlevel=False, behavior=self._behavior
+            )
             self._numbaview = None
 
     def __getattr__(self, where):
@@ -1788,28 +1802,43 @@ def __setitem__(self, where, what):
                     TypeError("only fields may be assigned in-place (by field name)")
                 )
 
-            self._layout = ak.operations.with_field(
-                self._layout, what, where, highlevel=False
+            self._layout = ak.operations.ak_with_field._impl(
+                self._layout, what, where, highlevel=False, behavior=self._behavior
             )
             self._numbaview = None
 
     def __delitem__(self, where):
         """
         Args:
-            where (str): Field name to add to records in the array.
+            where (str or tuple of str): Field name to remove from the record.
 
-        Removes a field from records in the array.
+        For example:
+
+            >>> record = ak.Record({"x": 3.3, "y": {"this": 10, "that": 20}})
+            >>> del record["y", "that"]
+            >>> record.show()
+            {x: 3.3,
+             y: {this: 10}}
+
+        See #ak.without_field for a variant that does not change the #ak.Record
+        in-place. (Internally, this method uses #ak.without_field, so performance
+        is not a factor in choosing one over the other.)
         """
         with ak._errors.OperationErrorContext(
             "ak.Record.__delitem__",
             dict(self=self, field_name=where),
         ):
-            names = ak.operations.fields(self._layout)
-            if where not in names:
+            if not (
+                isinstance(where, str)
+                or (isinstance(where, tuple) and all(isinstance(x, str) for x in where))
+            ):
                 raise ak._errors.wrap_error(
-                    TypeError(f"array fields do not contain {where!r}")
+                    TypeError("only fields may be removed in-place (by field name)")
                 )
-            self._layout = self._layout[[x for x in names if x != where]]
+
+            self._layout = ak.operations.ak_without_field._impl(
+                self._layout, where, highlevel=False, behavior=self._behavior
+            )
             self._numbaview = None
 
     def __getattr__(self, where):
diff --git a/src/awkward/operations/__init__.py b/src/awkward/operations/__init__.py
--- a/src/awkward/operations/__init__.py
+++ b/src/awkward/operations/__init__.py
@@ -92,6 +92,7 @@
 from awkward.operations.ak_with_field import with_field
 from awkward.operations.ak_with_name import with_name
 from awkward.operations.ak_with_parameter import with_parameter
+from awkward.operations.ak_without_field import without_field
 from awkward.operations.ak_without_parameters import without_parameters
 from awkward.operations.ak_zeros_like import zeros_like
 from awkward.operations.ak_zip import zip
diff --git a/src/awkward/operations/ak_without_field.py b/src/awkward/operations/ak_without_field.py
new file mode 100644
--- /dev/null
+++ b/src/awkward/operations/ak_without_field.py
@@ -0,0 +1,93 @@
+# BSD 3-Clause License; see https://github.com/scikit-hep/awkward-1.0/blob/main/LICENSE
+
+from collections.abc import Sequence
+
+import awkward as ak
+
+np = ak._nplikes.NumpyMetadata.instance()
+
+
+def without_field(array, where, *, highlevel=True, behavior=None):
+    """
+    Args:
+        array: Array-like data (anything #ak.to_layout recognizes).
+        where (str or non-empy sequence of str): If str, the name of the field
+            to be removed. If a sequence, it is interpreted as a path where to
+            remove the field in a nested record.
+        highlevel (bool): If True, return an #ak.Array; otherwise, return
+            a low-level #ak.contents.Content subclass.
+        behavior (None or dict): Custom #ak.behavior for the output array, if
+            high-level.
+
+    Returns an #ak.Array or #ak.Record (or low-level equivalent, if
+    `highlevel=False`) with an existing field removed. This function does not
+    change the array in-place.
+
+    See #ak.Array.__delitem__ and #ak.Record.__delitem__ for a variant that
+    changes the high-level object in-place. (These methods internally use
+    #ak.without_field, so performance is not a factor in choosing one over the
+    other.)
+    """
+    with ak._errors.OperationErrorContext(
+        "ak.without_field",
+        dict(array=array, where=where, highlevel=highlevel, behavior=behavior),
+    ):
+        return _impl(array, where, highlevel, behavior)
+
+
+def _impl(base, where, highlevel, behavior):
+    if isinstance(where, str):
+        where = [where]
+    elif not (isinstance(where, Sequence) and all(isinstance(x, str) for x in where)):
+        raise ak._errors.wrap_error(
+            TypeError(
+                "Field names must be given as a single string, or a sequence of strings"
+            )
+        )
+
+    behavior = ak._util.behavior_of(base, behavior=behavior)
+    base = ak.operations.to_layout(base, allow_record=True, allow_other=False)
+
+    def action(layout, depth_context, **kwargs):
+        if isinstance(layout, ak.contents.RecordArray):
+            field, *next_where = depth_context["where"]
+
+            # For parent record arrays, we don't change the fields, just
+            # modify the contents
+            i_field = layout.field_to_index(field)
+            if len(next_where):
+                next_contents = []
+                for i, content in enumerate(layout.contents):
+                    if i == i_field:
+                        # Visit this content to remove the next item in `where`
+                        next_content = ak._do.recursively_apply(
+                            content,
+                            action,
+                            behavior,
+                            depth_context={"where": next_where},
+                        )
+                        next_contents.append(next_content)
+                    else:
+                        next_contents.append(content)
+                return layout.copy(contents=next_contents)
+            # If we're at the final layout
+            else:
+                next_contents = [
+                    c for i, c in enumerate(layout.contents) if i != i_field
+                ]
+
+                if layout.is_tuple:
+                    next_fields = None
+                else:
+                    next_fields = [
+                        f for i, f in enumerate(layout.fields) if i != i_field
+                    ]
+
+                return layout.copy(contents=next_contents, fields=next_fields)
+        else:
+            return None
+
+    out = ak._do.recursively_apply(
+        base, action, behavior, depth_context={"where": where}
+    )
+    return ak._util.wrap(out, behavior, highlevel)


Test patch:
diff --git a/tests/test_1961-ak-without-field.py b/tests/test_1961-ak-without-field.py
new file mode 100644
--- /dev/null
+++ b/tests/test_1961-ak-without-field.py
@@ -0,0 +1,151 @@
+# BSD 3-Clause License; see https://github.com/scikit-hep/awkward-1.0/blob/main/LICENSE
+import pytest
+
+import awkward as ak
+
+
+def test_one_level_record():
+    base = ak.zip(
+        {"x": [1, 2, 3], "y": [8, 9, 10]},
+    )
+
+    assert ak.without_field(base, where=["x"]).to_list() == [
+        {"y": 8},
+        {"y": 9},
+        {"y": 10},
+    ]
+    assert ak.fields(base) == ["x", "y"]
+
+    del base["x"]
+    assert base.to_list() == [
+        {"y": 8},
+        {"y": 9},
+        {"y": 10},
+    ]
+
+
+def test_one_level_tuple():
+    base = ak.zip(
+        [[1, 2, 3], [8, 9, 10]],
+    )
+
+    assert ak.without_field(base, where=["0"]).to_list() == [
+        (8,),
+        (9,),
+        (10,),
+    ]
+    assert ak.fields(base) == ["0", "1"]
+
+    del base["0"]
+    assert base.to_list() == [
+        (8,),
+        (9,),
+        (10,),
+    ]
+
+
+def test_two_level_record():
+    base = ak.zip(
+        {"a": ak.zip({"x": [1, 2, 3], "y": [8, 9, 10]}), "b": [1, 2, 3]},
+        depth_limit=1,
+    )
+
+    assert ak.without_field(base, where=["a", "x"]).to_list() == [
+        {"b": 1, "a": {"y": 8}},
+        {"b": 2, "a": {"y": 9}},
+        {"b": 3, "a": {"y": 10}},
+    ]
+    assert ak.fields(base) == ["a", "b"]
+    assert ak.fields(base["a"]) == ["x", "y"]
+
+    del base["a", "x"]
+    assert base.to_list() == [
+        {"b": 1, "a": {"y": 8}},
+        {"b": 2, "a": {"y": 9}},
+        {"b": 3, "a": {"y": 10}},
+    ]
+
+
+def test_two_level_tuple():
+    base = ak.zip(
+        [ak.zip([[1, 2, 3], [8, 9, 10]]), [1, 2, 3]],
+        depth_limit=1,
+    )
+
+    assert ak.without_field(base, where=["0", "0"]).to_list() == [
+        ((8,), 1),
+        ((9,), 2),
+        ((10,), 3),
+    ]
+    assert ak.fields(base) == ["0", "1"]
+    assert ak.fields(base["0"]) == ["0", "1"]
+
+    del base["0", "0"]
+    assert base.to_list() == [
+        ((8,), 1),
+        ((9,), 2),
+        ((10,), 3),
+    ]
+
+
+def test_two_level_mixed():
+    base = ak.zip(
+        [ak.zip({"x": [1, 2, 3], "y": [8, 9, 10]}), [1, 2, 3]],
+        depth_limit=1,
+    )
+
+    assert ak.without_field(base, where=["0", "x"]).to_list() == [
+        ({"y": 8}, 1),
+        ({"y": 9}, 2),
+        ({"y": 10}, 3),
+    ]
+    assert ak.fields(base) == ["0", "1"]
+    assert ak.fields(base["0"]) == ["x", "y"]
+
+    del base["0", "x"]
+    assert base.to_list() == [
+        ({"y": 8}, 1),
+        ({"y": 9}, 2),
+        ({"y": 10}, 3),
+    ]
+
+
+def test_one_level_delete_the_only_field():
+    base = ak.zip({"x": [1, 2, 3]})
+    assert ak.without_field(base, where=["x"]).to_list() == [
+        {},
+        {},
+        {},
+    ]
+    assert ak.fields(base) == ["x"]
+
+    del base["x"]
+    assert base.to_list() == [
+        {},
+        {},
+        {},
+    ]
+
+
+def test_two_level_delete_the_only_field():
+    base = ak.zip({"a": ak.zip({"x": [1, 2, 3]})}, depth_limit=1)
+    assert ak.without_field(base, where=["a", "x"]).to_list() == [
+        {"a": {}},
+        {"a": {}},
+        {"a": {}},
+    ]
+    assert ak.fields(base) == ["a"]
+
+    del base["a", "x"]
+    assert base.to_list() == [
+        {"a": {}},
+        {"a": {}},
+        {"a": {}},
+    ]
+
+
+def test_check_no_fields():
+    base = ak.zip({"a": ak.zip({"x": [1, 2, 3]})}, depth_limit=1)
+
+    with pytest.raises(IndexError, match=r"no field"):
+        ak.without_field(base, "x")