Repository: corteva/rioxarray

Issue:
Add set_attrs/update_attrs methods (or an equivalent)
Currently, you could set the nodata or crs metadata in the attrs, but it won't be re-discovered if you already attempted to access them (reguardless if it was found or not). These methods would reset the cache properly so the accessor knows to re-search for these properties.

Discussion:

Patch:
diff --git a/rioxarray/__init__.py b/rioxarray/__init__.py
index d75dff25..5746e0ec 100644
--- a/rioxarray/__init__.py
+++ b/rioxarray/__init__.py
@@ -6,5 +6,5 @@ __author__ = """rioxarray Contributors"""
 __email__ = "alansnow21@gmail.com"
 
 import rioxarray.rioxarray  # noqa
-from rioxarray._version import __version__  # noqa
 from rioxarray._io import open_rasterio  # noqa
+from rioxarray._version import __version__  # noqa
diff --git a/rioxarray/_io.py b/rioxarray/_io.py
index 8974ca49..0565fe27 100644
--- a/rioxarray/_io.py
+++ b/rioxarray/_io.py
@@ -13,16 +13,15 @@ from distutils.version import LooseVersion
 
 import numpy as np
 from xarray import DataArray, Dataset
-from xarray.core import indexing
-from xarray.core.utils import is_scalar
 from xarray.backends.common import BackendArray
 from xarray.backends.file_manager import CachingFileManager
 from xarray.backends.locks import SerializableLock
+from xarray.core import indexing
+from xarray.core.utils import is_scalar
 
 from rioxarray.exceptions import RioXarrayError
 from rioxarray.rioxarray import affine_to_coords
 
-
 # TODO: should this be GDAL_LOCK instead?
 RASTERIO_LOCK = SerializableLock()
 
diff --git a/rioxarray/rioxarray.py b/rioxarray/rioxarray.py
index 6309aff5..28597969 100644
--- a/rioxarray/rioxarray.py
+++ b/rioxarray/rioxarray.py
@@ -23,6 +23,7 @@ from rasterio.enums import Resampling
 from rasterio.features import geometry_mask
 from scipy.interpolate import griddata
 
