Repository: pydata/xarray

Issue:
strftime and/or format support for DatetimeAccessor
There's no easy way currently to control the conversion to string of time values/series. For this purpose, Panda's own `.dt` attribute has an implementation of [strftime](https://pandas.pydata.org/pandas-docs/stable/generated/pandas.Series.dt.strftime.html).

Is there interest in adding similar functionality to xarray?

Discussion:
pep8speaks: Hello @abrammer! Thanks for opening this PR. We checked the lines you've touched for [PEP 8](https://www.python.org/dev/peps/pep-0008) issues, and found:



* In the file [`xarray/core/accessors.py`](https://github.com/pydata/xarray/blob/5de4db1f36e928cb68859acb45c07629ad38a112/xarray/core/accessors.py):

> [Line 116:80](https://github.com/pydata/xarray/blob/5de4db1f36e928cb68859acb45c07629ad38a112/xarray/core/accessors.py#L116): [E501](https://duckduckgo.com/?q=pep8%20E501) line too long (87 > 79 characters)
> [Line 120:1](https://github.com/pydata/xarray/blob/5de4db1f36e928cb68859acb45c07629ad38a112/xarray/core/accessors.py#L120): [E302](https://duckduckgo.com/?q=pep8%20E302) expected 2 blank lines, found 1

* In the file [`xarray/tests/test_accessors.py`](https://github.com/pydata/xarray/blob/5de4db1f36e928cb68859acb45c07629ad38a112/xarray/tests/test_accessors.py):

> [Line 186:1](https://github.com/pydata/xarray/blob/5de4db1f36e928cb68859acb45c07629ad38a112/xarray/tests/test_accessors.py#L186): [W293](https://duckduckgo.com/?q=pep8%20W293) blank line contains whitespace
> [Line 189:9](https://github.com/pydata/xarray/blob/5de4db1f36e928cb68859acb45c07629ad38a112/xarray/tests/test_accessors.py#L189): [E122](https://duckduckgo.com/?q=pep8%20E122) continuation line missing indentation or outdented

* In the file [`xarray/tests/test_cftimeindex.py`](https://github.com/pydata/xarray/blob/5de4db1f36e928cb68859acb45c07629ad38a112/xarray/tests/test_cftimeindex.py):

> [Line 787:1](https://github.com/pydata/xarray/blob/5de4db1f36e928cb68859acb45c07629ad38a112/xarray/tests/test_cftimeindex.py#L787): [E302](https://duckduckgo.com/?q=pep8%20E302) expected 2 blank lines, found 1



Patch:
diff --git a/ci/requirements-py36.yml b/ci/requirements-py36.yml
index 03242426..d6dafd8d 100644
--- a/ci/requirements-py36.yml
+++ b/ci/requirements-py36.yml
@@ -24,12 +24,11 @@ dependencies:
   - bottleneck
   - zarr
   - pseudonetcdf>=3.0.1
-  - eccodes
+  - cfgrib>=0.9.2
   - cdms2
   - pynio
   - iris>=1.10
   - pydap
   - lxml
   - pip:
-    - cfgrib>=0.9.2
     - mypy==0.660
diff --git a/ci/requirements-py37.yml b/ci/requirements-py37.yml
index 0cece4ed..c5f5d71b 100644
--- a/ci/requirements-py37.yml
+++ b/ci/requirements-py37.yml
@@ -25,9 +25,8 @@ dependencies:
   - bottleneck
   - zarr
   - pseudonetcdf>=3.0.1
+  - cfgrib>=0.9.2
   - lxml
-  - eccodes
   - pydap
   - pip:
-    - cfgrib>=0.9.2
     - mypy==0.650
diff --git a/doc/api-hidden.rst b/doc/api-hidden.rst
index 4b2fed8b..8f82b30a 100644
--- a/doc/api-hidden.rst
+++ b/doc/api-hidden.rst
@@ -153,3 +153,4 @@
 
    CFTimeIndex.shift
    CFTimeIndex.to_datetimeindex
+   CFTimeIndex.strftime
diff --git a/doc/time-series.rst b/doc/time-series.rst
index 53efcd45..e198887d 100644
--- a/doc/time-series.rst
+++ b/doc/time-series.rst
@@ -152,6 +152,15 @@ __ http://pandas.pydata.org/pandas-docs/stable/timeseries.html#offset-aliases
 
     ds['time'].dt.floor('D')
 
+The ``.dt`` accessor can also be used to generate formatted datetime strings
+for arrays utilising the same formatting as the standard `datetime.strftime`_.
+
+.. _datetime.strftime: https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior
+
+.. ipython:: python
+
+    ds['time'].dt.strftime('%a, %b %d %H:%M')
+
 .. _resampling:
 
 Resampling and grouped operations
diff --git a/doc/weather-climate.rst b/doc/weather-climate.rst
index 1950ba62..a17ecd2f 100644
--- a/doc/weather-climate.rst
+++ b/doc/weather-climate.rst
@@ -71,6 +71,18 @@ instance, we can create the same dates and DataArray we created above using:
    dates = xr.cftime_range(start='0001', periods=24, freq='MS', calendar='noleap')
    da = xr.DataArray(np.arange(24), coords=[dates], dims=['time'], name='foo')
 
+With :py:meth:`~xarray.CFTimeIndex.strftime` we can also easily generate formatted strings from
+the datetime values of a :py:class:`~xarray.CFTimeIndex` directly or through the
+:py:meth:`~xarray.DataArray.dt` accessor for a :py:class:`~xarray.DataArray`
+using the same formatting as the standard `datetime.strftime`_ convention .
+
+.. _datetime.strftime: https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior
+
+.. ipython:: python
+
+    dates.strftime('%c')
+    da['time'].dt.strftime('%Y%m%d')
+
 For data indexed by a :py:class:`~xarray.CFTimeIndex` xarray currently supports:
 
 - `Partial datetime string indexing`_ using strictly `ISO 8601-format`_ partial
diff --git a/doc/whats-new.rst b/doc/whats-new.rst
index ab7c1559..55773af9 100644
--- a/doc/whats-new.rst
+++ b/doc/whats-new.rst
@@ -42,6 +42,13 @@ Enhancements
   helpful for avoiding file-lock errors when trying to write to files opened
   using ``open_dataset()`` or ``open_dataarray()``. (:issue:`2887`)
   By `Dan Nowacki <https://github.com/dnowacki-usgs>`_.
+- Added ``strftime`` method to ``.dt`` accessor, making it simpler to hand a
+  datetime ``DataArray`` to other code expecting formatted dates and times.
+  (:issue:`2090`). By `Alan Brammer <https://github.com/abrammer>`_ and
+  `Ryan May <https://github.com/dopplershift>`_.
+- Like :py:class:`pandas.DatetimeIndex`, :py:class:`CFTimeIndex` now supports a
+  :py:meth:`~xarray.CFTimeIndex.strftime` method to return an index of string
+  formatted datetimes. By `Alan Brammer <https://github.com/abrammer>`_.
 
 Bug fixes
 ~~~~~~~~~
@@ -54,6 +61,8 @@ Bug fixes
   By `Deepak Cherian <https://github.com/dcherian`_.
 - A deep copy deep-copies the coords (:issue:`1463`)
   By `Martin Pletcher <https://github.com/pletchm>`_.
+- Removed usages of `pytest.config`, which is deprecated (:issue:`2988`:)
+  By `Maximilian Roos <https://github.com/max-sixty>`_.
 
 .. _whats-new.0.12.1:
 
diff --git a/xarray/coding/cftimeindex.py b/xarray/coding/cftimeindex.py
index 1456f8ce..6ce7831a 100644
--- a/xarray/coding/cftimeindex.py
+++ b/xarray/coding/cftimeindex.py
@@ -476,6 +476,35 @@ class CFTimeIndex(pd.Index):
                 'dates.'.format(calendar), RuntimeWarning, stacklevel=2)
         return pd.DatetimeIndex(nptimes)
 
+    def strftime(self, date_format):
+        """
+        Return an Index of formatted strings specified by date_format, which
+        supports the same string format as the python standard library. Details
+        of the string format can be found in `python string format doc
+        <https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior>`__
+
+        Parameters
+        ----------
+        date_format : str
+            Date format string (e.g. "%Y-%m-%d")
+
+        Returns
+        -------
+        Index
+            Index of formatted strings
+
+        Examples
+        --------
+        >>> rng = xr.cftime_range(start='2000', periods=5, freq='2MS',
+        ...                       calendar='noleap')
+        >>> rng.strftime('%B %d, %Y, %r')
+        Index(['January 01, 2000, 12:00:00 AM', 'March 01, 2000, 12:00:00 AM',
+               'May 01, 2000, 12:00:00 AM', 'July 01, 2000, 12:00:00 AM',
+               'September 01, 2000, 12:00:00 AM'],
+              dtype='object')
+        """
+        return pd.Index([date.strftime(date_format) for date in self._data])
+
 
 def _parse_iso8601_without_reso(date_type, datetime_str):
     date, _ = _parse_iso8601_with_reso(date_type, datetime_str)
diff --git a/xarray/core/accessors.py b/xarray/core/accessors.py
index 640060fa..806e1579 100644
--- a/xarray/core/accessors.py
+++ b/xarray/core/accessors.py
@@ -110,6 +110,38 @@ def _round_field(values, name, freq):
         return _round_series(values, name, freq)
 
 
+def _strftime_through_cftimeindex(values, date_format):
+    """Coerce an array of cftime-like values to a CFTimeIndex
+    and access requested datetime component
+    """
+    from ..coding.cftimeindex import CFTimeIndex
+    values_as_cftimeindex = CFTimeIndex(values.ravel())
+
+    field_values = values_as_cftimeindex.strftime(date_format)
+    return field_values.values.reshape(values.shape)
+
+
+def _strftime_through_series(values, date_format):
+    """Coerce an array of datetime-like values to a pandas Series and
+    apply string formatting
+    """
+    values_as_series = pd.Series(values.ravel())
+    strs = values_as_series.dt.strftime(date_format)
+    return strs.values.reshape(values.shape)
+
+
+def _strftime(values, date_format):
+    if is_np_datetime_like(values.dtype):
+        access_method = _strftime_through_series
+    else:
+        access_method = _strftime_through_cftimeindex
+    if isinstance(values, dask_array_type):
+        from dask.array import map_blocks
+        return map_blocks(access_method, values, date_format)
+    else:
+        return access_method(values, date_format)
+
+
 class DatetimeAccessor:
     """Access datetime fields for DataArrays with datetime-like dtypes.
 
@@ -256,3 +288,39 @@ class DatetimeAccessor:
             Array-like of datetime fields accessed for each element in values
         '''
         return self._tslib_round_accessor("round", freq)
+
+    def strftime(self, date_format):
+        '''
+        Return an array of formatted strings specified by date_format, which
+        supports the same string format as the python standard library. Details
+        of the string format can be found in `python string format doc
+        <https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior>`__
+
+        Parameters
+        ----------
+        date_format : str
+            date format string (e.g. "%Y-%m-%d")
+
+        Returns
+        -------
+        formatted strings : same type as values
+            Array-like of strings formatted for each element in values
+
+        Examples
+        --------
+        >>> rng = xr.Dataset({'time': datetime.datetime(2000, 1, 1)})
+        >>> rng['time'].dt.strftime('%B %d, %Y, %r')
+        <xarray.DataArray 'strftime' ()>
+        array('January 01, 2000, 12:00:00 AM', dtype=object)
+        """
+
+        '''
+        obj_type = type(self._obj)
+
+        result = _strftime(self._obj.data, date_format)
+
+        return obj_type(
+            result,
+            name="strftime",
+            coords=self._obj.coords,
+            dims=self._obj.dims)
diff --git a/xarray/core/combine.py b/xarray/core/combine.py
index 1abd14cd..6d922064 100644
--- a/xarray/core/combine.py
+++ b/xarray/core/combine.py
@@ -4,7 +4,7 @@ from collections import Counter, OrderedDict
 
 import pandas as pd
 
-from . import utils
+from . import utils, dtypes
 from .alignment import align
 from .merge import merge
 from .variable import IndexVariable, Variable, as_variable
@@ -14,7 +14,7 @@ from .computation import result_name
 
 def concat(objs, dim=None, data_vars='all', coords='different',
            compat='equals', positions=None, indexers=None, mode=None,
-           concat_over=None):
+           concat_over=None, fill_value=dtypes.NA):
     """Concatenate xarray objects along a new or existing dimension.
 
     Parameters
@@ -66,6 +66,8 @@ def concat(objs, dim=None, data_vars='all', coords='different',
         List of integer arrays which specifies the integer positions to which
         to assign each dataset along the concatenated dimension. If not
         supplied, objects are concatenated in the provided order.
+    fill_value : scalar, optional
+        Value to use for newly missing values
     indexers, mode, concat_over : deprecated
 
     Returns
@@ -117,7 +119,7 @@ def concat(objs, dim=None, data_vars='all', coords='different',
     else:
         raise TypeError('can only concatenate xarray Dataset and DataArray '
                         'objects, got %s' % type(first_obj))
-    return f(objs, dim, data_vars, coords, compat, positions)
+    return f(objs, dim, data_vars, coords, compat, positions, fill_value)
 
 
 def _calc_concat_dim_coord(dim):
@@ -212,7 +214,8 @@ def _calc_concat_over(datasets, dim, data_vars, coords):
     return concat_over, equals
 
 
-def _dataset_concat(datasets, dim, data_vars, coords, compat, positions):
+def _dataset_concat(datasets, dim, data_vars, coords, compat, positions,
+                    fill_value=dtypes.NA):
     """
     Concatenate a sequence of datasets along a new or existing dimension
     """
@@ -225,7 +228,8 @@ def _dataset_concat(datasets, dim, data_vars, coords, compat, positions):
     dim, coord = _calc_concat_dim_coord(dim)
     # Make sure we're working on a copy (we'll be loading variables)
     datasets = [ds.copy() for ds in datasets]
-    datasets = align(*datasets, join='outer', copy=False, exclude=[dim])
+    datasets = align(*datasets, join='outer', copy=False, exclude=[dim],
+                     fill_value=fill_value)
 
     concat_over, equals = _calc_concat_over(datasets, dim, data_vars, coords)
 
@@ -317,7 +321,7 @@ def _dataset_concat(datasets, dim, data_vars, coords, compat, positions):
 
 
 def _dataarray_concat(arrays, dim, data_vars, coords, compat,
-                      positions):
+                      positions, fill_value=dtypes.NA):
     arrays = list(arrays)
 
     if data_vars != 'all':
@@ -336,14 +340,15 @@ def _dataarray_concat(arrays, dim, data_vars, coords, compat,
         datasets.append(arr._to_temp_dataset())
 
     ds = _dataset_concat(datasets, dim, data_vars, coords, compat,
-                         positions)
+                         positions, fill_value)
     result = arrays[0]._from_temp_dataset(ds, name)
 
     result.name = result_name(arrays)
     return result
 
 
-def _auto_concat(datasets, dim=None, data_vars='all', coords='different'):
+def _auto_concat(datasets, dim=None, data_vars='all', coords='different',
+                 fill_value=dtypes.NA):
     if len(datasets) == 1 and dim is None:
         # There is nothing more to combine, so kick out early.
         return datasets[0]
@@ -366,7 +371,8 @@ def _auto_concat(datasets, dim=None, data_vars='all', coords='different'):
                                  'supply the ``concat_dim`` argument '
                                  'explicitly')
             dim, = concat_dims
-        return concat(datasets, dim=dim, data_vars=data_vars, coords=coords)
+        return concat(datasets, dim=dim, data_vars=data_vars,
+                      coords=coords, fill_value=fill_value)
 
 
 _CONCAT_DIM_DEFAULT = utils.ReprObject('<inferred>')
@@ -442,7 +448,8 @@ def _check_shape_tile_ids(combined_tile_ids):
 
 
 def _combine_nd(combined_ids, concat_dims, data_vars='all',
-                coords='different', compat='no_conflicts'):
+                coords='different', compat='no_conflicts',
+                fill_value=dtypes.NA):
     """
     Concatenates and merges an N-dimensional structure of datasets.
 
@@ -472,13 +479,14 @@ def _combine_nd(combined_ids, concat_dims, data_vars='all',
                                                          dim=concat_dim,
                                                          data_vars=data_vars,
                                                          coords=coords,
-                                                         compat=compat)
+                                                         compat=compat,
+                                                         fill_value=fill_value)
     combined_ds = list(combined_ids.values())[0]
     return combined_ds
 
 
 def _auto_combine_all_along_first_dim(combined_ids, dim, data_vars,
-                                      coords, compat):
+                                      coords, compat, fill_value=dtypes.NA):
     # Group into lines of datasets which must be combined along dim
     # need to sort by _new_tile_id first for groupby to work
     # TODO remove all these sorted OrderedDicts once python >= 3.6 only
@@ -490,7 +498,8 @@ def _auto_combine_all_along_first_dim(combined_ids, dim, data_vars,
         combined_ids = OrderedDict(sorted(group))
         datasets = combined_ids.values()
         new_combined_ids[new_id] = _auto_combine_1d(datasets, dim, compat,
-                                                    data_vars, coords)
+                                                    data_vars, coords,
+                                                    fill_value)
     return new_combined_ids
 
 
@@ -500,18 +509,20 @@ def vars_as_keys(ds):
 
 def _auto_combine_1d(datasets, concat_dim=_CONCAT_DIM_DEFAULT,
                      compat='no_conflicts',
-                     data_vars='all', coords='different'):
+                     data_vars='all', coords='different',
+                     fill_value=dtypes.NA):
     # This is just the old auto_combine function (which only worked along 1D)
     if concat_dim is not None:
         dim = None if concat_dim is _CONCAT_DIM_DEFAULT else concat_dim
         sorted_datasets = sorted(datasets, key=vars_as_keys)
         grouped_by_vars = itertools.groupby(sorted_datasets, key=vars_as_keys)
         concatenated = [_auto_concat(list(ds_group), dim=dim,
-                                     data_vars=data_vars, coords=coords)
+                                     data_vars=data_vars, coords=coords,
+                                     fill_value=fill_value)
                         for id, ds_group in grouped_by_vars]
     else:
         concatenated = datasets
-    merged = merge(concatenated, compat=compat)
+    merged = merge(concatenated, compat=compat, fill_value=fill_value)
     return merged
 
 
@@ -521,7 +532,7 @@ def _new_tile_id(single_id_ds_pair):
 
 
 def _auto_combine(datasets, concat_dims, compat, data_vars, coords,
-                  infer_order_from_coords, ids):
+                  infer_order_from_coords, ids, fill_value=dtypes.NA):
     """
     Calls logic to decide concatenation order before concatenating.
     """
@@ -550,12 +561,14 @@ def _auto_combine(datasets, concat_dims, compat, data_vars, coords,
 
     # Repeatedly concatenate then merge along each dimension
     combined = _combine_nd(combined_ids, concat_dims, compat=compat,
-                           data_vars=data_vars, coords=coords)
+                           data_vars=data_vars, coords=coords,
+                           fill_value=fill_value)
     return combined
 
 
 def auto_combine(datasets, concat_dim=_CONCAT_DIM_DEFAULT,
-                 compat='no_conflicts', data_vars='all', coords='different'):
+                 compat='no_conflicts', data_vars='all', coords='different',
+                 fill_value=dtypes.NA):
     """Attempt to auto-magically combine the given datasets into one.
     This method attempts to combine a list of datasets into a single entity by
     inspecting metadata and using a combination of concat and merge.
@@ -596,6 +609,8 @@ def auto_combine(datasets, concat_dim=_CONCAT_DIM_DEFAULT,
         Details are in the documentation of concat
     coords : {'minimal', 'different', 'all' or list of str}, optional
         Details are in the documentation of conca
+    fill_value : scalar, optional
+        Value to use for newly missing values
 
     Returns
     -------
@@ -622,4 +637,4 @@ def auto_combine(datasets, concat_dim=_CONCAT_DIM_DEFAULT,
     return _auto_combine(datasets, concat_dims=concat_dims, compat=compat,
                          data_vars=data_vars, coords=coords,
                          infer_order_from_coords=infer_order_from_coords,
-                         ids=False)
+                         ids=False, fill_value=fill_value)


Test patch:
diff --git a/conftest.py b/conftest.py
index d7f4e0c8..ffceb27e 100644
--- a/conftest.py
+++ b/conftest.py
@@ -1,5 +1,7 @@
 """Configuration for pytest."""
 
+import pytest
+
 
 def pytest_addoption(parser):
     """Add command-line flags for pytest."""
@@ -7,3 +9,21 @@ def pytest_addoption(parser):
                      help="runs flaky tests")
     parser.addoption("--run-network-tests", action="store_true",
                      help="runs tests requiring a network connection")
+
+
+def pytest_collection_modifyitems(config, items):
+
+    if not config.getoption("--run-flaky"):
+        skip_flaky = pytest.mark.skip(
+            reason="set --run-flaky option to run flaky tests")
+        for item in items:
+            if "flaky" in item.keywords:
+                item.add_marker(skip_flaky)
+
+    if not config.getoption("--run-network-tests"):
+        skip_network = pytest.mark.skip(
+            reason="set --run-network-tests option to run tests requiring an"
+            "internet connection")
+        for item in items:
+            if "network" in item.keywords:
+                item.add_marker(skip_network)
diff --git a/xarray/tests/__init__.py b/xarray/tests/__init__.py
index e9d670e4..5e559fce 100644
--- a/xarray/tests/__init__.py
+++ b/xarray/tests/__init__.py
@@ -108,23 +108,8 @@ if has_dask:
     else:
         dask.config.set(scheduler='single-threaded')
 
-# pytest config
-try:
-    _SKIP_FLAKY = not pytest.config.getoption("--run-flaky")
-    _SKIP_NETWORK_TESTS = not pytest.config.getoption("--run-network-tests")
-except (ValueError, AttributeError):
-    # Can't get config from pytest, e.g., because xarray is installed instead
-    # of being run from a development version (and hence conftests.py is not
-    # available). Don't run flaky tests.
-    _SKIP_FLAKY = True
-    _SKIP_NETWORK_TESTS = True
-
-flaky = pytest.mark.skipif(
-    _SKIP_FLAKY, reason="set --run-flaky option to run flaky tests")
-network = pytest.mark.skipif(
-    _SKIP_NETWORK_TESTS,
-    reason="set --run-network-tests option to run tests requiring an "
-    "internet connection")
+flaky = pytest.mark.flaky
+network = pytest.mark.network
 
 
 @contextmanager
diff --git a/xarray/tests/test_accessors.py b/xarray/tests/test_accessors.py
index 6bda5772..09041a6a 100644
--- a/xarray/tests/test_accessors.py
+++ b/xarray/tests/test_accessors.py
@@ -42,6 +42,10 @@ class TestDatetimeAccessor:
         assert_equal(days, self.data.time.dt.day)
         assert_equal(hours, self.data.time.dt.hour)
 
+    def test_strftime(self):
+        assert ('2000-01-01 01:00:00' == self.data.time.dt.strftime(
+            '%Y-%m-%d %H:%M:%S')[1])
+
     def test_not_datetime_type(self):
         nontime_data = self.data.copy()
         int_data = np.arange(len(self.data.time)).astype('int8')
@@ -60,6 +64,7 @@ class TestDatetimeAccessor:
         floor = self.times_data.dt.floor('D')
         ceil = self.times_data.dt.ceil('D')
         round = self.times_data.dt.round('D')
+        strftime = self.times_data.dt.strftime('%Y-%m-%d %H:%M:%S')
 
         dask_times_arr = da.from_array(self.times_arr, chunks=(5, 5, 50))
         dask_times_2d = xr.DataArray(dask_times_arr,
@@ -73,12 +78,14 @@ class TestDatetimeAccessor:
         dask_floor = dask_times_2d.dt.floor('D')
         dask_ceil = dask_times_2d.dt.ceil('D')
         dask_round = dask_times_2d.dt.round('D')
+        dask_strftime = dask_times_2d.dt.strftime('%Y-%m-%d %H:%M:%S')
 
         # Test that the data isn't eagerly evaluated
         assert isinstance(dask_year.data, da.Array)
         assert isinstance(dask_month.data, da.Array)
         assert isinstance(dask_day.data, da.Array)
         assert isinstance(dask_hour.data, da.Array)
+        assert isinstance(dask_strftime.data, da.Array)
 
         # Double check that outcome chunksize is unchanged
         dask_chunks = dask_times_2d.chunks
@@ -86,6 +93,7 @@ class TestDatetimeAccessor:
         assert dask_month.data.chunks == dask_chunks
         assert dask_day.data.chunks == dask_chunks
         assert dask_hour.data.chunks == dask_chunks
+        assert dask_strftime.data.chunks == dask_chunks
 
         # Check the actual output from the accessors
         assert_equal(years, dask_year.compute())
@@ -95,6 +103,7 @@ class TestDatetimeAccessor:
         assert_equal(floor, dask_floor.compute())
         assert_equal(ceil, dask_ceil.compute())
         assert_equal(round, dask_round.compute())
+        assert_equal(strftime, dask_strftime.compute())
 
     def test_seasons(self):
         dates = pd.date_range(start="2000/01/01", freq="M", periods=12)
@@ -169,6 +178,21 @@ def test_field_access(data, field):
     assert_equal(result, expected)
 
 
+@pytest.mark.skipif(not has_cftime, reason='cftime not installed')
+def test_cftime_strftime_access(data):
+    """ compare cftime formatting against datetime formatting """
+    date_format = '%Y%m%d%H'
+    result = data.time.dt.strftime(date_format)
+    datetime_array = xr.DataArray(
+        xr.coding.cftimeindex.CFTimeIndex(
+            data.time.values).to_datetimeindex(),
+        name="stftime",
+        coords=data.time.coords,
+        dims=data.time.dims)
+    expected = datetime_array.dt.strftime(date_format)
+    assert_equal(result, expected)
+
+
 @pytest.mark.skipif(not has_dask, reason='dask not installed')
 @pytest.mark.skipif(not has_cftime, reason='cftime not installed')
 @pytest.mark.parametrize('field', ['year', 'month', 'day', 'hour',
diff --git a/xarray/tests/test_cftimeindex.py b/xarray/tests/test_cftimeindex.py
index c5cdf0a3..999af4c9 100644
--- a/xarray/tests/test_cftimeindex.py
+++ b/xarray/tests/test_cftimeindex.py
@@ -785,6 +785,17 @@ def test_parse_array_of_cftime_strings():
     np.testing.assert_array_equal(result, expected)
 
 
+@pytest.mark.skipif(not has_cftime, reason='cftime not installed')
+@pytest.mark.parametrize('calendar', _ALL_CALENDARS)
+def test_strftime_of_cftime_array(calendar):
+    date_format = '%Y%m%d%H%M'
+    cf_values = xr.cftime_range('2000', periods=5, calendar=calendar)
+    dt_values = pd.date_range('2000', periods=5)
+    expected = dt_values.strftime(date_format)
+    result = cf_values.strftime(date_format)
+    assert result.equals(expected)
+
+
 @pytest.mark.skipif(not has_cftime, reason='cftime not installed')
 @pytest.mark.parametrize('calendar', _ALL_CALENDARS)
 @pytest.mark.parametrize('unsafe', [False, True])
diff --git a/xarray/tests/test_combine.py b/xarray/tests/test_combine.py
index 1d8ed169..a477df0b 100644
--- a/xarray/tests/test_combine.py
+++ b/xarray/tests/test_combine.py
@@ -7,6 +7,7 @@ import pandas as pd
 import pytest
 
 from xarray import DataArray, Dataset, Variable, auto_combine, concat
+from xarray.core import dtypes
 from xarray.core.combine import (
     _auto_combine, _auto_combine_1d, _auto_combine_all_along_first_dim,
     _check_shape_tile_ids, _combine_nd, _infer_concat_order_from_positions,
@@ -237,6 +238,20 @@ class TestConcatDataset:
         assert expected.equals(actual)
         assert isinstance(actual.x.to_index(), pd.MultiIndex)
 
+    @pytest.mark.parametrize('fill_value', [dtypes.NA, 2, 2.0])
+    def test_concat_fill_value(self, fill_value):
+        datasets = [Dataset({'a': ('x', [2, 3]), 'x': [1, 2]}),
+                    Dataset({'a': ('x', [1, 2]), 'x': [0, 1]})]
+        if fill_value == dtypes.NA:
+            # if we supply the default, we expect the missing value for a
+            # float array
+            fill_value = np.nan
+        expected = Dataset({'a': (('t', 'x'),
+                                  [[fill_value, 2, 3], [1, 2, fill_value]])},
+                           {'x': [0, 1, 2]})
+        actual = concat(datasets, dim='t', fill_value=fill_value)
+        assert_identical(actual, expected)
+
 
 class TestConcatDataArray:
     def test_concat(self):
@@ -306,6 +321,19 @@ class TestConcatDataArray:
         assert combined.shape == (2, 3, 3)
         assert combined.dims == ('z', 'x', 'y')
 
+    @pytest.mark.parametrize('fill_value', [dtypes.NA, 2, 2.0])
+    def test_concat_fill_value(self, fill_value):
+        foo = DataArray([1, 2], coords=[('x', [1, 2])])
+        bar = DataArray([1, 2], coords=[('x', [1, 3])])
+        if fill_value == dtypes.NA:
+            # if we supply the default, we expect the missing value for a
+            # float array
+            fill_value = np.nan
+        expected = DataArray([[1, 2, fill_value], [1, fill_value, 2]],
+                             dims=['y', 'x'], coords={'x': [1, 2, 3]})
+        actual = concat((foo, bar), dim='y', fill_value=fill_value)
+        assert_identical(actual, expected)
+
 
 class TestAutoCombine:
 
@@ -417,6 +445,20 @@ class TestAutoCombine:
                            {'baz': [100]})
         assert_identical(expected, actual)
 
+    @pytest.mark.parametrize('fill_value', [dtypes.NA, 2, 2.0])
+    def test_auto_combine_fill_value(self, fill_value):
+        datasets = [Dataset({'a': ('x', [2, 3]), 'x': [1, 2]}),
+                    Dataset({'a': ('x', [1, 2]), 'x': [0, 1]})]
+        if fill_value == dtypes.NA:
+            # if we supply the default, we expect the missing value for a
+            # float array
+            fill_value = np.nan
+        expected = Dataset({'a': (('t', 'x'),
+                                  [[fill_value, 2, 3], [1, 2, fill_value]])},
+                           {'x': [0, 1, 2]})
+        actual = auto_combine(datasets, concat_dim='t', fill_value=fill_value)
+        assert_identical(expected, actual)
+
 
 def assert_combined_tile_ids_equal(dict1, dict2):
     assert len(dict1) == len(dict2)