Repository: python/cpython
Pull request: gh-111623 Serialize tuples with sub interpreters
<!--
Thanks for your contribution!
Please read this comment in its entirety. It's quite important.

# Pull Request title

It should be in the following format:

```
gh-NNNNN: Summary of the changes made
```

Where: gh-NNNNN refers to the GitHub issue number.

Most PRs will require an issue number. Trivial changes, like fixing a typo, do not need an issue.

# Backport Pull Request title

If this is a backport PR (PR made against branches other than `main`),
please ensure that the PR title is in the following format:

```
[X.Y] <title from the original PR> (GH-NNNN)
```

Where: [X.Y] is the branch name, e.g. [3.6].

GH-NNNN refers to the PR number from `main`.

-->

Closes #111623

<!-- gh-issue-number: gh-111623 -->
* Issue: gh-111623
<!-- /gh-issue-number -->


Problem statement:
Enable cross-interpreter sharing of tuples
# Feature or enhancement

### Proposal:

This issue is for adding support of sharing tuples (and tuples of tuples) through the crossinterp API and the interpreters module when that arrives.

The data structure will have to have a variable length.

```c
struct _shared_tuple_data {
    Py_ssize_t len;
    _PyCrossInterpreterData **data;
};
```

It will use the xid registry to lookup the type values in the tuple, then encode them using the crossinterpdatafunc callbacks. This should work recursively if the tuple contains a tuple.

PR to follow.

### Has this already been discussed elsewhere?

No response given

### Links to previous discussion of this feature:

_No response_

<!-- gh-linked-prs -->
### Linked PRs
* gh-111628
<!-- /gh-linked-prs -->

Hints from issue discussion:

Commit message: Add two fault cases

Patch to review:
diff --git a/Lib/test/test__xxsubinterpreters.py b/Lib/test/test__xxsubinterpreters.py
index ae7dfa19acc519..cdf752ad65ec07 100644
--- a/Lib/test/test__xxsubinterpreters.py
+++ b/Lib/test/test__xxsubinterpreters.py
@@ -105,6 +105,7 @@ def test_default_shareables(self):
                 True,
                 False,
                 100.0,
+                (),
                 ]
         for obj in shareables:
             with self.subTest(obj):
@@ -195,6 +196,29 @@ def test_bool(self):
     def test_float(self):
         self._assert_values([0.0, 1.1, -1.0, 0.12345678, -0.12345678])
 
+    def test_tuple(self):
+        self._assert_values([(), (1,), ("hello", "world", ), (1, True, "hello")])
+        # Test nesting
+        self._assert_values([((1,),), ((1, 2), (3, 4)), ((1, 2), (3, 4), (5, 6))])
+
+    def test_tuples_containing_non_shareable_types(self):
+        non_shareables = [
+                Exception(),
+                object(),
+        ]
+        for s in non_shareables:
+            value = tuple([0, 1., s])
+            with self.subTest(repr(value)):
+                # XXX Assert the NotShareableError when it is exported
+                with self.assertRaises(Exception):
+                    _testinternalcapi.get_crossinterp_data(value)
+            # Check nested as well
+            value = tuple([0, 1., (s,)])
+            with self.subTest("nested " + repr(value)):
+                # XXX Assert the NotShareableError when it is exported
+                with self.assertRaises(Exception):
+                    _testinternalcapi.get_crossinterp_data(value)
+
 
 class ModuleTests(TestBase):
 
diff --git a/Lib/test/test_interpreters.py b/Lib/test/test_interpreters.py
index 74f86088b45590..2068eeb9cc73d1 100644
--- a/Lib/test/test_interpreters.py
+++ b/Lib/test/test_interpreters.py
@@ -781,6 +781,8 @@ def test_default_shareables(self):
                 True,
                 False,
                 100.0,
+                (),
+                (1, True),
                 ]
         for obj in shareables:
             with self.subTest(obj):
