Repository: pydata/xarray

Issue:
failing upstream-dev CI: matplotlib
Recent changes in `matplotlib` (my guess is matplotlib/matplotlib#17830) seem to have broken a few of our tests:

<details><summary>tracebacks</summary>

```
________________________ TestContour.test_single_level _________________________

self = <xarray.tests.test_plot.TestContour object at 0x7fc60f7c3e20>

        # add_colorbar defaults to false
>       self.plotmethod(levels=[0.1])

xarray/tests/test_plot.py:1561: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
xarray/plot/plot.py:827: in plotmethod
    return newplotfunc(**allargs)
xarray/plot/plot.py:699: in newplotfunc
    cmap_params, cbar_kwargs = _process_cmap_cbar_kwargs(
xarray/plot/utils.py:819: in _process_cmap_cbar_kwargs
    cmap_params = _determine_cmap_params(**cmap_kwargs)
xarray/plot/utils.py:291: in _determine_cmap_params
    cmap, newnorm = _build_discrete_cmap(cmap, levels, extend, filled)
xarray/plot/utils.py:77: in _build_discrete_cmap
    new_cmap, cnorm = mpl.colors.from_levels_and_colors(levels, pal, extend=extend)
/usr/share/miniconda/envs/xarray-tests/lib/python3.8/site-packages/matplotlib/colors.py:2200: in from_levels_and_colors
    norm = BoundaryNorm(levels, ncolors=n_data_colors)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <matplotlib.colors.BoundaryNorm object at 0x7fc60f5048e0>
boundaries = [0.1], ncolors = 0, clip = False

    def __init__(self, boundaries, ncolors, clip=False, *, extend='neither'):
        """
        Parameters
        ----------
        boundaries : array-like
            Monotonically increasing sequence of at least 2 boundaries.
        ncolors : int
            Number of colors in the colormap to be used.
        clip : bool, optional
        """
        if clip and extend != 'neither':
            raise ValueError("'clip=True' is not compatible with 'extend'")
        self.clip = clip
        self.vmin = boundaries[0]
        self.vmax = boundaries[-1]
        self.boundaries = np.asarray(boundaries)
        self.N = len(self.boundaries)
        if self.N < 2:
>           raise ValueError("You must provide at least 2 boundaries "
                             f"(1 region) but you passed in {boundaries!r}")
E           ValueError: You must provide at least 2 boundaries (1 region) but you passed in [0.1]

/usr/share/miniconda/envs/xarray-tests/lib/python3.8/site-packages/matplotlib/colors.py:1508: ValueError
________________________ test_facetgrid_single_contour _________________________

    @requires_matplotlib
    def test_facetgrid_single_contour():

    @requires_matplotlib
    def test_facetgrid_single_contour():
        # regression test for GH3569
        x, y = np.meshgrid(np.arange(12), np.arange(12))
        z = xr.DataArray(np.sqrt(x ** 2 + y ** 2))
        z2 = xr.DataArray(np.sqrt(x ** 2 + y ** 2) + 1)
        ds = xr.concat([z, z2], dim="time")
        ds["time"] = [0, 1]
    
>       ds.plot.contour(col="time", levels=[4], colors=["k"])

xarray/tests/test_plot.py:2409: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
xarray/plot/plot.py:827: in plotmethod
    return newplotfunc(**allargs)
xarray/plot/plot.py:638: in newplotfunc
    return _easy_facetgrid(darray, kind="dataarray", **allargs)
xarray/plot/facetgrid.py:644: in _easy_facetgrid
    return g.map_dataarray(plotfunc, x, y, **kwargs)
xarray/plot/facetgrid.py:248: in map_dataarray
    cmap_params, cbar_kwargs = _process_cmap_cbar_kwargs(
xarray/plot/utils.py:819: in _process_cmap_cbar_kwargs
    cmap_params = _determine_cmap_params(**cmap_kwargs)
xarray/plot/utils.py:291: in _determine_cmap_params
    cmap, newnorm = _build_discrete_cmap(cmap, levels, extend, filled)
xarray/plot/utils.py:77: in _build_discrete_cmap
    new_cmap, cnorm = mpl.colors.from_levels_and_colors(levels, pal, extend=extend)
/usr/share/miniconda/envs/xarray-tests/lib/python3.8/site-packages/matplotlib/colors.py:2200: in from_levels_and_colors
    norm = BoundaryNorm(levels, ncolors=n_data_colors)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
self = <matplotlib.colors.BoundaryNorm object at 0x7fc60d47b280>
boundaries = [4], ncolors = 0, clip = False

    def __init__(self, boundaries, ncolors, clip=False, *, extend='neither'):
        """
        Parameters
        ----------
        boundaries : array-like
            Monotonically increasing sequence of at least 2 boundaries.
        ncolors : int
            Number of colors in the colormap to be used.
        clip : bool, optional
            If clip is ``True``, out of range values are mapped to 0 if they
            are below ``boundaries[0]`` or mapped to ``ncolors - 1`` if they
            are above ``boundaries[-1]``.
    
            If clip is ``False``, out of range values are mapped to -1 if
            they are below ``boundaries[0]`` or mapped to *ncolors* if they are
            above ``boundaries[-1]``. These are then converted to valid indices
            by `Colormap.__call__`.
        extend : {'neither', 'both', 'min', 'max'}, default: 'neither'
            Extend the number of bins to include one or both of the
            regions beyond the boundaries.  For example, if ``extend``
            is 'min', then the color to which the region between the first
            pair of boundaries is mapped will be distinct from the first
            color in the colormap, and by default a
            `~matplotlib.colorbar.Colorbar` will be drawn with
            the triangle extension on the left or lower end.
    
        Returns
        -------
        int16 scalar or array
    
        Notes
        -----
        *boundaries* defines the edges of bins, and data falling within a bin
        is mapped to the color with the same index.
    
        If the number of bins, including any extensions, is less than
        *ncolors*, the color index is chosen by linear interpolation, mapping
        the ``[0, nbins - 1]`` range onto the ``[0, ncolors - 1]`` range.
        """
        if clip and extend != 'neither':
            raise ValueError("'clip=True' is not compatible with 'extend'")
        self.clip = clip
        self.vmin = boundaries[0]
        self.vmax = boundaries[-1]
        self.boundaries = np.asarray(boundaries)
        self.N = len(self.boundaries)
        if self.N < 2:
>           raise ValueError("You must provide at least 2 boundaries "
                             f"(1 region) but you passed in {boundaries!r}")
E           ValueError: You must provide at least 2 boundaries (1 region) but you passed in [4]

/usr/share/miniconda/envs/xarray-tests/lib/python3.8/site-packages/matplotlib/colors.py:1508: ValueError
```

</details>

Discussion:
keewis: hmm... it seems that's because the repository is down, so now we're falling back to the PyPI version. @tacaswell, can you confirm?
tacaswell: It does look like the rackspace server we were pushing to seems to be down.  This is more of a @matthew-brett question.
matthew-brett: Thanks @tacaswell :)   Er - sorry - I'm lost - is the problem that xarray can't see the Matplotlib --pre binaries?
keewis: yes, we can't seem to use `pip` to install the pre binaries using
```
python -m pip install \                                                                                                                                                                                                       
        -f https://7933911d6844c6c53a7d-47bd50c35cd79bd838daf386af554a83.ssl.cf2.rackcdn.com \                                                                                                                                    
        --no-deps \                                                                                                                                                                                                               
        --pre \                                                                                                                                                                                                                   
        --upgrade \                                                                                                                                                                                                               
        matplotlib
```