+from rioxarray.crs import crs_to_wkt
 from rioxarray.exceptions import (
     DimensionError,
     InvalidDimensionOrder,
@@ -31,7 +32,6 @@ from rioxarray.exceptions import (
     OneDimensionalRaster,
     TooManyDimensions,
 )
-from rioxarray.crs import crs_to_wkt
 
 FILL_VALUE_NAMES = ("_FillValue", "missing_value", "fill_value", "nodata")
 UNWANTED_RIO_ATTRS = ("nodatavals", "crs", "is_tiled", "res")
@@ -143,7 +143,7 @@ def _add_attrs_proj(new_data_array, src_data_array):
     if "_FillValue" in new_data_array.encoding:
         new_attrs.pop("_FillValue", None)
 
-    new_data_array.attrs = new_attrs
+    new_data_array.rio.set_attrs(new_attrs, inplace=True)
 
     # make sure projection added
     add_xy_grid_meta(new_data_array.coords)
@@ -229,6 +229,23 @@ class XRasterBase(object):
         """
         raise NotImplementedError
 
+    def _get_obj(self, inplace):
+        """
+        Get the object to modify.
+
+        Parameters
+        ----------
+        inplace: bool
+            If True, returns self.
+
+        Returns
+        -------
+        xarray.Dataset or xarray.DataArray:
+        """
+        if inplace:
+            return self._obj
+        return self._obj.copy(deep=True)
+
     def set_crs(self, input_crs, inplace=True):
         """
         Set the CRS value for the Dataset/DataArray without modifying
@@ -237,7 +254,9 @@ class XRasterBase(object):
         Parameters
         ----------
         input_crs: object
-            Anythong accepted by `rasterio.crs.CRS.from_user_input`.
+            Anything accepted by `rasterio.crs.CRS.from_user_input`.
+        inplace: bool, optional
+            If True, it will write to the existing dataset. Default is False.
 
         Returns
         -------
@@ -248,12 +267,9 @@ class XRasterBase(object):
         if hasattr(input_crs, "wkt"):
             input_crs = input_crs.wkt
         crs = CRS.from_user_input(input_crs)
-        if inplace:
-            self._crs = crs
-            return self._obj
-        xobj = self._obj.copy(deep=True)
-        xobj.rio._crs = crs
-        return xobj
+        obj = self._get_obj(inplace=inplace)
+        obj.rio._crs = crs
+        return obj
 
     def write_crs(
         self, input_crs=None, grid_mapping_name=DEFAULT_GRID_MAP, inplace=False
@@ -264,7 +280,7 @@ class XRasterBase(object):
         Parameters
         ----------
         input_crs: object
-            Anythong accepted by `rasterio.crs.CRS.from_user_input`.
+            Anything accepted by `rasterio.crs.CRS.from_user_input`.
         grid_mapping_name: str, optional
             Name of the coordinate to store the CRS information in.
         inplace: bool, optional
@@ -278,10 +294,8 @@ class XRasterBase(object):
         """
         if input_crs is not None:
             data_obj = self.set_crs(input_crs, inplace=inplace)
-        elif inplace:
-            data_obj = self._obj
         else:
-            data_obj = self._obj.copy(deep=True)
+            data_obj = self._get_obj(inplace=inplace)
 
         # remove old grid maping coordinate if exists
         try:
@@ -299,7 +313,7 @@ class XRasterBase(object):
         # http://cfconventions.org/Data/cf-conventions/cf-conventions-1.7/cf-conventions.html#appendix-grid-mappings
         # http://desktop.arcgis.com/en/arcmap/10.3/manage-data/netcdf/spatial-reference-for-netcdf-data.htm
         grid_map_attrs["crs_wkt"] = crs_wkt
-        data_obj.coords[grid_mapping_name].attrs = grid_map_attrs
+        data_obj.coords[grid_mapping_name].rio.set_attrs(grid_map_attrs, inplace=True)
 
         # add grid mapping attribute to variables
         if hasattr(data_obj, "data_vars"):
@@ -308,15 +322,62 @@ class XRasterBase(object):
                     self.x_dim in data_obj[var].dims
                     and self.y_dim in data_obj[var].dims
                 ):
-                    var_attrs = dict(data_obj[var].attrs)
-                    var_attrs.update(grid_mapping=grid_mapping_name)
-                    data_obj[var].attrs = var_attrs
+                    data_obj[var].rio.update_attrs(
+                        dict(grid_mapping=grid_mapping_name), inplace=True
+                    )
         else:
-            var_attrs = dict(data_obj.attrs)
-            var_attrs.update(grid_mapping=grid_mapping_name)
-            data_obj.attrs = var_attrs
+            data_obj.rio.update_attrs(
+                dict(grid_mapping=grid_mapping_name), inplace=True
+            )
+        return data_obj
+
+    def set_attrs(self, new_attrs, inplace=False):
+        """
+        Set the attributes of the dataset/dataarray and reset
+        rioxarray properties to re-search for them.
+
+        Parameters
+        ----------
+        new_attrs: dict
+            A dictionary of new attributes.
+        inplace: bool, optional
+            If True, it will write to the existing dataset. Default is False.
+
+        Returns
+        -------
+        xarray.Dataset or xarray.DataArray:
+        Modified dataset with new attributes.
+        """
+        data_obj = self._get_obj(inplace=inplace)
+        # set the attributes
+        data_obj.attrs = new_attrs
+        # reset rioxarray properties
+        data_obj.rio._nodata = None
+        data_obj.rio._crs = None
         return data_obj
 
+    def update_attrs(self, new_attrs, inplace=False):
+        """
+        Update the attributes of the dataset/dataarray and reset
+        rioxarray properties to re-search for them.
+
+        Parameters
+        ----------
+        new_attrs: dict
+            A dictionary of new attributes to update with.
+        inplace: bool, optional
+            If True, it will write to the existing dataset. Default is False.
+
+        Returns
+        -------
+        xarray.Dataset or xarray.DataArray:
+        Modified dataset with updated attributes.
+
+        """
+        data_attrs = dict(self._obj.attrs)
+        data_attrs.update(**new_attrs)
+        return self.set_attrs(data_attrs, inplace=inplace)
+
     def set_spatial_dims(self, x_dim, y_dim, inplace=True):
         """
         This sets the spatial dimensions of the dataset.
@@ -348,12 +409,9 @@ class XRasterBase(object):
             else:
                 raise DimensionError("y dimension not found: {}".format(y_dim))
 
-        if not inplace:
-            obj_copy = self._obj.copy()
-            set_dims(obj_copy, x_dim, y_dim)
-            return obj_copy
-        set_dims(self._obj, x_dim, y_dim)
-        return self._obj
+        data_obj = self._get_obj(inplace=inplace)
+        set_dims(data_obj, x_dim, y_dim)
+        return data_obj
 
     @property
     def x_dim(self):
@@ -402,6 +460,54 @@ class RasterArray(XRasterBase):
         # properties
         self._nodata = None
 
+    def set_nodata(self, input_nodata, inplace=True):
+        """
+        Set the nodata value for the DataArray without modifying
+        the data array.
+
+        Parameters
+        ----------
+        input_nodata: object
+            Valid nodata for dtype.
+        inplace: bool, optional
+            If True, it will write to the existing dataset. Default is False.
+
+        Returns
+        -------
+        xarray.DataArray: Dataset with nodata attribute set.
+        """
+        obj = self._get_obj(inplace=inplace)
+        obj.rio._nodata = input_nodata
+        return obj
+
+    def write_nodata(self, input_nodata, inplace=False):
+        """
+        Write the nodata to the DataArray in a CF compliant manner.
+
+        Parameters
+        ----------
+        input_nodata: object
+            Nodata value for the DataArray.
+            If input_nodata is None, it will remove the _FillValue attribute.
+        inplace: bool, optional
+            If True, it will write to the existing DataArray. Default is False.
+
+        Returns
+        -------
+        xarray.DataArray: Modified DataArray with CF compliant nodata information.
+
+        """
+        data_obj = self._get_obj(inplace=inplace)
+        input_nodata = False if input_nodata is None else input_nodata
+        if input_nodata is not False:
+            data_obj.rio.update_attrs(dict(_FillValue=input_nodata), inplace=True)
+        else:
+            new_vars = dict(data_obj.attrs)
+            new_vars.pop("_FillValue", None)
+            data_obj.rio.set_attrs(new_vars, inplace=True)
+        data_obj.rio.set_nodata(input_nodata, inplace=True)
+        return data_obj
+
     @property
     def crs(self):
         """:obj:`rasterio.crs.CRS`:
@@ -1081,7 +1187,10 @@ class RasterDataset(XRasterBase):
             Retrieve projection from `xarray.Dataset`
         """
         if self._crs is None:
-            self._crs = self._obj[self.vars[0]].rio.crs
+            try:
+                self._crs = self._obj[self.vars[0]].rio.crs
+            except IndexError:
+                pass
         return self._crs
 
     def reproject(
diff --git a/sphinx/examples/convert_to_raster.ipynb b/sphinx/examples/convert_to_raster.ipynb
index 69942f16..f14b15df 100644
--- a/sphinx/examples/convert_to_raster.ipynb
+++ b/sphinx/examples/convert_to_raster.ipynb
@@ -213,7 +213,7 @@
     {
      "data": {
       "text/plain": [
-       "OrderedDict()"
+       "(OrderedDict(), None, None)"
       ]
      },
      "execution_count": 7,
@@ -223,33 +223,13 @@
    ],
    "source": [
     "new_ds = rds.green + rds.blue\n",
-    "new_ds.attrs"
+    "new_ds.attrs, new_ds.rio.crs, new_ds.rio.nodata"
    ]
   },
   {
    "cell_type": "code",
    "execution_count": 8,
    "metadata": {},
-   "outputs": [
-    {
-     "data": {
-      "text/plain": [
-       "(None, None)"
-      ]
-     },
-     "execution_count": 8,
-     "metadata": {},
-     "output_type": "execute_result"
-    }
-   ],
-   "source": [
-    "new_ds.rio.crs, new_ds.rio.nodata"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 9,
-   "metadata": {},
    "outputs": [],
    "source": [
     "new_ds.rio.to_raster(\"combination.tif\")"
@@ -257,7 +237,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 10,
+   "execution_count": 9,
    "metadata": {},
    "outputs": [
     {
@@ -274,38 +254,34 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 11,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "new_ds = rds.green + rds.blue\n",
-    "new_ds.attrs = rds.green.attrs\n",
-    "new_ds = new_ds.rio.write_crs(rds.green.rio.crs)"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 12,
+   "execution_count": 10,
    "metadata": {},
    "outputs": [
     {
      "data": {
       "text/plain": [
-       "(CRS.from_epsg(32722), 0.0)"
+       "(OrderedDict([('grid_mapping', 'spatial_ref'),\n",
+       "              ('units', 'DN'),\n",
+       "              ('nodata', 0.0)]),\n",
+       " CRS.from_epsg(32722),\n",
+       " 0.0)"
       ]
      },
-     "execution_count": 12,
+     "execution_count": 10,
      "metadata": {},
      "output_type": "execute_result"
     }
    ],
    "source": [
-    "new_ds.rio.crs, new_ds.rio.nodata"
+    "new_ds = rds.green + rds.blue\n",
+    "new_ds.rio.write_crs(rds.green.rio.crs, inplace=True)\n",
+    "new_ds.rio.update_attrs(rds.green.attrs, inplace=True)\n",
+    "new_ds.attrs, new_ds.rio.crs, new_ds.rio.nodata"
    ]
   },
   {
    "cell_type": "code",
-   "execution_count": 13,
+   "execution_count": 11,
    "metadata": {},
    "outputs": [],
    "source": [
@@ -314,7 +290,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 14,
+   "execution_count": 12,
    "metadata": {},
    "outputs": [
     {
diff --git a/sphinx/history.rst b/sphinx/history.rst
index 2fb7c774..1a306ff4 100644
--- a/sphinx/history.rst
+++ b/sphinx/history.rst
@@ -5,6 +5,7 @@ History
 ------
 - Add support for opening netcdf/hdf files with `rioxarray.open_rasterio` (issue #32)
 - Added support for custom CRS with wkt attribute for datacube CRS support (issue #35)
+- Added `rio.set_nodata()`, `rio.write_nodata()`, `rio.set_attrs()`, `rio.update_attrs()` (issue #37)
 
 0.0.9
 -----


Test patch:
diff --git a/test/conftest.py b/test/conftest.py
index 63ca75aa..8f1ab378 100644
--- a/test/conftest.py
+++ b/test/conftest.py
@@ -1,7 +1,8 @@
 import os
 
-from numpy.testing import assert_almost_equal, assert_array_equal
 import xarray
+from numpy.testing import assert_almost_equal, assert_array_equal
+
 from rioxarray.rioxarray import UNWANTED_RIO_ATTRS
 
 TEST_DATA_DIR = os.path.join(os.path.dirname(__file__), "test_data")
diff --git a/test/integration/test_integration__io.py b/test/integration/test_integration__io.py
index 2776a8de..0616db13 100644
--- a/test/integration/test_integration__io.py
+++ b/test/integration/test_integration__io.py
@@ -5,18 +5,17 @@ import pickle
 import shutil
 import sys
 import tempfile
+from test.conftest import TEST_COMPARE_DATA_DIR, _assert_xarrays_equal
 
 import mock
-import numpy
 import numpy as np
-from numpy.testing import assert_almost_equal
-from xarray.testing import assert_allclose, assert_identical, assert_equal
 import pytest
 import xarray as xr
+from numpy.testing import assert_almost_equal
 from xarray import DataArray
+from xarray.testing import assert_allclose, assert_equal, assert_identical
 
 import rioxarray
-from test.conftest import TEST_COMPARE_DATA_DIR, _assert_xarrays_equal
 
 
 def test_open_rasterio_mask_chunk_clip():
@@ -29,7 +28,7 @@ def test_open_rasterio_mask_chunk_clip():
         assert str(xdi.data.dtype) == "float64"
         assert str(type(xdi.data)) == "<class 'dask.array.core.Array'>"
         assert xdi.chunks == ((1,), (245,), (574,))
-        assert numpy.isnan(xdi.values).sum() == 52119
+        assert np.isnan(xdi.values).sum() == 52119
         assert xdi.encoding == {"_FillValue": 0.0}
         attrs = dict(xdi.attrs)
         assert_almost_equal(
diff --git a/test/integration/test_integration_rioxarray.py b/test/integration/test_integration_rioxarray.py
index 840a6855..78532b6a 100644
--- a/test/integration/test_integration_rioxarray.py
+++ b/test/integration/test_integration_rioxarray.py
@@ -852,6 +852,10 @@ def test_set_spatial_dims__missing():
         test_da.rio.set_spatial_dims(x_dim="long", y_dim="lati")
 
 
+def test_crs_empty_dataset():
+    assert xarray.Dataset().rio.crs is None
+
+
 def test_crs_setter():
     test_da = xarray.DataArray(
         numpy.zeros((5, 5)),
@@ -996,3 +1000,84 @@ def test_crs_get_custom():
     assert test_da.rio.crs.wkt == CustomCRS().wkt
     test_ds = xarray.Dataset({"test": test_da})
     assert test_ds.rio.crs.wkt == CustomCRS().wkt
+
+
+def test_nodata_setter():
+    test_da = xarray.DataArray(
+        numpy.zeros((5, 5)),
+        dims=("y", "x"),
+        coords={"y": numpy.arange(1, 6), "x": numpy.arange(2, 7)},
+    )
+    assert test_da.rio.nodata is None
+    out_ds = test_da.rio.set_nodata(-1)
+    assert out_ds.rio.nodata == -1
+
+
+def test_nodata_setter__copy():
+    test_da = xarray.DataArray(
+        numpy.zeros((5, 5)),
+        dims=("y", "x"),
+        coords={"y": numpy.arange(1, 6), "x": numpy.arange(2, 7)},
+    )
+    assert test_da.rio.nodata is None
+    out_ds = test_da.rio.set_nodata(-1, inplace=False)
+    assert test_da.rio.nodata is None
+    assert out_ds.rio.nodata == -1
+
+
+def test_nodata_writer__array__copy():
+    test_da = xarray.DataArray(
+        numpy.zeros((5, 5)),
+        dims=("y", "x"),
+        coords={"y": numpy.arange(1, 6), "x": numpy.arange(2, 7)},
+    )
+    assert test_da.rio.nodata is None
+    out_da = test_da.rio.write_nodata(-1)
+    assert test_da.rio.nodata is None
+    assert out_da.attrs["_FillValue"] == -1
+    assert out_da.rio.nodata == -1
+    out_da.rio._nodata = None
+    assert out_da.rio.nodata == -1
+    test_da.rio._nodata = None
+    assert test_da.rio.nodata is None
+    assert "_FillValue" not in test_da.attrs
+
+
+def test_nodata_writer__array__inplace():
+    test_da = xarray.DataArray(
+        numpy.zeros((5, 5)),
+        dims=("y", "x"),
+        coords={"y": numpy.arange(1, 6), "x": numpy.arange(2, 7)},
+    )
+    assert test_da.rio.nodata is None
+    out_da = test_da.rio.write_nodata(-1, inplace=True)
+    assert "_FillValue" in test_da.attrs
+    assert out_da.attrs["_FillValue"] == test_da.attrs["_FillValue"]
+    test_da.rio._nodata = None
+    assert test_da.rio.nodata == -1
+    assert out_da.attrs == test_da.attrs
+    out_da.rio._nodata = None
+    assert out_da.rio.nodata == -1
+
+
+def test_nodata_writer__missing():
+    test_da = xarray.DataArray(
+        numpy.zeros((5, 5)),
+        dims=("y", "x"),
+        coords={"y": numpy.arange(1, 6), "x": numpy.arange(2, 7)},
+    )
+    test_da.rio.write_nodata(None)
+    assert not test_da.attrs
+
+
+def test_nodata_writer__remove():
+    test_da = xarray.DataArray(
+        numpy.zeros((5, 5)),
+        dims=("y", "x"),
+        coords={"y": numpy.arange(1, 6), "x": numpy.arange(2, 7)},
+    )
+    test_nd = test_da.rio.write_nodata(-1)
+    assert not test_da.attrs
+    assert test_nd.attrs["_FillValue"] == -1
+    test_nd.rio.write_nodata(None, inplace=True)
+    assert not test_nd.attrs