Repository: zarr-developers/zarr-python

Issue:
Compact JSON in .zarray
JSON output is deliberately made human-readable with much whitespace. That produces large `.zarray` files with string arrays and categorize. In one small example with about 150 different strings, the human-readable `.zarray` was 3837 bytes and the compact version was 1284 bytes. With a larger variety of strings, the difference would be larger.

As Zarr is a storage format that isn't intended for human readability, I would like to propose to write JSON with `indent=None, separators=(",", ":")`.

Discussion:
d-v-b: The correct tests would compare JSON documents with different levels of `json_indent` values, to ensure that the indentation is working correctly. If we do want those tests, I can add them, but I think I would rather defer that for a big sprint to fill out the metadata tests.
jhamman: I like the feature and I'm happy to see this go in. I expect the specific implementation will change a bit with #1851 but that is fine with me.
will-moore: Looks great, thanks. Working as expected for me. 👍 

Patch:
diff --git a/.github/workflows/releases.yml b/.github/workflows/releases.yml
index 51fcf085..b54cbe48 100644
--- a/.github/workflows/releases.yml
+++ b/.github/workflows/releases.yml
@@ -55,7 +55,7 @@ jobs:
         with:
           name: releases
           path: dist
-      - uses: pypa/gh-action-pypi-publish@v1.8.14
+      - uses: pypa/gh-action-pypi-publish@v1.9.0
         with:
           user: __token__
           password: ${{ secrets.pypi_password }}
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 4bb39f0e..1ef226cd 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -7,7 +7,7 @@ default_language_version:
   python: python3
 repos:
   - repo: https://github.com/astral-sh/ruff-pre-commit
-    rev: 'v0.4.7'
+    rev: 'v0.4.9'
     hooks:
     - id: ruff
       args: ["--fix", "--show-fixes"]
diff --git a/docs/release.rst b/docs/release.rst
index b78e709c..7e540359 100644
--- a/docs/release.rst
+++ b/docs/release.rst
@@ -13,10 +13,24 @@ Release notes
     # to document your changes. On releases it will be
     # re-indented so that it does not show up in the notes.
 
-.. _unreleased(v3):
+.. note::
+   Zarr-Python 2.18.* is expected be the final release in the 2.* series. Work on Zarr-Python 3.0 is underway.
+   See `GH1777 <https://github.com/zarr-developers/zarr-python/issues/1777>`_ for more details on the upcoming
+   3.0 release.
 
-Unreleased (v3)
----------------
+.. release_3.0.0-alpha:
+
+3.0.0-alpha
+-----------
+
+.. warning::
+   Zarr-Python 3.0.0-alpha is a pre-release of the upcoming 3.0 release. This release is not feature complete or
+   expected to be ready for production applications.
+
+.. note::
+   The complete release notes for 3.0 have not been added to this document yet. See the
+   `3.0.0-alpha <https://github.com/zarr-developers/zarr-python/releases/tag/v3.0.0-alpha>`_ release on GitHub
+   for a record of changes included in this release.
 
 Enhancements
 ~~~~~~~~~~~~
@@ -31,10 +45,160 @@ Maintenance
   The dependency relationship is now reversed: the test suite imports this class from ``zarr-python``.
   By :user:`Davis Bennett <d-v-b>` :issue:`1601`.
 
-.. _unreleased:
 
-Unreleased (v2)
----------------
+.. _release_2.18.2:
+
+2.18.2
+------
+
+Enhancements
+~~~~~~~~~~~~
+
+* Add Zstd codec to old V3 code path.
+  By :user:`Ryan Abernathey <rabernat>`
+
+.. _release_2.18.1:
+
+2.18.1
+------
+
+Maintenance
+~~~~~~~~~~~
+* Fix a regression when getting or setting a single value from arrays with size-1 chunks.
+  By :user:`Deepak Cherian <dcherian>` :issue:`1874`
+
+.. _release_2.18.0:
+
+2.18.0
+------
+
+Enhancements
+~~~~~~~~~~~~
+* Performance improvement for reading and writing chunks if any of the dimensions is size 1.
+  By :user:`Deepak Cherian <dcherian>` :issue:`1730`.
+
+Maintenance
+~~~~~~~~~~~
+* Enable ruff/bugbear rules (B) and fix issues.
+  By :user:`Dimitri Papadopoulos Orfanos <DimitriPapadopoulos>` :issue:`1702`.
+
+* Minor updates to use `np.inf` instead of `np.PINF` / `np.NINF` in preparation for NumPy 2.0.0 release.
+  By :user:`Joe Hamman <jhamman>` :issue:`1842`.
+
+Deprecations
+~~~~~~~~~~~~
+
+* Deprecate experimental v3 support by issuing a `FutureWarning`.
+  Also updated docs to warn about using the experimental v3 version.
+  By :user:`Joe Hamman <jhamman>` :issue:`1802` and :issue:`1807`.
+
+* Deprecate the following stores: :class:`zarr.storage.DBMStore`, :class:`zarr.storage.LMDBStore`,
+  :class:`zarr.storage.SQLiteStore`, :class:`zarr.storage.MongoDBStore`, :class:`zarr.storage.RedisStore`,
+  and :class:`zarr.storage.ABSStore`. These stores are slated to be removed from Zarr-Python in version 3.0.
+  By :user:`Joe Hamman <jhamman>` :issue:`1801`.
+
+.. _release_2.17.2:
+
+2.17.2
+------
+
+Enhancements
+~~~~~~~~~~~~
+
+* [v3] Dramatically reduce number of ``__contains__`` requests in favor of optimistically calling `__getitem__`
+  and handling any error that may arise.
+  By :user:`Deepak Cherian <dcherian>` :issue:`1741`.
+
+* [v3] Reuse the downloaded array metadata when creating an ``Array``.
+  By :user:`Deepak Cherian <dcherian>` :issue:`1734`.
+
+* Optimize ``Array.info`` so that it calls `getsize` only once.
+  By :user:`Deepak Cherian <dcherian>` :issue:`1733`.
+
+* Override IPython ``_repr_*_`` methods to avoid expensive lookups against object stores.
+  By :user:`Deepak Cherian <dcherian>` :issue:`1716`.
+
+* FSStore now raises rather than return bad data.
+  By :user:`Martin Durant <martindurant>` and :user:`Ian Carroll <itcarroll>` :issue:`1604`.
+
+* Avoid redundant ``__contains__``.
+  By :user:`Deepak Cherian <dcherian>` :issue:`1739`.
+
+Docs
+~~~~
+
+* Fix link to GCSMap in ``tutorial.rst``.
+  By :user:`Daniel Jahn <dahnj>` :issue:`1689`.
+
+* Endorse `SPEC0000 <https://scientific-python.org/specs/spec-0000/>`_ and state version support policy in ``installation.rst``.
+  By :user:`Sanket Verma <msankeys963>` :issue:`1665`.
+
+* Migrate v1 and v2 specification to `Zarr-Specs <https://zarr-specs.readthedocs.io/en/latest/specs.html>`_.
+  By :user:`Sanket Verma <msankeys963>` :issue:`1582`.
+
+Maintenance
+~~~~~~~~~~~
+
+* Add CI test environment for Python 3.12
+  By :user:`Joe Hamman <jhamman>` :issue:`1719`.
+
+* Bump minimum supported NumPy version to 1.23 (per spec 0000)
+  By :user:`Joe Hamman <jhamman>` :issue:`1719`.
+
+* Minor fixes: Using ``is`` instead of ``type`` and removing unnecessary ``None``.
+  By :user:`Dimitri Papadopoulos Orfanos <DimitriPapadopoulos>` :issue:`1737`.
+
+* Fix tests failure related to Pytest 8.
+  By :user:`David Stansby <dstansby>` :issue:`1714`.
+
+.. _release_2.17.1:
+
+2.17.1
+------
+
+Enhancements
+~~~~~~~~~~~~
+
+* Change occurrences of % and format() to f-strings.
+  By :user:`Dimitri Papadopoulos Orfanos <DimitriPapadopoulos>` :issue:`1423`.
+
+* Proper argument for numpy.reshape.
+  By :user:`Dimitri Papadopoulos Orfanos <DmitriPapadopoulos>` :issue:`1425`.
+
+* Add typing to dimension separator arguments.
+  By :user:`David Stansby <dstansby>` :issue:`1620`.
+
+Docs
+~~~~
+
+* ZIP related tweaks.
+  By :user:`Davis Bennett <d-v-b>` :issue:`1641`.
+
+Maintenance
+~~~~~~~~~~~
+
+* Update config.yml with Zulip.
+  By :user:`Josh Moore <joshmoore>`.
+
+* Replace Gitter with the new Zulip Chat link.
+  By :user:`Sanket Verma <msankeys963>` :issue:`1685`.
+
+* Fix RTD build.
+  By :user:`Sanket Verma <msankeys963>` :issue:`1694`.
+
+.. _release_2.17.0:
+
+2.17.0
+------
+
+Enhancements
+~~~~~~~~~~~~
+
+* Added type hints to ``zarr.creation.create()``.
+  By :user:`David Stansby <dstansby>` :issue:`1536`.
+
+* Pyodide support: Don't require fasteners on Emscripten.
+  By :user:`Hood Chatham <hoodmane>` :issue:`1663`.
 
 Docs
 ~~~~
@@ -57,10 +221,30 @@ Docs
 * Minor tweak to advanced indexing tutorial examples.
   By :user:`Ross Barnowski <rossbar>` :issue:`1550`.
 
+* Automatically document array members using sphinx-automodapi.
+  By :user:`David Stansby <dstansby>` :issue:`1547`.
+
+* Add a markdown file documenting the current and former core-developer team.
+  By :user:`Joe Hamman <jhamman>` :issue:`1628`.
+
+* Add Norman Rzepka to core-dev team.
+  By :user:`Joe Hamman <jhamman>` :issue:`1630`.
+
+* Added section about accessing ZIP archives on s3.
+  By :user:`Jeff Peck <jeffpeck10x>` :issue:`1613`, :issue:`1615`, and :user:`Davis Bennett <d-v-b>` :issue:`1641`.
+
+* Add V3 roadmap and design document.
+  By :user:`Joe Hamman <jhamman>` :issue:`1583`.
 
 Maintenance
 ~~~~~~~~~~~
 
+* Drop Python 3.8 and NumPy 1.20
+  By :user:`Josh Moore <joshmoore>`; :issue:`1557`.
+
+* Cache result of ``FSStore._fsspec_installed()``.
+  By :user:`Janick Martinez Esturo <ph03>` :issue:`1581`.
+
 * Extend copyright notice to 2023.
   By :user:`Jack Kelly <JackKelly>` :issue:`1528`.
 
@@ -79,6 +263,27 @@ Maintenance
 * Remove ``sphinx-rtd-theme`` dependency from ``pyproject.toml``.
   By :user:`Sanket Verma <MSanKeys963>` :issue:`1563`.
 
+* Remove ``CODE_OF_CONDUCT.md`` file from the Zarr-Python repository.
+  By :user:`Sanket Verma <MSanKeys963>` :issue:`1572`.
+
+* Bump version of black in pre-commit.
+  By :user:`David Stansby <dtstansby>` :issue:`1559`.
+
+* Use list comprehension where applicable.
+  By :user:`Dimitri Papadopoulos Orfanos <DimitriPapadopoulos>` :issue:`1555`.
+
+* Use format specification mini-language to format string.
+  By :user:`Dimitri Papadopoulos Orfanos <DimitriPapadopoulos>` :issue:`1558`.
+
+* Single startswith() call instead of multiple ones.
+  By :user:`Dimitri Papadopoulos Orfanos <DimitriPapadopoulos>` :issue:`1556`.
+
+* Move codespell options around.
+  By :user:`Dimitri Papadopoulos Orfanos <DimitriPapadopoulos>` :issue:`1196`.
+
+* Remove unused mypy ignore comments.
+  By :user:`David Stansby <dtstansby>` :issue:`1602`.
+
 .. _release_2.16.1:
 
 2.16.1
@@ -120,10 +325,10 @@ Maintenance
   By :user:`Davis Bennett <d-v-b>` :issue:`1462`.
 
 * Style the codebase with ``ruff`` and ``black``.
-  By :user:`Davis Bennett` <d-v-b> :issue:`1459`
+  By :user:`Davis Bennett <d-v-b>` :issue:`1459`
 
 * Ensure that chunks is tuple of ints upon array creation.
-  By :user:`Philipp Hanslovsky` <hanslovsky> :issue:`1461`
+  By :user:`Philipp Hanslovsky <hanslovsky>` :issue:`1461`
 
 .. _release_2.15.0:
 
@@ -511,7 +716,7 @@ Maintenance
   By :user:`Saransh Chopra <Saransh-cpp>` :issue:`1079`.
 
 * Remove option to return None from _ensure_store.
-  By :user:`Greggory Lee <grlee77>` :issue:`1068`.
+  By :user:`Gregory Lee <grlee77>` :issue:`1068`.
 
 * Fix a typo of "integers".
   By :user:`Richard Scott <RichardScottOZ>` :issue:`1056`.
@@ -529,7 +734,7 @@ Enhancements
   Since the format is not yet finalized, the classes and functions are not
   automatically imported into the regular `zarr` name space. Setting the
   `ZARR_V3_EXPERIMENTAL_API` environment variable will activate them.
-  By :user:`Greggory Lee <grlee77>`; :issue:`898`, :issue:`1006`, and :issue:`1007`
+  By :user:`Gregory Lee <grlee77>`; :issue:`898`, :issue:`1006`, and :issue:`1007`
   as well as by :user:`Josh Moore <joshmoore>` :issue:`1032`.
 
 * **Create FSStore from an existing fsspec filesystem**. If you have created
@@ -651,7 +856,7 @@ Enhancements
   higher-level array creation and convenience functions still accept plain
   Python dicts or other mutable mappings for the ``store`` argument, but will
   internally convert these to a ``KVStore``.
-  By :user:`Greggory Lee <grlee77>`; :issue:`839`, :issue:`789`, and :issue:`950`.
+  By :user:`Gregory Lee <grlee77>`; :issue:`839`, :issue:`789`, and :issue:`950`.
 
 * Allow to assign array ``fill_values`` and update metadata accordingly.
   By :user:`Ryan Abernathey <rabernat>`, :issue:`662`.
@@ -798,7 +1003,7 @@ Bug fixes
 ~~~~~~~~~
 
 * Fix FSStore.listdir behavior for nested directories.
-  By :user:`Greggory Lee <grlee77>`; :issue:`802`.
+  By :user:`Gregory Lee <grlee77>`; :issue:`802`.
 
 .. _release_2.9.4:
 
@@ -882,7 +1087,7 @@ Bug fixes
   By :user:`Josh Moore <joshmoore>`; :issue:`781`.
 
 * avoid NumPy 1.21.0 due to https://github.com/numpy/numpy/issues/19325
-  By :user:`Greggory Lee <grlee77>`; :issue:`791`.
+  By :user:`Gregory Lee <grlee77>`; :issue:`791`.
 
 Maintenance
 ~~~~~~~~~~~
@@ -894,7 +1099,7 @@ Maintenance
   By :user:`Elliott Sales de Andrade <QuLogic>`; :issue:`799`.
 
 * TST: add missing assert in test_hexdigest.
-  By :user:`Greggory Lee <grlee77>`; :issue:`801`.
+  By :user:`Gregory Lee <grlee77>`; :issue:`801`.
 
 .. _release_2.8.3:
 
@@ -1538,11 +1743,11 @@ Bug fixes
 Documentation
 ~~~~~~~~~~~~~
 
-* Some changes have been made to the :ref:`spec_v2` document to clarify
+* Some changes have been made to the Zarr Specification v2 document to clarify
   ambiguities and add some missing information. These changes do not break compatibility
   with any of the material as previously implemented, and so the changes have been made
   in-place in the document without incrementing the document version number. See the
