Repository: scikit-hep/awkward

Issue:
ak.Array.__getattr__ should pass through Record fields



Discussion:
In both Python and Numba.

Resolving patch:
diff --git a/awkward1/_util.py b/awkward1/_util.py
--- a/awkward1/_util.py
+++ b/awkward1/_util.py
@@ -118,6 +118,16 @@ def forward(x, space, brackets=True, wrap=True):
                     sp = ", "
                 if brackets:
                     yield "]"
+            elif isinstance(x, awkward1.layout.Record) and x.istuple:
+                yield space + "("
+                sp = ""
+                for i in range(x.numfields):
+                    key = sp
+                    for token in forward(x[str(i)], ""):
+                        yield key + token
+                        key = ""
+                    sp = ", "
+                yield ")"
             elif isinstance(x, awkward1.layout.Record):
                 yield space + "{"
                 sp = ""
@@ -160,6 +170,19 @@ def backward(x, space, brackets=True, wrap=True):
                     sp = ", "
                 if brackets:
                     yield "["
+            elif isinstance(x, awkward1.layout.Record) and x.istuple:
+                yield ")" + space
+                for i in range(x.numfields - 1, -1, -1):
+                    last = None
+                    for token in backward(x[str(i)], ""):
+                        if last is not None:
+                            yield last
+                        last = token
+                    if last is not None:
+                        yield last
+                    if i != 0:
+                        yield ", "
+                yield "("
             elif isinstance(x, awkward1.layout.Record):
                 yield "}" + space
                 keys = x.keys()
diff --git a/awkward1/highlevel.py b/awkward1/highlevel.py
--- a/awkward1/highlevel.py
+++ b/awkward1/highlevel.py
@@ -1,5 +1,8 @@
 # BSD 3-Clause License; see https://github.com/jpivarski/awkward-1.0/blob/master/LICENSE
 
+import re
+import keyword
+
 import numpy
 
 import awkward1._numpy
@@ -7,6 +10,8 @@
 import awkward1.layout
 import awkward1.operations.convert
 
+_dir_pattern = re.compile(r"^[a-zA-Z_]\w*$")
+
 class Array(awkward1._numpy.NDArrayOperatorsMixin, awkward1._pandas.PandasMixin):
     def __init__(self, data, type=None, classes=None, functions=None):
         if isinstance(data, awkward1.layout.Content):
@@ -65,6 +70,52 @@ def __iter__(self):
     def __getitem__(self, where):
         return awkward1._util.wrap(self._layout[where], self._classes, self._functions)
 
+    def __getattr__(self, where):
+        if where in dir(super(Array, self)):
+            return super(Array, self).__getattribute__(where)
+        else:
+            if where in self._layout.keys():
+                try:
+                    return self[where]
+                except Exception as err:
+                    raise AttributeError("while trying to get field {0}, an exception occurred:\n{1}: {2}".format(repr(where), type(err), str(err)))
+            else:
+                raise AttributeError("no field named {0}".format(repr(where)))
+
+    def __dir__(self):
+        return sorted(set(dir(super(Array, self)) + [x for x in self._layout.keys() if _dir_pattern.match(x) and not keyword.iskeyword(x)]))
+
+    @property
+    def i0(self):
+        return self["0"]
+    @property
+    def i1(self):
+        return self["1"]
+    @property
+    def i2(self):
+        return self["2"]
+    @property
+    def i3(self):
+        return self["3"]
+    @property
+    def i4(self):
+        return self["4"]
+    @property
+    def i5(self):
+        return self["5"]
+    @property
+    def i6(self):
+        return self["6"]
+    @property
+    def i7(self):
+        return self["7"]
+    @property
+    def i8(self):
+        return self["8"]
+    @property
+    def i9(self):
+        return self["9"]
+
     def __str__(self, limit_value=85):
         return awkward1._util.minimally_touching_string(limit_value, self._layout, self._classes, self._functions)
 


Test patch:
diff --git a/tests/test_PR094_getattr_for_record_field.py b/tests/test_PR094_getattr_for_record_field.py
new file mode 100644
--- /dev/null
+++ b/tests/test_PR094_getattr_for_record_field.py
@@ -0,0 +1,25 @@
+# BSD 3-Clause License; see https://github.com/jpivarski/awkward-1.0/blob/master/LICENSE
+
+import sys
+
+import pytest
+import numpy
+
+import awkward1
+
+def test_field_names():
+    array = awkward1.Array([[{"x": 1.1, "y": [1], "z": "one"}, {"x": 2.2, "y": [2, 2], "z": "two"}, {"x": 3.3, "y": [3, 3, 3], "z": "three"}], [], [{"x": 4.4, "y": [4, 4, 4, 4], "z": "four"}, {"x": 5.5, "y": [5, 5, 5, 5, 5], "z": "five"}]])
+    assert "x" in dir(array)
+    assert "y" in dir(array)
+    assert "z" in dir(array)
+
+    assert awkward1.tolist(array.x) == [[1.1, 2.2, 3.3], [], [4.4, 5.5]]
+    assert awkward1.tolist(array.y) == [[[1], [2, 2], [3, 3, 3]], [], [[4, 4, 4, 4], [5, 5, 5, 5, 5]]]
+    assert awkward1.tolist(array.z) == [["one", "two", "three"], [], ["four", "five"]]
+
+def test_tuple_ids():
+    array = awkward1.Array([[(1.1, [1], "one"), (2.2, [2, 2], "two"), (3.3, [3, 3, 3], "three")], [], [(4.4, [4, 4, 4, 4], "four"), (5.5, [5, 5, 5, 5, 5], "five")]])
+
+    assert awkward1.tolist(array.i0) == [[1.1, 2.2, 3.3], [], [4.4, 5.5]]
+    assert awkward1.tolist(array.i1) == [[[1], [2, 2], [3, 3, 3]], [], [[4, 4, 4, 4], [5, 5, 5, 5, 5]]]
+    assert awkward1.tolist(array.i2) == [["one", "two", "three"], [], ["four", "five"]]