also, when pointing my browser to that url, I get a `resource not found` page. I'm not sure if I did something different back then, but I think when I last did that about a month ago I got a page listing the available files instead.
matthew-brett: Yup - sorry - those URLs went down a while ago, as Tom said.   Tom - are you uploading --pre binaries to Anaconda or similar?
keewis: if not, it might be worth copying `numpy`, `pandas` and `sklearn`, which upload to https://pypi.anaconda.org/scipy-wheels-nightly/
tacaswell: Can you point me at the configs that upload to the correct place?
keewis: I don't know how those projects upload to that repository. @TomAugspurger, can you help with that?
TomAugspurger: Yep. I believe that @ogrisel can add you to the organization on anaconda.org so that you can create a key to upload to packages.
max-sixty: Is this fixed now?
keewis: no, unfortunately not: right now, we're not installing `matplotlib` in that CI at all. I guess we're waiting on MacPython/matplotlib-wheels#13 to get fixed, so we can install the dev version of matplotlib. After that works, we might have to fix some test failures (*and* we might see #4226 again).
max-sixty: Ah, thanks @keewis !
andersy005: @keewis, is it safe to close this PR? As I understand it, this has been addressed in other PRs. 
keewis: Not sure. Removing the `matplotlib-base` package as well is just a placeholder for the changes I opened this PR for. Back then we noticed some failures due to changes in `matplotlib` upstream, but before I could debug them the `rackcdn` index went down.