-  section on :ref:`spec_v2_changes` in the specification document for more information.
+  section on changes in the specification document for more information.
 * A new :ref:`tutorial_indexing` section has been added to the tutorial.
 * A new :ref:`tutorial_strings` section has been added to the tutorial
   (:issue:`135`, :issue:`175`).
diff --git a/pyproject.toml b/pyproject.toml
index 80e03322..96a884b7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -27,10 +27,12 @@ dependencies = [
     'numpy>=1.24',
     'fasteners',
     'numcodecs>=0.10.0',
+    'fsspec>2024',
     'crc32c',
     'zstandard',
     'typing_extensions',
-    'donfig'
+    'donfig',
+    'pytest'
 ]
 dynamic = [
   "version",
@@ -111,7 +113,12 @@ extra-dependencies = [
     "pytest-cov",
     "msgpack",
     "lmdb",
+    "s3fs",
     "pytest-asyncio",
+    "moto[s3]",
+    "flask-cors",
+    "flask",
+    "requests",
     "mypy"
 ]
 features = ["extra"]
diff --git a/src/zarr/__init__.py b/src/zarr/__init__.py
index fdab564c..227b0cf6 100644
--- a/src/zarr/__init__.py
+++ b/src/zarr/__init__.py
@@ -1,34 +1,68 @@
-from __future__ import annotations
-
-import zarr.codecs  # noqa: F401
 from zarr._version import version as __version__
+from zarr.api.synchronous import (
+    array,
+    consolidate_metadata,
+    copy,
+    copy_all,
+    copy_store,
+    create,
+    empty,
+    empty_like,
+    full,
+    full_like,
+    group,
+    load,
+    ones,
+    ones_like,
+    open,
+    open_array,
+    open_consolidated,
+    open_group,
+    open_like,
+    save,
+    save_array,
+    save_group,
+    tree,
+    zeros,
+    zeros_like,
+)
 from zarr.array import Array, AsyncArray
-from zarr.config import config  # noqa: F401
+from zarr.config import config
 from zarr.group import AsyncGroup, Group
-from zarr.store import (
-    StoreLike,
-    make_store_path,
-)
-from zarr.sync import sync as _sync
 
 # in case setuptools scm screw up and find version to be 0.0.0
 assert not __version__.startswith("0.0.0")
 
-
-async def open_auto_async(store: StoreLike) -> AsyncArray | AsyncGroup:
-    store_path = make_store_path(store)
-    try:
-        return await AsyncArray.open(store_path)
-    except KeyError:
-        return await AsyncGroup.open(store_path)
-
-
-def open_auto(store: StoreLike) -> Array | Group:
-    object = _sync(
-        open_auto_async(store),
-    )
-    if isinstance(object, AsyncArray):
-        return Array(object)
-    if isinstance(object, AsyncGroup):
-        return Group(object)
-    raise TypeError(f"Unexpected object type. Got {type(object)}.")
+__all__ = [
+    "__version__",
+    "config",
+    "Array",
+    "AsyncArray",
+    "Group",
+    "AsyncGroup",
+    "tree",
+    "array",
+    "consolidate_metadata",
+    "copy",
+    "copy_all",
+    "copy_store",
+    "create",
+    "empty",
+    "empty_like",
+    "full",
+    "full_like",
+    "group",
+    "load",
+    "ones",
+    "ones_like",
+    "open",
+    "open_array",
+    "open_consolidated",
+    "open_group",
+    "open_like",
+    "save",
+    "save_array",
+    "save_group",
+    "zeros",
+    "zeros_like",
+]
diff --git a/src/zarr/api/__init__.py b/src/zarr/api/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/zarr/api/asynchronous.py b/src/zarr/api/asynchronous.py
new file mode 100644
index 00000000..52d07fb6
--- /dev/null
+++ b/src/zarr/api/asynchronous.py
@@ -0,0 +1,929 @@
+from __future__ import annotations
+
+import asyncio
+import warnings
+from collections.abc import Iterable
+from typing import Any, Literal, Union, cast
+
+import numpy as np
+import numpy.typing as npt
+
+from zarr.abc.codec import Codec
+from zarr.array import Array, AsyncArray
+from zarr.buffer import NDArrayLike
+from zarr.chunk_key_encodings import ChunkKeyEncoding
+from zarr.common import JSON, ChunkCoords, MemoryOrder, OpenMode, ZarrFormat
+from zarr.group import AsyncGroup
+from zarr.metadata import ArrayV2Metadata, ArrayV3Metadata
+from zarr.store import (
+    StoreLike,
+    make_store_path,
+)
+
+# TODO: this type could use some more thought, noqa to avoid "Variable "asynchronous.ArrayLike" is not valid as a type"
+ArrayLike = Union[AsyncArray | Array | npt.NDArray[Any]]  # noqa
+PathLike = str
+
+
+def _get_shape_chunks(a: ArrayLike | Any) -> tuple[ChunkCoords | None, ChunkCoords | None]:
+    """helper function to get the shape and chunks from an array-like object"""
+    shape = None
+    chunks = None
+
+    if hasattr(a, "shape") and isinstance(a.shape, tuple):
+        shape = a.shape
+
+        if hasattr(a, "chunks") and isinstance(a.chunks, tuple) and (len(a.chunks) == len(a.shape)):
+            chunks = a.chunks
+
+        elif hasattr(a, "chunklen"):
+            # bcolz carray
+            chunks = (a.chunklen,) + a.shape[1:]
+
+    return shape, chunks
+
+
+def _like_args(a: ArrayLike, kwargs: dict[str, Any]) -> dict[str, Any]:
+    """set default values for shape and chunks if they are not present in the array-like object"""
+
+    new = kwargs.copy()
+
+    shape, chunks = _get_shape_chunks(a)
+    if shape is not None:
+        new["shape"] = shape
+    if chunks is not None:
+        new["chunks"] = chunks
+
+    if hasattr(a, "dtype"):
+        new["dtype"] = a.dtype
+
+    if isinstance(a, AsyncArray):
+        new["order"] = a.order
+        if isinstance(a.metadata, ArrayV2Metadata):
+            new["compressor"] = a.metadata.compressor
+            new["filters"] = a.metadata.filters
+
+        if isinstance(a.metadata, ArrayV3Metadata):
+            new["codecs"] = a.metadata.codecs
+        else:
+            raise ValueError(f"Unsupported zarr format: {a.metadata.zarr_format}")
+    else:
+        # TODO: set default values compressor/codecs
+        # to do this, we may need to evaluate if this is a v2 or v3 array
+        # new["compressor"] = "default"
+        pass
+
+    return new
+
+
+def _handle_zarr_version_or_format(
+    *, zarr_version: ZarrFormat | None, zarr_format: ZarrFormat | None
+) -> ZarrFormat | None:
+    """handle the deprecated zarr_version kwarg and return zarr_format"""
+    if zarr_format is not None and zarr_version is not None and zarr_format != zarr_version:
+        raise ValueError(
+            f"zarr_format {zarr_format} does not match zarr_version {zarr_version}, please only set one"
+        )
+    if zarr_version is not None:
+        warnings.warn(
+            "zarr_version is deprecated, use zarr_format", DeprecationWarning, stacklevel=2
+        )
+        return zarr_version
+    return zarr_format
+
+
+def _default_zarr_version() -> ZarrFormat:
+    """return the default zarr_version"""
+    # TODO: set default value from config
+    return 3
+
+
+async def consolidate_metadata(*args: Any, **kwargs: Any) -> AsyncGroup:
+    raise NotImplementedError
+
+
+async def copy(*args: Any, **kwargs: Any) -> tuple[int, int, int]:
+    raise NotImplementedError
+
+
+async def copy_all(*args: Any, **kwargs: Any) -> tuple[int, int, int]:
+    raise NotImplementedError
+
+
+async def copy_store(*args: Any, **kwargs: Any) -> tuple[int, int, int]:
+    raise NotImplementedError
+
+
+async def load(
+    *,
+    store: StoreLike,
+    path: str | None = None,
+    zarr_format: ZarrFormat | None = None,
+    zarr_version: ZarrFormat | None = None,
+) -> NDArrayLike | dict[str, NDArrayLike]:
+    """Load data from an array or group into memory.
+
+    Parameters
+    ----------
+    store : Store or string
+        Store or path to directory in file system or name of zip file.
+    path : str or None, optional
+        The path within the store from which to load.
+
+    Returns
+    -------
+    out
+        If the path contains an array, out will be a numpy array. If the path contains
+        a group, out will be a dict-like object where keys are array names and values
+        are numpy arrays.
+
+    See Also
+    --------
+    save, savez
+
+    Notes
+    -----
+    If loading data from a group of arrays, data will not be immediately loaded into
+    memory. Rather, arrays will be loaded into memory as they are requested.
+    """
+    zarr_format = _handle_zarr_version_or_format(zarr_version=zarr_version, zarr_format=zarr_format)
+
+    obj = await open(store=store, path=path, zarr_format=zarr_format)
+    if isinstance(obj, AsyncArray):
+        return await obj.getitem(slice(None))
+    else:
+        raise NotImplementedError("loading groups not yet supported")
+
+
+async def open(
+    *,
+    store: StoreLike | None = None,
+    mode: OpenMode | None = None,  # type and value changed
+    zarr_version: ZarrFormat | None = None,  # deprecated
+    zarr_format: ZarrFormat | None = None,
+    path: str | None = None,
+    **kwargs: Any,  # TODO: type kwargs as valid args to open_array
+) -> AsyncArray | AsyncGroup:
+    """Convenience function to open a group or array using file-mode-like semantics.
+
+    Parameters
+    ----------
+    store : Store or string, optional
+        Store or path to directory in file system or name of zip file.
+    mode : {'r', 'r+', 'a', 'w', 'w-'}, optional
+        Persistence mode: 'r' means read only (must exist); 'r+' means
+        read/write (must exist); 'a' means read/write (create if doesn't
+        exist); 'w' means create (overwrite if exists); 'w-' means create
+        (fail if exists).
+    zarr_format : {2, 3, None}, optional
+        The zarr format to use when saving.
+    path : str or None, optional
+        The path within the store to open.
+    **kwargs
+        Additional parameters are passed through to :func:`zarr.creation.open_array` or
+        :func:`zarr.hierarchy.open_group`.
+
+    Returns
+    -------
+    z : array or group
+        Return type depends on what exists in the given store.
+    """
+    zarr_format = _handle_zarr_version_or_format(zarr_version=zarr_version, zarr_format=zarr_format)
+    store_path = make_store_path(store, mode=mode)
+
+    if path is not None:
+        store_path = store_path / path
+
+    try:
+        return await open_array(store=store_path, zarr_format=zarr_format, **kwargs)
+    except KeyError:
+        return await open_group(store=store_path, zarr_format=zarr_format, **kwargs)
+
+
+async def open_consolidated(*args: Any, **kwargs: Any) -> AsyncGroup:
+    raise NotImplementedError
+
+
+async def save(
+    store: StoreLike,
+    *args: NDArrayLike,
+    zarr_version: ZarrFormat | None = None,  # deprecated
+    zarr_format: ZarrFormat | None = None,
+    path: str | None = None,
+    **kwargs: Any,  # TODO: type kwargs as valid args to save
+) -> None:
+    """Convenience function to save an array or group of arrays to the local file system.
+
+    Parameters
+    ----------
+    store : Store or string
+        Store or path to directory in file system or name of zip file.
+    args : ndarray
+        NumPy arrays with data to save.
+    zarr_format : {2, 3, None}, optional
+        The zarr format to use when saving.
+    path : str or None, optional
+        The path within the group where the arrays will be saved.
+    kwargs
+        NumPy arrays with data to save.
+    """
+    zarr_format = _handle_zarr_version_or_format(zarr_version=zarr_version, zarr_format=zarr_format)
+
+    if len(args) == 0 and len(kwargs) == 0:
+        raise ValueError("at least one array must be provided")
+    if len(args) == 1 and len(kwargs) == 0:
+        await save_array(store, args[0], zarr_format=zarr_format, path=path)
+    else:
+        await save_group(store, *args, zarr_format=zarr_format, path=path, **kwargs)
+
+
+async def save_array(
+    store: StoreLike,
+    arr: NDArrayLike,
+    *,
+    zarr_version: ZarrFormat | None = None,  # deprecated
+    zarr_format: ZarrFormat | None = None,
+    path: str | None = None,
+    **kwargs: Any,  # TODO: type kwargs as valid args to create
+) -> None:
+    """Convenience function to save a NumPy array to the local file system, following a
+    similar API to the NumPy save() function.
+
+    Parameters
+    ----------
+    store : Store or string
+        Store or path to directory in file system or name of zip file.
+    arr : ndarray
+        NumPy array with data to save.
+    zarr_format : {2, 3, None}, optional
+        The zarr format to use when saving.
+    path : str or None, optional
+        The path within the store where the array will be saved.
+    kwargs
+        Passed through to :func:`create`, e.g., compressor.
+    """
+    zarr_format = (
+        _handle_zarr_version_or_format(zarr_version=zarr_version, zarr_format=zarr_format)
+        or _default_zarr_version()
+    )
+
+    store_path = make_store_path(store, mode="w")
+    if path is not None:
+        store_path = store_path / path
+    new = await AsyncArray.create(
+        store_path,
+        zarr_format=zarr_format,
+        shape=arr.shape,
+        dtype=arr.dtype,
+        chunks=arr.shape,
+        **kwargs,
+    )
+    await new.setitem(slice(None), arr)
+
+
+async def save_group(
+    store: StoreLike,
+    *args: NDArrayLike,
+    zarr_version: ZarrFormat | None = None,  # deprecated
+    zarr_format: ZarrFormat | None = None,
+    path: str | None = None,
+    **kwargs: NDArrayLike,
+) -> None:
+    """Convenience function to save several NumPy arrays to the local file system, following a
+    similar API to the NumPy savez()/savez_compressed() functions.
+
+    Parameters
+    ----------
+    store : Store or string
+        Store or path to directory in file system or name of zip file.
+    args : ndarray
+        NumPy arrays with data to save.
+    zarr_format : {2, 3, None}, optional
+        The zarr format to use when saving.
+    path : str or None, optional
+        Path within the store where the group will be saved.
+    kwargs
+        NumPy arrays with data to save.
+    """
+    zarr_format = _handle_zarr_version_or_format(zarr_version=zarr_version, zarr_format=zarr_format)
+
+    if len(args) == 0 and len(kwargs) == 0:
+        raise ValueError("at least one array must be provided")
+    aws = []
+    for i, arr in enumerate(args):
+        aws.append(save_array(store, arr, zarr_format=zarr_format, path=f"{path}/arr_{i}"))
+    for k, arr in kwargs.items():
+        _path = f"{path}/{k}" if path is not None else k
+        aws.append(save_array(store, arr, zarr_format=zarr_format, path=_path))
+    await asyncio.gather(*aws)
+
+
+async def tree(*args: Any, **kwargs: Any) -> None:
+    raise NotImplementedError
+
+
+async def array(data: NDArrayLike, **kwargs: Any) -> AsyncArray:
+    """Create an array filled with `data`.
+
+    Parameters
+    ----------
+    data : array_like
+        The data to fill the array with.
+    kwargs
+        Passed through to :func:`create`.
+
+    Returns
+    -------
+    array : array
+        The new array.
+    """
+
+    # ensure data is array-like
+    if not hasattr(data, "shape") or not hasattr(data, "dtype"):
+        data = np.asanyarray(data)
+
+    # setup dtype
+    kw_dtype = kwargs.get("dtype")
+    if kw_dtype is None:
+        kwargs["dtype"] = data.dtype
+    else:
+        kwargs["dtype"] = kw_dtype
+
+    # setup shape and chunks
+    data_shape, data_chunks = _get_shape_chunks(data)
+    kwargs["shape"] = data_shape
+    kw_chunks = kwargs.get("chunks")
+    if kw_chunks is None:
+        kwargs["chunks"] = data_chunks
+    else:
+        kwargs["chunks"] = kw_chunks
+
+    read_only = kwargs.pop("read_only", False)
+    if read_only:
+        raise ValueError("read_only=True is no longer supported when creating new arrays")
+
+    # instantiate array
+    z = await create(**kwargs)
+
+    # fill with data
+    await z.setitem(slice(None), data)
+
+    return z
+
+
+async def group(
+    *,  # Note: this is a change from v2
+    store: StoreLike | None = None,
+    overwrite: bool = False,
+    chunk_store: StoreLike | None = None,  # not used
+    cache_attrs: bool | None = None,  # not used, default changed
+    synchronizer: Any | None = None,  # not used
+    path: str | None = None,
+    zarr_version: ZarrFormat | None = None,  # deprecated
+    zarr_format: ZarrFormat | None = None,
+    meta_array: Any | None = None,  # not used
+    attributes: dict[str, JSON] | None = None,
+) -> AsyncGroup:
+    """Create a group.
+
+    Parameters
+    ----------
+    store : Store or string, optional
+        Store or path to directory in file system.
+    overwrite : bool, optional
+        If True, delete any pre-existing data in `store` at `path` before
+        creating the group.
+    chunk_store : Store, optional
+        Separate storage for chunks. If not provided, `store` will be used
+        for storage of both chunks and metadata.
+    cache_attrs : bool, optional
+        If True (default), user attributes will be cached for attribute read
+        operations. If False, user attributes are reloaded from the store prior
+        to all attribute read operations.
+    synchronizer : object, optional
+        Array synchronizer.
+    path : string, optional
+        Group path within store.
+    meta_array : array-like, optional
+        An array instance to use for determining arrays to create and return
+        to users. Use `numpy.empty(())` by default.
+    zarr_format : {2, 3, None}, optional
+        The zarr format to use when saving.
+
+    Returns
+    -------
+    g : group
+        The new group.
+    """
+
+    zarr_format = (
+        _handle_zarr_version_or_format(zarr_version=zarr_version, zarr_format=zarr_format)
+        or _default_zarr_version()
+    )
+
+    store_path = make_store_path(store)
+    if path is not None:
+        store_path = store_path / path
+
+    if chunk_store is not None:
+        warnings.warn("chunk_store is not yet implemented", RuntimeWarning, stacklevel=2)
+    if cache_attrs is not None:
+        warnings.warn("cache_attrs is not yet implemented", RuntimeWarning, stacklevel=2)
+    if synchronizer is not None:
+        warnings.warn("synchronizer is not yet implemented", RuntimeWarning, stacklevel=2)
+    if meta_array is not None:
+        warnings.warn("meta_array is not yet implemented", RuntimeWarning, stacklevel=2)
+
+    if attributes is None:
+        attributes = {}
+
+    try:
+        return await AsyncGroup.open(store=store_path, zarr_format=zarr_format)
+    except (KeyError, FileNotFoundError):
+        return await AsyncGroup.create(
+            store=store_path,
+            zarr_format=zarr_format,
+            exists_ok=overwrite,
+            attributes=attributes,
+        )
+
+
+async def open_group(
+    *,  # Note: this is a change from v2
+    store: StoreLike | None = None,
+    mode: OpenMode | None = None,  # not used
+    cache_attrs: bool | None = None,  # not used, default changed
+    synchronizer: Any = None,  # not used
+    path: str | None = None,
+    chunk_store: StoreLike | None = None,  # not used
+    storage_options: dict[str, Any] | None = None,  # not used
+    zarr_version: ZarrFormat | None = None,  # deprecated
+    zarr_format: ZarrFormat | None = None,
+    meta_array: Any | None = None,  # not used
+    attributes: dict[str, JSON] | None = None,
+) -> AsyncGroup:
+    """Open a group using file-mode-like semantics.
+
+    Parameters
+    ----------
+    store : Store or string, optional
+        Store or path to directory in file system or name of zip file.
+    mode : {'r', 'r+', 'a', 'w', 'w-'}, optional
+        Persistence mode: 'r' means read only (must exist); 'r+' means
+        read/write (must exist); 'a' means read/write (create if doesn't
+        exist); 'w' means create (overwrite if exists); 'w-' means create
+        (fail if exists).
+    cache_attrs : bool, optional
+        If True (default), user attributes will be cached for attribute read
+        operations. If False, user attributes are reloaded from the store prior
+        to all attribute read operations.
+    synchronizer : object, optional
+        Array synchronizer.
+    path : string, optional
+        Group path within store.
+    chunk_store : Store or string, optional
+        Store or path to directory in file system or name of zip file.
+    storage_options : dict
+        If using an fsspec URL to create the store, these will be passed to
+        the backend implementation. Ignored otherwise.
+    meta_array : array-like, optional
+        An array instance to use for determining arrays to create and return
+        to users. Use `numpy.empty(())` by default.
+
+    Returns
+    -------
+    g : group
+        The new group.
+    """
+
+    zarr_format = (
+        _handle_zarr_version_or_format(zarr_version=zarr_version, zarr_format=zarr_format)
+        or _default_zarr_version()
+    )
+
+    if cache_attrs is not None:
+        warnings.warn("cache_attrs is not yet implemented", RuntimeWarning, stacklevel=2)
+    if synchronizer is not None:
+        warnings.warn("synchronizer is not yet implemented", RuntimeWarning, stacklevel=2)
+    if meta_array is not None:
+        warnings.warn("meta_array is not yet implemented", RuntimeWarning, stacklevel=2)
+    if chunk_store is not None:
+        warnings.warn("chunk_store is not yet implemented", RuntimeWarning, stacklevel=2)
+    if storage_options is not None:
+        warnings.warn("storage_options is not yet implemented", RuntimeWarning, stacklevel=2)
+
+    store_path = make_store_path(store, mode=mode)
+    if path is not None:
+        store_path = store_path / path
+
+    if attributes is None:
+        attributes = {}
+
+    try:
+        return await AsyncGroup.open(store_path, zarr_format=zarr_format)
+    except (KeyError, FileNotFoundError):
+        return await AsyncGroup.create(
+            store_path, zarr_format=zarr_format, exists_ok=True, attributes=attributes
+        )
+
+
+async def create(
+    shape: ChunkCoords,
+    *,  # Note: this is a change from v2
+    chunks: ChunkCoords | None = None,  # TODO: v2 allowed chunks=True
+    dtype: npt.DTypeLike | None = None,
+    compressor: dict[str, JSON] | None = None,  # TODO: default and type change
+    fill_value: Any = 0,  # TODO: need type
+    order: MemoryOrder | None = None,  # TODO: default change
+    store: str | StoreLike | None = None,
+    synchronizer: Any | None = None,
+    overwrite: bool = False,
+    path: PathLike | None = None,
+    chunk_store: StoreLike | None = None,
+    filters: list[dict[str, JSON]] | None = None,  # TODO: type has changed
+    cache_metadata: bool | None = None,
+    cache_attrs: bool | None = None,
+    read_only: bool | None = None,
+    object_codec: Codec | None = None,  # TODO: type has changed
+    dimension_separator: Literal[".", "/"] | None = None,
+    write_empty_chunks: bool = False,  # TODO: default has changed
+    zarr_version: ZarrFormat | None = None,  # deprecated
+    zarr_format: ZarrFormat | None = None,
+    meta_array: Any | None = None,  # TODO: need type
+    attributes: dict[str, JSON] | None = None,
+    # v3 only
+    chunk_shape: ChunkCoords | None = None,
+    chunk_key_encoding: (
+        ChunkKeyEncoding
+        | tuple[Literal["default"], Literal[".", "/"]]
+        | tuple[Literal["v2"], Literal[".", "/"]]
+        | None
+    ) = None,
+    codecs: Iterable[Codec | dict[str, JSON]] | None = None,
+    dimension_names: Iterable[str] | None = None,
+    **kwargs: Any,
+) -> AsyncArray:
+    """Create an array.
+
+    Parameters
+    ----------
+    shape : int or tuple of ints
+        Array shape.
+    chunks : int or tuple of ints, optional
+        Chunk shape. If True, will be guessed from `shape` and `dtype`. If
+        False, will be set to `shape`, i.e., single chunk for the whole array.
+        If an int, the chunk size in each dimension will be given by the value
+        of `chunks`. Default is True.
+    dtype : string or dtype, optional
+        NumPy dtype.
+    compressor : Codec, optional
+        Primary compressor.
+    fill_value : object
+        Default value to use for uninitialized portions of the array.
+    order : {'C', 'F'}, optional
+        Memory layout to be used within each chunk.
+    store : Store or string
+        Store or path to directory in file system or name of zip file.
+    synchronizer : object, optional
+        Array synchronizer.
+    overwrite : bool, optional
+        If True, delete all pre-existing data in `store` at `path` before
+        creating the array.
+    path : string, optional
+        Path under which array is stored.
+    chunk_store : MutableMapping, optional
+        Separate storage for chunks. If not provided, `store` will be used
+        for storage of both chunks and metadata.
+    filters : sequence of Codecs, optional
+        Sequence of filters to use to encode chunk data prior to compression.
+    cache_metadata : bool, optional
+        If True, array configuration metadata will be cached for the
+        lifetime of the object. If False, array metadata will be reloaded
+        prior to all data access and modification operations (may incur
+        overhead depending on storage and data access pattern).
+    cache_attrs : bool, optional
+        If True (default), user attributes will be cached for attribute read
+        operations. If False, user attributes are reloaded from the store prior
+        to all attribute read operations.
+    read_only : bool, optional
+        True if array should be protected against modification.
+    object_codec : Codec, optional
+        A codec to encode object arrays, only needed if dtype=object.
+    dimension_separator : {'.', '/'}, optional
+        Separator placed between the dimensions of a chunk.
+
+        .. versionadded:: 2.8
+
+    write_empty_chunks : bool, optional
+        If True (default), all chunks will be stored regardless of their
+        contents. If False, each chunk is compared to the array's fill value
+        prior to storing. If a chunk is uniformly equal to the fill value, then
+        that chunk is not be stored, and the store entry for that chunk's key
+        is deleted. This setting enables sparser storage, as only chunks with
+        non-fill-value data are stored, at the expense of overhead associated
+        with checking the data of each chunk.
+
+        .. versionadded:: 2.11
+
+    zarr_format : {2, 3, None}, optional
+        The zarr format to use when saving.
+    meta_array : array-like, optional
+        An array instance to use for determining arrays to create and return
+        to users. Use `numpy.empty(())` by default.
+
+        .. versionadded:: 2.13
+
+    Returns
+    -------
+    z : array
+        The array.
+    """
+    zarr_format = (
+        _handle_zarr_version_or_format(zarr_version=zarr_version, zarr_format=zarr_format)
+        or _default_zarr_version()
+    )
+
+    if zarr_format == 2 and chunks is None:
+        chunks = shape
+    if zarr_format == 3 and chunk_shape is None:
+        chunk_shape = shape
+
+    if order is not None:
+        warnings.warn(
+            "order is deprecated, use config `array.order` instead",
+            DeprecationWarning,
+            stacklevel=2,
+        )
+    if synchronizer is not None:
+        warnings.warn("synchronizer is not yet implemented", RuntimeWarning, stacklevel=2)
+    if chunk_store is not None:
+        warnings.warn("chunk_store is not yet implemented", RuntimeWarning, stacklevel=2)
+    if cache_metadata is not None:
+        warnings.warn("cache_metadata is not yet implemented", RuntimeWarning, stacklevel=2)
+    if cache_attrs is not None:
+        warnings.warn("cache_attrs is not yet implemented", RuntimeWarning, stacklevel=2)
+    if object_codec is not None:
+        warnings.warn("object_codec is not yet implemented", RuntimeWarning, stacklevel=2)
+    if dimension_separator is not None:
+        if zarr_format == 3:
+            raise ValueError(
+                "dimension_separator is not supported for zarr format 3, use chunk_key_encoding instead"
+            )
+        else:
+            warnings.warn(
+                "dimension_separator is not yet implemented", RuntimeWarning, stacklevel=2
+            )
+    if write_empty_chunks:
+        warnings.warn("write_empty_chunks is not yet implemented", RuntimeWarning, stacklevel=2)
+    if meta_array is not None:
+        warnings.warn("meta_array is not yet implemented", RuntimeWarning, stacklevel=2)
+
+    mode = cast(OpenMode, "r" if read_only else "w")
+    store_path = make_store_path(store, mode=mode)
+    if path is not None:
+        store_path = store_path / path
+
+    return await AsyncArray.create(
+        store_path,
+        shape=shape,
+        chunks=chunks,
+        dtype=dtype,
+        compressor=compressor,
+        fill_value=fill_value,
+        exists_ok=overwrite,  # TODO: name change
+        filters=filters,
+        dimension_separator=dimension_separator,
+        zarr_format=zarr_format,
+        chunk_shape=chunk_shape,
+        chunk_key_encoding=chunk_key_encoding,
+        codecs=codecs,
+        dimension_names=dimension_names,
+        attributes=attributes,
+        **kwargs,
+    )
+
+
+async def empty(shape: ChunkCoords, **kwargs: Any) -> AsyncArray:
+    """Create an empty array.
+
+    Parameters
+    ----------
+    shape : int or tuple of int
+        Shape of the empty array.
+    **kwargs
+        Keyword arguments passed to :func:`zarr.api.asynchronous.create`.
+
+    Notes
+    -----
+    The contents of an empty Zarr array are not defined. On attempting to
+    retrieve data from an empty Zarr array, any values may be returned,
+    and these are not guaranteed to be stable from one access to the next.
+    """
+    return await create(shape=shape, fill_value=None, **kwargs)
+
+
+async def empty_like(a: ArrayLike, **kwargs: Any) -> AsyncArray:
+    """Create an empty array like `a`.
+
+    Parameters
+    ----------
+    a : array-like
+        The array to create an empty array like.
+    **kwargs
+        Keyword arguments passed to :func:`zarr.api.asynchronous.create`.
+
+    Returns
+    -------
+    Array
+        The new array.
+    """
+    like_kwargs = _like_args(a, kwargs)
+    return await empty(**like_kwargs)
+
+
+# TODO: add type annotations for fill_value and kwargs
+async def full(shape: ChunkCoords, fill_value: Any, **kwargs: Any) -> AsyncArray:
+    """Create an array, with `fill_value` being used as the default value for
+    uninitialized portions of the array.
+
+    Parameters
+    ----------
+    shape : int or tuple of int
+        Shape of the empty array.
+    fill_value : scalar
+        Fill value.
+    **kwargs
+        Keyword arguments passed to :func:`zarr.api.asynchronous.create`.
+
+    Returns
+    -------
+    Array
+        The new array.
+    """
+    return await create(shape=shape, fill_value=fill_value, **kwargs)
+
+
+# TODO: add type annotations for kwargs
+async def full_like(a: ArrayLike, **kwargs: Any) -> AsyncArray:
+    """Create a filled array like `a`.
+
+    Parameters
+    ----------
+    a : array-like
+        The array to create an empty array like.
+    **kwargs
+        Keyword arguments passed to :func:`zarr.api.asynchronous.create`.
+
+    Returns
+    -------
+    Array
+        The new array.
+    """
+    like_kwargs = _like_args(a, kwargs)
+    if isinstance(a, AsyncArray):
+        kwargs.setdefault("fill_value", a.metadata.fill_value)
+    return await full(**like_kwargs)
+
+
+async def ones(shape: ChunkCoords, **kwargs: Any) -> AsyncArray:
+    """Create an array, with one being used as the default value for
+    uninitialized portions of the array.
+
+    Parameters
+    ----------
+    shape : int or tuple of int
+        Shape of the empty array.
+    **kwargs
+        Keyword arguments passed to :func:`zarr.api.asynchronous.create`.
+
+    Returns
+    -------
+    Array
+        The new array.
+    """
+    return await create(shape=shape, fill_value=1, **kwargs)
+
+
+async def ones_like(a: ArrayLike, **kwargs: Any) -> AsyncArray:
+    """Create an array of ones like `a`.
+
+    Parameters
+    ----------
+    a : array-like
+        The array to create an empty array like.
+    **kwargs
+        Keyword arguments passed to :func:`zarr.api.asynchronous.create`.
+
+    Returns
+    -------
+    Array
+        The new array.
+    """
+    like_kwargs = _like_args(a, kwargs)
+    return await ones(**like_kwargs)
+
+
+async def open_array(
+    *,  # note: this is a change from v2
+    store: StoreLike | None = None,
+    zarr_version: ZarrFormat | None = None,  # deprecated
+    zarr_format: ZarrFormat | None = None,
+    path: PathLike | None = None,
+    **kwargs: Any,  # TODO: type kwargs as valid args to save
+) -> AsyncArray:
+    """Open an array using file-mode-like semantics.
+
+    Parameters
+    ----------
+    store : Store or string
+        Store or path to directory in file system or name of zip file.
+    zarr_format : {2, 3, None}, optional
+        The zarr format to use when saving.
+    path : string, optional
+        Path in store to array.
+    **kwargs
+        Any keyword arguments to pass to the array constructor.
+
+    Returns
+    -------
+    AsyncArray
+        The opened array.
+    """
+
+    store_path = make_store_path(store)
+    if path is not None:
+        store_path = store_path / path
+
+    zarr_format = _handle_zarr_version_or_format(zarr_version=zarr_version, zarr_format=zarr_format)
+
+    try:
+        return await AsyncArray.open(store_path, zarr_format=zarr_format)
+    except KeyError as e:
+        if store_path.store.writeable:
+            pass
+        else:
+            raise e
+
+    # if array was not found, create it
+    return await create(store=store, path=path, zarr_format=zarr_format, **kwargs)
+
+
+async def open_like(a: ArrayLike, path: str, **kwargs: Any) -> AsyncArray:
+    """Open a persistent array like `a`.
+
+    Parameters
+    ----------
+    a : Array
+        The shape and data-type of a define these same attributes of the returned array.
+    path : str
+        The path to the new array.
+    **kwargs
+        Any keyword arguments to pass to the array constructor.
+
+    Returns
+    -------
+    AsyncArray
+        The opened array.
+    """
+    like_kwargs = _like_args(a, kwargs)
+    if isinstance(a, (AsyncArray | Array)):
+        kwargs.setdefault("fill_value", a.metadata.fill_value)
+    return await open_array(path=path, **like_kwargs)
+
+
+async def zeros(shape: ChunkCoords, **kwargs: Any) -> AsyncArray:
+    """Create an array, with zero being used as the default value for
+    uninitialized portions of the array.
+
+    Parameters
+    ----------
+    shape : int or tuple of int
+        Shape of the empty array.
+    **kwargs
+        Keyword arguments passed to :func:`zarr.api.asynchronous.create`.
+
+    Returns
+    -------
+    Array
+        The new array.
+    """
+    return await create(shape=shape, fill_value=0, **kwargs)
+
+
+async def zeros_like(a: ArrayLike, **kwargs: Any) -> AsyncArray:
+    """Create an array of zeros like `a`.
+
+    Parameters
+    ----------
+    a : array-like
+        The array to create an empty array like.
+    **kwargs
+        Keyword arguments passed to :func:`zarr.api.asynchronous.create`.
+
+    Returns
+    -------
+    Array
+        The new array.
+    """
+    like_kwargs = _like_args(a, kwargs)
+    return await zeros(**like_kwargs)
diff --git a/src/zarr/api/synchronous.py b/src/zarr/api/synchronous.py
new file mode 100644
index 00000000..57b9d563
--- /dev/null
+++ b/src/zarr/api/synchronous.py
@@ -0,0 +1,273 @@
+from __future__ import annotations
+
+from typing import Any
+
+import zarr.api.asynchronous as async_api
+from zarr.array import Array, AsyncArray
+from zarr.buffer import NDArrayLike
+from zarr.common import JSON, ChunkCoords, OpenMode, ZarrFormat
+from zarr.group import Group
+from zarr.store import StoreLike
+from zarr.sync import sync
+
+
+def consolidate_metadata(*args: Any, **kwargs: Any) -> Group:
+    return Group(sync(async_api.consolidate_metadata(*args, **kwargs)))
+
+
+def copy(*args: Any, **kwargs: Any) -> tuple[int, int, int]:
+    return sync(async_api.copy(*args, **kwargs))
+
+
+def copy_all(*args: Any, **kwargs: Any) -> tuple[int, int, int]:
+    return sync(async_api.copy_all(*args, **kwargs))
+
+
+def copy_store(*args: Any, **kwargs: Any) -> tuple[int, int, int]:
+    return sync(async_api.copy_store(*args, **kwargs))
+
+
+def load(
+    store: StoreLike, zarr_version: ZarrFormat | None = None, path: str | None = None
+) -> NDArrayLike | dict[str, NDArrayLike]:
+    return sync(async_api.load(store=store, zarr_version=zarr_version, path=path))
+
+
+def open(
+    *,
+    store: StoreLike | None = None,
+    mode: OpenMode | None = None,  # type and value changed
+    zarr_version: ZarrFormat | None = None,  # deprecated
+    zarr_format: ZarrFormat | None = None,
+    path: str | None = None,
+    **kwargs: Any,  # TODO: type kwargs as valid args to async_api.open
+) -> Array | Group:
+    obj = sync(
+        async_api.open(
+            store=store,
+            mode=mode,
+            zarr_version=zarr_version,
+            zarr_format=zarr_format,
+            path=path,
+            **kwargs,
+        )
+    )
+    if isinstance(obj, AsyncArray):
+        return Array(obj)
+    else:
+        return Group(obj)
+
+
+def open_consolidated(*args: Any, **kwargs: Any) -> Group:
+    return Group(sync(async_api.open_consolidated(*args, **kwargs)))
+
+
+def save(
+    store: StoreLike,
+    *args: NDArrayLike,
+    zarr_version: ZarrFormat | None = None,  # deprecated
+    zarr_format: ZarrFormat | None = None,
+    path: str | None = None,
+    **kwargs: Any,  # TODO: type kwargs as valid args to async_api.save
+) -> None:
+    return sync(
+        async_api.save(
+            store, *args, zarr_version=zarr_version, zarr_format=zarr_format, path=path, **kwargs
+        )
+    )
+
+
+def save_array(
+    store: StoreLike,
+    arr: NDArrayLike,
+    *,
+    zarr_version: ZarrFormat | None = None,  # deprecated
+    zarr_format: ZarrFormat | None = None,
+    path: str | None = None,
+    **kwargs: Any,  # TODO: type kwargs as valid args to async_api.save_array
+) -> None:
+    return sync(
+        async_api.save_array(
+            store=store,
+            arr=arr,
+            zarr_version=zarr_version,
+            zarr_format=zarr_format,
+            path=path,
+            **kwargs,
+        )
+    )
+
+
+def save_group(
+    store: StoreLike,
+    *args: NDArrayLike,
+    zarr_version: ZarrFormat | None = None,  # deprecated
+    zarr_format: ZarrFormat | None = None,
+    path: str | None = None,
+    **kwargs: NDArrayLike,
+) -> None:
+    return sync(
+        async_api.save_group(
+            store,
+            *args,
+            zarr_version=zarr_version,
+            zarr_format=zarr_format,
+            path=path,
+            **kwargs,
+        )
+    )
+
+
+def tree(*args: Any, **kwargs: Any) -> None:
+    return sync(async_api.tree(*args, **kwargs))
+
+
+# TODO: add type annotations for kwargs
+def array(data: NDArrayLike, **kwargs: Any) -> Array:
+    return Array(sync(async_api.array(data=data, **kwargs)))
+
+
+def group(
+    *,  # Note: this is a change from v2
+    store: StoreLike | None = None,
+    overwrite: bool = False,
+    chunk_store: StoreLike | None = None,  # not used in async_api
+    cache_attrs: bool | None = None,  # default changed, not used in async_api
+    synchronizer: Any | None = None,  # not used in async_api
+    path: str | None = None,
+    zarr_version: ZarrFormat | None = None,  # deprecated
+    zarr_format: ZarrFormat | None = None,
+    meta_array: Any | None = None,  # not used in async_api
+    attributes: dict[str, JSON] | None = None,
+) -> Group:
+    return Group(
+        sync(
+            async_api.group(
+                store=store,
+                overwrite=overwrite,
+                chunk_store=chunk_store,
+                cache_attrs=cache_attrs,
+                synchronizer=synchronizer,
+                path=path,
+                zarr_version=zarr_version,
+                zarr_format=zarr_format,
+                meta_array=meta_array,
+                attributes=attributes,
+            )
+        )
+    )
+
+
+def open_group(
+    *,  # Note: this is a change from v2
+    store: StoreLike | None = None,
+    mode: OpenMode | None = None,  # not used in async api
+    cache_attrs: bool | None = None,  # default changed, not used in async api
+    synchronizer: Any = None,  # not used in async api
+    path: str | None = None,
+    chunk_store: StoreLike | None = None,  # not used in async api
+    storage_options: dict[str, Any] | None = None,  # not used in async api
+    zarr_version: ZarrFormat | None = None,  # deprecated
+    zarr_format: ZarrFormat | None = None,
+    meta_array: Any | None = None,  # not used in async api
+) -> Group:
+    return Group(
+        sync(
+            async_api.open_group(
+                store=store,
+                mode=mode,
+                cache_attrs=cache_attrs,
+                synchronizer=synchronizer,
+                path=path,
+                chunk_store=chunk_store,
+                storage_options=storage_options,
+                zarr_version=zarr_version,
+                zarr_format=zarr_format,
+                meta_array=meta_array,
+            )
+        )
+    )
+
+
+# TODO: add type annotations for kwargs
+def create(*args: Any, **kwargs: Any) -> Array:
+    return Array(sync(async_api.create(*args, **kwargs)))
+
+
+# TODO: add type annotations for kwargs
+def empty(shape: ChunkCoords, **kwargs: Any) -> Array:
+    return Array(sync(async_api.empty(shape, **kwargs)))
+
+
+# TODO: move ArrayLike to common module
+# TODO: add type annotations for kwargs
+def empty_like(a: async_api.ArrayLike, **kwargs: Any) -> Array:
+    return Array(sync(async_api.empty_like(a, **kwargs)))
+
+
+# TODO: add type annotations for kwargs and fill_value
+def full(shape: ChunkCoords, fill_value: Any, **kwargs: Any) -> Array:
+    return Array(sync(async_api.full(shape=shape, fill_value=fill_value, **kwargs)))
+
+
+# TODO: move ArrayLike to common module
+# TODO: add type annotations for kwargs
+def full_like(a: async_api.ArrayLike, **kwargs: Any) -> Array:
+    return Array(sync(async_api.full_like(a, **kwargs)))
+
+
+# TODO: add type annotations for kwargs
+def ones(shape: ChunkCoords, **kwargs: Any) -> Array:
+    return Array(sync(async_api.ones(shape, **kwargs)))
+
+
+# TODO: add type annotations for kwargs
+def ones_like(a: async_api.ArrayLike, **kwargs: Any) -> Array:
+    return Array(sync(async_api.ones_like(a, **kwargs)))
+
+
+# TODO: update this once async_api.open_array is fully implemented
+def open_array(*args: Any, **kwargs: Any) -> Array:
+    return Array(sync(async_api.open_array(*args, **kwargs)))
+
+
+# TODO: add type annotations for kwargs
+def open_like(a: async_api.ArrayLike, **kwargs: Any) -> Array:
+    return Array(sync(async_api.open_like(a, **kwargs)))
+
+
+# TODO: add type annotations for kwargs
+def zeros(*args: Any, **kwargs: Any) -> Array:
+    return Array(sync(async_api.zeros(*args, **kwargs)))
+
+
+# TODO: add type annotations for kwargs
+def zeros_like(a: async_api.ArrayLike, **kwargs: Any) -> Array:
+    return Array(sync(async_api.zeros_like(a, **kwargs)))
+
+
+consolidate_metadata.__doc__ = async_api.copy.__doc__
+copy.__doc__ = async_api.copy.__doc__
+copy_all.__doc__ = async_api.copy_all.__doc__
+copy_store.__doc__ = async_api.copy_store.__doc__
+load.__doc__ = async_api.load.__doc__
+open.__doc__ = async_api.open.__doc__
+open_consolidated.__doc__ = async_api.open_consolidated.__doc__
+save.__doc__ = async_api.save.__doc__
+save_array.__doc__ = async_api.save_array.__doc__
+save_group.__doc__ = async_api.save_group.__doc__
+tree.__doc__ = async_api.tree.__doc__
+array.__doc__ = async_api.array.__doc__
+group.__doc__ = async_api.group.__doc__
+open_group.__doc__ = async_api.open_group.__doc__
+create.__doc__ = async_api.create.__doc__
+empty.__doc__ = async_api.empty.__doc__
+empty_like.__doc__ = async_api.empty_like.__doc__
+full.__doc__ = async_api.full.__doc__
+full_like.__doc__ = async_api.full_like.__doc__
+ones.__doc__ = async_api.ones.__doc__
+ones_like.__doc__ = async_api.ones_like.__doc__
+open_array.__doc__ = async_api.open_array.__doc__
+open_like.__doc__ = async_api.open_like.__doc__
+zeros.__doc__ = async_api.zeros.__doc__
+zeros_like.__doc__ = async_api.zeros_like.__doc__
diff --git a/src/zarr/array.py b/src/zarr/array.py
index 28b19f44..9ac1ce41 100644
--- a/src/zarr/array.py
+++ b/src/zarr/array.py
@@ -387,6 +387,10 @@ class AsyncArray:
     def attrs(self) -> dict[str, JSON]:
         return self.metadata.attributes
 
+    @property
+    def read_only(self) -> bool:
+        return bool(~self.store_path.store.writeable)
+
     @property
     def path(self) -> str:
         """Storage path."""
@@ -693,6 +697,10 @@ class Array:
     def order(self) -> Literal["C", "F"]:
         return self._async_array.order
 
+    @property
+    def read_only(self) -> bool:
+        return self._async_array.read_only
+
     def __getitem__(self, selection: Selection) -> NDArrayLike:
         fields, pure_selection = pop_fields(selection)
         if is_pure_fancy_indexing(pure_selection, self.ndim):
diff --git a/src/zarr/common.py b/src/zarr/common.py
index bca9f171..9349f9f0 100644
--- a/src/zarr/common.py
+++ b/src/zarr/common.py
@@ -34,6 +34,7 @@ SliceSelection = tuple[slice, ...]
 Selection = slice | SliceSelection
 ZarrFormat = Literal[2, 3]
 JSON = None | str | int | float | Enum | dict[str, "JSON"] | list["JSON"] | tuple["JSON", ...]
+MemoryOrder = Literal["C", "F"]
 OpenMode = Literal["r", "r+", "a", "w", "w-"]
 
 
@@ -134,7 +135,9 @@ def parse_named_configuration(
     return name_parsed, configuration_parsed
 
 
-def parse_shapelike(data: Iterable[int]) -> tuple[int, ...]:
+def parse_shapelike(data: int | Iterable[int]) -> tuple[int, ...]:
+    if isinstance(data, int):
+        return (data,)
     if not isinstance(data, Iterable):
         raise TypeError(f"Expected an iterable. Got {data} instead.")
     data_tuple = tuple(data)
diff --git a/src/zarr/config.py b/src/zarr/config.py
index 7c5b48a1..e711a98c 100644
--- a/src/zarr/config.py
+++ b/src/zarr/config.py
@@ -11,6 +11,7 @@ config = Config(
             "array": {"order": "C"},
             "async": {"concurrency": None, "timeout": None},
             "codec_pipeline": {"batch_size": 1},
+            "json_indent": 2,
         }
     ],
 )