diff --git a/Misc/NEWS.d/next/Core and Builtins/2023-11-02-15-00-57.gh-issue-111623.BZxYc8.rst b/Misc/NEWS.d/next/Core and Builtins/2023-11-02-15-00-57.gh-issue-111623.BZxYc8.rst
new file mode 100644
index 00000000000000..39f26093fc4f7a
--- /dev/null
+++ b/Misc/NEWS.d/next/Core and Builtins/2023-11-02-15-00-57.gh-issue-111623.BZxYc8.rst	
@@ -0,0 +1,2 @@
+Add support for sharing tuples between interpreters using the cross-interpreter
+API
diff --git a/Python/crossinterp.c b/Python/crossinterp.c
index 5e3bf4cb6aa238..ecd4cab3ba1878 100644
--- a/Python/crossinterp.c
+++ b/Python/crossinterp.c
@@ -713,6 +713,76 @@ _bool_shared(PyThreadState *tstate, PyObject *obj,
     return 0;
 }
 
+struct _shared_tuple_data {
+    Py_ssize_t len;
+    _PyCrossInterpreterData ** data;
+};
+
+static PyObject *
+_new_tuple_object(_PyCrossInterpreterData *data)
+{
+    struct _shared_tuple_data *shared = (struct _shared_tuple_data *)(data->data);
+    PyObject *tuple = PyTuple_New(shared->len);
+    if (tuple == NULL) {
+        return NULL;
+    }
+
+    for (Py_ssize_t i = 0; i < shared->len; i++) {
+        PyObject *item = _PyCrossInterpreterData_NewObject(shared->data[i]);
+        if (item == NULL){
+            Py_DECREF(tuple);
+            return NULL;
+        }
+        PyTuple_SET_ITEM(tuple, i, item);
+    }
+    return tuple;
+}
+
+static void
+_tuple_shared_free(void* data)
+{
+    struct _shared_tuple_data *shared = (struct _shared_tuple_data *)(data);
+    for (Py_ssize_t i = 0; i < shared->len; i++) {
+        _PyCrossInterpreterData_Release(shared->data[i]);
+    }
+    PyMem_Free(shared->data);
+    PyMem_RawFree(shared);
+}
+
+static int
+_tuple_shared(PyThreadState *tstate, PyObject *obj,
+             _PyCrossInterpreterData *data)
+{
+    if (_PyCrossInterpreterData_InitWithSize(
+            data, tstate->interp, sizeof(struct _shared_tuple_data), obj,
+            _new_tuple_object
+            ) < 0)
+    {
+        return -1;
+    }
+    struct _shared_tuple_data *shared = (struct _shared_tuple_data *)data->data;
+    shared->len = PyTuple_GET_SIZE(obj);
+    shared->data = (_PyCrossInterpreterData **) PyMem_Calloc(shared->len, sizeof(_PyCrossInterpreterData *));
+    if (shared->data == NULL) {
+        PyErr_Format(PyExc_MemoryError, "not enough memory");
+        return -1;
+    }
+
+    for (Py_ssize_t i = 0; i < shared->len; i++) {
+        shared->data[i] = _PyCrossInterpreterData_New();
+        if (shared->data[i] == NULL){
+            PyMem_Free(shared->data);
+            return -1; // PyErr_NoMemory already set
+        }
+        PyObject *item = PyTuple_GET_ITEM(obj, i);
+        if (_PyObject_GetCrossInterpreterData(item, shared->data[i]) != 0) {
+            return -1;
+        }
+    }
+    data->free = _tuple_shared_free;
+    return 0;
+}
+
 static void
 _register_builtins_for_crossinterpreter_data(struct _xidregistry *xidregistry)
 {
@@ -745,6 +815,11 @@ _register_builtins_for_crossinterpreter_data(struct _xidregistry *xidregistry)
     if (_xidregistry_add_type(xidregistry, &PyFloat_Type, _float_shared) != 0) {
         Py_FatalError("could not register float for cross-interpreter sharing");
     }
+
+    // tuple
+    if (_xidregistry_add_type(xidregistry, &PyTuple_Type, _tuple_shared) != 0) {
+        Py_FatalError("could not register tuple for cross-interpreter sharing");
+    }
 }
 
 /* registry lifecycle */


Code review comments:
diff_hunk: @@ -195,6 +196,29 @@ def test_bool(self):
     def test_float(self):
         self._assert_values([0.0, 1.1, -1.0, 0.12345678, -0.12345678])
 
+    def test_tuple(self):
+        self._assert_values([(), (1,), ("hello", "world", ), (1, True, "hello")])
+        # Test nesting
+        self._assert_values([((1,),), ((1, 2), (3, 4)), ((1, 2), (3, 4), (5, 6))])
path: Lib/test/test__xxsubinterpreters.py
text: @user1:
Hmm, that's a tad hard to read.  Consider putting one entry per line:

```suggestion
        self._assert_values([
            ((1,),),
            ((1, 2), (3, 4)),
            ((1, 2), (3, 4), (5, 6)),
        ])
```

diff_hunk: @@ -195,6 +196,29 @@ def test_bool(self):
     def test_float(self):
         self._assert_values([0.0, 1.1, -1.0, 0.12345678, -0.12345678])
 
+    def test_tuple(self):
+        self._assert_values([(), (1,), ("hello", "world", ), (1, True, "hello")])
+        # Test nesting
+        self._assert_values([((1,),), ((1, 2), (3, 4)), ((1, 2), (3, 4), (5, 6))])
+
+    def test_tuples_containing_non_shareable_types(self):
+        non_shareables = [
+                Exception(),
+                object(),
+        ]
+        for s in non_shareables:
+            value = tuple([0, 1., s])
path: Lib/test/test__xxsubinterpreters.py
text: @user1:
```suggestion
            value = tuple([0, 1.0, s])
```

For readability.

diff_hunk: @@ -781,6 +781,8 @@ def test_default_shareables(self):
                 True,
                 False,
                 100.0,
+                (),
+                (1, True),
path: Lib/test/test_interpreters.py
text: @user1:
Yeah, putting the empty tuple + non-empty case here makes sense.  Consider including nesting in the non-empty case, to demonstrate the extent of complexity:

```suggestion
                (),
                (1, ('spam', 'eggs'), True),
```

diff_hunk: @@ -713,6 +713,76 @@ _bool_shared(PyThreadState *tstate, PyObject *obj,
     return 0;
 }
 
