Repository: HEFTIEProject/zarr-benchmarks
Pull request #22: Add tensorstore benchmarks
Author: login: K-Meech
Created: 2025-04-01T14:44:08Z | Merged: 2025-04-02T11:53:19Z

For https://github.com/HEFTIEProject/zarr-benchmarks/issues/10

This PR adds benchmarks for read/write of zarr spec v2 with `tensorstore`. Test with `tox -- --dev-image`.


Diff:
diff --git a/README.md b/README.md
index 1ea3a9e..6434260 100644
--- a/README.md
+++ b/README.md
@@ -88,8 +88,8 @@ Then run tox with:
 tox
 ```
 
-This will run all benchmarks via `zarr-python` version 2 and 3 with the example
-Human Organ Atlas image.
+This will run all benchmarks via `zarr-python` version 2 + 3 and `tensorstore`
+with the example Human Organ Atlas image.
 
 For a quicker run, add `--dev-image`:
 
@@ -108,6 +108,19 @@ tox -- --dev-image --rounds=1 --warmup-rounds=0
 Everything after the first `--` will be passed to the internal `pytest` call, so
 you can also add any pytest options you require.
 
+To run a subset of the benchmarks, use the `-e` option:
+
+```bash
+# tensorstore only
+tox run -e py313-tensorstore
+
+# zarr-python v2 only
+tox run -e py313-zarrv2
+
+# zarr-python v3 only
+tox run -e py313-zarrv3
+```
+
 ## Running pre-commit locally
 
 If you want quick development, you can always do git commit -n which will
diff --git a/pyproject.toml b/pyproject.toml
index 3581546..c771c31 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -10,6 +10,7 @@ dependencies = [
     "imageio",
     "pytest",
     "pytest-benchmark",
+    "tensorstore",
     "tox",
     "zarr",
 ]
@@ -29,20 +30,61 @@ dev = [
 Homepage = "https://github.com/HEFTIEProject/zarr-benchmarks"
 Issues = "https://github.com/HEFTIEProject/zarr-benchmarks/issues"
 
+[tool.pytest]
+
+[tool.pytest.ini_options]
+addopts = "--strict-markers"
+markers = [
+    "tensorstore",
+    "zarr_python",
+]
+
 # override sorting inline arrays for tox configuration - otherwise 'commands' lines are sorted and broken
 [tool.tomlsort]
 overrides."tool.tox.*".inline_arrays = false
 
 [tool.tox]
-env_list = ["py313-zarrv2", "py313-zarrv3"]
+env_list = ["py313-zarrv2", "py313-zarrv3", "py313-tensorstore"]
 requires = ["tox>=4.24"]
 
+[tool.tox.env.py313-tensorstore]
+commands = [
+    [
+        "pytest",
+        "--benchmark-save=tensorstore",
+        "--benchmark-save-data",
+        "-m",
+        "tensorstore",
+        {extend = true, replace = "posargs"},
+    ],
+]
+deps = ["tensorstore==0.1.73", "zarr==3.0.6", "numcodecs==0.15.1"]
+description = "Run benchmarks under tensorstore"
+
 [tool.tox.env.py313-zarrv2]
-commands = [["pytest", "--benchmark-save=zarr-python-v2", "--benchmark-save-data", {extend = true, replace = "posargs"}]]
+commands = [
+    [
+        "pytest",
+        "--benchmark-save=zarr-python-v2",
+        "--benchmark-save-data",
+        "-m",
+        "zarr_python",
+        {extend = true, replace = "posargs"},
+    ],
+]
 deps = ["zarr==2.18.4", "numcodecs==0.15.1"]
 description = "Run benchmarks under zarr-python version 2"
 
 [tool.tox.env.py313-zarrv3]
-commands = [["pytest", "--benchmark-save=zarr-python-v3", "--benchmark-save-data", {extend = true, replace = "posargs"}]]
+commands = [
+    [
+        "pytest",
+        "--benchmark-save=zarr-python-v3",
+        "--benchmark-save-data",
+        "-m",
+        "zarr_python",
+        {extend = true, replace = "posargs"},
+    ],
+]
 deps = ["zarr==3.0.6", "numcodecs==0.15.1"]
 description = "Run benchmarks under zarr-python version 3"
diff --git a/src/read_write_tensorstore.py b/src/read_write_tensorstore.py
new file mode 100644
index 0000000..be6fb37
--- /dev/null
+++ b/src/read_write_tensorstore.py
@@ -0,0 +1,80 @@
+from typing import Literal
+import tensorstore as ts
+import numpy as np
+import pathlib
+import utils
+
+
+def read_zarr_array(store_path: pathlib.Path) -> np.array:
+    """Read the v2 zarr spec with tensorstore"""
+    zarr_read = ts.open(
+        {
+            "driver": "zarr",
+            "kvstore": {
+                "driver": "file",
+                "path": str(store_path.resolve()),
+            },
+        },
+        read=True,
+    ).result()
+    read_image = zarr_read[:].read().result()
+    return read_image
+
+
+def write_zarr_array(
+    image: np.array,
+    store_path: pathlib.Path,
+    *,
+    overwrite: bool,
+    chunks: tuple[int],
+    compressor: dict,
+) -> None:
+    """Write the v2 zarr spec with tensorstore"""
+    if overwrite:
+        utils.remove_output_dir(store_path)
+
+    dataset = ts.open(
+        {
+            "driver": "zarr",
+            "kvstore": {
+                "driver": "file",
+                "path": str(store_path.resolve()),
+            },
+            "metadata": {
+                "dtype": image.dtype.str,
+                "shape": image.shape,
+                "chunks": chunks,
+                "compressor": compressor,
+            },
+            "create": True,
+            "delete_existing": False,
+        },
+    ).result()
+
+    write_future = dataset[:].write(image)
+    write_future.result()
+
+
+def get_blosc_compressor(
+    cname: str, clevel: int, shuffle: Literal["shuffle", "noshuffle", "bitshuffle"]
+) -> dict:
+    # see the zarr shuffle docs: https://google.github.io/tensorstore/driver/zarr/index.html#json-driver/zarr/Compressor/blosc.shuffle
+    match shuffle:
+        case "noshuffle":
+            shuffle_int = 0
+        case "shuffle":
+            shuffle_int = 1
+        case "bitshuffle":
+            shuffle_int = 2
+        case _:
+            raise ValueError(f"invalid shuffle value for blosc {shuffle}")
+
+    return {"id": "blosc", "cname": cname, "clevel": clevel, "shuffle": shuffle_int}
+
+
+def get_gzip_compressor(level: int) -> dict:
+    return {"id": "gzip", "level": level}
+
+
+def get_zstd_compressor(level: int) -> dict:
+    return {"id": "zstd", "level": level}
diff --git a/src/read_write_zarr_v2.py b/src/read_write_zarr_v2.py
index c3eba9e..9c2c29c 100644
--- a/src/read_write_zarr_v2.py
+++ b/src/read_write_zarr_v2.py
@@ -23,6 +23,7 @@ def read_zarr_array(store_path: pathlib.Path) -> np.array:
 def write_zarr_array(
     image: np.array,
     store_path: pathlib.Path,
+    *,
     overwrite: bool,
     chunks: tuple[int],
     compressor: numcodecs.abc.Codec,
diff --git a/src/read_write_zarr_v3.py b/src/read_write_zarr_v3.py
index 3d32f75..595b4fe 100644
--- a/src/read_write_zarr_v3.py
+++ b/src/read_write_zarr_v3.py
@@ -23,6 +23,7 @@ def read_zarr_array(store_path: pathlib.Path) -> np.array:
 def write_zarr_array(
     image: np.array,
     store_path: pathlib.Path,
+    *,
     overwrite: bool,
     chunks: tuple[int],
     compressor: Any = "auto",
diff --git a/tests/test_read_tensorstore_benchmark.py b/tests/test_read_tensorstore_benchmark.py
new file mode 100644
index 0000000..06b75e2
--- /dev/null
+++ b/tests/test_read_tensorstore_benchmark.py
@@ -0,0 +1,110 @@
+import pytest
+from tests.benchmark_parameters import (
+    CHUNK_SIZE,
+    BLOSC_CLEVEL,
+    BLOSC_CNAME,
+    BLOSC_SHUFFLE,
+    GZIP_LEVEL,
+    ZSTD_LEVEL,
+)
+import read_write_tensorstore
+
+try:
+    import read_write_zarr_v3 as read_write_zarr
+except ImportError:
+    import read_write_zarr_v2 as read_write_zarr
+
+pytestmark = [pytest.mark.tensorstore]
+
+
+@pytest.mark.benchmark(group="read")
+@pytest.mark.parametrize("chunk_size", CHUNK_SIZE)
+@pytest.mark.parametrize("blosc_clevel", BLOSC_CLEVEL)
+@pytest.mark.parametrize("blosc_shuffle", BLOSC_SHUFFLE)
+@pytest.mark.parametrize("blosc_cname", BLOSC_CNAME)
+def test_read_blosc(
+    benchmark,
+    image,
+    rounds,
+    warmup_rounds,
+    store_path,
+    chunk_size,
+    blosc_clevel,
+    blosc_shuffle,
+    blosc_cname,
+):
+    blosc_compressor = read_write_tensorstore.get_blosc_compressor(
+        blosc_cname, blosc_clevel, blosc_shuffle
+    )
+
+    read_write_tensorstore.write_zarr_array(
+        image=image,
+        store_path=store_path,
+        overwrite=True,
+        chunks=(chunk_size, chunk_size, chunk_size),
+        compressor=blosc_compressor,
+    )
+
+    compression_ratio = read_write_zarr.get_compression_ratio(store_path)
+    benchmark.extra_info["compression_ratio"] = compression_ratio
+
+    benchmark.pedantic(
+        read_write_tensorstore.read_zarr_array,
+        args=(store_path,),
+        rounds=rounds,
+        warmup_rounds=warmup_rounds,
+    )
+
+
+@pytest.mark.benchmark(group="read")
+@pytest.mark.parametrize("chunk_size", CHUNK_SIZE)
+@pytest.mark.parametrize("gzip_level", GZIP_LEVEL)
+def test_read_gzip(
+    benchmark, image, rounds, warmup_rounds, store_path, chunk_size, gzip_level
+):
+    gzip_compressor = read_write_tensorstore.get_gzip_compressor(gzip_level)
+
+    read_write_tensorstore.write_zarr_array(
+        image=image,
+        store_path=store_path,
+        overwrite=True,
+        chunks=(chunk_size, chunk_size, chunk_size),
+        compressor=gzip_compressor,
+    )
+
+    compression_ratio = read_write_zarr.get_compression_ratio(store_path)
+    benchmark.extra_info["compression_ratio"] = compression_ratio
+
+    benchmark.pedantic(
+        read_write_tensorstore.read_zarr_array,
+        args=(store_path,),
+        rounds=rounds,
+        warmup_rounds=warmup_rounds,
+    )
+
+
+@pytest.mark.benchmark(group="read")
+@pytest.mark.parametrize("chunk_size", CHUNK_SIZE)
+@pytest.mark.parametrize("zstd_level", ZSTD_LEVEL)
+def test_read_zstd(
+    benchmark, image, rounds, warmup_rounds, store_path, chunk_size, zstd_level
+):
+    zstd_compressor = read_write_tensorstore.get_zstd_compressor(zstd_level)
+
+    read_write_tensorstore.write_zarr_array(
+        image=image,
+        store_path=store_path,
+        overwrite=True,
+        chunks=(chunk_size, chunk_size, chunk_size),
+        compressor=zstd_compressor,
+    )
+
+    compression_ratio = read_write_zarr.get_compression_ratio(store_path)
+    benchmark.extra_info["compression_ratio"] = compression_ratio
+
+    benchmark.pedantic(
+        read_write_tensorstore.read_zarr_array,
+        args=(store_path,),
+        rounds=rounds,
+        warmup_rounds=warmup_rounds,
+    )
diff --git a/tests/test_read_benchmark.py b/tests/test_read_zarr_python_benchmark.py
similarity index 95%
rename from tests/test_read_benchmark.py
rename to tests/test_read_zarr_python_benchmark.py
index e56e54f..37a7d65 100644
--- a/tests/test_read_benchmark.py
+++ b/tests/test_read_zarr_python_benchmark.py
@@ -13,10 +13,10 @@
 except ImportError:
     import read_write_zarr_v2 as read_write_zarr
 
+pytestmark = [pytest.mark.zarr_python]
 
-@pytest.mark.benchmark(
-    group="read",
-)
+
+@pytest.mark.benchmark(group="read")
 @pytest.mark.parametrize("chunk_size", CHUNK_SIZE)
 @pytest.mark.parametrize("blosc_clevel", BLOSC_CLEVEL)
 @pytest.mark.parametrize("blosc_shuffle", BLOSC_SHUFFLE)
@@ -55,9 +55,7 @@ def test_read_blosc(
     )
 
 
-@pytest.mark.benchmark(
-    group="read",
-)
+@pytest.mark.benchmark(group="read")
 @pytest.mark.parametrize("chunk_size", CHUNK_SIZE)
 @pytest.mark.parametrize("gzip_level", GZIP_LEVEL)
 def test_read_gzip(
@@ -84,9 +82,7 @@ def test_read_gzip(
     )
 
 
-@pytest.mark.benchmark(
-    group="read",
-)
+@pytest.mark.benchmark(group="read")
 @pytest.mark.parametrize("chunk_size", CHUNK_SIZE)
 @pytest.mark.parametrize("zstd_level", ZSTD_LEVEL)
 def test_read_zstd(
diff --git a/tests/test_write_tensorstore_benchmark.py b/tests/test_write_tensorstore_benchmark.py
new file mode 100644
index 0000000..83a421c
--- /dev/null
+++ b/tests/test_write_tensorstore_benchmark.py
@@ -0,0 +1,103 @@
+import pytest
+from utils import remove_output_dir
+from tests.benchmark_parameters import (
+    CHUNK_SIZE,
+    BLOSC_CLEVEL,
+    BLOSC_SHUFFLE,
+    BLOSC_CNAME,
+    GZIP_LEVEL,
+    ZSTD_LEVEL,
+)
+import read_write_tensorstore
+
+pytestmark = [pytest.mark.tensorstore]
+
+
+@pytest.mark.benchmark(group="write")
+@pytest.mark.parametrize("chunk_size", CHUNK_SIZE)
+@pytest.mark.parametrize("blosc_clevel", BLOSC_CLEVEL)
+@pytest.mark.parametrize("blosc_shuffle", BLOSC_SHUFFLE)
+@pytest.mark.parametrize("blosc_cname", BLOSC_CNAME)
+def test_write_blosc(
+    benchmark,
+    image,
+    rounds,
+    warmup_rounds,
+    store_path,
+    chunk_size,
+    blosc_clevel,
+    blosc_shuffle,
+    blosc_cname,
+):
+    blosc_compressor = read_write_tensorstore.get_blosc_compressor(
+        blosc_cname, blosc_clevel, blosc_shuffle
+    )
+
+    def setup():
+        remove_output_dir(store_path)
+        return (), {
+            "image": image,
+            "store_path": store_path,
+            "overwrite": False,
+            "chunks": (chunk_size, chunk_size, chunk_size),
+            "compressor": blosc_compressor,
+        }
+
+    benchmark.pedantic(
+        read_write_tensorstore.write_zarr_array,
+        setup=setup,
+        rounds=rounds,
+        warmup_rounds=warmup_rounds,
+    )
+
+
+@pytest.mark.benchmark(group="write")
+@pytest.mark.parametrize("chunk_size", CHUNK_SIZE)
+@pytest.mark.parametrize("gzip_level", GZIP_LEVEL)
+def test_write_gzip(
+    benchmark, image, rounds, warmup_rounds, store_path, chunk_size, gzip_level
+):
+    gzip_compressor = read_write_tensorstore.get_gzip_compressor(gzip_level)
+
+    def setup():
+        remove_output_dir(store_path)
+        return (), {
+            "image": image,
+            "store_path": store_path,
+            "overwrite": False,
+            "chunks": (chunk_size, chunk_size, chunk_size),
+            "compressor": gzip_compressor,
+        }
+
+    benchmark.pedantic(
+        read_write_tensorstore.write_zarr_array,
+        setup=setup,
+        rounds=rounds,
+        warmup_rounds=warmup_rounds,
+    )
+
+
+@pytest.mark.benchmark(group="write")
+@pytest.mark.parametrize("chunk_size", CHUNK_SIZE)
+@pytest.mark.parametrize("zstd_level", ZSTD_LEVEL)
+def test_write_zstd(
+    benchmark, image, rounds, warmup_rounds, store_path, chunk_size, zstd_level
+):
+    zstd_compressor = read_write_tensorstore.get_zstd_compressor(zstd_level)
+
+    def setup():
+        remove_output_dir(store_path)
+        return (), {
+            "image": image,
+            "store_path": store_path,
+            "overwrite": False,
+            "chunks": (chunk_size, chunk_size, chunk_size),
+            "compressor": zstd_compressor,
+        }
+
+    benchmark.pedantic(
+        read_write_tensorstore.write_zarr_array,
+        setup=setup,
+        rounds=rounds,
+        warmup_rounds=warmup_rounds,
+    )
diff --git a/tests/test_write_benchmark.py b/tests/test_write_zarr_python_benchmark.py
similarity index 94%
rename from tests/test_write_benchmark.py
rename to tests/test_write_zarr_python_benchmark.py
index 2e7d725..e43dfbe 100644
--- a/tests/test_write_benchmark.py
+++ b/tests/test_write_zarr_python_benchmark.py
@@ -14,10 +14,10 @@
 except ImportError:
     import read_write_zarr_v2 as read_write_zarr
 
+pytestmark = [pytest.mark.zarr_python]
 
-@pytest.mark.benchmark(
-    group="write",
-)
+
+@pytest.mark.benchmark(group="write")
 @pytest.mark.parametrize("chunk_size", CHUNK_SIZE)
 @pytest.mark.parametrize("blosc_clevel", BLOSC_CLEVEL)
 @pytest.mark.parametrize("blosc_shuffle", BLOSC_SHUFFLE)
@@ -55,9 +55,7 @@ def setup():
     )
 
 
-@pytest.mark.benchmark(
-    group="write",
-)
+@pytest.mark.benchmark(group="write")
 @pytest.mark.parametrize("chunk_size", CHUNK_SIZE)
 @pytest.mark.parametrize("gzip_level", GZIP_LEVEL)
 def test_write_gzip(
@@ -83,9 +81,7 @@ def setup():
     )
 
 
-@pytest.mark.benchmark(
-    group="write",
-)
+@pytest.mark.benchmark(group="write")
 @pytest.mark.parametrize("chunk_size", CHUNK_SIZE)
 @pytest.mark.parametrize("zstd_level", ZSTD_LEVEL)
 def test_write_zstd(


Reviews:
user: login: dstansby
body: LGTM 👍 One major suggestion about marking whole files instead of individual functions.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T15:18:43Z
user: login: K-Meech
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T15:41:35Z
user: login: K-Meech
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T15:45:30Z
user: login: K-Meech
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T08:03:02Z
user: login: dstansby
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T11:02:43Z
user: login: K-Meech
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T11:45:17Z

Review comments:
diff_hunk: @@ -10,6 +10,7 @@ dependencies = [
     "imageio",
     "pytest",
     "pytest-benchmark",
+    "tensorstore",
path: pyproject.toml
user: login: dstansby
body: As a general question, do dependencies actually need to be included at the top level here, if they're installed in isolated environments with tox when running the tests anyway? I'm thinking that e.g. for the `zarr-python` tests, there's no need to install `tensorstore` in that environment.

This is probably orthogonal to this PR, and I can open an issue if you think that's best?
created_at: 2025-04-01T15:10:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,79 @@
+from typing import Literal
+import tensorstore as ts
+import numpy as np
+import pathlib
+import utils
+
+
+def read_zarr_array(store_path: pathlib.Path) -> np.array:
+    """Read the v2 zarr spec with tensorstore"""
+    zarr_read = ts.open(
+        {
+            "driver": "zarr",
+            "kvstore": {
+                "driver": "file",
+                "path": str(store_path),
path: src/read_write_tensorstore.py
user: login: dstansby
body: ```suggestion
                "path": str(store_path.resolve()),