diff --git a/src/zarr/convenience.py b/src/zarr/convenience.py
new file mode 100644
index 00000000..be0a6b28
--- /dev/null
+++ b/src/zarr/convenience.py
@@ -0,0 +1,35 @@
+import warnings
+
+from zarr.api.synchronous import (
+    consolidate_metadata,
+    copy,
+    copy_all,
+    copy_store,
+    load,
+    open,
+    open_consolidated,
+    save,
+    save_array,
+    save_group,
+    tree,
+)
+
+warnings.warn(
+    "zarr.convenience is deprecated, use zarr.api.synchronous",
+    DeprecationWarning,
+    stacklevel=2,
+)
+
+__all__ = [
+    "open",
+    "save_array",
+    "save_group",
+    "save",
+    "load",
+    "tree",
+    "copy_store",
+    "copy",
+    "copy_all",
+    "consolidate_metadata",
+    "open_consolidated",
+]
diff --git a/src/zarr/creation.py b/src/zarr/creation.py
new file mode 100644
index 00000000..df3f7646
--- /dev/null
+++ b/src/zarr/creation.py
@@ -0,0 +1,37 @@
+import warnings
+
+from zarr.api.synchronous import (
+    array,
+    create,
+    empty,
+    empty_like,
+    full,
+    full_like,
+    ones,
+    ones_like,
+    open_array,
+    open_like,
+    zeros,
+    zeros_like,
+)
+
+warnings.warn(
+    "zarr.creation is deprecated, use zarr.api.synchronous",
+    DeprecationWarning,
+    stacklevel=2,
+)
+
+__all__ = [
+    "create",
+    "empty",
+    "zeros",
+    "ones",
+    "full",
+    "array",
+    "open_array",
+    "empty_like",
+    "zeros_like",
+    "ones_like",
+    "full_like",
+    "open_like",
+]
diff --git a/src/zarr/group.py b/src/zarr/group.py
index ccad0e56..e6e2ac18 100644
--- a/src/zarr/group.py
+++ b/src/zarr/group.py
@@ -25,6 +25,7 @@ from zarr.common import (
     ChunkCoords,
     ZarrFormat,
 )
