Repository: xgcm/xhistogram

Issue:
Bins argument of type int doesn't work
Currently the documentation suggests that one can pass an int `xhist(bins=...)` similarly to that of numpy, but it doesn't work and looks like it isn't tested for in the pytest suite.

```python
import numpy as np
import xarray as xr

# Demo data
A = np.arange(100)
da = xr.DataArray(A, dims='time').rename('test')
```

Case 1:
`xhist(da, bins=10)`

```python-traceback
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-48-1c6580238521> in <module>
----> 1 xhist(da, bins=10)

~/miniconda3/envs/analysis/lib/python3.6/site-packages/xhistogram/xarray.py in histogram(bins, dim, weights, density, block_size, bin_dim_suffix, bin_edge_suffix, *args)
     72     # some sanity checks
     73     # TODO: replace this with a more robust function
---> 74     assert len(bins)==N_args
     75     for bin in bins:
     76         assert isinstance(bin, np.ndarray), 'all bins must be numpy arrays'

TypeError: object of type 'int' has no len()
```

Case 2:
`xhist(da, bins=[10])`

```python-traceback
---------------------------------------------------------------------------
AssertionError                            Traceback (most recent call last)
<ipython-input-49-85ba491a8442> in <module>
----> 1 xhist(da, bins=[10])

~/miniconda3/envs/analysis/lib/python3.6/site-packages/xhistogram/xarray.py in histogram(bins, dim, weights, density, block_size, bin_dim_suffix, bin_edge_suffix, *args)
     74     assert len(bins)==N_args
     75     for bin in bins:
---> 76         assert isinstance(bin, np.ndarray), 'all bins must be numpy arrays'
     77 
     78     for a in args:

AssertionError: all bins must be numpy arrays
```

Case 3:
`xhist(da, bins=[np.array(10)])`

```python-traceback
---------------------------------------------------------------------------
AssertionError                            Traceback (most recent call last)
<ipython-input-50-394f2e7f4bcc> in <module>
----> 1 xhist(da, bins=[np.array(10)])

~/miniconda3/envs/analysis/lib/python3.6/site-packages/xhistogram/xarray.py in histogram(bins, dim, weights, density, block_size, bin_dim_suffix, bin_edge_suffix, *args)
    127 
    128     h_data = _histogram(*args_data, weights=weights_data, bins=bins, axis=axis,
--> 129                         block_size=block_size)
    130 
    131     # create output dims

~/miniconda3/envs/analysis/lib/python3.6/site-packages/xhistogram/core.py in histogram(bins, axis, weights, density, block_size, *args)
    245     h = _histogram_2d_vectorized(*all_args_reshaped, bins=bins,
    246                                  weights=weights_reshaped,
--> 247                                  density=density, block_size=block_size)
    248 
    249     if h.shape[0] == 1:

~/miniconda3/envs/analysis/lib/python3.6/site-packages/xhistogram/core.py in _histogram_2d_vectorized(bins, weights, density, right, block_size, *args)
     95     for a, b in zip(args, bins):
     96         assert a.ndim == 2
---> 97         assert b.ndim == 1
     98         assert a.shape == a0.shape
     99     if weights is not None:

AssertionError: 
```

Comments:

Patch:
diff --git a/doc/contributing.rst b/doc/contributing.rst
index 67aa942..e2a8407 100644
--- a/doc/contributing.rst
+++ b/doc/contributing.rst
@@ -140,6 +140,13 @@ Preparing Pull Requests
 Release History
 ---------------
 
+v0.2.1 (not yet released)
+~~~~~~~~~~~~~~~~~~~~~~~~~
+- Implemented various options for users for providing bins to
+  xhistogram that mimic the numpy histogram API. This included
+  adding a range argument to the xhistogram API :issue:`13`.
+  By `Dougie Squire <https://github.com/dougiesquire>`_.
+
 v0.2.0
 ~~~~~~
 