```

Maybe, to resolve relative paths first?
created_at: 2025-04-01T15:12:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,79 @@
+from typing import Literal
+import tensorstore as ts
+import numpy as np
+import pathlib
+import utils
+
+
+def read_zarr_array(store_path: pathlib.Path) -> np.array:
+    """Read the v2 zarr spec with tensorstore"""
+    zarr_read = ts.open(
+        {
+            "driver": "zarr",
+            "kvstore": {
+                "driver": "file",
+                "path": str(store_path),
+            },
+        },
+        read=True,
+    ).result()
+    read_image = zarr_read[:].read().result()
+    return read_image
+
+
+def write_zarr_array(
+    image: np.array,
+    store_path: pathlib.Path,
+    overwrite: bool,
path: src/read_write_tensorstore.py
user: login: dstansby
body: ```suggestion
    *,
    overwrite: bool,
```
My instict would be to make the later arguments keyword only to avoid having calls like `write_zarr_array(arr, Path(...), True, (1, 2, 3), ...)` where it's not obvious what `True` or the chunks actually are.
created_at: 2025-04-01T15:13:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -14,9 +14,8 @@
     import read_write_zarr_v2 as read_write_zarr
 
path: tests/test_read_zarr_python_benchmark.py
user: login: dstansby
body: ```suggestion
pytestmarks = [pytest.mark.zarr_python]
```
I think you can put a mark at the top of a file to mark the whole file, instead of having to mark the functions individually.
created_at: 2025-04-01T15:16:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -14,9 +14,8 @@
     import read_write_zarr_v2 as read_write_zarr
 
path: tests/test_read_zarr_python_benchmark.py
user: login: K-Meech
body: Thanks @dstansby - I've changed them all to use `pytestmark` now :smile:
created_at: 2025-04-01T15:41:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -10,6 +10,7 @@ dependencies = [
     "imageio",
     "pytest",
     "pytest-benchmark",
+    "tensorstore",
path: pyproject.toml
user: login: K-Meech
body: Good point! They could be specified directly in the relevant tox environments, so there's no installation of un-needed dependencies. It is handy to able to `pip install .` with these dependencies for local testing though e.g. to try out different `pytest` commands to run the benchmarks. 

If you could open a separate issue - that would be great!
created_at: 2025-04-01T15:45:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,79 @@
+from typing import Literal
+import tensorstore as ts
+import numpy as np
+import pathlib
+import utils
+
+
+def read_zarr_array(store_path: pathlib.Path) -> np.array:
+    """Read the v2 zarr spec with tensorstore"""
+    zarr_read = ts.open(
+        {
+            "driver": "zarr",
+            "kvstore": {
+                "driver": "file",
+                "path": str(store_path),
+            },
+        },
+        read=True,
+    ).result()
+    read_image = zarr_read[:].read().result()
+    return read_image
+
+
+def write_zarr_array(
+    image: np.array,
+    store_path: pathlib.Path,
+    overwrite: bool,
path: src/read_write_tensorstore.py
user: login: K-Meech
body: 👍 I've added this for all of the `write_zarr_array` functions
created_at: 2025-04-02T08:03:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -10,6 +10,7 @@ dependencies = [
     "imageio",
     "pytest",
     "pytest-benchmark",
+    "tensorstore",
path: pyproject.toml
user: login: K-Meech
body: Shall I open a separate issue for this @dstansby ? Or did you want to?
created_at: 2025-04-02T11:45:17Z
author_association: CONTRIBUTOR

PR comments:
user: login: dstansby
created_at: 2025-04-02T11:04:24Z
body: I merged the GH actions PR, so maybe worth merging in main here to get a GH actions run in before this is merged?
author_association: CONTRIBUTOR
user: login: K-Meech
created_at: 2025-04-02T11:44:20Z
body: I merged in main, and all the actions pass with no problems 👍 
author_association: CONTRIBUTOR