+from zarr.config import config
 from zarr.store import StoreLike, StorePath, make_store_path
 from zarr.sync import SyncMixin, sync
 
@@ -79,14 +80,21 @@ class GroupMetadata(Metadata):
     node_type: Literal["group"] = field(default="group", init=False)
 
     def to_buffer_dict(self) -> dict[str, Buffer]:
+        json_indent = config.get("json_indent")
         if self.zarr_format == 3:
-            return {ZARR_JSON: Buffer.from_bytes(json.dumps(self.to_dict()).encode())}
+            return {
+                ZARR_JSON: Buffer.from_bytes(
+                    json.dumps(self.to_dict(), indent=json_indent).encode()
+                )
+            }
         else:
             return {
                 ZGROUP_JSON: Buffer.from_bytes(
-                    json.dumps({"zarr_format": self.zarr_format}).encode()
+                    json.dumps({"zarr_format": self.zarr_format}, indent=json_indent).encode()
+                ),
+                ZATTRS_JSON: Buffer.from_bytes(
+                    json.dumps(self.attributes, indent=json_indent).encode()
                 ),
-                ZATTRS_JSON: Buffer.from_bytes(json.dumps(self.attributes).encode()),
             }
 
     def __init__(self, attributes: dict[str, Any] | None = None, zarr_format: ZarrFormat = 3):