diff --git a/xhistogram/core.py b/xhistogram/core.py
index 9275e3b..3470506 100644
--- a/xhistogram/core.py
+++ b/xhistogram/core.py
@@ -3,8 +3,10 @@ Numpy API for xhistogram.
 """
 
 
+import dask
 import numpy as np
 from functools import reduce
+from collections.abc import Iterable
 from .duck_array_ops import (
     digitize,
     bincount,
@@ -13,16 +15,45 @@ from .duck_array_ops import (
     concatenate,
     broadcast_arrays,
 )
-import warnings
 
+# range is a keyword so save the builtin so they can use it.
+_range = range
 
-def _ensure_bins_is_a_list_of_arrays(bins, N_expected):
+
+def _ensure_correctly_formatted_bins(bins, N_expected):
+    # TODO: This could be done better / more robustly
+    if bins is None:
+        raise ValueError("bins must be provided")
+    if isinstance(bins, (int, str, np.ndarray)):
+        bins = N_expected * [bins]
     if len(bins) == N_expected:
         return bins
-    elif N_expected == 1:
-        return [bins]
     else:
-        raise ValueError("Can't figure out what to do with bins.")
+        raise ValueError(
+            "The number of bin definitions doesn't match the number of args"
+        )
+
+
+def _ensure_correctly_formatted_range(range_, N_expected):
+    # TODO: This could be done better / more robustly
+    def _iterable_nested(x):
+        return all(isinstance(i, Iterable) for i in x)
+
+    if range_ is not None:
+        if (len(range_) == 2) & (not _iterable_nested(range_)):
+            return N_expected * [range_]
+        elif N_expected == len(range_):
+            if all(len(x) == 2 for x in range_):
+                return range_
+            else:
+                raise ValueError(
+                    "range should be provided as (lower_range, upper_range). In the "
+                    + "case of multiple args, range should be a list of such tuples"
+                )
+        else:
+            raise ValueError("The number of ranges doesn't match the number of args")
+    else:
+        return N_expected * [range_]
 
 
 def _bincount_2d(bin_indices, weights, N, hist_shapes):
@@ -148,7 +179,13 @@ def _histogram_2d_vectorized(
 
 
 def histogram(
-    *args, bins=None, axis=None, weights=None, density=False, block_size="auto"
+    *args,
+    bins=None,
+    range=None,
+    axis=None,
+    weights=None,
+    density=False,
+    block_size="auto",
 ):
     """Histogram applied along specified axis / axes.
 