They made some progress with uploading to `scipy-wheels-nightly` since then, but that was using Travis so now they're moving the upload CI to [github actions](https://github.com/MacPython/matplotlib-wheels/pull/15).

Once that works and the wheels are available on `scipy-wheels-nightly` I will change this PR to have the `upstream-dev` CI install from there and attempt to fix any bug that may be discovered.
andersy005: > Removing the matplotlib-base package as well is just a placeholder for the changes I opened this PR for. Back then we noticed some failures due to changes in matplotlib upstream, but before I could debug them the rackcdn index went down.

Ah, I hadn't noticed that the `rackcdn` index was involved. My assumption was based on seeing the other upstream dev CI working :). Thank you for the clarification 

keewis: if CI agrees this should finally be ready for merging. We still don't run our tests against nightly versions of `matplotlib`, but for that we will have to wait on upstream.

Patch:
diff --git a/doc/whats-new.rst b/doc/whats-new.rst
index 9e59fdc5..9c16fb74 100644
--- a/doc/whats-new.rst
+++ b/doc/whats-new.rst
@@ -22,6 +22,8 @@ v0.17.1 (unreleased)
 
 New Features
 ~~~~~~~~~~~~
+- Allow passing ``combine_attrs`` to :py:meth:`Dataset.merge` (:pull:`4895`).
+  By `Justus Magin <https://github.com/keewis>`_.
 - Support for `dask.graph_manipulation
   <https://docs.dask.org/en/latest/graph_manipulation.html>`_ (requires dask >=2021.3)
   By `Guido Imperiale <https://github.com/crusaderky>`_
diff --git a/xarray/coding/cftimeindex.py b/xarray/coding/cftimeindex.py
index 948bff10..aafd620c 100644
--- a/xarray/coding/cftimeindex.py
+++ b/xarray/coding/cftimeindex.py
@@ -59,6 +59,12 @@ ITEMS_IN_REPR_MAX_ELSE_ELLIPSIS = 100
 REPR_ELLIPSIS_SHOW_ITEMS_FRONT_END = 10
 
 
+if LooseVersion(pd.__version__) > LooseVersion("1.2.3"):
+    OUT_OF_BOUNDS_TIMEDELTA_ERROR = pd.errors.OutOfBoundsTimedelta
+else:
+    OUT_OF_BOUNDS_TIMEDELTA_ERROR = OverflowError
+
+
 def named(name, pattern):
     return "(?P<" + name + ">" + pattern + ")"
 
@@ -562,7 +568,7 @@ class CFTimeIndex(pd.Index):
         elif _contains_cftime_datetimes(np.array(other)):
             try:
                 return pd.TimedeltaIndex(np.array(self) - np.array(other))