@@ -200,7 +208,7 @@ class AsyncGroup:
         key: str,
     ) -> AsyncArray | AsyncGroup:
         store_path = self.store_path / key
-        logger.warning("key=%s, store_path=%s", key, store_path)
+        logger.debug("key=%s, store_path=%s", key, store_path)
 
         # Note:
         # in zarr-python v2, we first check if `key` references an Array, else if `key` references
@@ -316,7 +324,7 @@ class AsyncGroup:
         self,
         path: str,
         shape: ChunkCoords,
-        dtype: npt.DTypeLike,
+        dtype: npt.DTypeLike = "float64",
         fill_value: Any | None = None,
         attributes: dict[str, JSON] | None = None,
         # v3 only
diff --git a/src/zarr/metadata.py b/src/zarr/metadata.py
index bcb70bd4..c6a71c00 100644
--- a/src/zarr/metadata.py
+++ b/src/zarr/metadata.py
@@ -16,6 +16,7 @@ from zarr.buffer import Buffer, BufferPrototype, default_buffer_prototype
 from zarr.chunk_grids import ChunkGrid, RegularChunkGrid
 from zarr.chunk_key_encodings import ChunkKeyEncoding, parse_separator
 from zarr.codecs._v2 import V2Compressor, V2Filters
+from zarr.config import config
 
 if TYPE_CHECKING:
     from typing_extensions import Self