+struct _shared_tuple_data {
+    Py_ssize_t len;
+    _PyCrossInterpreterData ** data;
path: Python/crossinterp.c
text: @user1:
```suggestion
    _PyCrossInterpreterData **data;
```

@user1:
FWIW, I was going to suggest allocating this array and each item all at once:

```suggestion
    _PyCrossInterpreterData *data[0];
```

```c
static int
_tuple_shared(PyThreadState *tstate, PyObject *obj,
             _PyCrossInterpreterData *data)
{
    Py_ssize_t len = PyTuple_GET_SIZE(obj);
    if (len < 0) {
        return -1;
    }
    const size_t size = (sizeof(struct _shared_tuple_data)
                                   + (sizeof(_PyCrossInterpreterData) * len));
    if (_PyCrossInterpreterData_InitWithSize(
            data, tstate->interp, size, obj, _new_tuple_object
            ) < 0)
    ...
```

Then you don't have to allocate/free the array separately, nor each item.  You would, of course, need to change `shared->data[i]` to `&shared->data[i]` in all the relevant places.

Note that this hypothetically makes `_tuple_shared_free()` a little trickier.  On the one hand, it means you don't have to free each item, nor the array.  On the other hand, you have to deal with the (remote) possibility that the tuple's cross-interpreter data might be tied to a different interpreter than one or more of the items.  If that were to happen, the `_PyCrossInterpreterData_Release()` call in `_tuple_shared_free()` would cause the item to be released "later" in the other interpreter via a pending call.  There's a race between when that happens and when the tuple's cross-interpreter data (`data->data` here) is freed.  If the latter happens first then you get a crash.

As long as we know that all items belong to the same interpreter as the tuple then we're fine, and we can be confident about that (and throw in an assert or two to verify).

I said "was going to" because, ultimately I don't think allocating the parts separately or all at once makes a huge difference.  That said, you're welcome to take the all-at-once approach I've described here. 😄

diff_hunk: @@ -713,6 +713,76 @@ _bool_shared(PyThreadState *tstate, PyObject *obj,
     return 0;
 }
 
+struct _shared_tuple_data {
+    Py_ssize_t len;
+    _PyCrossInterpreterData ** data;
+};
+
+static PyObject *
+_new_tuple_object(_PyCrossInterpreterData *data)
+{
+    struct _shared_tuple_data *shared = (struct _shared_tuple_data *)(data->data);
+    PyObject *tuple = PyTuple_New(shared->len);
+    if (tuple == NULL) {
+        return NULL;
+    }
+
+    for (Py_ssize_t i = 0; i < shared->len; i++) {
+        PyObject *item = _PyCrossInterpreterData_NewObject(shared->data[i]);
+        if (item == NULL){
+            Py_DECREF(tuple);
+            return NULL;
+        }
+        PyTuple_SET_ITEM(tuple, i, item);
+    }
+    return tuple;
+}
+
+static void
+_tuple_shared_free(void* data)
+{
+    struct _shared_tuple_data *shared = (struct _shared_tuple_data *)(data);
+    for (Py_ssize_t i = 0; i < shared->len; i++) {
+        _PyCrossInterpreterData_Release(shared->data[i]);
+    }
+    PyMem_Free(shared->data);
+    PyMem_RawFree(shared);
+}
+
+static int
+_tuple_shared(PyThreadState *tstate, PyObject *obj,
+             _PyCrossInterpreterData *data)
+{
+    if (_PyCrossInterpreterData_InitWithSize(
+            data, tstate->interp, sizeof(struct _shared_tuple_data), obj,
+            _new_tuple_object
+            ) < 0)
+    {
+        return -1;
+    }
+    struct _shared_tuple_data *shared = (struct _shared_tuple_data *)data->data;
+    shared->len = PyTuple_GET_SIZE(obj);
+    shared->data = (_PyCrossInterpreterData **) PyMem_Calloc(shared->len, sizeof(_PyCrossInterpreterData *));
+    if (shared->data == NULL) {
+        PyErr_Format(PyExc_MemoryError, "not enough memory");
path: Python/crossinterp.c
text: @user1:
```suggestion
        PyErr_NoMemory();
```

diff_hunk: @@ -713,6 +713,76 @@ _bool_shared(PyThreadState *tstate, PyObject *obj,
     return 0;
 }
 
+struct _shared_tuple_data {
+    Py_ssize_t len;
+    _PyCrossInterpreterData ** data;
+};
+
+static PyObject *
+_new_tuple_object(_PyCrossInterpreterData *data)
+{
+    struct _shared_tuple_data *shared = (struct _shared_tuple_data *)(data->data);
+    PyObject *tuple = PyTuple_New(shared->len);
+    if (tuple == NULL) {
+        return NULL;
+    }
+
+    for (Py_ssize_t i = 0; i < shared->len; i++) {
+        PyObject *item = _PyCrossInterpreterData_NewObject(shared->data[i]);
+        if (item == NULL){
+            Py_DECREF(tuple);
+            return NULL;
+        }
+        PyTuple_SET_ITEM(tuple, i, item);
+    }
+    return tuple;
+}
+
+static void
+_tuple_shared_free(void* data)
+{
+    struct _shared_tuple_data *shared = (struct _shared_tuple_data *)(data);
+    for (Py_ssize_t i = 0; i < shared->len; i++) {
+        _PyCrossInterpreterData_Release(shared->data[i]);
+    }
+    PyMem_Free(shared->data);
+    PyMem_RawFree(shared);
+}
+
+static int
+_tuple_shared(PyThreadState *tstate, PyObject *obj,
+             _PyCrossInterpreterData *data)
+{
+    if (_PyCrossInterpreterData_InitWithSize(
+            data, tstate->interp, sizeof(struct _shared_tuple_data), obj,
+            _new_tuple_object
+            ) < 0)
+    {
+        return -1;
+    }
+    struct _shared_tuple_data *shared = (struct _shared_tuple_data *)data->data;
+    shared->len = PyTuple_GET_SIZE(obj);
+    shared->data = (_PyCrossInterpreterData **) PyMem_Calloc(shared->len, sizeof(_PyCrossInterpreterData *));
+    if (shared->data == NULL) {
+        PyErr_Format(PyExc_MemoryError, "not enough memory");
+        return -1;
+    }
+
+    for (Py_ssize_t i = 0; i < shared->len; i++) {
+        shared->data[i] = _PyCrossInterpreterData_New();
+        if (shared->data[i] == NULL){
+            PyMem_Free(shared->data);
path: Python/crossinterp.c
text: @user1:
We need to be sure the entries from 0 to i-1 are released, along with the data itself:

```suggestion
            _tuple_shared_free(shared);
```

diff_hunk: @@ -713,6 +713,76 @@ _bool_shared(PyThreadState *tstate, PyObject *obj,
     return 0;
 }
 
+struct _shared_tuple_data {
+    Py_ssize_t len;
+    _PyCrossInterpreterData ** data;
+};
+
+static PyObject *
+_new_tuple_object(_PyCrossInterpreterData *data)
+{
+    struct _shared_tuple_data *shared = (struct _shared_tuple_data *)(data->data);
+    PyObject *tuple = PyTuple_New(shared->len);
+    if (tuple == NULL) {
+        return NULL;
+    }
+
+    for (Py_ssize_t i = 0; i < shared->len; i++) {
+        PyObject *item = _PyCrossInterpreterData_NewObject(shared->data[i]);
+        if (item == NULL){
+            Py_DECREF(tuple);
+            return NULL;
+        }
+        PyTuple_SET_ITEM(tuple, i, item);
+    }
+    return tuple;
+}
+
+static void
+_tuple_shared_free(void* data)
+{
+    struct _shared_tuple_data *shared = (struct _shared_tuple_data *)(data);
+    for (Py_ssize_t i = 0; i < shared->len; i++) {
+        _PyCrossInterpreterData_Release(shared->data[i]);
+    }
+    PyMem_Free(shared->data);
+    PyMem_RawFree(shared);
+}
+
+static int
+_tuple_shared(PyThreadState *tstate, PyObject *obj,
+             _PyCrossInterpreterData *data)
+{
+    if (_PyCrossInterpreterData_InitWithSize(
+            data, tstate->interp, sizeof(struct _shared_tuple_data), obj,
+            _new_tuple_object
+            ) < 0)
+    {
+        return -1;
+    }
+    struct _shared_tuple_data *shared = (struct _shared_tuple_data *)data->data;
+    shared->len = PyTuple_GET_SIZE(obj);
path: Python/crossinterp.c
text: @user1:
IIRC, `PyTuple_GET_SIZE() can fail, so:

```suggestion
    if (_PyCrossInterpreterData_InitWithSize(
            data, tstate->interp, sizeof(struct _shared_tuple_data), obj,
            _new_tuple_object
            ) < 0)
    {
        return -1;
    }
    struct _shared_tuple_data *shared = (struct _shared_tuple_data *)data->data;
    shared->len = PyTuple_GET_SIZE(obj);
    if (shared->len < 0) {
        PyMem_RawFree(shared);
        return -1;
    }
```

or, better, get len before allocating anything:

```suggestion
    Py_ssize_t len = PyTuple_GET_SIZE(obj);
    if (len < 0) {
        return -1;
    }
    if (_PyCrossInterpreterData_InitWithSize(
            data, tstate->interp, sizeof(struct _shared_tuple_data), obj,
            _new_tuple_object
            ) < 0)
    {
        return -1;
    }
    struct _shared_tuple_data *shared = (struct _shared_tuple_data *)data->data;
    shared->len = len;
```

diff_hunk: @@ -713,6 +713,76 @@ _bool_shared(PyThreadState *tstate, PyObject *obj,
     return 0;
 }
 
+struct _shared_tuple_data {
+    Py_ssize_t len;
+    _PyCrossInterpreterData ** data;
+};
+
+static PyObject *
+_new_tuple_object(_PyCrossInterpreterData *data)
+{
+    struct _shared_tuple_data *shared = (struct _shared_tuple_data *)(data->data);
+    PyObject *tuple = PyTuple_New(shared->len);
+    if (tuple == NULL) {
+        return NULL;
+    }
+
+    for (Py_ssize_t i = 0; i < shared->len; i++) {
+        PyObject *item = _PyCrossInterpreterData_NewObject(shared->data[i]);
+        if (item == NULL){
+            Py_DECREF(tuple);
+            return NULL;
+        }
+        PyTuple_SET_ITEM(tuple, i, item);
+    }
+    return tuple;
+}
+
+static void
+_tuple_shared_free(void* data)
+{
+    struct _shared_tuple_data *shared = (struct _shared_tuple_data *)(data);
+    for (Py_ssize_t i = 0; i < shared->len; i++) {
+        _PyCrossInterpreterData_Release(shared->data[i]);
+    }
path: Python/crossinterp.c
text: @user1:
Make sure we skip entries that aren't set, just like `tupledealloc()` does:

```suggestion
    struct _shared_tuple_data *shared = (struct _shared_tuple_data *)(data);
    for (Py_ssize_t i = 0; i < shared->len; i++) {
        if (shared->data[i] != NULL) {
            _PyCrossInterpreterData_Release(shared->data[i]);
        }
    }
```

or, if we are confident that a `NULL` entry indicates all of the rest will be `NULL`:

```suggestion
    struct _shared_tuple_data *shared = (struct _shared_tuple_data *)(data);
    for (Py_ssize_t i = 0; i < shared->len; i++) {
        if (shared->data[i] != NULL) {
            break;
        }
    }
```

However, I'd probably go with the first one, just to play it safe.

@user1:
We also need to delete the items after they are released:

```suggestion
    struct _shared_tuple_data *shared = (struct _shared_tuple_data *)(data);
    for (Py_ssize_t i = 0; i < shared->len; i++) {
        if (shared->data[i] != NULL) {
            _PyCrossInterpreterData_ReleaseAndRawFree(shared->data[i]);
            shared->data[i] = NULL;
        }
    }
```

or, since we always expect all items to come from the same interpreter as the tuple:

```suggestion
    struct _shared_tuple_data *shared = (struct _shared_tuple_data *)(data);
#ifndef NDEBUG
    int64_t interpid = PyInterpreterState_GetID(_PyInterpreterState_GET()));
#endif
    for (Py_ssize_t i = 0; i < shared->len; i++) {
        if (shared->data[i] != NULL) {
            assert(shared->data[i]->interpid == interpid);
            _PyCrossInterpreterData_Release(shared->data[i]);
            PyMem_RawFree(shared->data[i]);
            shared->data[i] = NULL;
        }
    }
```

diff_hunk: @@ -195,6 +196,29 @@ def test_bool(self):
     def test_float(self):
         self._assert_values([0.0, 1.1, -1.0, 0.12345678, -0.12345678])
 
+    def test_tuple(self):
+        self._assert_values([(), (1,), ("hello", "world", ), (1, True, "hello")])
+        # Test nesting
+        self._assert_values([((1,),), ((1, 2), (3, 4)), ((1, 2), (3, 4), (5, 6))])
+
+    def test_tuples_containing_non_shareable_types(self):
+        non_shareables = [
+                Exception(),
+                object(),
+        ]
+        for s in non_shareables:
+            value = tuple([0, 1., s])
+            with self.subTest(repr(value)):
+                # XXX Assert the NotShareableError when it is exported
+                with self.assertRaises(Exception):
path: Lib/test/test__xxsubinterpreters.py
text: @user1:
FWIW, it is a `ValueError` subclass.

```suggestion
                with self.assertRaises(ValueError):
```

diff_hunk: @@ -195,6 +196,29 @@ def test_bool(self):
     def test_float(self):
         self._assert_values([0.0, 1.1, -1.0, 0.12345678, -0.12345678])
 
+    def test_tuple(self):
+        self._assert_values([(), (1,), ("hello", "world", ), (1, True, "hello")])
+        # Test nesting
+        self._assert_values([((1,),), ((1, 2), (3, 4)), ((1, 2), (3, 4), (5, 6))])
+
+    def test_tuples_containing_non_shareable_types(self):
+        non_shareables = [
+                Exception(),
+                object(),
+        ]
+        for s in non_shareables:
+            value = tuple([0, 1., s])
+            with self.subTest(repr(value)):
+                # XXX Assert the NotShareableError when it is exported
+                with self.assertRaises(Exception):
+                    _testinternalcapi.get_crossinterp_data(value)
+            # Check nested as well
+            value = tuple([0, 1., (s,)])
+            with self.subTest("nested " + repr(value)):
+                # XXX Assert the NotShareableError when it is exported
+                with self.assertRaises(Exception):
path: Lib/test/test__xxsubinterpreters.py
text: @user1:
Likewise, a `ValueError `subclass.

```suggestion
                with self.assertRaises(ValueError):
```

diff_hunk: @@ -105,6 +105,7 @@ def test_default_shareables(self):
                 True,
                 False,
                 100.0,
+                (),
path: Lib/test/test__xxsubinterpreters.py
text: @user1:
For this test consider making the tuple an interesting one:

```suggestion
                (1, ('spam', 'eggs')),
```

We cover the various boring cases in the new `test_tuple()` below.