Repository: zarr-developers/zarr-python

Issue:
Enhancement: Chunk level access api / indexing by chunk rather than voxel
I may be missing something, but zarr currently seems to offers a high level interface to the chunked data that appears to be largely transparent to the fact that the underlying data is in fact chunked (much like HDF5). I've got a few use cases where I'd like to be able to directly operate on the underlying chunks rather than the whole large dataset.

My target application is multi-dimensional image processing in large 3D biomedical datasets which there are many situations where it would make sense to perform operations on individual chunks in parallel. 

Whilst it would be possible to a) read the chunk size of an array and b) work out how to slice the dataset in multiples of that chunk size, a direct chunk level access might be easier and more efficient. In essence I'm suggesting something which exposes a simplified version of `Array._chunk_getitem`  and `Array._chunk_setitem` which would only ever get  or set a complete chunk.

If you extended this concept somewhat by having the functions return a lightweight`Chunk` object which was essentially a reference to the location of the array, a chunk ID, and a `.data` property which gave you the data for that chunk and additionally exposed an iterator in the `Array` class , you could conceivably write code like:

```python
def do_something(chunk):
    res = some_processing_function(chunk.data)
    with zarr.open('output_file_uri') as z1:
        z1.save_chunk(res, chunk.chunk_id)

with multiprocessing.Pool() as pool:
    pool.map(do_something, array.chunk_iterator)
```