@@ -29,6 +30,7 @@ from zarr.common import (
     ZARRAY_JSON,
     ZATTRS_JSON,
     ChunkCoords,
+    ZarrFormat,
     parse_dtype,
     parse_fill_value,
     parse_shapelike,
@@ -115,9 +117,10 @@ class DataType(Enum):
 @dataclass(frozen=True, kw_only=True)
 class ArrayMetadata(Metadata, ABC):
     shape: ChunkCoords
-    chunk_grid: ChunkGrid
     fill_value: Any
+    chunk_grid: ChunkGrid
     attributes: dict[str, JSON]
+    zarr_format: ZarrFormat
 
     @property
     @abstractmethod
@@ -270,8 +273,11 @@ class ArrayV3Metadata(ArrayMetadata):
                 return config
             raise TypeError
 
+        json_indent = config.get("json_indent")
         return {
-            ZARR_JSON: Buffer.from_bytes(json.dumps(self.to_dict(), default=_json_convert).encode())
+            ZARR_JSON: Buffer.from_bytes(
+                json.dumps(self.to_dict(), default=_json_convert, indent=json_indent).encode()
+            )
         }
 
     @classmethod
@@ -392,9 +398,12 @@ class ArrayV2Metadata(ArrayMetadata):
         assert isinstance(zarray_dict, dict)
         zattrs_dict = zarray_dict.pop("attributes", {})
         assert isinstance(zattrs_dict, dict)
+        json_indent = config.get("json_indent")
         return {
-            ZARRAY_JSON: Buffer.from_bytes(json.dumps(zarray_dict, default=_json_convert).encode()),
-            ZATTRS_JSON: Buffer.from_bytes(json.dumps(zattrs_dict).encode()),
+            ZARRAY_JSON: Buffer.from_bytes(
+                json.dumps(zarray_dict, default=_json_convert, indent=json_indent).encode()
+            ),
+            ZATTRS_JSON: Buffer.from_bytes(json.dumps(zattrs_dict, indent=json_indent).encode()),
         }
 
     @classmethod
diff --git a/src/zarr/store/__init__.py b/src/zarr/store/__init__.py
index b7cd6cc0..fbdcdb92 100644
--- a/src/zarr/store/__init__.py
+++ b/src/zarr/store/__init__.py
@@ -1,7 +1,6 @@
-# flake8: noqa
-from zarr.store.core import StorePath, StoreLike, make_store_path
-from zarr.store.remote import RemoteStore
+from zarr.store.core import StoreLike, StorePath, make_store_path
 from zarr.store.local import LocalStore
 from zarr.store.memory import MemoryStore
+from zarr.store.remote import RemoteStore
 
 __all__ = ["StorePath", "StoreLike", "make_store_path", "RemoteStore", "LocalStore", "MemoryStore"]
diff --git a/src/zarr/store/core.py b/src/zarr/store/core.py
index 70c39db1..512c8383 100644
--- a/src/zarr/store/core.py
+++ b/src/zarr/store/core.py
@@ -7,13 +7,14 @@ from zarr.abc.store import Store
 from zarr.buffer import Buffer, BufferPrototype, default_buffer_prototype
 from zarr.common import OpenMode
 from zarr.store.local import LocalStore
+from zarr.store.memory import MemoryStore
 
 
 def _dereference_path(root: str, path: str) -> str:
     assert isinstance(root, str)
     assert isinstance(path, str)
     root = root.rstrip("/")
-    path = f"{root}/{path}" if root != "" else path
+    path = f"{root}/{path}" if root else path
     path = path.rstrip("/")
     return path
 
@@ -65,7 +66,7 @@ class StorePath:
 StoreLike = Store | StorePath | Path | str
 
 
-def make_store_path(store_like: StoreLike, *, mode: OpenMode | None = None) -> StorePath:
+def make_store_path(store_like: StoreLike | None, *, mode: OpenMode | None = None) -> StorePath:
     if isinstance(store_like, StorePath):
         if mode is not None:
             assert mode == store_like.store.mode
@@ -74,31 +75,10 @@ def make_store_path(store_like: StoreLike, *, mode: OpenMode | None = None) -> S
         if mode is not None:
             assert mode == store_like.mode
         return StorePath(store_like)
+    elif store_like is None:
+        if mode is None:
+            mode = "w"  # exception to the default mode = 'r'
+        return StorePath(MemoryStore(mode=mode))
     elif isinstance(store_like, str):
-        assert mode is not None
-        return StorePath(LocalStore(Path(store_like), mode=mode))
+        return StorePath(LocalStore(Path(store_like), mode=mode or "r"))
     raise TypeError
-
-
-def _normalize_interval_index(
-    data: Buffer, interval: None | tuple[int | None, int | None]
-) -> tuple[int, int]:
-    """
-    Convert an implicit interval into an explicit start and length
-    """
-    if interval is None:
-        start = 0
-        length = len(data)
-    else:
-        maybe_start, maybe_len = interval
-        if maybe_start is None:
-            start = 0
-        else:
-            start = maybe_start
-
-        if maybe_len is None:
-            length = len(data) - start
-        else:
-            length = maybe_len
-
-    return (start, length)
diff --git a/src/zarr/store/memory.py b/src/zarr/store/memory.py
index d75e8c34..43d65ce8 100644
--- a/src/zarr/store/memory.py
+++ b/src/zarr/store/memory.py
@@ -5,7 +5,7 @@ from collections.abc import AsyncGenerator, MutableMapping
 from zarr.abc.store import Store
 from zarr.buffer import Buffer, BufferPrototype
 from zarr.common import OpenMode, concurrent_map
-from zarr.store.core import _normalize_interval_index
+from zarr.store.utils import _normalize_interval_index
 
 
 # TODO: this store could easily be extended to wrap any MutableMapping store from v2
diff --git a/src/zarr/store/remote.py b/src/zarr/store/remote.py
index 3eb057f9..db826f45 100644
--- a/src/zarr/store/remote.py
+++ b/src/zarr/store/remote.py
@@ -1,9 +1,12 @@
 from __future__ import annotations
 
+from collections.abc import AsyncGenerator
 from typing import TYPE_CHECKING, Any
 
+import fsspec
+
 from zarr.abc.store import Store
-from zarr.buffer import Buffer, BufferPrototype
+from zarr.buffer import Buffer, BufferPrototype, default_buffer_prototype
 from zarr.common import OpenMode
 from zarr.store.core import _dereference_path
 
@@ -11,93 +14,177 @@ if TYPE_CHECKING:
     from fsspec.asyn import AsyncFileSystem
     from upath import UPath
 
+    from zarr.buffer import Buffer
+    from zarr.common import BytesLike
+
 
 class RemoteStore(Store):
+    # based on FSSpec
     supports_writes: bool = True
     supports_partial_writes: bool = False
     supports_listing: bool = True
 
-    root: UPath
+    _fs: AsyncFileSystem
+    path: str
+    allowed_exceptions: tuple[type[Exception], ...]
 
     def __init__(
-        self, url: UPath | str, *, mode: OpenMode = "r", **storage_options: dict[str, Any]
+        self,
+        url: UPath | str,
+        mode: OpenMode = "r",
+        allowed_exceptions: tuple[type[Exception], ...] = (
+            FileNotFoundError,
+            IsADirectoryError,
+            NotADirectoryError,
+        ),
+        **storage_options: Any,
     ):
-        import fsspec
-        from upath import UPath
+        """
+        Parameters
+        ----------
+        url: root of the datastore. In fsspec notation, this is usually like "protocol://path/to".
+            Can also be a upath.UPath instance/
+        allowed_exceptions: when fetching data, these cases will be deemed to correspond to missing
+            keys, rather than some other IO failure
+        storage_options: passed on to fsspec to make the filesystem instance. If url is a UPath,
+            this must not be used.
+        """
 
         super().__init__(mode=mode)
 
         if isinstance(url, str):
-            self.root = UPath(url, **storage_options)
+            self._fs, self.path = fsspec.url_to_fs(url, **storage_options)
+        elif hasattr(url, "protocol") and hasattr(url, "fs"):
+            # is UPath-like - but without importing
+            if storage_options:
+                raise ValueError(
+                    "If constructed with a UPath object, no additional "
+                    "storage_options are allowed"
+                )
+            self.path = url.path
+            self._fs = url._fs
         else:
-            assert (
-                len(storage_options) == 0
-            ), "If constructed with a UPath object, no additional storage_options are allowed."
-            self.root = url.rstrip("/")
-
+            raise ValueError("URL not understood, %s", url)
+        self.allowed_exceptions = allowed_exceptions
         # test instantiate file system
-        fs, _ = fsspec.core.url_to_fs(str(self.root), asynchronous=True, **self.root._kwargs)
-        assert fs.__class__.async_impl, "FileSystem needs to support async operations."
+        if not self._fs.async_impl:
+            raise TypeError("FileSystem needs to support async operations")
 
     def __str__(self) -> str:
-        return str(self.root)
+        return f"Remote fsspec store: {type(self._fs).__name__} , {self.path}"
 
     def __repr__(self) -> str:
-        return f"RemoteStore({str(self)!r})"
-
-    def _make_fs(self) -> tuple[AsyncFileSystem, str]:
-        import fsspec
-
-        storage_options = self.root._kwargs.copy()
-        storage_options.pop("_url", None)
-        fs, root = fsspec.core.url_to_fs(str(self.root), asynchronous=True, **self.root._kwargs)
-        assert fs.__class__.async_impl, "FileSystem needs to support async operations."
-        return fs, root
+        return f"<RemoteStore({type(self._fs).__name__} , {self.path})>"
 
     async def get(
         self,
         key: str,
-        prototype: BufferPrototype,
+        prototype: BufferPrototype = default_buffer_prototype,
         byte_range: tuple[int | None, int | None] | None = None,
     ) -> Buffer | None:
-        assert isinstance(key, str)
-        fs, root = self._make_fs()
-        path = _dereference_path(root, key)
+        path = _dereference_path(self.path, key)
 
         try:
-            value: Buffer | None = await (
-                fs._cat_file(path, start=byte_range[0], end=byte_range[1])
-                if byte_range
-                else fs._cat_file(path)
+            if byte_range:
+                # fsspec uses start/end, not start/length
+                start, length = byte_range
+                if start is not None and length is not None:
+                    end = start + length
+                elif length is not None:
+                    end = length
+                else:
+                    end = None
+            value: Buffer = prototype.buffer.from_bytes(
+                await (
+                    self._fs._cat_file(path, start=byte_range[0], end=end)
+                    if byte_range
+                    else self._fs._cat_file(path)
+                )
             )
-        except (FileNotFoundError, IsADirectoryError, NotADirectoryError):
-            return None
+            return value
 
-        return value
+        except self.allowed_exceptions:
+            return None
+        except OSError as e:
+            if "not satisfiable" in str(e):
+                # this is an s3-specific condition we probably don't want to leak
+                return prototype.buffer.from_bytes(b"")
+            raise
 
-    async def set(self, key: str, value: Buffer, byte_range: tuple[int, int] | None = None) -> None:
+    async def set(
+        self,
+        key: str,
+        value: Buffer,
+        byte_range: tuple[int, int] | None = None,
+    ) -> None:
         self._check_writable()
-        assert isinstance(key, str)
-        fs, root = self._make_fs()
-        path = _dereference_path(root, key)
-
+        path = _dereference_path(self.path, key)
         # write data
         if byte_range:
-            with fs._open(path, "r+b") as f:
-                f.seek(byte_range[0])
-                f.write(value)
-        else:
-            await fs._pipe_file(path, value)
+            raise NotImplementedError
+        await self._fs._pipe_file(path, value.to_bytes())
 
     async def delete(self, key: str) -> None:
         self._check_writable()
-        fs, root = self._make_fs()
-        path = _dereference_path(root, key)
-        if await fs._exists(path):
-            await fs._rm(path)
+        path = _dereference_path(self.path, key)
+        try:
+            await self._fs._rm(path)
+        except FileNotFoundError:
+            pass
+        except self.allowed_exceptions:
+            pass
 
     async def exists(self, key: str) -> bool:
-        fs, root = self._make_fs()
-        path = _dereference_path(root, key)
-        exists: bool = await fs._exists(path)
+        path = _dereference_path(self.path, key)
+        exists: bool = await self._fs._exists(path)
         return exists
+
+    async def get_partial_values(
+        self,
+        prototype: BufferPrototype,
+        key_ranges: list[tuple[str, tuple[int | None, int | None]]],
+    ) -> list[Buffer | None]:
+        if key_ranges:
+            paths, starts, stops = zip(
+                *(
+                    (
+                        _dereference_path(self.path, k[0]),
+                        k[1][0],
+                        ((k[1][0] or 0) + k[1][1]) if k[1][1] is not None else None,
+                    )
+                    for k in key_ranges
+                ),
+                strict=False,
+            )
+        else:
+            return []
+        # TODO: expectations for exceptions or missing keys?
+        res = await self._fs._cat_ranges(list(paths), starts, stops, on_error="return")
+        # the following is an s3-specific condition we probably don't want to leak
+        res = [b"" if (isinstance(r, OSError) and "not satisfiable" in str(r)) else r for r in res]
+        for r in res:
+            if isinstance(r, Exception) and not isinstance(r, self.allowed_exceptions):
+                raise r
+
+        return [None if isinstance(r, Exception) else prototype.buffer.from_bytes(r) for r in res]
+
+    async def set_partial_values(self, key_start_values: list[tuple[str, int, BytesLike]]) -> None:
+        raise NotImplementedError
+
+    async def list(self) -> AsyncGenerator[str, None]:
+        allfiles = await self._fs._find(self.path, detail=False, withdirs=False)
+        for onefile in (a.replace(self.path + "/", "") for a in allfiles):
+            yield onefile
+
+    async def list_dir(self, prefix: str) -> AsyncGenerator[str, None]:
+        prefix = f"{self.path}/{prefix.rstrip('/')}"
+        try:
+            allfiles = await self._fs._ls(prefix, detail=False)
+        except FileNotFoundError:
+            return
+        for onefile in (a.replace(prefix + "/", "") for a in allfiles):
+            yield onefile
+
+    async def list_prefix(self, prefix: str) -> AsyncGenerator[str, None]:
+        for onefile in await self._fs._ls(prefix, detail=False):
+            yield onefile
diff --git a/src/zarr/store/utils.py b/src/zarr/store/utils.py
new file mode 100644
index 00000000..17c92342
--- /dev/null
+++ b/src/zarr/store/utils.py
@@ -0,0 +1,25 @@
+from zarr.buffer import Buffer
+
+
+def _normalize_interval_index(
+    data: Buffer, interval: None | tuple[int | None, int | None]
+) -> tuple[int, int]:
+    """
+    Convert an implicit interval into an explicit start and length
+    """
+    if interval is None:
+        start = 0
+        length = len(data)
+    else:
+        maybe_start, maybe_len = interval
+        if maybe_start is None:
+            start = 0
+        else:
+            start = maybe_start
+
+        if maybe_len is None:
+            length = len(data) - start
+        else:
+            length = maybe_len
+
+    return (start, length)


Test patch:
diff --git a/src/zarr/testing/store.py b/src/zarr/testing/store.py
index 5929f470..9c37ce04 100644
--- a/src/zarr/testing/store.py
+++ b/src/zarr/testing/store.py
@@ -4,7 +4,7 @@ import pytest
 
 from zarr.abc.store import Store
 from zarr.buffer import Buffer, default_buffer_prototype
-from zarr.store.core import _normalize_interval_index
+from zarr.store.utils import _normalize_interval_index
 from zarr.testing.utils import assert_bytes_equal
 
 S = TypeVar("S", bound=Store)
@@ -171,12 +171,14 @@ class StoreTests(Generic[S]):
                 f"foo/c/{i}", Buffer.from_bytes(i.to_bytes(length=3, byteorder="little"))
             )
 
+    @pytest.mark.xfail
     async def test_list_prefix(self, store: S) -> None:
         # TODO: we currently don't use list_prefix anywhere
         raise NotImplementedError
 
     async def test_list_dir(self, store: S) -> None:
-        assert [k async for k in store.list_dir("")] == []
+        out = [k async for k in store.list_dir("")]
+        assert out == []
         assert [k async for k in store.list_dir("foo")] == []
         await store.set("foo/zarr.json", Buffer.from_bytes(b"bar"))
         await store.set("foo/c/1", Buffer.from_bytes(b"\x01"))
diff --git a/tests/v2/test_storage.py b/tests/v2/test_storage.py
index 17b80e6a..88e99e91 100644
--- a/tests/v2/test_storage.py
+++ b/tests/v2/test_storage.py
@@ -399,7 +399,9 @@ class StoreTests:
             assert [] == store.listdir(self.root + "c/x/y")
             assert [] == store.listdir(self.root + "c/d/y")
             assert [] == store.listdir(self.root + "c/d/y/z")
-            assert [] == store.listdir(self.root + "c/e/f")
+            # the following is listdir(filepath), for which fsspec gives [filepath]
+            # as posix would, but an empty list was previously assumed
+            # assert [] == store.listdir(self.root + "c/e/f")
 
         # test rename (optional)
         if store.is_erasable():
@@ -1064,9 +1066,8 @@ class TestFSStore(StoreTests):
         store[self.root + "foo"] = b"hello"
         assert "foo" in os.listdir(str(path1) + "/" + self.root)
         assert self.root + "foo" in store
-        assert not os.listdir(str(path2))
-        assert store[self.root + "foo"] == b"hello"
         assert "foo" in os.listdir(str(path2))
+        assert store[self.root + "foo"] == b"hello"
 
     def test_deep_ndim(self):
         import zarr.v2
@@ -1285,6 +1286,8 @@ class TestFSStoreFromFilesystem(StoreTests):
 @pytest.fixture()
 def s3(request):
     # writable local S3 system
+    pytest.skip("old v3 tests are disabled", allow_module_level=True)
+
     import shlex
     import subprocess
     import time
@@ -1299,7 +1302,7 @@ def s3(request):
     s3fs = pytest.importorskip("s3fs")
     pytest.importorskip("moto")
 
-    port = 5555
+    port = 5556
     endpoint_uri = "http://127.0.0.1:%d/" % port
     proc = subprocess.Popen(
         shlex.split("moto_server s3 -p %d" % port),
@@ -1318,6 +1321,7 @@ def s3(request):
         timeout -= 0.1  # pragma: no cover
         time.sleep(0.1)  # pragma: no cover
     s3so = dict(client_kwargs={"endpoint_url": endpoint_uri}, use_listings_cache=False)
+    s3fs.S3FileSystem.clear_instance_cache()
     s3 = s3fs.S3FileSystem(anon=False, **s3so)
     s3.mkdir("test")
     request.cls.s3so = s3so
diff --git a/tests/v3/test_api.py b/tests/v3/test_api.py
new file mode 100644
index 00000000..31e6fbfc
--- /dev/null
+++ b/tests/v3/test_api.py
@@ -0,0 +1,778 @@
+import numpy as np
+import pytest
+from numpy.testing import assert_array_equal
+
+import zarr
+from zarr import Array, Group
+from zarr.abc.store import Store
+from zarr.api.synchronous import load, open, open_group, save, save_array, save_group
+
+
+def test_open_array(memory_store: Store) -> None:
+    store = memory_store
+
+    # open array, create if doesn't exist
+    z = open(store=store, shape=100)
+    assert isinstance(z, Array)
+    assert z.shape == (100,)
+
+    # open array, overwrite
+    store._store_dict = {}
+    z = open(store=store, shape=200, mode="w")  # mode="w"
+    assert isinstance(z, Array)
+    assert z.shape == (200,)
+
+    # open array, read-only
+    ro_store = type(store)(store_dict=store._store_dict, mode="r")
+    z = open(store=ro_store)
+    assert isinstance(z, Array)
+    assert z.shape == (200,)
+    assert z.read_only
+
+    # path not found
+    with pytest.raises(ValueError):
+        open(store="doesnotexist", mode="r")
+
+
+def test_open_group(memory_store: Store) -> None:
+    store = memory_store
+
+    # open group, create if doesn't exist
+    g = open_group(store=store)
+    g.create_group("foo")
+    assert isinstance(g, Group)
+    assert "foo" in g
+
+    # open group, overwrite
+    # g = open_group(store=store)
+    # assert isinstance(g, Group)
+    # assert "foo" not in g
+
+    # open group, read-only
+    ro_store = type(store)(store_dict=store._store_dict, mode="r")
+    g = open_group(store=ro_store)
+    assert isinstance(g, Group)
+    # assert g.read_only
+
+
+def test_save_errors() -> None:
+    with pytest.raises(ValueError):
+        # no arrays provided
+        save_group("data/group.zarr")
+    with pytest.raises(TypeError):
+        # no array provided
+        save_array("data/group.zarr")
+    with pytest.raises(ValueError):
+        # no arrays provided
+        save("data/group.zarr")
+
+
+# def test_lazy_loader():
+#     foo = np.arange(100)
+#     bar = np.arange(100, 0, -1)
+#     store = "data/group.zarr"
+#     save(store, foo=foo, bar=bar)
+#     loader = load(store)
+#     assert "foo" in loader
+#     assert "bar" in loader
+#     assert "baz" not in loader
+#     assert len(loader) == 2
+#     assert sorted(loader) == ["bar", "foo"]
+#     assert_array_equal(foo, loader["foo"])
+#     assert_array_equal(bar, loader["bar"])
+#     assert "LazyLoader: " in repr(loader)
+
+
+def test_load_array(memory_store: Store) -> None:
+    store = memory_store
+    foo = np.arange(100)
+    bar = np.arange(100, 0, -1)
+    save(store, foo=foo, bar=bar)
+
+    # can also load arrays directly into a numpy array
+    for array_name in ["foo", "bar"]:
+        array = load(store, path=array_name)
+        assert isinstance(array, np.ndarray)
+        if array_name == "foo":
+            assert_array_equal(foo, array)
+        else:
+            assert_array_equal(bar, array)
+
+
+def test_tree() -> None:
+    g1 = zarr.group()
+    g1.create_group("foo")
+    g3 = g1.create_group("bar")
+    g3.create_group("baz")
+    g5 = g3.create_group("qux")
+    g5.create_array("baz", shape=100, chunks=10)
+    # TODO: complete after tree has been reimplemented
+    # assert repr(zarr.tree(g1)) == repr(g1.tree())
+    # assert str(zarr.tree(g1)) == str(g1.tree())
+
+
+# @pytest.mark.parametrize("stores_from_path", [False, True])
+# @pytest.mark.parametrize(
+#     "with_chunk_store,listable",
+#     [(False, True), (True, True), (False, False)],
+#     ids=["default-listable", "with_chunk_store-listable", "default-unlistable"],
+# )
+# def test_consolidate_metadata(with_chunk_store, listable, monkeypatch, stores_from_path):
+#     # setup initial data
+#     if stores_from_path:
+#         store = tempfile.mkdtemp()
+#         atexit.register(atexit_rmtree, store)
+#         if with_chunk_store:
+#             chunk_store = tempfile.mkdtemp()
+#             atexit.register(atexit_rmtree, chunk_store)
+#         else:
+#             chunk_store = None
+#     else:
+#         store = MemoryStore()
+#         chunk_store = MemoryStore() if with_chunk_store else None
+#     path = None
+#     z = group(store, chunk_store=chunk_store, path=path)
+
+#     # Reload the actual store implementation in case str
+#     store_to_copy = z.store
+
+#     z.create_group("g1")
+#     g2 = z.create_group("g2")
+#     g2.attrs["hello"] = "world"
+#     arr = g2.create_array("arr", shape=(20, 20), chunks=(5, 5), dtype="f8")
+#     assert 16 == arr.nchunks
+#     assert 0 == arr.nchunks_initialized
+#     arr.attrs["data"] = 1
+#     arr[:] = 1.0
+#     assert 16 == arr.nchunks_initialized
+
+#     if stores_from_path:
+#         # get the actual store class for use with consolidate_metadata
+#         store_class = z._store
+#     else:
+#         store_class = store
+
+#     # perform consolidation
+#     out = consolidate_metadata(store_class, path=path)
+#     assert isinstance(out, Group)
+#     assert ["g1", "g2"] == list(out)
+#     if not stores_from_path:
+#         assert isinstance(out._store, ConsolidatedMetadataStore)
+#         assert ".zmetadata" in store
+#         meta_keys = [
+#             ".zgroup",
+#             "g1/.zgroup",
+#             "g2/.zgroup",
+#             "g2/.zattrs",
+#             "g2/arr/.zarray",
+#             "g2/arr/.zattrs",
+#         ]
+
+#         for key in meta_keys:
+#             del store[key]
+
+#     # https://github.com/zarr-developers/zarr-python/issues/993
+#     # Make sure we can still open consolidated on an unlistable store:
+#     if not listable:
+#         fs_memory = pytest.importorskip("fsspec.implementations.memory")
+#         monkeypatch.setattr(fs_memory.MemoryFileSystem, "isdir", lambda x, y: False)
+#         monkeypatch.delattr(fs_memory.MemoryFileSystem, "ls")
+#         fs = fs_memory.MemoryFileSystem()
+#         store_to_open = FSStore("", fs=fs)
+#         # copy original store to new unlistable store
+#         store_to_open.update(store_to_copy)
+
+#     else:
+#         store_to_open = store
+
+#     # open consolidated
+#     z2 = open_consolidated(store_to_open, chunk_store=chunk_store, path=path)
+#     assert ["g1", "g2"] == list(z2)
+#     assert "world" == z2.g2.attrs["hello"]
+#     assert 1 == z2.g2.arr.attrs["data"]
+#     assert (z2.g2.arr[:] == 1.0).all()
+#     assert 16 == z2.g2.arr.nchunks
+#     if listable:
+#         assert 16 == z2.g2.arr.nchunks_initialized
+#     else:
+#         with pytest.raises(NotImplementedError):
+#             _ = z2.g2.arr.nchunks_initialized
+
+#     if stores_from_path:
+#         # path string is note a BaseStore subclass so cannot be used to
+#         # initialize a ConsolidatedMetadataStore.
+
+#         with pytest.raises(ValueError):
+#             cmd = ConsolidatedMetadataStore(store)
+#     else:
+#         # tests del/write on the store
+
+#         cmd = ConsolidatedMetadataStore(store)
+#         with pytest.raises(PermissionError):
+#             del cmd[".zgroup"]
+#         with pytest.raises(PermissionError):
+#             cmd[".zgroup"] = None
+
+#         # test getsize on the store
+#         assert isinstance(getsize(cmd), Integral)
+
+#     # test new metadata are not writeable
+#     with pytest.raises(PermissionError):
+#         z2.create_group("g3")
+#     with pytest.raises(PermissionError):
+#         z2.create_dataset("spam", shape=42, chunks=7, dtype="i4")
+#     with pytest.raises(PermissionError):
+#         del z2["g2"]
+
+#     # test consolidated metadata are not writeable
+#     with pytest.raises(PermissionError):
+#         z2.g2.attrs["hello"] = "universe"
+#     with pytest.raises(PermissionError):
+#         z2.g2.arr.attrs["foo"] = "bar"
+
+#     # test the data are writeable
+#     z2.g2.arr[:] = 2
+#     assert (z2.g2.arr[:] == 2).all()
+
+#     # test invalid modes
+#     with pytest.raises(ValueError):
+#         open_consolidated(store, chunk_store=chunk_store, mode="a", path=path)
+#     with pytest.raises(ValueError):
+#         open_consolidated(store, chunk_store=chunk_store, mode="w", path=path)
+#     with pytest.raises(ValueError):
+#         open_consolidated(store, chunk_store=chunk_store, mode="w-", path=path)
+
+#     # make sure keyword arguments are passed through without error
+#     open_consolidated(
+#         store,
+#         chunk_store=chunk_store,
+#         path=path,
+#         cache_attrs=True,
+#         synchronizer=None,
+#     )
+
+
+# @pytest.mark.parametrize(
+#     "options",
+#     (
+#         {"dimension_separator": "/"},
+#         {"dimension_separator": "."},
+#         {"dimension_separator": None},
+#     ),
+# )
+# def test_save_array_separator(tmpdir, options):
+#     data = np.arange(6).reshape((3, 2))
+#     url = tmpdir.join("test.zarr")
+#     save_array(url, data, **options)
+
+
+# class TestCopyStore(unittest.TestCase):
+#     _version = 2
+
+#     def setUp(self):
+#         source = dict()
+#         source["foo"] = b"xxx"
+#         source["bar/baz"] = b"yyy"
+#         source["bar/qux"] = b"zzz"
+#         self.source = source
+
+#     def _get_dest_store(self):
+#         return dict()
+
+#     def test_no_paths(self):
+#         source = self.source
+#         dest = self._get_dest_store()
+#         copy_store(source, dest)
+#         assert len(source) == len(dest)
+#         for key in source:
+#             assert source[key] == dest[key]
+
+#     def test_source_path(self):
+#         source = self.source
+#         # paths should be normalized
+#         for source_path in "bar", "bar/", "/bar", "/bar/":
+#             dest = self._get_dest_store()
+#             copy_store(source, dest, source_path=source_path)
+#             assert 2 == len(dest)
+#             for key in source:
+#                 if key.startswith("bar/"):
+#                     dest_key = key.split("bar/")[1]
+#                     assert source[key] == dest[dest_key]
+#                 else:
+#                     assert key not in dest
+
+#     def test_dest_path(self):
+#         source = self.source
+#         # paths should be normalized
+#         for dest_path in "new", "new/", "/new", "/new/":
+#             dest = self._get_dest_store()
+#             copy_store(source, dest, dest_path=dest_path)
+#             assert len(source) == len(dest)
+#             for key in source:
+#                 if self._version == 3:
+#                     dest_key = key[:10] + "new/" + key[10:]
+#                 else:
+#                     dest_key = "new/" + key
+#                 assert source[key] == dest[dest_key]
+
+#     def test_source_dest_path(self):
+#         source = self.source
+#         # paths should be normalized
+#         for source_path in "bar", "bar/", "/bar", "/bar/":
+#             for dest_path in "new", "new/", "/new", "/new/":
+#                 dest = self._get_dest_store()
+#                 copy_store(source, dest, source_path=source_path, dest_path=dest_path)
+#                 assert 2 == len(dest)
+#                 for key in source:
+#                     if key.startswith("bar/"):
+#                         dest_key = "new/" + key.split("bar/")[1]
+#                         assert source[key] == dest[dest_key]
+#                     else:
+#                         assert key not in dest
+#                         assert ("new/" + key) not in dest
+
+#     def test_excludes_includes(self):
+#         source = self.source
+
+#         # single excludes
+#         dest = self._get_dest_store()
+#         excludes = "f.*"
+#         copy_store(source, dest, excludes=excludes)
+#         assert len(dest) == 2
+
+#         root = ""
+#         assert root + "foo" not in dest
+
+#         # multiple excludes
+#         dest = self._get_dest_store()
+#         excludes = "b.z", ".*x"
+#         copy_store(source, dest, excludes=excludes)
+#         assert len(dest) == 1
+#         assert root + "foo" in dest
+#         assert root + "bar/baz" not in dest
+#         assert root + "bar/qux" not in dest
+
+#         # excludes and includes
+#         dest = self._get_dest_store()
+#         excludes = "b.*"
+#         includes = ".*x"
+#         copy_store(source, dest, excludes=excludes, includes=includes)
+#         assert len(dest) == 2
+#         assert root + "foo" in dest
+#         assert root + "bar/baz" not in dest
+#         assert root + "bar/qux" in dest
+
+#     def test_dry_run(self):
+#         source = self.source
+#         dest = self._get_dest_store()
+#         copy_store(source, dest, dry_run=True)
+#         assert 0 == len(dest)
+
+#     def test_if_exists(self):
+#         source = self.source
+#         dest = self._get_dest_store()
+#         root = ""
+#         dest[root + "bar/baz"] = b"mmm"
+
+#         # default ('raise')
+#         with pytest.raises(CopyError):
+#             copy_store(source, dest)
+
+#         # explicit 'raise'
+#         with pytest.raises(CopyError):
+#             copy_store(source, dest, if_exists="raise")
+
+#         # skip
+#         copy_store(source, dest, if_exists="skip")
+#         assert 3 == len(dest)
+#         assert dest[root + "foo"] == b"xxx"
+#         assert dest[root + "bar/baz"] == b"mmm"
+#         assert dest[root + "bar/qux"] == b"zzz"
+
+#         # replace
+#         copy_store(source, dest, if_exists="replace")
+#         assert 3 == len(dest)
+#         assert dest[root + "foo"] == b"xxx"
+#         assert dest[root + "bar/baz"] == b"yyy"
+#         assert dest[root + "bar/qux"] == b"zzz"
+
+#         # invalid option
+#         with pytest.raises(ValueError):
+#             copy_store(source, dest, if_exists="foobar")
+
+
+# def check_copied_array(original, copied, without_attrs=False, expect_props=None):
+#     # setup
+#     source_h5py = original.__module__.startswith("h5py.")
+#     dest_h5py = copied.__module__.startswith("h5py.")
+#     zarr_to_zarr = not (source_h5py or dest_h5py)
+#     h5py_to_h5py = source_h5py and dest_h5py
+#     zarr_to_h5py = not source_h5py and dest_h5py
+#     h5py_to_zarr = source_h5py and not dest_h5py
+#     if expect_props is None:
+#         expect_props = dict()
+#     else:
+#         expect_props = expect_props.copy()
+
+#     # common properties in zarr and h5py
+#     for p in "dtype", "shape", "chunks":
+#         expect_props.setdefault(p, getattr(original, p))
+
+#     # zarr-specific properties
+#     if zarr_to_zarr:
+#         for p in "compressor", "filters", "order", "fill_value":
+#             expect_props.setdefault(p, getattr(original, p))
+
+#     # h5py-specific properties
+#     if h5py_to_h5py:
+#         for p in (
+#             "maxshape",
+#             "compression",
+#             "compression_opts",
+#             "shuffle",
+#             "scaleoffset",
+#             "fletcher32",
+#             "fillvalue",
+#         ):
+#             expect_props.setdefault(p, getattr(original, p))
+
+#     # common properties with some name differences
+#     if h5py_to_zarr:
+#         expect_props.setdefault("fill_value", original.fillvalue)
+#     if zarr_to_h5py:
+#         expect_props.setdefault("fillvalue", original.fill_value)
+
+#     # compare properties
+#     for k, v in expect_props.items():
+#         assert v == getattr(copied, k)
+
+#     # compare data
+#     assert_array_equal(original[:], copied[:])
+
+#     # compare attrs
+#     if without_attrs:
+#         for k in original.attrs.keys():
+#             assert k not in copied.attrs
+#     else:
+#         if dest_h5py and "filters" in original.attrs:
+#             # special case in v3 (storing filters metadata under attributes)
+#             # we explicitly do not copy this info over to HDF5
+#             original_attrs = original.attrs.asdict().copy()
+#             original_attrs.pop("filters")
+#         else:
+#             original_attrs = original.attrs
+#         assert sorted(original_attrs.items()) == sorted(copied.attrs.items())
+
+
+# def check_copied_group(original, copied, without_attrs=False, expect_props=None, shallow=False):
+#     # setup
+#     if expect_props is None:
+#         expect_props = dict()
+#     else:
+#         expect_props = expect_props.copy()
+
+#     # compare children
+#     for k, v in original.items():
+#         if hasattr(v, "shape"):
+#             assert k in copied
+#             check_copied_array(v, copied[k], without_attrs=without_attrs, expect_props=expect_props)
+#         elif shallow:
+#             assert k not in copied
+#         else:
+#             assert k in copied
+#             check_copied_group(
+#                 v,
+#                 copied[k],
+#                 without_attrs=without_attrs,
+#                 shallow=shallow,
+#                 expect_props=expect_props,
+#             )
+
+#     # compare attrs
+#     if without_attrs:
+#         for k in original.attrs.keys():
+#             assert k not in copied.attrs
+#     else:
+#         assert sorted(original.attrs.items()) == sorted(copied.attrs.items())
+
+
+# def test_copy_all():
+#     """
+#     https://github.com/zarr-developers/zarr-python/issues/269
+
+#     copy_all used to not copy attributes as `.keys()` does not return hidden `.zattrs`.
+
+#     """
+#     original_group = zarr.group(store=MemoryStore(), overwrite=True)
+#     original_group.attrs["info"] = "group attrs"
+#     original_subgroup = original_group.create_group("subgroup")
+#     original_subgroup.attrs["info"] = "sub attrs"
+
+#     destination_group = zarr.group(store=MemoryStore(), overwrite=True)
+
+#     # copy from memory to directory store
+#     copy_all(
+#         original_group,
+#         destination_group,
+#         dry_run=False,
+#     )
+
+#     assert "subgroup" in destination_group
+#     assert destination_group.attrs["info"] == "group attrs"
+#     assert destination_group.subgroup.attrs["info"] == "sub attrs"
+
+
+# class TestCopy:
+#     @pytest.fixture(params=[False, True], ids=["zarr", "hdf5"])
+#     def source(self, request, tmpdir):
+#         def prep_source(source):
+#             foo = source.create_group("foo")
+#             foo.attrs["experiment"] = "weird science"
+#             baz = foo.create_dataset("bar/baz", data=np.arange(100), chunks=(50,))
+#             baz.attrs["units"] = "metres"
+#             if request.param:
+#                 extra_kws = dict(
+#                     compression="gzip",
+#                     compression_opts=3,
+#                     fillvalue=84,
+#                     shuffle=True,
+#                     fletcher32=True,
+#                 )
+#             else:
+#                 extra_kws = dict(compressor=Zlib(3), order="F", fill_value=42, filters=[Adler32()])
+#             source.create_dataset(
+#                 "spam",
+#                 data=np.arange(100, 200).reshape(20, 5),
+#                 chunks=(10, 2),
+#                 dtype="i2",
+#                 **extra_kws,
+#             )
+#             return source
+
+#         if request.param:
+#             h5py = pytest.importorskip("h5py")
+#             fn = tmpdir.join("source.h5")
+#             with h5py.File(str(fn), mode="w") as h5f:
+#                 yield prep_source(h5f)
+#         else:
+#             yield prep_source(group())
+
+#     @pytest.fixture(params=[False, True], ids=["zarr", "hdf5"])
+#     def dest(self, request, tmpdir):
+#         if request.param:
+#             h5py = pytest.importorskip("h5py")
+#             fn = tmpdir.join("dest.h5")
+#             with h5py.File(str(fn), mode="w") as h5f:
+#                 yield h5f
+#         else:
+#             yield group()
+
+#     def test_copy_array(self, source, dest):
+#         # copy array with default options
+#         copy(source["foo/bar/baz"], dest)
+#         check_copied_array(source["foo/bar/baz"], dest["baz"])
+#         copy(source["spam"], dest)
+#         check_copied_array(source["spam"], dest["spam"])
+
+#     def test_copy_bad_dest(self, source, dest):
+#         # try to copy to an array, dest must be a group
+#         dest = dest.create_dataset("eggs", shape=(100,))
+#         with pytest.raises(ValueError):
+#             copy(source["foo/bar/baz"], dest)
+
+#     def test_copy_array_name(self, source, dest):
+#         # copy array with name
+#         copy(source["foo/bar/baz"], dest, name="qux")
+#         assert "baz" not in dest
+#         check_copied_array(source["foo/bar/baz"], dest["qux"])
+
+#     def test_copy_array_create_options(self, source, dest):
+#         dest_h5py = dest.__module__.startswith("h5py.")
+
+#         # copy array, provide creation options
+#         compressor = Zlib(9)
+#         create_kws = dict(chunks=(10,))
+#         if dest_h5py:
+#             create_kws.update(
+#                 compression="gzip", compression_opts=9, shuffle=True, fletcher32=True, fillvalue=42
+#             )
+#         else:
+#             create_kws.update(compressor=compressor, fill_value=42, order="F", filters=[Adler32()])
+#         copy(source["foo/bar/baz"], dest, without_attrs=True, **create_kws)
+#         check_copied_array(
+#             source["foo/bar/baz"], dest["baz"], without_attrs=True, expect_props=create_kws
+#         )
+
+#     def test_copy_array_exists_array(self, source, dest):
+#         # copy array, dest array in the way
+#         dest.create_dataset("baz", shape=(10,))
+
+#         # raise
+#         with pytest.raises(CopyError):
+#             # should raise by default
+#             copy(source["foo/bar/baz"], dest)
+#         assert (10,) == dest["baz"].shape
+#         with pytest.raises(CopyError):
+#             copy(source["foo/bar/baz"], dest, if_exists="raise")
+#         assert (10,) == dest["baz"].shape
+
+#         # skip
+#         copy(source["foo/bar/baz"], dest, if_exists="skip")
+#         assert (10,) == dest["baz"].shape
+
+#         # replace
+#         copy(source["foo/bar/baz"], dest, if_exists="replace")
+#         check_copied_array(source["foo/bar/baz"], dest["baz"])
+
+#         # invalid option
+#         with pytest.raises(ValueError):
+#             copy(source["foo/bar/baz"], dest, if_exists="foobar")
+
+#     def test_copy_array_exists_group(self, source, dest):
+#         # copy array, dest group in the way
+#         dest.create_group("baz")
+
+#         # raise
+#         with pytest.raises(CopyError):
+#             copy(source["foo/bar/baz"], dest)
+#         assert not hasattr(dest["baz"], "shape")
+#         with pytest.raises(CopyError):
+#             copy(source["foo/bar/baz"], dest, if_exists="raise")
+#         assert not hasattr(dest["baz"], "shape")
+
+#         # skip
+#         copy(source["foo/bar/baz"], dest, if_exists="skip")
+#         assert not hasattr(dest["baz"], "shape")
+
+#         # replace
+#         copy(source["foo/bar/baz"], dest, if_exists="replace")
+#         check_copied_array(source["foo/bar/baz"], dest["baz"])
+
+#     def test_copy_array_skip_initialized(self, source, dest):
+#         dest_h5py = dest.__module__.startswith("h5py.")
+
+#         dest.create_dataset("baz", shape=(100,), chunks=(10,), dtype="i8")
+#         assert not np.all(source["foo/bar/baz"][:] == dest["baz"][:])
+
+#         if dest_h5py:
+#             with pytest.raises(ValueError):
+#                 # not available with copy to h5py
+#                 copy(source["foo/bar/baz"], dest, if_exists="skip_initialized")
+
+#         else:
+#             # copy array, dest array exists but not yet initialized
+#             copy(source["foo/bar/baz"], dest, if_exists="skip_initialized")
+#             check_copied_array(source["foo/bar/baz"], dest["baz"])
+
+#             # copy array, dest array exists and initialized, will be skipped
+#             dest["baz"][:] = np.arange(100, 200)
+#             copy(source["foo/bar/baz"], dest, if_exists="skip_initialized")
+#             assert_array_equal(np.arange(100, 200), dest["baz"][:])
+#             assert not np.all(source["foo/bar/baz"][:] == dest["baz"][:])
+
+#     def test_copy_group(self, source, dest):
+#         # copy group, default options
+#         copy(source["foo"], dest)
+#         check_copied_group(source["foo"], dest["foo"])
+
+#     def test_copy_group_no_name(self, source, dest):
+#         with pytest.raises(TypeError):
+#             # need a name if copy root
+#             copy(source, dest)
+
+#         copy(source, dest, name="root")
+#         check_copied_group(source, dest["root"])
+
+#     def test_copy_group_options(self, source, dest):
+#         # copy group, non-default options
+#         copy(source["foo"], dest, name="qux", without_attrs=True)
+#         assert "foo" not in dest
+#         check_copied_group(source["foo"], dest["qux"], without_attrs=True)
+
+#     def test_copy_group_shallow(self, source, dest):
+#         # copy group, shallow
+#         copy(source, dest, name="eggs", shallow=True)
+#         check_copied_group(source, dest["eggs"], shallow=True)
+
+#     def test_copy_group_exists_group(self, source, dest):
+#         # copy group, dest groups exist
+#         dest.create_group("foo/bar")
+#         copy(source["foo"], dest)
+#         check_copied_group(source["foo"], dest["foo"])
+
+#     def test_copy_group_exists_array(self, source, dest):
+#         # copy group, dest array in the way
+#         dest.create_dataset("foo/bar", shape=(10,))
+
+#         # raise
+#         with pytest.raises(CopyError):
+#             copy(source["foo"], dest)
+#         assert dest["foo/bar"].shape == (10,)
+#         with pytest.raises(CopyError):
+#             copy(source["foo"], dest, if_exists="raise")
+#         assert dest["foo/bar"].shape == (10,)
+
+#         # skip
+#         copy(source["foo"], dest, if_exists="skip")
+#         assert dest["foo/bar"].shape == (10,)
+
+#         # replace
+#         copy(source["foo"], dest, if_exists="replace")
+#         check_copied_group(source["foo"], dest["foo"])
+
+#     def test_copy_group_dry_run(self, source, dest):
+#         # dry run, empty destination
+#         n_copied, n_skipped, n_bytes_copied = copy(
+#             source["foo"], dest, dry_run=True, return_stats=True
+#         )
+#         assert 0 == len(dest)
+#         assert 3 == n_copied
+#         assert 0 == n_skipped
+#         assert 0 == n_bytes_copied
+
+#         # dry run, array exists in destination
+#         baz = np.arange(100, 200)
+#         dest.create_dataset("foo/bar/baz", data=baz)
+#         assert not np.all(source["foo/bar/baz"][:] == dest["foo/bar/baz"][:])
+#         assert 1 == len(dest)
+
+#         # raise
+#         with pytest.raises(CopyError):
+#             copy(source["foo"], dest, dry_run=True)
+#         assert 1 == len(dest)
+
+#         # skip
+#         n_copied, n_skipped, n_bytes_copied = copy(
+#             source["foo"], dest, dry_run=True, if_exists="skip", return_stats=True
+#         )
+#         assert 1 == len(dest)
+#         assert 2 == n_copied
+#         assert 1 == n_skipped
+#         assert 0 == n_bytes_copied
+#         assert_array_equal(baz, dest["foo/bar/baz"])
+
+#         # replace
+#         n_copied, n_skipped, n_bytes_copied = copy(
+#             source["foo"], dest, dry_run=True, if_exists="replace", return_stats=True
+#         )
+#         assert 1 == len(dest)
+#         assert 3 == n_copied
+#         assert 0 == n_skipped
+#         assert 0 == n_bytes_copied
+#         assert_array_equal(baz, dest["foo/bar/baz"])
+
+#     def test_logging(self, source, dest, tmpdir):
+#         # callable log
+#         copy(source["foo"], dest, dry_run=True, log=print)
+
+#         # file name
+#         fn = str(tmpdir.join("log_name"))
+#         copy(source["foo"], dest, dry_run=True, log=fn)
+
+#         # file
+#         with tmpdir.join("log_file").open(mode="w") as f:
+#             copy(source["foo"], dest, dry_run=True, log=f)
+
+#         # bad option
+#         with pytest.raises(TypeError):
+#             copy(source["foo"], dest, dry_run=True, log=True)
diff --git a/tests/v3/test_common.py b/tests/v3/test_common.py
index 3bdbd2bf..cc33aa75 100644
--- a/tests/v3/test_common.py
+++ b/tests/v3/test_common.py
@@ -64,7 +64,7 @@ def parse_indexing_order_valid(data: Literal["C", "F"]):
     assert parse_indexing_order(data) == data
 
 
-@pytest.mark.parametrize("data", [10, ("0", 1, 2, 3), {"0": "0"}, []])
+@pytest.mark.parametrize("data", [("0", 1, 2, 3), {"0": "0"}, []])
 def test_parse_shapelike_invalid(data: Any):
     if isinstance(data, Iterable):
         if len(data) == 0:
diff --git a/tests/v3/test_config.py b/tests/v3/test_config.py
index aed9775d..684ab0df 100644
--- a/tests/v3/test_config.py
+++ b/tests/v3/test_config.py
@@ -1,19 +1,32 @@
+from typing import Any
+
+import pytest
+
 from zarr.config import config
 
 
-def test_config_defaults_set():
+def test_config_defaults_set() -> None:
     # regression test for available defaults
     assert config.defaults == [
         {
             "array": {"order": "C"},
             "async": {"concurrency": None, "timeout": None},
             "codec_pipeline": {"batch_size": 1},
+            "json_indent": 2,
         }
     ]
     assert config.get("array.order") == "C"
+    assert config.get("async.concurrency") is None
+    assert config.get("async.timeout") is None
+    assert config.get("codec_pipeline.batch_size") == 1
+    assert config.get("json_indent") == 2
 
 
-def test_config_defaults_can_be_overridden():
-    assert config.get("array.order") == "C"
-    with config.set({"array.order": "F"}):
-        assert config.get("array.order") == "F"
+@pytest.mark.parametrize(
+    "key, old_val, new_val",
+    [("array.order", "C", "F"), ("async.concurrency", None, 10), ("json_indent", 2, 0)],
+)
+def test_config_defaults_can_be_overridden(key: str, old_val: Any, new_val: Any) -> None:
+    assert config.get(key) == old_val
+    with config.set({key: new_val}):
+        assert config.get(key) == new_val
diff --git a/tests/v3/test_store.py b/tests/v3/test_store/test_local.py
similarity index 50%
rename from tests/v3/test_store.py
rename to tests/v3/test_store/test_local.py
index 52882ea7..191a137d 100644
--- a/tests/v3/test_store.py
+++ b/tests/v3/test_store/test_local.py
@@ -1,48 +1,12 @@
 from __future__ import annotations
 
-from typing import Any
-
 import pytest
 
 from zarr.buffer import Buffer
 from zarr.store.local import LocalStore
-from zarr.store.memory import MemoryStore
 from zarr.testing.store import StoreTests
 
 
-class TestMemoryStore(StoreTests[MemoryStore]):
-    store_cls = MemoryStore
-
-    def set(self, store: MemoryStore, key: str, value: Buffer) -> None:
-        store._store_dict[key] = value
-
-    def get(self, store: MemoryStore, key: str) -> Buffer:
-        return store._store_dict[key]
-
-    @pytest.fixture(scope="function", params=[None, {}])
-    def store_kwargs(self, request) -> dict[str, Any]:
-        return {"store_dict": request.param, "mode": "w"}
-
-    @pytest.fixture(scope="function")
-    def store(self, store_kwargs: dict[str, Any]) -> MemoryStore:
-        return self.store_cls(**store_kwargs)
-
-    def test_store_repr(self, store: MemoryStore) -> None:
-        assert str(store) == f"memory://{id(store._store_dict)}"
-
-    def test_store_supports_writes(self, store: MemoryStore) -> None:
-        assert store.supports_writes
-
-    def test_store_supports_listing(self, store: MemoryStore) -> None:
-        assert store.supports_listing
-
-    def test_store_supports_partial_writes(self, store: MemoryStore) -> None:
-        assert store.supports_partial_writes
-
-    def test_list_prefix(self, store: MemoryStore) -> None:
-        assert True
-
-
 class TestLocalStore(StoreTests[LocalStore]):
     store_cls = LocalStore
 
diff --git a/tests/v3/test_store/test_memory.py b/tests/v3/test_store/test_memory.py
new file mode 100644
index 00000000..96b8b19e
--- /dev/null
+++ b/tests/v3/test_store/test_memory.py
@@ -0,0 +1,40 @@
+from __future__ import annotations
+
+import pytest
+
+from zarr.buffer import Buffer
+from zarr.store.memory import MemoryStore
+from zarr.testing.store import StoreTests
+
+
+class TestMemoryStore(StoreTests[MemoryStore]):
+    store_cls = MemoryStore
+
+    def set(self, store: MemoryStore, key: str, value: Buffer) -> None:
+        store._store_dict[key] = value
+
+    def get(self, store: MemoryStore, key: str) -> Buffer:
+        return store._store_dict[key]
+
+    @pytest.fixture(scope="function", params=[None, {}])
+    def store_kwargs(self, request) -> dict[str, str | None | dict[str, Buffer]]:
+        return {"store_dict": request.param, "mode": "w"}
+
+    @pytest.fixture(scope="function")
+    def store(self, store_kwargs: str | None | dict[str, Buffer]) -> MemoryStore:
+        return self.store_cls(**store_kwargs)
+
+    def test_store_repr(self, store: MemoryStore) -> None:
+        assert str(store) == f"memory://{id(store._store_dict)}"
+
+    def test_store_supports_writes(self, store: MemoryStore) -> None:
+        assert store.supports_writes
+
+    def test_store_supports_listing(self, store: MemoryStore) -> None:
+        assert store.supports_listing
+
+    def test_store_supports_partial_writes(self, store: MemoryStore) -> None:
+        assert store.supports_partial_writes
+
+    def test_list_prefix(self, store: MemoryStore) -> None:
+        assert True
diff --git a/tests/v3/test_store/test_remote.py b/tests/v3/test_store/test_remote.py
new file mode 100644
index 00000000..936cf206
--- /dev/null
+++ b/tests/v3/test_store/test_remote.py
@@ -0,0 +1,118 @@
+import os
+
+import fsspec
+import pytest
+
+from zarr.buffer import Buffer, default_buffer_prototype
+from zarr.store import RemoteStore
+from zarr.testing.store import StoreTests
+
+s3fs = pytest.importorskip("s3fs")
+requests = pytest.importorskip("requests")
+moto_server = pytest.importorskip("moto.moto_server.threaded_moto_server")
+moto = pytest.importorskip("moto")
+
+# ### amended from s3fs ### #
+test_bucket_name = "test"
+secure_bucket_name = "test-secure"
+port = 5555
+endpoint_uri = f"http://127.0.0.1:{port}/"
+
+
+@pytest.fixture(scope="module")
+def s3_base():
+    # writable local S3 system
+
+    # This fixture is module-scoped, meaning that we can reuse the MotoServer across all tests
+    server = moto_server.ThreadedMotoServer(ip_address="127.0.0.1", port=port)
+    server.start()
+    if "AWS_SECRET_ACCESS_KEY" not in os.environ:
+        os.environ["AWS_SECRET_ACCESS_KEY"] = "foo"
+    if "AWS_ACCESS_KEY_ID" not in os.environ:
+        os.environ["AWS_ACCESS_KEY_ID"] = "foo"
+
+    yield
+    server.stop()
+
+
+def get_boto3_client():
+    from botocore.session import Session
+
+    # NB: we use the sync botocore client for setup
+    session = Session()
+    return session.create_client("s3", endpoint_url=endpoint_uri)
+
+
+@pytest.fixture(autouse=True, scope="function")
+def s3(s3_base):
+    client = get_boto3_client()
+    client.create_bucket(Bucket=test_bucket_name, ACL="public-read")
+    s3fs.S3FileSystem.clear_instance_cache()
+    s3 = s3fs.S3FileSystem(anon=False, client_kwargs={"endpoint_url": endpoint_uri})
+    s3.invalidate_cache()
+    yield s3
+    requests.post(f"{endpoint_uri}/moto-api/reset")
+
+
+# ### end from s3fs ### #
+
+
+async def alist(it):
+    out = []
+    async for a in it:
+        out.append(a)
+    return out
+
+
+async def test_basic():
+    store = RemoteStore(f"s3://{test_bucket_name}", mode="w", endpoint_url=endpoint_uri, anon=False)
+    assert not await alist(store.list())
+    assert not await store.exists("foo")
+    data = b"hello"
+    await store.set("foo", Buffer.from_bytes(data))
+    assert await store.exists("foo")
+    assert (await store.get("foo")).to_bytes() == data
+    out = await store.get_partial_values(
+        prototype=default_buffer_prototype, key_ranges=[("foo", (1, None))]
+    )
+    assert out[0].to_bytes() == data[1:]
+
+
+class TestRemoteStoreS3(StoreTests[RemoteStore]):
+    store_cls = RemoteStore
+
+    @pytest.fixture(scope="function")
+    def store_kwargs(self) -> dict[str, str | bool]:
+        return {
+            "mode": "w",
+            "endpoint_url": endpoint_uri,
+            "anon": False,
+            "url": f"s3://{test_bucket_name}",
+        }
+
+    @pytest.fixture(scope="function")
+    def store(self, store_kwargs: dict[str, str | bool]) -> RemoteStore:
+        self._fs, _ = fsspec.url_to_fs(asynchronous=False, **store_kwargs)
+        out = self.store_cls(asynchronous=True, **store_kwargs)
+        return out
+
+    def get(self, store: RemoteStore, key: str) -> Buffer:
+        return Buffer.from_bytes(self._fs.cat(f"{store.path}/{key}"))
+
+    def set(self, store: RemoteStore, key: str, value: Buffer) -> None:
+        self._fs.write_bytes(f"{store.path}/{key}", value.to_bytes())
+
+    def test_store_repr(self, store: RemoteStore) -> None:
+        rep = str(store)
+        assert "fsspec" in rep
+        assert store.path in rep
+
+    def test_store_supports_writes(self, store: RemoteStore) -> None:
+        assert True
+
+    @pytest.mark.xfail
+    def test_store_supports_partial_writes(self, store: RemoteStore) -> None:
+        raise AssertionError
+
+    def test_store_supports_listing(self, store: RemoteStore) -> None:
+        assert True