-            except OverflowError:
+            except OUT_OF_BOUNDS_TIMEDELTA_ERROR:
                 raise ValueError(
                     "The time difference exceeds the range of values "
                     "that can be expressed at the nanosecond resolution."
@@ -573,7 +579,7 @@ class CFTimeIndex(pd.Index):
     def __rsub__(self, other):
         try:
             return pd.TimedeltaIndex(other - np.array(self))
-        except OverflowError:
+        except OUT_OF_BOUNDS_TIMEDELTA_ERROR:
             raise ValueError(
                 "The time difference exceeds the range of values "
                 "that can be expressed at the nanosecond resolution."
diff --git a/xarray/core/dataset.py b/xarray/core/dataset.py
index a4001c74..db45157e 100644
--- a/xarray/core/dataset.py
+++ b/xarray/core/dataset.py
@@ -3917,6 +3917,7 @@ class Dataset(Mapping, ImplementsDatasetReduce, DataWithCoords):
         compat: str = "no_conflicts",
         join: str = "outer",
         fill_value: Any = dtypes.NA,
+        combine_attrs: str = "override",
     ) -> "Dataset":
         """Merge the arrays of two datasets into a single dataset.
 
@@ -3945,7 +3946,6 @@ class Dataset(Mapping, ImplementsDatasetReduce, DataWithCoords):
             - 'no_conflicts': only values which are not null in both datasets
               must be equal. The returned dataset then contains the combination
               of all non-null values.
-
         join : {"outer", "inner", "left", "right", "exact"}, optional
             Method for joining ``self`` and ``other`` along shared dimensions:
 
@@ -3957,6 +3957,18 @@ class Dataset(Mapping, ImplementsDatasetReduce, DataWithCoords):
         fill_value : scalar or dict-like, optional
             Value to use for newly missing values. If a dict-like, maps
             variable names (including coordinates) to fill values.
+        combine_attrs : {"drop", "identical", "no_conflicts", "drop_conflicts", \
+                        "override"}, default: "override"
+            String indicating how to combine attrs of the objects being merged:
+
+            - "drop": empty attrs on returned Dataset.
+            - "identical": all attrs must be the same on every object.
+            - "no_conflicts": attrs from all objects are combined, any that have
+              the same name must also have the same value.
+            - "drop_conflicts": attrs from all objects are combined, any that have
+              the same name but different values are dropped.
+            - "override": skip comparing and copy attrs from the first dataset to
+              the result.
 
         Returns
         -------
@@ -3976,6 +3988,7 @@ class Dataset(Mapping, ImplementsDatasetReduce, DataWithCoords):
             compat=compat,
             join=join,
             fill_value=fill_value,
+            combine_attrs=combine_attrs,
         )
         return self._replace(**merge_result._asdict())
 
diff --git a/xarray/core/merge.py b/xarray/core/merge.py
index 14beeff3..ec95563b 100644
--- a/xarray/core/merge.py
+++ b/xarray/core/merge.py
@@ -893,6 +893,7 @@ def dataset_merge_method(
     compat: str,
     join: str,
     fill_value: Any,
+    combine_attrs: str,
 ) -> _MergeResult:
     """Guts of the Dataset.merge method."""
     # we are locked into supporting overwrite_vars for the Dataset.merge
@@ -922,7 +923,12 @@ def dataset_merge_method(
         priority_arg = 2
 
     return merge_core(
-        objs, compat, join, priority_arg=priority_arg, fill_value=fill_value
+        objs,
+        compat,
+        join,
+        priority_arg=priority_arg,
+        fill_value=fill_value,
+        combine_attrs=combine_attrs,
     )
 
 
diff --git a/xarray/core/options.py b/xarray/core/options.py
index d421b4c4..12969890 100644
--- a/xarray/core/options.py
+++ b/xarray/core/options.py
@@ -85,6 +85,7 @@ class set_options:
 
     - ``display_width``: maximum display width for ``repr`` on xarray objects.
       Default: ``80``.
+    - ``display_max_rows``: maximum display rows. Default: ``12``.
     - ``arithmetic_join``: DataArray/Dataset alignment in binary operations.
       Default: ``'inner'``.
     - ``file_cache_maxsize``: maximum number of open files to hold in xarray's
diff --git a/xarray/plot/utils.py b/xarray/plot/utils.py
index 5510cf7f..a83bc28e 100644
--- a/xarray/plot/utils.py
+++ b/xarray/plot/utils.py
@@ -60,6 +60,9 @@ def _build_discrete_cmap(cmap, levels, extend, filled):
     """
     import matplotlib as mpl
 
+    if len(levels) == 1:
+        levels = [levels[0], levels[0]]
+
     if not filled:
         # non-filled contour plots
         extend = "max"


Test patch:
diff --git a/xarray/tests/test_dataarray.py b/xarray/tests/test_dataarray.py
index b28a5302..259e9108 100644
--- a/xarray/tests/test_dataarray.py
+++ b/xarray/tests/test_dataarray.py
@@ -7,6 +7,7 @@ from textwrap import dedent
 import numpy as np
 import pandas as pd
 import pytest
+from pandas.tseries.frequencies import to_offset
 
 import xarray as xr
 from xarray import (
@@ -2990,9 +2991,13 @@ class TestDataArray:
         actual = array.resample(time="24H").reduce(np.mean)
         assert_identical(expected, actual)
 
+        # Our use of `loffset` may change if we align our API with pandas' changes.
+        # ref https://github.com/pydata/xarray/pull/4537
         actual = array.resample(time="24H", loffset="-12H").mean()
-        expected = DataArray(array.to_series().resample("24H", loffset="-12H").mean())
-        assert_identical(expected, actual)
+        expected_ = array.to_series().resample("24H").mean()
+        expected_.index += to_offset("-12H")
+        expected = DataArray.from_series(expected_)
+        assert_identical(actual, expected)
 
         with raises_regex(ValueError, "index must be monotonic"):
             array[[2, 0, 1]].resample(time="1D")
diff --git a/xarray/tests/test_dataset.py b/xarray/tests/test_dataset.py
index 8afd6d68..13cd03ac 100644
--- a/xarray/tests/test_dataset.py
+++ b/xarray/tests/test_dataset.py
@@ -9,6 +9,7 @@ import numpy as np
 import pandas as pd
 import pytest
 from pandas.core.indexes.datetimes import DatetimeIndex
+from pandas.tseries.frequencies import to_offset
 
 import xarray as xr
 from xarray import (
@@ -3899,11 +3900,13 @@ class TestDataset:
         )
         ds.attrs["dsmeta"] = "dsdata"
 
-        actual = ds.resample(time="24H", loffset="-12H").mean("time").time
-        expected = xr.DataArray(
-            ds.bar.to_series().resample("24H", loffset="-12H").mean()
-        ).time
-        assert_identical(expected, actual)
+        # Our use of `loffset` may change if we align our API with pandas' changes.
+        # ref https://github.com/pydata/xarray/pull/4537
+        actual = ds.resample(time="24H", loffset="-12H").mean().bar
+        expected_ = ds.bar.to_series().resample("24H").mean()
+        expected_.index += to_offset("-12H")
+        expected = DataArray.from_series(expected_)
+        assert_identical(actual, expected)
 
     def test_resample_by_mean_discarding_attrs(self):
         times = pd.date_range("2000-01-01", freq="6H", periods=10)
diff --git a/xarray/tests/test_merge.py b/xarray/tests/test_merge.py
index 27e2b10d..5b84eccc 100644
--- a/xarray/tests/test_merge.py
+++ b/xarray/tests/test_merge.py
@@ -418,3 +418,34 @@ class TestMergeMethod:
         da = xr.DataArray(data=1, name="b")
 
         assert_identical(ds.merge(da), xr.merge([ds, da]))
+
+    @pytest.mark.parametrize(
+        ["combine_attrs", "attrs1", "attrs2", "expected_attrs", "expect_error"],
+        # don't need to test thoroughly
+        (
+            ("drop", {"a": 0, "b": 1, "c": 2}, {"a": 1, "b": 2, "c": 3}, {}, False),
+            (
+                "drop_conflicts",
+                {"a": 0, "b": 1, "c": 2},
+                {"b": 2, "c": 2, "d": 3},
+                {"a": 0, "c": 2, "d": 3},
+                False,
+            ),
+            ("override", {"a": 0, "b": 1}, {"a": 1, "b": 2}, {"a": 0, "b": 1}, False),
+            ("no_conflicts", {"a": 0, "b": 1}, {"a": 0, "b": 2}, None, True),
+            ("identical", {"a": 0, "b": 1}, {"a": 0, "b": 2}, None, True),
+        ),
+    )
+    def test_merge_combine_attrs(
+        self, combine_attrs, attrs1, attrs2, expected_attrs, expect_error
+    ):
+        ds1 = xr.Dataset(attrs=attrs1)
+        ds2 = xr.Dataset(attrs=attrs2)
+
+        if expect_error:
+            with pytest.raises(xr.MergeError):
+                ds1.merge(ds2, combine_attrs=combine_attrs)
+        else:
+            actual = ds1.merge(ds2, combine_attrs=combine_attrs)
+            expected = xr.Dataset(attrs=expected_attrs)
+            assert_identical(actual, expected)