In the longer term (and I'm not sure how to go about this - I might be better aiming for API compatibility with zarr rather than inclusion in zarr)  I'd want to enable data-local processing of individual chunks of an array which was chunked across a distributed file system. I've currently got a python solution for this for 2.5 dimensional data (xyt) but it's pretty specific to one use case and I would like to avoid duplicating other efforts as we make it more general.


Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/zarr-developers/zarr-python/pull/1428?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zarr-developers) Report
> Merging [#1428](https://app.codecov.io/gh/zarr-developers/zarr-python/pull/1428?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zarr-developers) (15cd669) into [main](https://app.codecov.io/gh/zarr-developers/zarr-python/commit/4132f360616a4c8bfa3dd4e979a4793c5d84cdfc?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zarr-developers) (4132f36) will **decrease** coverage by `0.02%`.
> The diff coverage is `97.90%`.

> :exclamation: Current head 15cd669 differs from pull request most recent head 6240e3f. Consider uploading reports for the commit 6240e3f to get more accurate results

```diff
@@             Coverage Diff             @@
##              main    #1428      +/-   ##
===========================================
- Coverage   100.00%   99.98%   -0.02%     
===========================================
  Files           37       37              
  Lines        14866    15005     +139     
===========================================
+ Hits         14866    15002     +136     
- Misses           0        3       +3     
```

| [Impacted Files](https://app.codecov.io/gh/zarr-developers/zarr-python/pull/1428?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zarr-developers) | Coverage Δ | |
|---|---|---|
| [zarr/core.py](https://app.codecov.io/gh/zarr-developers/zarr-python/pull/1428?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zarr-developers#diff-emFyci9jb3JlLnB5) | `99.61% <82.35%> (-0.39%)` | :arrow_down: |
| [zarr/indexing.py](https://app.codecov.io/gh/zarr-developers/zarr-python/pull/1428?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zarr-developers#diff-emFyci9pbmRleGluZy5weQ==) | `100.00% <100.00%> (ø)` | |
| [zarr/tests/test\_indexing.py](https://app.codecov.io/gh/zarr-developers/zarr-python/pull/1428?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zarr-developers#diff-emFyci90ZXN0cy90ZXN0X2luZGV4aW5nLnB5) | `100.00% <100.00%> (ø)` | |

... and [2 files with indirect coverage changes](https://app.codecov.io/gh/zarr-developers/zarr-python/pull/1428/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zarr-developers)

joshmoore: > * I wasn't exactly sure where to put it in the change log. Currently its in 2.15.0, however, should I move that to Unreleased section??

Yes please, and update your comments of `2.15` to be `2.16`? Huge thanks!
tasansal: > > * I wasn't exactly sure where to put it in the change log. Currently its in 2.15.0, however, should I move that to Unreleased section??
> 
> Yes please, and update your comments of `2.15` to be `2.16`? Huge thanks!

Done! Seems like all the CI passed too.
tasansal: @joshmoore ready to merge?
joshmoore: In general, this looks really well done. I'd still ping a few daskanistas (daskonauts?) for some feedback.

cc: @jrbourbeau @jni but of course others welcome!

Patch:
diff --git a/.github/workflows/releases.yml b/.github/workflows/releases.yml
index 8b1d5ccb..a00096bb 100644
--- a/.github/workflows/releases.yml
+++ b/.github/workflows/releases.yml
@@ -64,7 +64,7 @@ jobs:
         with:
           name: releases
           path: dist
-      - uses: pypa/gh-action-pypi-publish@v1.8.6
+      - uses: pypa/gh-action-pypi-publish@v1.8.7
         with:
           user: __token__
           password: ${{ secrets.pypi_password }}
diff --git a/.gitignore b/.gitignore
index 28e55442..4f0d5237 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,7 @@ __pycache__/
 # Distribution / packaging
 .Python
 env/
+.venv/
 build/
 develop-eggs/
 dist/
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 194e8b1d..583a2b01 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -15,7 +15,7 @@ repos:
       exclude: ^(venv/|docs/)
       types: ['python']
   - repo: https://github.com/codespell-project/codespell
-    rev: v2.2.4
+    rev: v2.2.5
     hooks:
       - id: codespell
         args: ["-L", "ba,ihs,kake,nd,noe,nwo,te,fo", "-S", "fixture"]
diff --git a/docs/api/core.rst b/docs/api/core.rst
index 5789fb99..c4075fdb 100644
--- a/docs/api/core.rst
+++ b/docs/api/core.rst
@@ -10,6 +10,8 @@ The Array class (``zarr.core``)
     .. automethod:: set_basic_selection
     .. automethod:: get_mask_selection
     .. automethod:: set_mask_selection
+    .. automethod:: get_block_selection
+    .. automethod:: set_block_selection
     .. automethod:: get_coordinate_selection
     .. automethod:: set_coordinate_selection
     .. automethod:: get_orthogonal_selection
diff --git a/docs/release.rst b/docs/release.rst
index 2f64454c..e8d1c440 100644
--- a/docs/release.rst
+++ b/docs/release.rst
@@ -1,24 +1,36 @@
 Release notes
 =============
 
+..
+    # Copy the warning statement _under_ the latest release version
+    # and unindent for pre-releases.
+
+    .. warning::
+       Pre-release! Use :command:`pip install --pre zarr` to evaluate this release.
+
 ..
     # Unindent the section between releases in order
     # to document your changes. On releases it will be
     # re-indented so that it does not show up in the notes.
 
-    .. _unreleased:
+.. _unreleased:
 
-    Unreleased
-    ----------
+Unreleased
+----------
+
+Bug fixes
+~~~~~~~~~
+
+* Add ``__contains__`` method to ``KVStore``. By :user:`Christoph Gohlke <cgohlke>` :issue:`1454`.
+
+    * **Block Indexing**: Implemented blockwise (chunk blocks) indexing to ``zarr.Array``.
+      By :user:`Altay Sansal <tasansal>` :issue:`1428`
 
 .. _release_2.15.0:
 
 2.15.0
 ------
 
-.. warning::
-   Pre-release! Use :command:`pip install --pre zarr` to evaluate this release.
-
 Enhancements
 ~~~~~~~~~~~~
 
diff --git a/docs/tutorial.rst b/docs/tutorial.rst
index 0f2e1c73..e3155acf 100644
--- a/docs/tutorial.rst
+++ b/docs/tutorial.rst
@@ -641,6 +641,84 @@ orthogonal indexing is also available directly on the array:
     >>> all(z.oindex[[0, 2], :] == z[[0, 2], :])
     True
 
+Block Indexing
+~~~~~~~~~~~~~~
+
+As of version 2.16.0, Zarr also support block indexing, which allows
+selections of whole chunks based on their logical indices along each dimension
+of an array. For example, this allows selecting a subset of chunk aligned rows and/or
+columns from a 2-dimensional array. E.g.::
+
+    >>> import zarr
+    >>> import numpy as np
+    >>> z = zarr.array(np.arange(100).reshape(10, 10), chunks=(3, 3))
+
+Retrieve items by specifying their block coordinates::
+
+    >>> z.get_block_selection(1)
+    array([[30, 31, 32, 33, 34, 35, 36, 37, 38, 39],
+           [40, 41, 42, 43, 44, 45, 46, 47, 48, 49],
+           [50, 51, 52, 53, 54, 55, 56, 57, 58, 59]])
+
+Equivalent slicing::
+
+    >>> z[3:6]
+    array([[30, 31, 32, 33, 34, 35, 36, 37, 38, 39],
+           [40, 41, 42, 43, 44, 45, 46, 47, 48, 49],
+           [50, 51, 52, 53, 54, 55, 56, 57, 58, 59]])
+
+
+For convenience, the block selection functionality is also available via the
+`blocks` property, e.g.::
+
+    >>> z.blocks[1]
+    array([[30, 31, 32, 33, 34, 35, 36, 37, 38, 39],
+           [40, 41, 42, 43, 44, 45, 46, 47, 48, 49],
+           [50, 51, 52, 53, 54, 55, 56, 57, 58, 59]])
+
+Block index arrays may be multidimensional to index multidimensional arrays.
+For example::
+
+    >>> z.blocks[0, 1:3]
+    array([[ 3,  4,  5,  6,  7,  8],
+           [13, 14, 15, 16, 17, 18],
+           [23, 24, 25, 26, 27, 28]])
+
+Data can also be modified. Let's start by a simple 2D array::
+
+    >>> import zarr
+    >>> import numpy as np
+    >>> z = zarr.zeros((6, 6), dtype=int, chunks=2)
+
+Set data for a selection of items::
+
+    >>> z.set_block_selection((1, 0), 1)
+    >>> z[...]
+    array([[0, 0, 0, 0, 0, 0],
+           [0, 0, 0, 0, 0, 0],
+           [1, 1, 0, 0, 0, 0],
+           [1, 1, 0, 0, 0, 0],
+           [0, 0, 0, 0, 0, 0],
+           [0, 0, 0, 0, 0, 0]])
+
+For convenience, this functionality is also available via the ``blocks`` property.
+E.g.::
+
+        >>> z.blocks[:, 2] = 7
+        >>> z[...]
+        array([[0, 0, 0, 0, 7, 7],
+               [0, 0, 0, 0, 7, 7],
+               [1, 1, 0, 0, 7, 7],
+               [1, 1, 0, 0, 7, 7],
+               [0, 0, 0, 0, 7, 7],
+               [0, 0, 0, 0, 7, 7]])
+
+Any combination of integer and slice can be used for block indexing::
+
+        >>> z.blocks[2, 1:3]
+        array([[0, 0, 7, 7],
+               [0, 0, 7, 7]])
+
 Indexing fields in structured arrays
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
diff --git a/requirements_dev_minimal.txt b/requirements_dev_minimal.txt
index e4ada638..df1ca116 100644
--- a/requirements_dev_minimal.txt
+++ b/requirements_dev_minimal.txt
@@ -5,4 +5,4 @@ numcodecs==0.11.0
 msgpack-python==0.5.6
 setuptools-scm==7.1.0
 # test requirements
-pytest==7.3.1
+pytest==7.3.2
diff --git a/requirements_dev_optional.txt b/requirements_dev_optional.txt
index 5c4b6ac2..0398d8f4 100644
--- a/requirements_dev_optional.txt
+++ b/requirements_dev_optional.txt
@@ -3,7 +3,7 @@
 lmdb==1.4.1; sys_platform != 'win32'
 # optional library requirements for Jupyter
 ipytree==0.2.2
-ipywidgets==8.0.6
+ipywidgets==8.0.7
 # optional library requirements for services
 # don't let pyup change pinning for azure-storage-blob, need to pin to older
 # version to get compatibility with azure storage emulator on appveyor (FIXME)
@@ -11,13 +11,13 @@ azure-storage-blob==12.16.0 # pyup: ignore
 redis==4.5.5
 types-redis
 types-setuptools
-pymongo==4.3.3
+pymongo==4.4.0
 # optional test requirements
 coverage
-pytest-cov==4.0.0
-pytest-doctestplus==0.12.1
+pytest-cov==4.1.0
+pytest-doctestplus==0.13.0
 pytest-timeout==2.1.0
-h5py==3.8.0
-fsspec==2023.5.0
-s3fs==2023.5.0
+h5py==3.9.0
+fsspec==2023.6.0
+s3fs==2023.6.0
 moto[server]>=4.0.8
diff --git a/zarr/core.py b/zarr/core.py
index 5537733b..80f424ba 100644
--- a/zarr/core.py
+++ b/zarr/core.py
@@ -22,6 +22,8 @@ from zarr.indexing import (
     OIndex,
     OrthogonalIndexer,
     VIndex,
+    BlockIndex,
+    BlockIndexer,
     PartialChunkIterator,
     check_fields,
     check_no_multi_fields,
@@ -139,6 +141,7 @@ class Array:
     info
     vindex
     oindex
+    blocks
     write_empty_chunks
     meta_array
 
@@ -154,6 +157,8 @@ class Array:
     set_mask_selection
     get_coordinate_selection
     set_coordinate_selection
+    get_block_selection
+    set_block_selection
     digest
     hexdigest
     resize
@@ -230,6 +235,7 @@ class Array:
         # initialize indexing helpers
         self._oindex = OIndex(self)
         self._vindex = VIndex(self)
+        self._blocks = BlockIndex(self)
 
     def _load_metadata(self):
         """(Re)load metadata from store."""
@@ -577,6 +583,12 @@ class Array:
         :func:`set_mask_selection` for documentation and examples."""
         return self._vindex
 
+    @property
+    def blocks(self):
+        """Shortcut for blocked chunked indexing, see :func:`get_block_selection` and
+        :func:`set_block_selection` for documentation and examples."""
+        return self._blocks
+
     @property
     def write_empty_chunks(self) -> bool:
         """A Boolean, True if chunks composed of the array's fill value
@@ -814,7 +826,8 @@ class Array:
         --------
         get_basic_selection, set_basic_selection, get_mask_selection, set_mask_selection,
         get_coordinate_selection, set_coordinate_selection, get_orthogonal_selection,
-        set_orthogonal_selection, vindex, oindex, __setitem__
+        set_orthogonal_selection, get_block_selection, set_block_selection,
+        vindex, oindex, blocks, __setitem__
 
         """
         fields, pure_selection = pop_fields(selection)
@@ -933,7 +946,8 @@ class Array:
         --------
         set_basic_selection, get_mask_selection, set_mask_selection,
         get_coordinate_selection, set_coordinate_selection, get_orthogonal_selection,
-        set_orthogonal_selection, vindex, oindex, __getitem__, __setitem__
+        set_orthogonal_selection, get_block_selection, set_block_selection,
+        vindex, oindex, blocks, __getitem__, __setitem__
 
         """
 
@@ -1089,7 +1103,8 @@ class Array:
         --------
         get_basic_selection, set_basic_selection, get_mask_selection, set_mask_selection,
         get_coordinate_selection, set_coordinate_selection, set_orthogonal_selection,
-        vindex, oindex, __getitem__, __setitem__
+        get_block_selection, set_block_selection,
+        vindex, oindex, blocks, __getitem__, __setitem__
 
         """
 
@@ -1160,7 +1175,8 @@ class Array:
         --------
         get_basic_selection, set_basic_selection, get_mask_selection, set_mask_selection,
         get_orthogonal_selection, set_orthogonal_selection, set_coordinate_selection,
-        vindex, oindex, __getitem__, __setitem__
+        get_block_selection, set_block_selection,
+        vindex, oindex, blocks, __getitem__, __setitem__
 
         """
 
@@ -1185,6 +1201,90 @@ class Array:
 
         return out
 
+    def get_block_selection(self, selection, out=None, fields=None):
+        """Retrieve a selection of individual chunk blocks, by providing the indices
+        (coordinates) for each chunk block.
+
+        Parameters
+        ----------
+        selection : tuple
+            An integer (coordinate) or slice for each dimension of the array.
+        out : ndarray, optional
+            If given, load the selected data directly into this array.
+        fields : str or sequence of str, optional
+            For arrays with a structured dtype, one or more fields can be specified to
+            extract data for.
+
+        Returns
+        -------
+        out : ndarray
+            A NumPy array containing the data for the requested selection.
+
+        Examples
+        --------
+        Setup a 2-dimensional array::
+
+            >>> import zarr
+            >>> import numpy as np
+            >>> z = zarr.array(np.arange(100).reshape(10, 10), chunks=(3, 3))
+
+        Retrieve items by specifying their block coordinates::
+
+            >>> z.get_block_selection((1, slice(None)))
+            array([[30, 31, 32, 33, 34, 35, 36, 37, 38, 39],
+                   [40, 41, 42, 43, 44, 45, 46, 47, 48, 49],
+                   [50, 51, 52, 53, 54, 55, 56, 57, 58, 59]])
+
+        Which is equivalent to::
+
+            >>> z[3:6, :]
+            array([[30, 31, 32, 33, 34, 35, 36, 37, 38, 39],
+                   [40, 41, 42, 43, 44, 45, 46, 47, 48, 49],
+                   [50, 51, 52, 53, 54, 55, 56, 57, 58, 59]])
+
+        For convenience, the block selection functionality is also available via the
+        `blocks` property, e.g.::
+
+            >>> z.blocks[1]
+            array([[30, 31, 32, 33, 34, 35, 36, 37, 38, 39],
+                   [40, 41, 42, 43, 44, 45, 46, 47, 48, 49],
+                   [50, 51, 52, 53, 54, 55, 56, 57, 58, 59]])
+
+        Notes
+        -----
+        Block indexing is a convenience indexing method to work on individual chunks
+        with chunk index slicing. It has the same concept as Dask's `Array.blocks`
+        indexing.
+
+        Slices are supported. However, only with a step size of one.
+
+        Block index arrays may be multidimensional to index multidimensional arrays.
+        For example::
+
+            >>> z.blocks[0, 1:3]
+            array([[ 3,  4,  5,  6,  7,  8],
+                   [13, 14, 15, 16, 17, 18],
+                   [23, 24, 25, 26, 27, 28]])
+
+        See Also
+        --------
+        get_basic_selection, set_basic_selection, get_mask_selection, set_mask_selection,
+        get_orthogonal_selection, set_orthogonal_selection, get_coordinate_selection,
+        set_coordinate_selection, set_block_selection,
+        vindex, oindex, blocks, __getitem__, __setitem__
+
+        """
+        if not self._cache_metadata:
+            self._load_metadata()
+
+        # check args
+        check_fields(fields, self._dtype)
+
+        # setup indexer
+        indexer = BlockIndexer(selection, self)
+
+        return self._get_selection(indexer=indexer, out=out, fields=fields)
+
     def get_mask_selection(self, selection, out=None, fields=None):
         """Retrieve a selection of individual items, by providing a Boolean array of the
         same shape as the array against which the selection is being made, where True
@@ -1238,8 +1338,8 @@ class Array:
         --------
         get_basic_selection, set_basic_selection, set_mask_selection,
         get_orthogonal_selection, set_orthogonal_selection, get_coordinate_selection,
-        set_coordinate_selection, vindex, oindex, __getitem__, __setitem__
-
+        set_coordinate_selection, get_block_selection, set_block_selection,
+        vindex, oindex, blocks, __getitem__, __setitem__
         """
 
         # refresh metadata
@@ -1376,7 +1476,8 @@ class Array:
         --------
         get_basic_selection, set_basic_selection, get_mask_selection, set_mask_selection,
         get_coordinate_selection, set_coordinate_selection, get_orthogonal_selection,
-        set_orthogonal_selection, vindex, oindex, __getitem__
+        set_orthogonal_selection, get_block_selection, set_block_selection,
+        vindex, oindex, blocks, __getitem__
 
         """
         fields, pure_selection = pop_fields(selection)
@@ -1464,7 +1565,8 @@ class Array:
         --------
         get_basic_selection, get_mask_selection, set_mask_selection,
         get_coordinate_selection, set_coordinate_selection, get_orthogonal_selection,
-        set_orthogonal_selection, vindex, oindex, __getitem__, __setitem__
+        set_orthogonal_selection, get_block_selection, set_block_selection,
+        vindex, oindex, blocks, __getitem__, __setitem__
 
         """
 
@@ -1555,7 +1657,8 @@ class Array:
         --------
         get_basic_selection, set_basic_selection, get_mask_selection, set_mask_selection,
         get_coordinate_selection, set_coordinate_selection, get_orthogonal_selection,
-        vindex, oindex, __getitem__, __setitem__
+        get_block_selection, set_block_selection,
+        vindex, oindex, blocks, __getitem__, __setitem__
 
         """
 
@@ -1627,7 +1730,8 @@ class Array:
         --------
         get_basic_selection, set_basic_selection, get_mask_selection, set_mask_selection,
         get_orthogonal_selection, set_orthogonal_selection, get_coordinate_selection,
-        vindex, oindex, __getitem__, __setitem__
+        get_block_selection, set_block_selection,
+        vindex, oindex, blocks, __getitem__, __setitem__
 
         """
 
@@ -1654,6 +1758,89 @@ class Array:
 
         self._set_selection(indexer, value, fields=fields)
 
+    def set_block_selection(self, selection, value, fields=None):
+        """Modify a selection of individual blocks, by providing the chunk indices
+        (coordinates) for each block to be modified.
+
+        Parameters
+        ----------
+        selection : tuple
+            An integer (coordinate) or slice for each dimension of the array.
+        value : scalar or array-like
+            Value to be stored into the array.
+        fields : str or sequence of str, optional
+            For arrays with a structured dtype, one or more fields can be specified to set
+            data for.
+
+        Examples
+        --------
+        Set up a 2-dimensional array::
+
+            >>> import zarr
+            >>> import numpy as np
+            >>> z = zarr.zeros((6, 6), dtype=int, chunks=2)
+
+        Set data for a selection of items::
+
+            >>> z.set_block_selection((1, 0), 1)
+            >>> z[...]
+            array([[0, 0, 0, 0, 0, 0],
+                   [0, 0, 0, 0, 0, 0],
+                   [1, 1, 0, 0, 0, 0],
+                   [1, 1, 0, 0, 0, 0],
+                   [0, 0, 0, 0, 0, 0],
+                   [0, 0, 0, 0, 0, 0]])
+
+        For convenience, this functionality is also available via the `blocks` property.
+        E.g.::
+
+            >>> z.blocks[2, 1] = 4
+            >>> z[...]
+            array([[0, 0, 0, 0, 0, 0],
+                   [0, 0, 0, 0, 0, 0],
+                   [1, 1, 0, 0, 0, 0],
+                   [1, 1, 0, 0, 0, 0],
+                   [0, 0, 4, 4, 0, 0],
+                   [0, 0, 4, 4, 0, 0]])
+
+            >>> z.blocks[:, 2] = 7
+            >>> z[...]
+            array([[0, 0, 0, 0, 7, 7],
+                   [0, 0, 0, 0, 7, 7],
+                   [1, 1, 0, 0, 7, 7],
+                   [1, 1, 0, 0, 7, 7],
+                   [0, 0, 4, 4, 7, 7],
+                   [0, 0, 4, 4, 7, 7]])
+
+        Notes
+        -----
+        Block indexing is a convenience indexing method to work on individual chunks
+        with chunk index slicing. It has the same concept as Dask's `Array.blocks`
+        indexing.
+
+        Slices are supported. However, only with a step size of one.
+
+        See Also
+        --------
+        get_basic_selection, set_basic_selection, get_mask_selection, set_mask_selection,
+        get_orthogonal_selection, set_orthogonal_selection, get_coordinate_selection,
+        get_block_selection, set_block_selection,
+        vindex, oindex, blocks, __getitem__, __setitem__
+
+        """
+        # guard conditions
+        if self._read_only:
+            raise ReadOnlyError()
+
+        # refresh metadata
+        if not self._cache_metadata:
+            self._load_metadata_nosync()
+
+        # setup indexer
+        indexer = BlockIndexer(selection, self)
+
+        self._set_selection(indexer, value, fields=fields)
+
     def set_mask_selection(self, selection, value, fields=None):
         """Modify a selection of individual items, by providing a Boolean array of the
         same shape as the array against which the selection is being made, where True
@@ -1712,7 +1899,8 @@ class Array:
         --------
         get_basic_selection, set_basic_selection, get_mask_selection,
         get_orthogonal_selection, set_orthogonal_selection, get_coordinate_selection,
-        set_coordinate_selection, vindex, oindex, __getitem__, __setitem__
+        set_coordinate_selection, get_block_selection, set_block_selection,
+        vindex, oindex, blocks, __getitem__, __setitem__
 
         """
 
diff --git a/zarr/indexing.py b/zarr/indexing.py
index 3fb3e2f2..bc2afba9 100644
--- a/zarr/indexing.py
+++ b/zarr/indexing.py
@@ -695,6 +695,94 @@ class OIndex:
         return self.array.set_orthogonal_selection(selection, value, fields=fields)
 
 
+# noinspection PyProtectedMember
+class BlockIndexer:
+
+    def __init__(self, selection, array):
+
+        # handle ellipsis
+        selection = replace_ellipsis(selection, array._shape)
+
+        # normalize list to array
+        selection = replace_lists(selection)
+
+        # setup per-dimension indexers
+        dim_indexers = []
+        for dim_sel, dim_len, dim_chunk_size in \
+                zip(selection, array._shape, array._chunks):
+            dim_numchunks = int(np.ceil(dim_len / dim_chunk_size))
+
+            if is_integer(dim_sel):
+                if dim_sel < 0:
+                    dim_sel = dim_numchunks + dim_sel
+
+                start = dim_sel * dim_chunk_size
+                stop = start + dim_chunk_size
+                slice_ = slice(start, stop)
+
+            elif is_slice(dim_sel):
+                start = dim_sel.start if dim_sel.start is not None else 0
+                stop = dim_sel.stop if dim_sel.stop is not None else dim_numchunks
+
+                if dim_sel.step not in {1, None}:
+                    raise IndexError('unsupported selection item for block indexing; '
+                                     'expected integer or slice with step=1, got {!r}'
+                                     .format(type(dim_sel)))
+
+                # Can't reuse wraparound_indices because it expects a numpy array
+                # We have integers here.
+                if start < 0:
+                    start = dim_numchunks + start
+                if stop < 0:
+                    stop = dim_numchunks + stop
+
+                start = start * dim_chunk_size
+                stop = stop * dim_chunk_size
+                slice_ = slice(start, stop)
+
+            else:
+                raise IndexError('unsupported selection item for block indexing; '
+                                 'expected integer or slice, got {!r}'
+                                 .format(type(dim_sel)))
+
+            dim_indexer = SliceDimIndexer(slice_, dim_len, dim_chunk_size)
+            dim_indexers.append(dim_indexer)
+
+            if start >= dim_len or start < 0:
+                raise BoundsCheckError(dim_len)
+
+        self.dim_indexers = dim_indexers
+        self.shape = tuple(s.nitems for s in self.dim_indexers)
+        self.drop_axes = None
+
+    def __iter__(self):
+        for dim_projections in itertools.product(*self.dim_indexers):
+            chunk_coords = tuple(p.dim_chunk_ix for p in dim_projections)
+            chunk_selection = tuple(p.dim_chunk_sel for p in dim_projections)
+            out_selection = tuple(p.dim_out_sel for p in dim_projections
+                                  if p.dim_out_sel is not None)
+
+            yield ChunkProjection(chunk_coords, chunk_selection, out_selection)
+
+
+class BlockIndex:
+
+    def __init__(self, array):
+        self.array = array
+
+    def __getitem__(self, selection):
+        fields, selection = pop_fields(selection)
+        selection = ensure_tuple(selection)
+        selection = replace_lists(selection)
+        return self.array.get_block_selection(selection, fields=fields)
+
+    def __setitem__(self, selection, value):
+        fields, selection = pop_fields(selection)
+        selection = ensure_tuple(selection)
+        selection = replace_lists(selection)
+        return self.array.set_block_selection(selection, value, fields=fields)
+
+
 # noinspection PyProtectedMember
 def is_coordinate_selection(selection, array):
     return (
diff --git a/zarr/storage.py b/zarr/storage.py
index e6c3f62f..ef1bd649 100644
--- a/zarr/storage.py
+++ b/zarr/storage.py
@@ -744,6 +744,9 @@ class KVStore(Store):
     def __delitem__(self, key):
         del self._mutable_mapping[key]
 
+    def __contains__(self, key):
+        return key in self._mutable_mapping
+
     def get(self, key, default=None):
         return self._mutable_mapping.get(key, default)
 


Test patch:
diff --git a/zarr/tests/test_core.py b/zarr/tests/test_core.py
index 1cac51ba..ab1a6e8a 100644
--- a/zarr/tests/test_core.py
+++ b/zarr/tests/test_core.py
@@ -8,6 +8,7 @@ from itertools import zip_longest
 from tempfile import mkdtemp
 
 import numpy as np
+import packaging.version
 import pytest
 from numcodecs import (BZ2, JSON, LZ4, Blosc, Categorize, Delta,
                        FixedScaleOffset, GZip, MsgPack, Pickle, VLenArray,
@@ -15,7 +16,6 @@ from numcodecs import (BZ2, JSON, LZ4, Blosc, Categorize, Delta,
 from numcodecs.compat import ensure_bytes, ensure_ndarray
 from numcodecs.tests.common import greetings
 from numpy.testing import assert_array_almost_equal, assert_array_equal
-from pkg_resources import parse_version
 
 import zarr
 from zarr._storage.store import (
@@ -338,6 +338,8 @@ class TestArray(unittest.TestCase):
         assert_array_equal(a[bix], z.get_mask_selection(bix))
         assert_array_equal(a[bix], z.oindex[bix])
         assert_array_equal(a[bix], z.vindex[bix])
+        assert_array_equal(a[200:400], z.get_block_selection(slice(2, 4)))
+        assert_array_equal(a[200:400], z.blocks[2:4])
 
         # set
         z.set_orthogonal_selection(slice(50, 150), 1)
@@ -358,7 +360,10 @@ class TestArray(unittest.TestCase):
         assert_array_equal(8, z.vindex[bix])
         z.oindex[bix] = 9
         assert_array_equal(9, z.oindex[bix])
-
+        z.set_block_selection(slice(2, 4), 10)
+        assert_array_equal(10, z[200:400])
+        z.blocks[2:4] = 11
+        assert_array_equal(11, z[200:400])
         z.store.close()
 
     # noinspection PyStatementEffect
@@ -810,6 +815,8 @@ class TestArray(unittest.TestCase):
             z.set_coordinate_selection([0, 1, 2], 42)
         with pytest.raises(PermissionError):
             z.vindex[[0, 1, 2]] = 42
+        with pytest.raises(PermissionError):
+            z.blocks[...] = 42
         with pytest.raises(PermissionError):
             z.set_mask_selection(np.ones(z.shape, dtype=bool), 42)
 
@@ -1389,7 +1396,7 @@ class TestArray(unittest.TestCase):
             z = self.create_array(shape=10, chunks=5, dtype="i4", object_codec=JSON())
         z.store.close()
 
-    @unittest.skipIf(parse_version(np.__version__) < parse_version('1.14.0'),
+    @unittest.skipIf(packaging.version.parse(np.__version__) < packaging.version.parse('1.14.0'),
                      "unsupported numpy version")
     def test_structured_array_contain_object(self):
 
diff --git a/zarr/tests/test_indexing.py b/zarr/tests/test_indexing.py
index f5f57be0..61e76c63 100644
--- a/zarr/tests/test_indexing.py
+++ b/zarr/tests/test_indexing.py
@@ -1096,7 +1096,6 @@ def _test_set_coordinate_selection(v, a, z, selection):
 
 
 def test_set_coordinate_selection_1d():
-
     # setup
     v = np.arange(1050, dtype=int)
     a = np.empty(v.shape, dtype=v.dtype)
@@ -1154,6 +1153,185 @@ def test_set_coordinate_selection_2d():
     _test_set_coordinate_selection(v, a, z, (ix0, ix1))
 
 
+def _test_get_block_selection(a, z, selection, expected_idx):
+    expect = a[expected_idx]
+    actual = z.get_block_selection(selection)
+    assert_array_equal(expect, actual)
+    actual = z.blocks[selection]
+    assert_array_equal(expect, actual)
+
+
+block_selections_1d = [
+    # test single item
+    0,
+    5,
+    # test wraparound
+    -1,
+    -4,
+    # test slice
+    slice(5),
+    slice(None, 3),
+    slice(5, 6),
+    slice(-3, -1),
+    slice(None),  # Full slice
+]
+
+block_selections_1d_array_projection = [
+    # test single item
+    slice(100),
+    slice(500, 600),
+    # test wraparound
+    slice(1000, None),
+    slice(700, 800),
+    # test slice
+    slice(500),
+    slice(None, 300),
+    slice(500, 600),
+    slice(800, 1000),
+    slice(None),
+]
+
+block_selections_1d_bad = [
+    # slice not supported
+    slice(3, 8, 2),
+    # bad stuff
+    2.3,
+    'foo',
+    b'xxx',
+    None,
+    (0, 0),
+    (slice(None), slice(None)),
+    [0, 5, 3]
+]
+
+
+def test_get_block_selection_1d():
+    # setup
+    a = np.arange(1050, dtype=int)
+    z = zarr.create(shape=a.shape, chunks=100, dtype=a.dtype)
+    z[:] = a
+
+    for selection, expected_idx in \
+            zip(block_selections_1d, block_selections_1d_array_projection):
+        _test_get_block_selection(a, z, selection, expected_idx)
+
+    bad_selections = block_selections_1d_bad + [
+        z.nchunks + 1,  # out of bounds
+        -(z.nchunks + 1),  # out of bounds
+    ]
+
+    for selection in bad_selections:
+        with pytest.raises(IndexError):
+            z.get_block_selection(selection)
+        with pytest.raises(IndexError):
+            z.blocks[selection]
+
+
+block_selections_2d = [
+    # test single item
+    (0, 0),
+    (1, 2),
+    # test wraparound
+    (-1, -1),
+    (-3, -2),
+    # test slice
+    (slice(1), slice(2)),
+    (slice(None, 2), slice(-2, -1)),
+    (slice(2, 3), slice(-2, None)),
+    (slice(-3, -1), slice(-3, -2)),
+    (slice(None), slice(None)),  # Full slice
+]
+
+block_selections_2d_array_projection = [
+    # test single item
+    (slice(300), slice(3)),
+    (slice(300, 600), slice(6, 9)),
+    # test wraparound
+    (slice(900, None), slice(9, None)),
+    (slice(300, 600), slice(6, 9)),
+    # test slice
+    (slice(300), slice(6)),
+    (slice(None, 600), slice(6, 9)),
+    (slice(600, 900), slice(6, None)),
+    (slice(300, 900), slice(3, 6)),
+    (slice(None), slice(None)),  # Full slice
+]
+
+
+def test_get_block_selection_2d():
+    # setup
+    a = np.arange(10000, dtype=int).reshape(1000, 10)
+    z = zarr.create(shape=a.shape, chunks=(300, 3), dtype=a.dtype)
+    z[:] = a
+
+    for selection, expected_idx in \
+            zip(block_selections_2d, block_selections_2d_array_projection):
+        _test_get_block_selection(a, z, selection, expected_idx)
+
+    with pytest.raises(IndexError):
+        selection = slice(5, 15), [1, 2, 3]
+        z.get_block_selection(selection)
+    with pytest.raises(IndexError):
+        selection = Ellipsis, [1, 2, 3]
+        z.get_block_selection(selection)
+    with pytest.raises(IndexError):  # out of bounds
+        selection = slice(15, 20), slice(None)
+        z.get_block_selection(selection)
+
+
+def _test_set_block_selection(v: np.ndarray, a: np.ndarray, z: zarr.Array, selection, expected_idx):
+    for value in 42, v[expected_idx], v[expected_idx].tolist():
+        # setup expectation
+        a[:] = 0
+        a[expected_idx] = value
+        # test long-form API
+        z[:] = 0
+        z.set_block_selection(selection, value)
+        assert_array_equal(a, z[:])
+        # test short-form API
+        z[:] = 0
+        z.blocks[selection] = value
+        assert_array_equal(a, z[:])
+
+
+def test_set_block_selection_1d():
+    # setup
+    v = np.arange(1050, dtype=int)
+    a = np.empty(v.shape, dtype=v.dtype)
+    z = zarr.create(shape=a.shape, chunks=100, dtype=a.dtype)
+
+    for selection, expected_idx in \
+            zip(block_selections_1d, block_selections_1d_array_projection):
+        _test_set_block_selection(v, a, z, selection, expected_idx)
+
+    for selection in block_selections_1d_bad:
+        with pytest.raises(IndexError):
+            z.set_block_selection(selection, 42)
+        with pytest.raises(IndexError):
+            z.blocks[selection] = 42
+
+
+def test_set_block_selection_2d():
+    # setup
+    v = np.arange(10000, dtype=int).reshape(1000, 10)
+    a = np.empty(v.shape, dtype=v.dtype)
+    z = zarr.create(shape=a.shape, chunks=(300, 3), dtype=a.dtype)
+
+    for selection, expected_idx in \
+            zip(block_selections_2d, block_selections_2d_array_projection):
+        _test_set_block_selection(v, a, z, selection, expected_idx)
+
+    with pytest.raises(IndexError):
+        selection = slice(5, 15), [1, 2, 3]
+        z.set_block_selection(selection, 42)
+    with pytest.raises(IndexError):
+        selection = Ellipsis, [1, 2, 3]
+        z.set_block_selection(selection, 42)
+    with pytest.raises(IndexError):  # out of bounds
+        selection = slice(15, 20), slice(None)
+        z.set_block_selection(selection, 42)
+
+
 def _test_get_mask_selection(a, z, selection):
     expect = a[selection]
     actual = z.get_mask_selection(selection)