Repository: pydata/xarray

Issue:
html repr fails for empty cftime arrays
### What happened?

The html repr of a cftime array wants to display the "calendar", which it cannot if it is empty.

### What did you expect to happen?

No error.

### Minimal Complete Verifiable Example

```Python
import numpy as np
import xarray as xr


data_obs = np.random.randn(3)
time_obs = xr.date_range("2000-01-01", periods=3, freq="YS", calendar="noleap")

obs = xr.DataArray(data_obs, coords={"time": time_obs})

o = obs[:0]

xr.core.formatting_html.array_repr(o)
```


### MVCE confirmation

- [ ] Minimal example — the example is as focused as reasonably possible to demonstrate the underlying issue in xarray.
- [ ] Complete example — the example is self-contained, including all data and the text of any traceback.
- [ ] Verifiable example — the example copy & pastes into an IPython prompt or [Binder notebook](https://mybinder.org/v2/gh/pydata/xarray/main?urlpath=lab/tree/doc/examples/blank_template.ipynb), returning the result.
- [ ] New issue — a search of GitHub Issues suggests this is not a duplicate.

### Relevant log output

```Python
ValueError                                Traceback (most recent call last)
Input In [1], in <cell line: 12>()
      8 obs = xr.DataArray(data_obs, coords={"time": time_obs})
     10 o = obs[:0]
---> 12 xr.core.formatting_html.array_repr(o)

File ~/code/xarray/xarray/core/formatting_html.py:318, in array_repr(arr)
    316 if hasattr(arr, "xindexes"):
    317     indexes = _get_indexes_dict(arr.xindexes)
--> 318     sections.append(index_section(indexes))
    320 sections.append(attr_section(arr.attrs))
    322 return _obj_repr(arr, header_components, sections)

File ~/code/xarray/xarray/core/formatting_html.py:195, in _mapping_section(mapping, name, details_func, max_items_collapse, expand_option_name, enabled)
    188 expanded = _get_boolean_with_default(
    189     expand_option_name, n_items < max_items_collapse
    190 )
    191 collapsed = not expanded
    193 return collapsible_section(
    194     name,
--> 195     details=details_func(mapping),
    196     n_items=n_items,
    197     enabled=enabled,
    198     collapsed=collapsed,
    199 )

File ~/code/xarray/xarray/core/formatting_html.py:155, in summarize_indexes(indexes)
    154 def summarize_indexes(indexes):
--> 155     indexes_li = "".join(
    156         f"<li class='xr-var-item'>{summarize_index(v, i)}</li>"
    157         for v, i in indexes.items()
    158     )
    159     return f"<ul class='xr-var-list'>{indexes_li}</ul>"

File ~/code/xarray/xarray/core/formatting_html.py:156, in <genexpr>(.0)
    154 def summarize_indexes(indexes):
    155     indexes_li = "".join(
--> 156         f"<li class='xr-var-item'>{summarize_index(v, i)}</li>"
    157         for v, i in indexes.items()
    158     )
    159     return f"<ul class='xr-var-list'>{indexes_li}</ul>"

File ~/code/xarray/xarray/core/formatting_html.py:140, in summarize_index(coord_names, index)
    138 index_id = f"index-{uuid.uuid4()}"
    139 preview = escape(inline_index_repr(index))
--> 140 details = short_index_repr_html(index)
    142 data_icon = _icon("icon-database")
    144 return (
    145     f"<div class='xr-index-name'><div>{name}</div></div>"
    146     f"<div class='xr-index-preview'>{preview}</div>"
   (...)
    150     f"<div class='xr-index-data'>{details}</div>"
    151 )

File ~/code/xarray/xarray/core/formatting_html.py:132, in short_index_repr_html(index)
    129 if hasattr(index, "_repr_html_"):
    130     return index._repr_html_()
--> 132 return f"<pre>{escape(repr(index))}</pre>"

File ~/code/xarray/xarray/core/indexes.py:547, in PandasIndex.__repr__(self)
    546 def __repr__(self):
--> 547     return f"PandasIndex({repr(self.index)})"

File ~/code/xarray/xarray/coding/cftimeindex.py:353, in CFTimeIndex.__repr__(self)
    345     end_str = format_times(
    346         self.values[-REPR_ELLIPSIS_SHOW_ITEMS_FRONT_END:],
    347         display_width,
    348         offset=offset,
    349         first_row_offset=offset,
    350     )
    351     datastr = "\n".join([front_str, f"{' '*offset}...", end_str])
--> 353 attrs_str = format_attrs(self)
    354 # oneliner only if smaller than display_width
    355 full_repr_str = f"{klass_name}([{datastr}], {attrs_str})"

File ~/code/xarray/xarray/coding/cftimeindex.py:272, in format_attrs(index, separator)
    267 def format_attrs(index, separator=", "):
    268     """Format attributes of CFTimeIndex for __repr__."""
    269     attrs = {
    270         "dtype": f"'{index.dtype}'",
    271         "length": f"{len(index)}",
--> 272         "calendar": f"'{index.calendar}'",
    273         "freq": f"'{index.freq}'" if len(index) >= 3 else None,
    274     }
    276     attrs_str = [f"{k}={v}" for k, v in attrs.items()]
    277     attrs_str = f"{separator}".join(attrs_str)

File ~/code/xarray/xarray/coding/cftimeindex.py:698, in CFTimeIndex.calendar(self)
    695 """The calendar used by the datetimes in the index."""
    696 from .times import infer_calendar_name
--> 698 return infer_calendar_name(self)

File ~/code/xarray/xarray/coding/times.py:374, in infer_calendar_name(dates)
    371             return sample.calendar
    373 # Error raise if dtype is neither datetime or "O", if cftime is not importable, and if element of 'O' dtype is not cftime.
--> 374 raise ValueError("Array does not contain datetime objects.")

ValueError: Array does not contain datetime objects.
```


### Anything else we need to know?

Bisected to 7379923de756a2bcc59044d548f8ab7a68b91d4e use `_repr_inline_` for indexes that define it.

### Environment

<details>



</details>


Discussion:
mathause: I updated `CFTimeIndex` to work for no data. I tried to get the same behavior as pandas and am happy to get feedback.

Patch:
diff --git a/doc/whats-new.rst b/doc/whats-new.rst
index f9d30817..db32de3c 100644
--- a/doc/whats-new.rst
+++ b/doc/whats-new.rst
@@ -69,7 +69,8 @@ Bug fixes
   By `Kai Mühlbauer <https://github.com/kmuehlbauer>`_.
 - Vendor `SerializableLock` from dask and use as default lock for netcdf4 backends (:issue:`8442`, :pull:`8571`).
   By `Kai Mühlbauer <https://github.com/kmuehlbauer>`_.
-
+- Add tests and fixes for empty :py:class:`CFTimeIndex`, including broken html repr (:issue:`7298`, :pull:`8600`).
+  By `Mathias Hauser <https://github.com/mathause>`_.
 
 Documentation
 ~~~~~~~~~~~~~
diff --git a/xarray/coding/cftimeindex.py b/xarray/coding/cftimeindex.py
index b38d8151..bddcea97 100644
--- a/xarray/coding/cftimeindex.py
+++ b/xarray/coding/cftimeindex.py
@@ -187,7 +187,7 @@ def _parsed_string_to_bounds(date_type, resolution, parsed):
 
 def get_date_field(datetimes, field):
     """Adapted from pandas.tslib.get_date_field"""
-    return np.array([getattr(date, field) for date in datetimes])
+    return np.array([getattr(date, field) for date in datetimes], dtype=np.int64)
 
 
 def _field_accessor(name, docstring=None, min_cftime_version="0.0"):
@@ -272,8 +272,8 @@ def format_attrs(index, separator=", "):
     attrs = {
         "dtype": f"'{index.dtype}'",
         "length": f"{len(index)}",
-        "calendar": f"'{index.calendar}'",
-        "freq": f"'{index.freq}'" if len(index) >= 3 else None,
+        "calendar": f"{index.calendar!r}",
+        "freq": f"{index.freq!r}",
     }
 
     attrs_str = [f"{k}={v}" for k, v in attrs.items()]
@@ -630,6 +630,10 @@ class CFTimeIndex(pd.Index):
         >>> times.to_datetimeindex()
         DatetimeIndex(['2000-01-01', '2000-01-02'], dtype='datetime64[ns]', freq=None)
         """
+
+        if not self._data.size:
+            return pd.DatetimeIndex([])
+
         nptimes = cftime_to_nptime(self)
         calendar = infer_calendar_name(self)
         if calendar not in _STANDARD_CALENDARS and not unsafe:
@@ -679,6 +683,9 @@ class CFTimeIndex(pd.Index):
         """Convert to integers with units of microseconds since 1970-01-01."""
         from xarray.core.resample_cftime import exact_cftime_datetime_difference
 
+        if not self._data.size:
+            return np.array([], dtype=np.int64)
+
         epoch = self.date_type(1970, 1, 1)
         return np.array(
             [
@@ -693,6 +700,9 @@ class CFTimeIndex(pd.Index):
         """The calendar used by the datetimes in the index."""
         from xarray.coding.times import infer_calendar_name
 
+        if not self._data.size:
+            return None
+
         return infer_calendar_name(self)
 
     @property
@@ -700,12 +710,19 @@ class CFTimeIndex(pd.Index):
         """The frequency used by the dates in the index."""
         from xarray.coding.frequencies import infer_freq
 
+        # min 3 elemtents required to determine freq
+        if self._data.size < 3:
+            return None
+
         return infer_freq(self)
 
     def _round_via_method(self, freq, method):
         """Round dates using a specified method."""
         from xarray.coding.cftime_offsets import CFTIME_TICKS, to_offset
 
+        if not self._data.size:
+            return CFTimeIndex(np.array(self))
+
         offset = to_offset(freq)
         if not isinstance(offset, CFTIME_TICKS):
             raise ValueError(f"{offset} is a non-fixed frequency")


Test patch:
diff --git a/xarray/tests/test_cftimeindex.py b/xarray/tests/test_cftimeindex.py
index e09fe246..062756e6 100644
--- a/xarray/tests/test_cftimeindex.py
+++ b/xarray/tests/test_cftimeindex.py
@@ -238,28 +238,57 @@ def test_assert_all_valid_date_type(date_type, index):
 )
 def test_cftimeindex_field_accessors(index, field, expected):
     result = getattr(index, field)
+    expected = np.array(expected, dtype=np.int64)
     assert_array_equal(result, expected)
+    assert result.dtype == expected.dtype
+
+
+@requires_cftime
+@pytest.mark.parametrize(
+    ("field"),
+    [
+        "year",
+        "month",
+        "day",
+        "hour",
+        "minute",
+        "second",
+        "microsecond",
+        "dayofyear",
+        "dayofweek",
+        "days_in_month",
+    ],
+)
+def test_empty_cftimeindex_field_accessors(field):
+    index = CFTimeIndex([])
+    result = getattr(index, field)
+    expected = np.array([], dtype=np.int64)
+    assert_array_equal(result, expected)
+    assert result.dtype == expected.dtype
 
 
 @requires_cftime
 def test_cftimeindex_dayofyear_accessor(index):
     result = index.dayofyear
-    expected = [date.dayofyr for date in index]
+    expected = np.array([date.dayofyr for date in index], dtype=np.int64)
     assert_array_equal(result, expected)
+    assert result.dtype == expected.dtype
 
 
 @requires_cftime
 def test_cftimeindex_dayofweek_accessor(index):
     result = index.dayofweek
-    expected = [date.dayofwk for date in index]
+    expected = np.array([date.dayofwk for date in index], dtype=np.int64)
     assert_array_equal(result, expected)
+    assert result.dtype == expected.dtype
 
 
 @requires_cftime
 def test_cftimeindex_days_in_month_accessor(index):
     result = index.days_in_month
-    expected = [date.daysinmonth for date in index]
+    expected = np.array([date.daysinmonth for date in index], dtype=np.int64)
     assert_array_equal(result, expected)
+    assert result.dtype == expected.dtype
 
 
 @requires_cftime
@@ -959,6 +988,31 @@ def test_cftimeindex_calendar_property(calendar, expected):
     assert index.calendar == expected
 
 
+@requires_cftime
+def test_empty_cftimeindex_calendar_property():
+    index = CFTimeIndex([])
+    assert index.calendar is None
+
+
+@requires_cftime
+@pytest.mark.parametrize(
+    "calendar",
+    [
+        "noleap",
+        "365_day",
+        "360_day",
+        "julian",
+        "gregorian",
+        "standard",
+        "proleptic_gregorian",
+    ],
+)
+def test_cftimeindex_freq_property_none_size_lt_3(calendar):
+    for periods in range(3):
+        index = xr.cftime_range(start="2000", periods=periods, calendar=calendar)
+        assert index.freq is None
+
+
 @requires_cftime
 @pytest.mark.parametrize(
     ("calendar", "expected"),
@@ -1152,6 +1206,18 @@ def test_rounding_methods_against_datetimeindex(freq, method):
     assert result.equals(expected)
 
 
+@requires_cftime
+@pytest.mark.parametrize("method", ["floor", "ceil", "round"])
+def test_rounding_methods_empty_cftimindex(method):
+    index = CFTimeIndex([])
+    result = getattr(index, method)("2s")
+
+    expected = CFTimeIndex([])
+
+    assert result.equals(expected)
+    assert result is not index
+
+
 @requires_cftime
 @pytest.mark.parametrize("method", ["floor", "ceil", "round"])
 def test_rounding_methods_invalid_freq(method):
@@ -1230,6 +1296,14 @@ def test_asi8_distant_date():
     np.testing.assert_array_equal(result, expected)
 
 
+@requires_cftime
+def test_asi8_empty_cftimeindex():
+    index = xr.CFTimeIndex([])
+    result = index.asi8
+    expected = np.array([], dtype=np.int64)
+    np.testing.assert_array_equal(result, expected)
+
+
 @requires_cftime
 def test_infer_freq_valid_types():
     cf_indx = xr.cftime_range("2000-01-01", periods=3, freq="D")
diff --git a/xarray/tests/test_formatting.py b/xarray/tests/test_formatting.py
index 181b0205..6ed4103a 100644
--- a/xarray/tests/test_formatting.py
+++ b/xarray/tests/test_formatting.py
@@ -10,7 +10,7 @@ from numpy.core import defchararray
 
 import xarray as xr
 from xarray.core import formatting
-from xarray.tests import requires_dask, requires_netCDF4
+from xarray.tests import requires_cftime, requires_dask, requires_netCDF4
 
 
 class TestFormatting:
@@ -803,3 +803,18 @@ def test_format_xindexes(as_dataset: bool) -> None:
 
     actual = repr(obj.xindexes)
     assert actual == expected
+
+
+@requires_cftime
+def test_empty_cftimeindex_repr() -> None:
+    index = xr.coding.cftimeindex.CFTimeIndex([])
+
+    expected = """\
+    Indexes:
+        time     CFTimeIndex([], dtype='object', length=0, calendar=None, freq=None)"""
+    expected = dedent(expected)
+
+    da = xr.DataArray([], coords={"time": index})
+
+    actual = repr(da.indexes)
+    assert actual == expected