@@ -158,23 +195,38 @@ def histogram(
         Input data. The number of input arguments determines the dimensonality
         of the histogram. For example, two arguments prodocue a 2D histogram.
         All args must have the same size.
-    bins :  int or array_like or a list of ints or arrays, optional
+    bins :  int, str or numpy array or a list of ints, strs and/or arrays, optional
         If a list, there should be one entry for each item in ``args``.
-        The bin specification:
+        The bin specifications are as follows:
 
-          * If int, the number of bins for all arguments in ``args``.
-          * If array_like, the bin edges for all arguments in ``args``.
-          * If a list of ints, the number of bins  for every argument in ``args``.
-          * If a list arrays, the bin edges for each argument in ``args``
-            (required format for Dask inputs).
-          * A combination [int, array] or [array, int], where int
-            is the number of bins and array is the bin edges.
+          * If int; the number of bins for all arguments in ``args``.
+          * If str; the method used to automatically calculate the optimal bin width
+            for all arguments in ``args``, as defined by numpy `histogram_bin_edges`.
+          * If numpy array; the bin edges for all arguments in ``args``.
+          * If a list of ints, strs and/or arrays; the bin specification as
+            above for every argument in ``args``.
 
         When bin edges are specified, all but the last (righthand-most) bin include
         the left edge and exclude the right edge. The last bin includes both edges.
 
-        A ``TypeError`` will be raised if ``args`` contains dask arrays and
-        ``bins`` are not specified explicitly as a list of arrays.
+        A TypeError will be raised if args contains dask arrays and bins are not
+        specified explicitly as an array or list of arrays. This is because other
+        bin specifications trigger computation.
+    range : (float, float) or a list of (float, float), optional
+        If a list, there should be one entry for each item in ``args``.
+        The range specifications are as follows:
+
+          * If (float, float); the lower and upper range(s) of the bins for all
+            arguments in ``args``. Values outside the range are ignored. The first
+            element of the range must be less than or equal to the second. `range`
+            affects the automatic bin computation as well. In this case, while bin
+            width is computed to be optimal based on the actual data within `range`,
+            the bin count will fill the entire range including portions containing
+            no data.
+          * If a list of (float, float); the ranges as above for every argument in
+            ``args``.
+          * If not provided, range is simply ``(arg.min(), arg.max())`` for each
+            arg.
     axis : None or int or tuple of ints, optional
         Axis or axes along which the histogram is computed. The default is to
         compute the histogram of the flattened array
@@ -203,25 +255,19 @@ def histogram(
     -------
     hist : array
         The values of the histogram.
+    bin_edges : list of arrays
+        Return the bin edges for each input array.
 
     See Also
     --------
     numpy.histogram, numpy.bincount, numpy.digitize
     """
 
-    # Future warning for https://github.com/xgcm/xhistogram/pull/45
-    warnings.warn(
-        "Future versions of xhistogram.core.histogram will return a "
-        + "tuple containing arrays of the the histogram bins and the "
-        + "histogram values, rather than just an array of the histogram "
-        + "values. This API change will only affect users of "
-        + "xhistogram.core. Users of xhistogram.xarray can ignore this "
-        + "message.",
-        FutureWarning,
-    )
-
     a0 = args[0]
     ndim = a0.ndim
+    n_inputs = len(args)
+
+    is_dask_array = any([dask.is_dask_collection(a) for a in args])
 
     if axis is not None:
         axis = np.atleast_1d(axis)
@@ -236,11 +282,11 @@ def histogram(
             axis_normed.append(ax_positive)
         axis = np.atleast_1d(axis_normed)
 
-    do_full_array = (axis is None) or (set(axis) == set(range(a0.ndim)))
+    do_full_array = (axis is None) or (set(axis) == set(_range(a0.ndim)))
     if do_full_array:
         kept_axes_shape = None
     else:
-        kept_axes_shape = tuple([a0.shape[i] for i in range(a0.ndim) if i not in axis])
+        kept_axes_shape = tuple([a0.shape[i] for i in _range(a0.ndim) if i not in axis])
 
     all_args = list(args)
     if weights is not None:
@@ -254,7 +300,7 @@ def histogram(
             # reshape the array to 2D
             # axis 0: preserved axis after histogram
             # axis 1: calculate histogram along this axis
-            new_pos = tuple(range(-len(axis), 0))
+            new_pos = tuple(_range(-len(axis), 0))
             c = np.moveaxis(a, axis, new_pos)
             split_idx = c.ndim - len(axis)
             dims_0 = c.shape[:split_idx]
@@ -272,8 +318,23 @@ def histogram(
     else:
         weights_reshaped = None
 
-    n_inputs = len(all_args_reshaped)
-    bins = _ensure_bins_is_a_list_of_arrays(bins, n_inputs)
+    # Some sanity checks and format bins and range correctly
+    bins = _ensure_correctly_formatted_bins(bins, n_inputs)
+    range = _ensure_correctly_formatted_range(range, n_inputs)
+
+    # histogram_bin_edges trigges computation on dask arrays. It would be possible
+    # to write a version of this that doesn't trigger when `range` is provided, but
+    # for now let's just use np.histogram_bin_edges
+    if is_dask_array:
+        if not all([isinstance(b, np.ndarray) for b in bins]):
+            raise TypeError(
+                "When using dask arrays, bins must be provided as numpy array(s) of edges"
+            )
+    else:
+        bins = [
+            np.histogram_bin_edges(a, b, r, weights_reshaped)
+            for a, b, r in zip(all_args_reshaped, bins, range)
+        ]
 
     bin_counts = _histogram_2d_vectorized(
         *all_args_reshaped,
@@ -306,4 +367,4 @@ def histogram(
         final_shape = kept_axes_shape + h.shape[1:]
         h = reshape(h, final_shape)
 
-    return h
+    return h, bins
diff --git a/xhistogram/xarray.py b/xhistogram/xarray.py
index 8c5e944..aea7a1f 100644
--- a/xhistogram/xarray.py
+++ b/xhistogram/xarray.py
@@ -3,22 +3,23 @@ Xarray API for xhistogram.
 """
 
 import xarray as xr
-import numpy as np
 from collections import OrderedDict
 from .core import histogram as _histogram
-import warnings
+
+# range is a keyword so save the builtin so they can use it.
+_range = range
 
 
 def histogram(
     *args,
     bins=None,
+    range=None,
     dim=None,
     weights=None,
     density=False,
     block_size="auto",
     keep_coords=False,
-    bin_dim_suffix="_bin",
-    bin_edge_suffix="_bin_edge"
+    bin_dim_suffix="_bin"
 ):
     """Histogram applied along specified dimensions.
 
@@ -28,23 +29,38 @@ def histogram(
         Input data. The number of input arguments determines the dimensonality of
         the histogram. For example, two arguments prodocue a 2D histogram. All
         args must be aligned and have the same dimensions.
-    bins :  int or array_like or a list of ints or arrays, optional
+    bins :  int, str or numpy array or a list of ints, strs and/or arrays, optional
         If a list, there should be one entry for each item in ``args``.
-        The bin specification:
+        The bin specifications are as follows:
 
-          * If int, the number of bins for all arguments in ``args``.
-          * If array_like, the bin edges for all arguments in ``args``.
-          * If a list of ints, the number of bins  for every argument in ``args``.
-          * If a list arrays, the bin edges for each argument in ``args``
-            (required format for Dask inputs).
-          * A combination [int, array] or [array, int], where int
-            is the number of bins and array is the bin edges.
+          * If int; the number of bins for all arguments in ``args``.
+          * If str; the method used to automatically calculate the optimal bin width
+            for all arguments in ``args``, as defined by numpy `histogram_bin_edges`.
+          * If numpy array; the bin edges for all arguments in ``args``.
+          * If a list of ints, strs and/or arrays; the bin specification as
+            above for every argument in ``args``.
 
         When bin edges are specified, all but the last (righthand-most) bin include
         the left edge and exclude the right edge. The last bin includes both edges.
 
-        A ``TypeError`` will be raised if ``args`` contains dask arrays and
-        ``bins`` are not specified explicitly as a list of arrays.
+        A TypeError will be raised if args contains dask arrays and bins are not
+        specified explicitly as an array or list of arrays. This is because other
+        bin specifications trigger computation.
+    range : (float, float) or a list of (float, float), optional
+        If a list, there should be one entry for each item in ``args``.
+        The range specifications are as follows:
+
+          * If (float, float); the lower and upper range(s) of the bins for all
+            arguments in ``args``. Values outside the range are ignored. The first
+            element of the range must be less than or equal to the second. `range`
+            affects the automatic bin computation as well. In this case, while bin
+            width is computed to be optimal based on the actual data within `range`,
+            the bin count will fill the entire range including portions containing
+            no data.
+          * If a list of (float, float); the ranges as above for every argument in
+            ``args``.
+          * If not provided, range is simply ``(arg.min(), arg.max())`` for each
+            arg.
     dim : tuple of strings, optional
         Dimensions over which which the histogram is computed. The default is to
         compute the histogram of the flattened array.
@@ -72,11 +88,15 @@ def histogram(
         chunks (dask inputs) or an experimental built-in heuristic (numpy inputs).
     keep_coords : bool, optional
         If ``True``, keep all coordinates. Default: ``False``
+    bin_dim_suffix : str, optional
+        Suffix to append to input arg names to define names of output bin
+        dimensions
 
     Returns
     -------
-    hist : array
-        The values of the histogram.
+    hist : xarray.DataArray
+        The values of the histogram. For each bin, the midpoint of the bin edges
+        is given along the bin coordinates.
 
     """
 
@@ -85,12 +105,6 @@ def histogram(
     # TODO: allow list of weights as well
     N_weights = 1 if weights is not None else 0
 
-    # some sanity checks
-    # TODO: replace this with a more robust function
-    assert len(bins) == N_args
-    for bin in bins:
-        assert isinstance(bin, np.ndarray), "all bins must be numpy arrays"
-
     for a in args:
         # TODO: make this a more robust check
         assert a.name is not None, "all arrays must have a name"
@@ -140,21 +154,15 @@ def histogram(
         dims_to_keep = []
         axis = None
 
-    # Allow future warning for https://github.com/xgcm/xhistogram/pull/45
-    with warnings.catch_warnings():
-        warnings.filterwarnings(
-            "ignore",
-            message="Future versions of xhistogram\\.core\\.histogram will return",
-            category=FutureWarning,
-        )
-        h_data = _histogram(
-            *args_data,
-            weights=weights_data,
-            bins=bins,
-            axis=axis,
-            density=density,
-            block_size=block_size
-        )
+    h_data, bins = _histogram(
+        *args_data,
+        weights=weights_data,
+        bins=bins,
+        range=range,
+        axis=axis,
+        density=density,
+        block_size=block_size
+    )
 
     # create output dims
     new_dims = [a.name + bin_dim_suffix for a in args[:N_args]]
@@ -195,7 +203,7 @@ def histogram(
     # this feels like a hack
     # def _histogram_wrapped(*args, **kwargs):
     #     alist = list(args)
-    #     weights = [alist.pop() for n in range(N_weights)]
+    #     weights = [alist.pop() for n in _range(N_weights)]
     #     if N_weights == 0:
     #         weights = None
     #     elif N_weights == 1:


Test patch:
diff --git a/xhistogram/test/test_core.py b/xhistogram/test/test_core.py
index eef22dd..e0df0ee 100644
--- a/xhistogram/test/test_core.py
+++ b/xhistogram/test/test_core.py
@@ -3,40 +3,61 @@ import numpy as np
 from itertools import combinations
 import dask.array as dsa
 
-from ..core import histogram
+from ..core import (
+    histogram,
+    _ensure_correctly_formatted_bins,
+    _ensure_correctly_formatted_range,
+)
 from .fixtures import empty_dask_array
 
 import pytest
 
 
+bins_int = 10
+bins_str = "auto"
+bins_arr = np.linspace(-4, 4, 10)
+range_ = (0, 1)
+
+
 @pytest.mark.parametrize("density", [False, True])
 @pytest.mark.parametrize("block_size", [None, 1, 2])
 @pytest.mark.parametrize("axis", [1, None])
-def test_histogram_results_1d(block_size, density, axis):
+@pytest.mark.parametrize("bins", [10, np.linspace(-4, 4, 10), "auto"])
+@pytest.mark.parametrize("range_", [None, (-4, 4)])
+def test_histogram_results_1d(block_size, density, axis, bins, range_):
     nrows, ncols = 5, 20
     # Setting the random seed here prevents np.testing.assert_allclose
     # from failing beow. We should investigate this further.
     np.random.seed(2)
     data = np.random.randn(nrows, ncols)
-    bins = np.linspace(-4, 4, 10)
 
-    h = histogram(data, bins=bins, axis=axis, block_size=block_size, density=density)
+    h, bin_edges = histogram(
+        data, bins=bins, range=range_, axis=axis, block_size=block_size, density=density
+    )
 
-    expected_shape = (nrows, len(bins) - 1) if axis == 1 else (len(bins) - 1,)
+    expected_shape = (
+        (nrows, len(bin_edges[0]) - 1) if axis == 1 else (len(bin_edges[0]) - 1,)
+    )
     assert h.shape == expected_shape
 
     # make sure we get the same thing as numpy.histogram
     if axis:
+        bins_np = np.histogram_bin_edges(
+            data, bins=bins, range=range_
+        )  # Use same bins for all slices below
         expected = np.stack(
-            [np.histogram(data[i], bins=bins, density=density)[0] for i in range(nrows)]
+            [
+                np.histogram(data[i], bins=bins_np, range=range_, density=density)[0]
+                for i in range(nrows)
+            ]
         )
     else:
-        expected = np.histogram(data, bins=bins, density=density)[0]
+        expected = np.histogram(data, bins=bins, range=range_, density=density)[0]
     norm = nrows if (density and axis) else 1
     np.testing.assert_allclose(h, expected / norm)
 
     if density:
-        widths = np.diff(bins)
+        widths = np.diff(bin_edges)
         integral = np.sum(h * widths)
         np.testing.assert_allclose(integral, 1.0)
 
@@ -46,9 +67,9 @@ def test_histogram_results_1d_weighted(block_size):
     nrows, ncols = 5, 20
     data = np.random.randn(nrows, ncols)
     bins = np.linspace(-4, 4, 10)
-    h = histogram(data, bins=bins, axis=1, block_size=block_size)
+    h, _ = histogram(data, bins=bins, axis=1, block_size=block_size)
     weights = 2 * np.ones_like(data)
-    h_w = histogram(data, bins=bins, axis=1, weights=weights, block_size=block_size)
+    h_w, _ = histogram(data, bins=bins, axis=1, weights=weights, block_size=block_size)
     np.testing.assert_array_equal(2 * h, h_w)
 
 
@@ -58,9 +79,9 @@ def test_histogram_results_1d_weighted_broadcasting(block_size):
     nrows, ncols = 5, 20
     data = np.random.randn(nrows, ncols)
     bins = np.linspace(-4, 4, 10)
-    h = histogram(data, bins=bins, axis=1, block_size=block_size)
+    h, _ = histogram(data, bins=bins, axis=1, block_size=block_size)
     weights = 2 * np.ones((1, ncols))
-    h_w = histogram(data, bins=bins, axis=1, weights=weights, block_size=block_size)
+    h_w, _ = histogram(data, bins=bins, axis=1, weights=weights, block_size=block_size)
     np.testing.assert_array_equal(2 * h, h_w)
 
 
@@ -73,7 +94,7 @@ def test_histogram_right_edge(block_size):
     data = np.ones((nrows, ncols))
     bins = np.array([0, 0.5, 1])  # All data at rightmost edge
 
-    h = histogram(data, bins=bins, axis=1, block_size=block_size)
+    h, _ = histogram(data, bins=bins, axis=1, block_size=block_size)
     assert h.shape == (nrows, len(bins) - 1)
 
     # make sure we get the same thing as histogram (all data in the last bin)
@@ -81,7 +102,7 @@ def test_histogram_right_edge(block_size):
     np.testing.assert_array_equal(hist, h.sum(axis=0))
 
     # now try with no axis
-    h_na = histogram(data, bins=bins, block_size=block_size)
+    h_na, _ = histogram(data, bins=bins, block_size=block_size)
     np.testing.assert_array_equal(hist, h_na)
 
 
@@ -94,7 +115,7 @@ def test_histogram_results_2d():
     nbins_b = 10
     bins_b = np.linspace(-4, 4, nbins_b + 1)
 
-    h = histogram(data_a, data_b, bins=[bins_a, bins_b])
+    h, _ = histogram(data_a, data_b, bins=[bins_a, bins_b])
     assert h.shape == (nbins_a, nbins_b)
 
     hist, _, _ = np.histogram2d(data_a.ravel(), data_b.ravel(), bins=[bins_a, bins_b])
@@ -110,7 +131,7 @@ def test_histogram_results_2d_density():
     nbins_b = 10
     bins_b = np.linspace(-4, 4, nbins_b + 1)
 
-    h = histogram(data_a, data_b, bins=[bins_a, bins_b], density=True)
+    h, _ = histogram(data_a, data_b, bins=[bins_a, bins_b], density=True)
     assert h.shape == (nbins_a, nbins_b)
 
     hist, _, _ = np.histogram2d(
@@ -138,7 +159,9 @@ def test_histogram_results_3d_density():
     nbins_c = 9
     bins_c = np.linspace(-4, 4, nbins_c + 1)
 
-    h = histogram(data_a, data_b, data_c, bins=[bins_a, bins_b, bins_c], density=True)
+    h, _ = histogram(
+        data_a, data_b, data_c, bins=[bins_a, bins_b, bins_c], density=True
+    )
 
     assert h.shape == (nbins_a, nbins_b, nbins_c)
 
@@ -173,18 +196,18 @@ def test_histogram_shape(use_dask, block_size):
     bins = np.linspace(-4, 4, 27)
 
     # no axis
-    c = histogram(b, bins=bins, block_size=block_size)
+    c, _ = histogram(b, bins=bins, block_size=block_size)
     assert c.shape == (len(bins) - 1,)
     # same thing
     for axis in [(0, 1, 2, 3), (0, 1, 3, 2), (3, 2, 1, 0), (3, 2, 0, 1)]:
-        c = histogram(b, bins=bins, axis=axis)
+        c, _ = histogram(b, bins=bins, axis=axis)
         assert c.shape == (len(bins) - 1,)
         if use_dask:
             assert isinstance(c, dsa.Array)
 
     # scalar axis (check positive and negative)
     for axis in list(range(4)) + list(range(-1, -5, -1)):
-        c = histogram(b, bins=bins, axis=axis, block_size=block_size)
+        c, _ = histogram(b, bins=bins, axis=axis, block_size=block_size)
         shape = list(b.shape)
         del shape[axis]
         expected_shape = tuple(shape) + (len(bins) - 1,)
@@ -195,10 +218,70 @@ def test_histogram_shape(use_dask, block_size):
     # two axes
     for i, j in combinations(range(4), 2):
         axis = (i, j)
-        c = histogram(b, bins=bins, axis=axis, block_size=block_size)
+        c, _ = histogram(b, bins=bins, axis=axis, block_size=block_size)
         shape = list(b.shape)
         partial_shape = [shape[k] for k in range(b.ndim) if k not in axis]
         expected_shape = tuple(partial_shape) + (len(bins) - 1,)
         assert c.shape == expected_shape
         if use_dask:
             assert isinstance(c, dsa.Array)
+
+
+def test_histogram_dask():
+    """ Test that fails with dask arrays and inappropriate bins"""
+    shape = 10, 15, 12, 20
+    b = empty_dask_array(shape, chunks=(1,) + shape[1:])
+    histogram(b, bins=bins_arr)  # Should work when bins is all numpy arrays
+    with pytest.raises(TypeError):  # Should fail otherwise
+        histogram(b, bins=bins_int)
+        histogram(b, bins=bins_str)
+        histogram(b, b, bins=[bins_arr, bins_int])
+
+
+@pytest.mark.parametrize(
+    "in_out",
+    [
+        (bins_int, 1, [bins_int]),  # ( bins_in, n_args, bins_out )
+        (bins_str, 1, [bins_str]),
+        (bins_arr, 1, [bins_arr]),
+        ([bins_int], 1, [bins_int]),
+        (bins_int, 2, 2 * [bins_int]),
+        (bins_str, 2, 2 * [bins_str]),
+        (bins_arr, 2, 2 * [bins_arr]),
+        ([bins_int, bins_str, bins_arr], 3, [bins_int, bins_str, bins_arr]),
+        ([bins_arr], 2, None),
+        (None, 1, None),
+        ([bins_arr, bins_arr], 1, None),
+    ],
+)
+def test_ensure_correctly_formatted_bins(in_out):
+    """ Test the helper function _ensure_correctly_formatted_bins"""
+    bins_in, n, bins_expected = in_out
+    if bins_expected is not None:
+        bins = _ensure_correctly_formatted_bins(bins_in, n)
+        assert bins == bins_expected
+    else:
+        with pytest.raises((ValueError, TypeError)):
+            _ensure_correctly_formatted_bins(bins_in, n)
+
+
+@pytest.mark.parametrize(
+    "in_out",
+    [
+        (range_, 1, [range_]),  # ( range_in, n_args, range_out )
+        (range_, 2, [range_, range_]),
+        ([range_, range_], 2, [range_, range_]),
+        ([(range_[0],)], 1, None),
+        ([range_], 2, None),
+        ([range_, range_], 1, None),
+    ],
+)
+def test_ensure_correctly_formatted_range(in_out):
+    """ Test the helper function _ensure_correctly_formatted_range"""
+    range_in, n, range_expected = in_out
+    if range_expected is not None:
+        range_ = _ensure_correctly_formatted_range(range_in, n)
+        assert range_ == range_expected
+    else:
+        with pytest.raises(ValueError):
+            _ensure_correctly_formatted_range(range_in, n)