Repository: pydata/xarray

Issue:
DataArray to_dict() without converting with numpy tolist()
Enhancement:
Following https://github.com/pydata/xarray/issues/432 and https://github.com/pydata/xarray/pull/917, I need to convert my DataArray to a dictionary, but do not need to fully-jsonize the data.  In fact, I would much prefer to get the data without using "decode_numpy_dict_values" to convert numpy to lists using tolist.

Is this feature requested elsewhere, or was it intentionally not considered as a use-case?  If not, happy to contribute code.

Discussion:
jmccreight: I would appreciate any edification on the Mypy failures. Looking at the indicated lines, i'm 🤷 .
jmccreight: I solved the mypy errors in a highly dubious way. 👀 
jmccreight: In the off-hand chance this is reviewed before I push again, do not merge. I have a fix to encodings not getting properly roundtripped in Ds.from_dict(ds.to_dict). it was minor to fix but making sure it's tested will take a min
dcherian: Perhaps we should have `array_to_list: bool` instead. And by default we just preserve the underlying array type.

Then the user could do `ds.as_numpy().to_dict(array_to_list=False)` to always get numpy arrays as in this PR.
jmccreight: @dcherian thanks! I didnt incoroprate any suggestions yet. 
regarding the inequality of encodings of datasets is obscured by `assert_identical(a, b)` not evaluating encodings. it seems like it should have an option to also check encodings (or not). 
jmccreight: i kinda implied, but I'll just state that the extra code to test equality of encodings is not handsome.
dcherian: Copying my  comment from https://github.com/pydata/xarray/issues/1599#issuecomment-1504276696
>  Perhaps we should have array_to_list: bool instead. If False, we just preserve the underlying array type.
> Then the user could do ds.as_numpy().to_dict(array_to_list=False) to always get numpy arrays as #7739

`array_data` or `data_as_array` could be other options
jmccreight: Making all the requested changes, the above should resolve momentarily.

I like this "trick"/suggestion:

```
And a design question/suggestion: what about instead of adding another kwarg, you could use data = True / False / "numpy"?
```

I will implement this if we are in agreement with @dcherian 

dcherian: > what about instead of adding another kwarg, you could use data = True / False / "numpy"?

Oh yeah, I like this. Only suggestion is `data =  True / False / "array" / "list"` where `True` and `"list"` are synonymous.

Patch:
diff --git a/.github/workflows/ci-additional.yaml b/.github/workflows/ci-additional.yaml
index f98bf6f9..f7097185 100644
--- a/.github/workflows/ci-additional.yaml
+++ b/.github/workflows/ci-additional.yaml
@@ -126,7 +126,7 @@ jobs:
           python -m mypy --install-types --non-interactive --cobertura-xml-report mypy_report
 
       - name: Upload mypy coverage to Codecov
-        uses: codecov/codecov-action@v3.1.2
+        uses: codecov/codecov-action@v3.1.3
         with:
           file: mypy_report/cobertura.xml
           flags: mypy
@@ -180,7 +180,7 @@ jobs:
           python -m mypy --install-types --non-interactive --cobertura-xml-report mypy_report
 
       - name: Upload mypy coverage to Codecov
-        uses: codecov/codecov-action@v3.1.2
+        uses: codecov/codecov-action@v3.1.3
         with:
           file: mypy_report/cobertura.xml
           flags: mypy39
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 06d7c4a8..457520a0 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -139,7 +139,7 @@ jobs:
           path: pytest.xml
 
       - name: Upload code coverage to Codecov
-        uses: codecov/codecov-action@v3.1.2
+        uses: codecov/codecov-action@v3.1.3
         with:
           file: ./coverage.xml
           flags: unittests
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index a69ca2f0..eece5d6e 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -16,7 +16,7 @@ repos:
         files: ^xarray/
   - repo: https://github.com/charliermarsh/ruff-pre-commit
     # Ruff version.
-    rev: 'v0.0.261'
+    rev: 'v0.0.262'
     hooks:
       - id: ruff
         args: ["--fix"]
diff --git a/doc/user-guide/computation.rst b/doc/user-guide/computation.rst
index d1f1274c..f8141f40 100644
--- a/doc/user-guide/computation.rst
+++ b/doc/user-guide/computation.rst
@@ -63,6 +63,10 @@ Data arrays also implement many :py:class:`numpy.ndarray` methods:
     arr.round(2)
     arr.T
 
+    intarr = xr.DataArray([0, 1, 2, 3, 4, 5])
+    intarr << 2  # only supported for int types
+    intarr >> 1
+
 .. _missing_values:
 
 Missing values
diff --git a/doc/whats-new.rst b/doc/whats-new.rst
index bfc040eb..8f9d2266 100644
--- a/doc/whats-new.rst
+++ b/doc/whats-new.rst
@@ -22,6 +22,9 @@ v2023.05.0 (unreleased)
 
 New Features
 ~~~~~~~~~~~~
+- Add support for lshift and rshift binary operators (`<<`, `>>`) on
+  :py:class:`xr.DataArray` of type :py:class:`int` (:issue:`7727` , :pull:`7741`).
+  By `Alan Brammer <https://github.com/abrammer>`_.
 
 
 Breaking changes
@@ -102,6 +105,10 @@ New Features
 - Added ability to save ``DataArray`` objects directly to Zarr using :py:meth:`~xarray.DataArray.to_zarr`.
   (:issue:`7692`, :pull:`7693`) .
   By `Joe Hamman <https://github.com/jhamman>`_.
+- Keyword argument `data='array'` to both :py:meth:`xarray.Dataset.to_dict` and
+  :py:meth:`xarray.DataArray.to_dict` will now return data as the underlying array type. Python lists are returned for `data='list'` or `data=True`. Supplying `data=False` only returns the schema without data. ``encoding=True`` returns the encoding dictionary for the underlying variable also.
+  (:issue:`1599`, :pull:`7739`) .
+  By `James McCreight <https://github.com/jmccreight>`_.
 
 Breaking changes
 ~~~~~~~~~~~~~~~~
diff --git a/requirements.txt b/requirements.txt
index 20638d26..95271051 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -4,4 +4,4 @@
 
 numpy >= 1.21
 packaging >= 21.3
-pandas >= 1.4, <2
+pandas >= 1.4
diff --git a/xarray/core/_typed_ops.py b/xarray/core/_typed_ops.py
index a6e6fdbf..d3a783be 100644
--- a/xarray/core/_typed_ops.py
+++ b/xarray/core/_typed_ops.py
@@ -42,6 +42,12 @@ class DatasetOpsMixin:
     def __or__(self, other):
         return self._binary_op(other, operator.or_)
 
+    def __lshift__(self, other):
+        return self._binary_op(other, operator.lshift)
+
+    def __rshift__(self, other):
+        return self._binary_op(other, operator.rshift)
+
     def __lt__(self, other):
         return self._binary_op(other, operator.lt)
 
@@ -123,6 +129,12 @@ class DatasetOpsMixin:
     def __ior__(self, other):
         return self._inplace_binary_op(other, operator.ior)
 
+    def __ilshift__(self, other):
+        return self._inplace_binary_op(other, operator.ilshift)
+
+    def __irshift__(self, other):
+        return self._inplace_binary_op(other, operator.irshift)
+
     def _unary_op(self, f, *args, **kwargs):
         raise NotImplementedError
 
@@ -160,6 +172,8 @@ class DatasetOpsMixin:
     __and__.__doc__ = operator.and_.__doc__
     __xor__.__doc__ = operator.xor.__doc__
     __or__.__doc__ = operator.or_.__doc__
+    __lshift__.__doc__ = operator.lshift.__doc__
+    __rshift__.__doc__ = operator.rshift.__doc__
     __lt__.__doc__ = operator.lt.__doc__
     __le__.__doc__ = operator.le.__doc__
     __gt__.__doc__ = operator.gt.__doc__
@@ -186,6 +200,8 @@ class DatasetOpsMixin:
     __iand__.__doc__ = operator.iand.__doc__
     __ixor__.__doc__ = operator.ixor.__doc__
     __ior__.__doc__ = operator.ior.__doc__
+    __ilshift__.__doc__ = operator.ilshift.__doc__
+    __irshift__.__doc__ = operator.irshift.__doc__
     __neg__.__doc__ = operator.neg.__doc__
     __pos__.__doc__ = operator.pos.__doc__
     __abs__.__doc__ = operator.abs.__doc__
@@ -232,6 +248,12 @@ class DataArrayOpsMixin:
     def __or__(self, other):
         return self._binary_op(other, operator.or_)
 
+    def __lshift__(self, other):
+        return self._binary_op(other, operator.lshift)
+
+    def __rshift__(self, other):
+        return self._binary_op(other, operator.rshift)
+
     def __lt__(self, other):
         return self._binary_op(other, operator.lt)
 
@@ -313,6 +335,12 @@ class DataArrayOpsMixin:
     def __ior__(self, other):
         return self._inplace_binary_op(other, operator.ior)
 
+    def __ilshift__(self, other):
+        return self._inplace_binary_op(other, operator.ilshift)
+
+    def __irshift__(self, other):
+        return self._inplace_binary_op(other, operator.irshift)
+
     def _unary_op(self, f, *args, **kwargs):
         raise NotImplementedError
 
@@ -350,6 +378,8 @@ class DataArrayOpsMixin:
     __and__.__doc__ = operator.and_.__doc__
     __xor__.__doc__ = operator.xor.__doc__
     __or__.__doc__ = operator.or_.__doc__
+    __lshift__.__doc__ = operator.lshift.__doc__
+    __rshift__.__doc__ = operator.rshift.__doc__
     __lt__.__doc__ = operator.lt.__doc__
     __le__.__doc__ = operator.le.__doc__
     __gt__.__doc__ = operator.gt.__doc__
@@ -376,6 +406,8 @@ class DataArrayOpsMixin:
     __iand__.__doc__ = operator.iand.__doc__
     __ixor__.__doc__ = operator.ixor.__doc__
     __ior__.__doc__ = operator.ior.__doc__
+    __ilshift__.__doc__ = operator.ilshift.__doc__
+    __irshift__.__doc__ = operator.irshift.__doc__
     __neg__.__doc__ = operator.neg.__doc__
     __pos__.__doc__ = operator.pos.__doc__
     __abs__.__doc__ = operator.abs.__doc__
@@ -422,6 +454,12 @@ class VariableOpsMixin:
     def __or__(self, other):
         return self._binary_op(other, operator.or_)
 
+    def __lshift__(self, other):
+        return self._binary_op(other, operator.lshift)
+
+    def __rshift__(self, other):
+        return self._binary_op(other, operator.rshift)
+
     def __lt__(self, other):
         return self._binary_op(other, operator.lt)
 
@@ -503,6 +541,12 @@ class VariableOpsMixin:
     def __ior__(self, other):
         return self._inplace_binary_op(other, operator.ior)
 
+    def __ilshift__(self, other):
+        return self._inplace_binary_op(other, operator.ilshift)
+
+    def __irshift__(self, other):
+        return self._inplace_binary_op(other, operator.irshift)
+
     def _unary_op(self, f, *args, **kwargs):
         raise NotImplementedError
 
@@ -540,6 +584,8 @@ class VariableOpsMixin:
     __and__.__doc__ = operator.and_.__doc__
     __xor__.__doc__ = operator.xor.__doc__
     __or__.__doc__ = operator.or_.__doc__
+    __lshift__.__doc__ = operator.lshift.__doc__
+    __rshift__.__doc__ = operator.rshift.__doc__
     __lt__.__doc__ = operator.lt.__doc__
     __le__.__doc__ = operator.le.__doc__
     __gt__.__doc__ = operator.gt.__doc__
@@ -566,6 +612,8 @@ class VariableOpsMixin:
     __iand__.__doc__ = operator.iand.__doc__
     __ixor__.__doc__ = operator.ixor.__doc__
     __ior__.__doc__ = operator.ior.__doc__
+    __ilshift__.__doc__ = operator.ilshift.__doc__
+    __irshift__.__doc__ = operator.irshift.__doc__
     __neg__.__doc__ = operator.neg.__doc__
     __pos__.__doc__ = operator.pos.__doc__
     __abs__.__doc__ = operator.abs.__doc__
@@ -612,6 +660,12 @@ class DatasetGroupByOpsMixin:
     def __or__(self, other):
         return self._binary_op(other, operator.or_)
 
+    def __lshift__(self, other):
+        return self._binary_op(other, operator.lshift)
+
+    def __rshift__(self, other):
+        return self._binary_op(other, operator.rshift)
+
     def __lt__(self, other):
         return self._binary_op(other, operator.lt)
 
@@ -670,6 +724,8 @@ class DatasetGroupByOpsMixin:
     __and__.__doc__ = operator.and_.__doc__
     __xor__.__doc__ = operator.xor.__doc__
     __or__.__doc__ = operator.or_.__doc__
+    __lshift__.__doc__ = operator.lshift.__doc__
+    __rshift__.__doc__ = operator.rshift.__doc__
     __lt__.__doc__ = operator.lt.__doc__
     __le__.__doc__ = operator.le.__doc__
     __gt__.__doc__ = operator.gt.__doc__
@@ -724,6 +780,12 @@ class DataArrayGroupByOpsMixin:
     def __or__(self, other):
         return self._binary_op(other, operator.or_)
 
+    def __lshift__(self, other):
+        return self._binary_op(other, operator.lshift)
+
+    def __rshift__(self, other):
+        return self._binary_op(other, operator.rshift)
+
     def __lt__(self, other):
         return self._binary_op(other, operator.lt)
 
@@ -782,6 +844,8 @@ class DataArrayGroupByOpsMixin:
     __and__.__doc__ = operator.and_.__doc__
     __xor__.__doc__ = operator.xor.__doc__
     __or__.__doc__ = operator.or_.__doc__
+    __lshift__.__doc__ = operator.lshift.__doc__
+    __rshift__.__doc__ = operator.rshift.__doc__
     __lt__.__doc__ = operator.lt.__doc__
     __le__.__doc__ = operator.le.__doc__
     __gt__.__doc__ = operator.gt.__doc__
diff --git a/xarray/core/_typed_ops.pyi b/xarray/core/_typed_ops.pyi
index 98a17a47..9e2ba2d3 100644
--- a/xarray/core/_typed_ops.pyi
+++ b/xarray/core/_typed_ops.pyi
@@ -44,6 +44,8 @@ class DatasetOpsMixin:
     def __and__(self: T_Dataset, other: DsCompatible) -> T_Dataset: ...
     def __xor__(self: T_Dataset, other: DsCompatible) -> T_Dataset: ...
     def __or__(self: T_Dataset, other: DsCompatible) -> T_Dataset: ...
+    def __lshift__(self: T_Dataset, other: DsCompatible) -> T_Dataset: ...
+    def __rshift__(self: T_Dataset, other: DsCompatible) -> T_Dataset: ...
     def __lt__(self: T_Dataset, other: DsCompatible) -> T_Dataset: ...
     def __le__(self: T_Dataset, other: DsCompatible) -> T_Dataset: ...
     def __gt__(self: T_Dataset, other: DsCompatible) -> T_Dataset: ...
@@ -135,6 +137,18 @@ class DataArrayOpsMixin:
     @overload
     def __or__(self: T_DataArray, other: DaCompatible) -> T_DataArray: ...
     @overload
+    def __lshift__(self, other: T_Dataset) -> T_Dataset: ...
+    @overload
+    def __lshift__(self, other: "DatasetGroupBy") -> "Dataset": ...
+    @overload
+    def __lshift__(self: T_DataArray, other: DaCompatible) -> T_DataArray: ...
+    @overload
+    def __rshift__(self, other: T_Dataset) -> T_Dataset: ...
+    @overload
+    def __rshift__(self, other: "DatasetGroupBy") -> "Dataset": ...
+    @overload
+    def __rshift__(self: T_DataArray, other: DaCompatible) -> T_DataArray: ...
+    @overload
     def __lt__(self, other: T_Dataset) -> T_Dataset: ...
     @overload
     def __lt__(self, other: "DatasetGroupBy") -> "Dataset": ...
@@ -305,6 +319,18 @@ class VariableOpsMixin:
     @overload
     def __or__(self: T_Variable, other: VarCompatible) -> T_Variable: ...
     @overload
+    def __lshift__(self, other: T_Dataset) -> T_Dataset: ...
+    @overload
+    def __lshift__(self, other: T_DataArray) -> T_DataArray: ...
+    @overload
+    def __lshift__(self: T_Variable, other: VarCompatible) -> T_Variable: ...
+    @overload
+    def __rshift__(self, other: T_Dataset) -> T_Dataset: ...
+    @overload
+    def __rshift__(self, other: T_DataArray) -> T_DataArray: ...
+    @overload
+    def __rshift__(self: T_Variable, other: VarCompatible) -> T_Variable: ...
+    @overload
     def __lt__(self, other: T_Dataset) -> T_Dataset: ...
     @overload
     def __lt__(self, other: T_DataArray) -> T_DataArray: ...
@@ -475,6 +501,18 @@ class DatasetGroupByOpsMixin:
     @overload
     def __or__(self, other: GroupByIncompatible) -> NoReturn: ...
     @overload
+    def __lshift__(self, other: T_Dataset) -> T_Dataset: ...
+    @overload
+    def __lshift__(self, other: "DataArray") -> "Dataset": ...
+    @overload
+    def __lshift__(self, other: GroupByIncompatible) -> NoReturn: ...
+    @overload
+    def __rshift__(self, other: T_Dataset) -> T_Dataset: ...
+    @overload
+    def __rshift__(self, other: "DataArray") -> "Dataset": ...
+    @overload
+    def __rshift__(self, other: GroupByIncompatible) -> NoReturn: ...
+    @overload
     def __lt__(self, other: T_Dataset) -> T_Dataset: ...
     @overload
     def __lt__(self, other: "DataArray") -> "Dataset": ...
@@ -635,6 +673,18 @@ class DataArrayGroupByOpsMixin:
     @overload
     def __or__(self, other: GroupByIncompatible) -> NoReturn: ...
     @overload
+    def __lshift__(self, other: T_Dataset) -> T_Dataset: ...
+    @overload
+    def __lshift__(self, other: T_DataArray) -> T_DataArray: ...
+    @overload
+    def __lshift__(self, other: GroupByIncompatible) -> NoReturn: ...
+    @overload
+    def __rshift__(self, other: T_Dataset) -> T_Dataset: ...
+    @overload
+    def __rshift__(self, other: T_DataArray) -> T_DataArray: ...
+    @overload
+    def __rshift__(self, other: GroupByIncompatible) -> NoReturn: ...
+    @overload
     def __lt__(self, other: T_Dataset) -> T_Dataset: ...
     @overload
     def __lt__(self, other: T_DataArray) -> T_DataArray: ...
diff --git a/xarray/core/dataarray.py b/xarray/core/dataarray.py
index f26085bd..7c2d02f7 100644
--- a/xarray/core/dataarray.py
+++ b/xarray/core/dataarray.py
@@ -4174,7 +4174,9 @@ class DataArray(
             zarr_version=zarr_version,
         )
 
-    def to_dict(self, data: bool = True, encoding: bool = False) -> dict[str, Any]:
+    def to_dict(
+        self, data: bool | Literal["list", "array"] = "list", encoding: bool = False
+    ) -> dict[str, Any]:
         """
         Convert this xarray.DataArray into a dictionary following xarray
         naming conventions.
@@ -4185,9 +4187,14 @@ class DataArray(
 
         Parameters
         ----------
-        data : bool, default: True
+        data : bool or {"list", "array"}, default: "list"
             Whether to include the actual data in the dictionary. When set to
-            False, returns just the schema.
+            False, returns just the schema. If set to "array", returns data as
+            underlying array type. If set to "list" (or True for backwards
+            compatibility), returns data in lists of Python data types. Note
+            that for obtaining the "list" output efficiently, use
+            `da.compute().to_dict(data="list")`.
+
         encoding : bool, default: False
             Whether to include the Dataset's encoding in the dictionary.
 
diff --git a/xarray/core/dataset.py b/xarray/core/dataset.py
index 74c961bd..7d903d94 100644
--- a/xarray/core/dataset.py
+++ b/xarray/core/dataset.py
@@ -6441,7 +6441,9 @@ class Dataset(
 
         return df
 
-    def to_dict(self, data: bool = True, encoding: bool = False) -> dict[str, Any]:
+    def to_dict(
+        self, data: bool | Literal["list", "array"] = "list", encoding: bool = False
+    ) -> dict[str, Any]:
         """
         Convert this dataset to a dictionary following xarray naming
         conventions.
@@ -6452,9 +6454,14 @@ class Dataset(
 
         Parameters
         ----------
-        data : bool, default: True
+        data : bool or {"list", "array"}, default: "list"
             Whether to include the actual data in the dictionary. When set to
-            False, returns just the schema.
+            False, returns just the schema. If set to "array", returns data as
+            underlying array type. If set to "list" (or True for backwards
+            compatibility), returns data in lists of Python data types. Note
+            that for obtaining the "list" output efficiently, use
+            `ds.compute().to_dict(data="list")`.
+
         encoding : bool, default: False
             Whether to include the Dataset's encoding in the dictionary.
 
@@ -6560,7 +6567,8 @@ class Dataset(
             )
         try:
             variable_dict = {
-                k: (v["dims"], v["data"], v.get("attrs")) for k, v in variables
+                k: (v["dims"], v["data"], v.get("attrs"), v.get("encoding"))
+                for k, v in variables
             }
         except KeyError as e:
             raise ValueError(
diff --git a/xarray/core/ops.py b/xarray/core/ops.py
index 009616c5..e1c35738 100644
--- a/xarray/core/ops.py
+++ b/xarray/core/ops.py
@@ -33,6 +33,8 @@ NUM_BINARY_OPS = [
     "and",
     "xor",
     "or",
+    "lshift",
+    "rshift",
 ]
 
 # methods which pass on the numpy return value unchanged
diff --git a/xarray/core/variable.py b/xarray/core/variable.py
index 9fe3c953..c19cb21c 100644
--- a/xarray/core/variable.py
+++ b/xarray/core/variable.py
@@ -633,11 +633,23 @@ class Variable(AbstractArray, NdimSizeLenMixin, VariableArithmetic):
         """Convert this variable to a pandas.Index"""
         return self.to_index_variable().to_index()
 
-    def to_dict(self, data: bool = True, encoding: bool = False) -> dict:
+    def to_dict(
+        self, data: bool | str = "list", encoding: bool = False
+    ) -> dict[str, Any]:
         """Dictionary representation of variable."""
-        item = {"dims": self.dims, "attrs": decode_numpy_dict_values(self.attrs)}
-        if data:
-            item["data"] = ensure_us_time_resolution(self.values).tolist()
+        item: dict[str, Any] = {
+            "dims": self.dims,
+            "attrs": decode_numpy_dict_values(self.attrs),
+        }
+        if data is not False:
+            if data in [True, "list"]:
+                item["data"] = ensure_us_time_resolution(self.to_numpy()).tolist()
+            elif data == "array":
+                item["data"] = ensure_us_time_resolution(self.data)
+            else:
+                msg = 'data argument must be bool, "list", or "array"'
+                raise ValueError(msg)
+
         else:
             item.update({"dtype": str(self.dtype), "shape": self.shape})
 
diff --git a/xarray/plot/dataarray_plot.py b/xarray/plot/dataarray_plot.py
index a80db915..7f11ddac 100644
--- a/xarray/plot/dataarray_plot.py
+++ b/xarray/plot/dataarray_plot.py
@@ -530,7 +530,7 @@ def line(
     if np.issubdtype(xplt.dtype, np.datetime64):
         for xlabels in ax.get_xticklabels():
             xlabels.set_rotation(30)
-            xlabels.set_ha("right")
+            xlabels.set_horizontalalignment("right")
 
     _update_axes(ax, xincrease, yincrease, xscale, yscale, xticks, yticks, xlim, ylim)
 
@@ -1099,7 +1099,7 @@ def _add_labels(
             # https://stackoverflow.com/questions/17430105/autofmt-xdate-deletes-x-axis-labels-of-all-subplots
             for labels in getattr(ax, f"get_{axis}ticklabels")():
                 labels.set_rotation(30)
-                labels.set_ha("right")
+                labels.set_horizontalalignment("right")
 
 
 @overload
@@ -1639,7 +1639,7 @@ def _plot2d(plotfunc):
         if np.issubdtype(xplt.dtype, np.datetime64):
             for xlabels in ax.get_xticklabels():
                 xlabels.set_rotation(30)
-                xlabels.set_ha("right")
+                xlabels.set_horizontalalignment("right")
 
         return primitive
 
diff --git a/xarray/util/generate_ops.py b/xarray/util/generate_ops.py
index 02a3725f..cf0673e7 100644
--- a/xarray/util/generate_ops.py
+++ b/xarray/util/generate_ops.py
@@ -30,6 +30,8 @@ BINOPS_NUM = (
     ("__and__", "operator.and_"),
     ("__xor__", "operator.xor"),
     ("__or__", "operator.or_"),
+    ("__lshift__", "operator.lshift"),
+    ("__rshift__", "operator.rshift"),
 )
 BINOPS_REFLEXIVE = (
     ("__radd__", "operator.add"),
@@ -54,6 +56,8 @@ BINOPS_INPLACE = (
     ("__iand__", "operator.iand"),
     ("__ixor__", "operator.ixor"),
     ("__ior__", "operator.ior"),
+    ("__ilshift__", "operator.ilshift"),
+    ("__irshift__", "operator.irshift"),
 )
 UNARY_OPS = (
     ("__neg__", "operator.neg"),


Test patch:
diff --git a/xarray/tests/test_dask.py b/xarray/tests/test_dask.py
index b37399d6..1171464a 100644
--- a/xarray/tests/test_dask.py
+++ b/xarray/tests/test_dask.py
@@ -178,6 +178,19 @@ class TestVariable(DaskTestCase):
         self.assertLazyAndIdentical(u + u, v + v)
         self.assertLazyAndIdentical(u[0] + u, v[0] + v)
 
+    def test_binary_op_bitshift(self) -> None:
+        # bit shifts only work on ints so we need to generate
+        # new eager and lazy vars
+        rng = np.random.default_rng(0)
+        values = rng.integers(low=-10000, high=10000, size=(4, 6))
+        data = da.from_array(values, chunks=(2, 2))
+        u = Variable(("x", "y"), values)
+        v = Variable(("x", "y"), data)
+        self.assertLazyAndIdentical(u << 2, v << 2)
+        self.assertLazyAndIdentical(u << 5, v << 5)
+        self.assertLazyAndIdentical(u >> 2, v >> 2)
+        self.assertLazyAndIdentical(u >> 5, v >> 5)
+
     def test_repr(self):
         expected = dedent(
             """\
diff --git a/xarray/tests/test_dataarray.py b/xarray/tests/test_dataarray.py
index dcbfd42c..6f6286d4 100644
--- a/xarray/tests/test_dataarray.py
+++ b/xarray/tests/test_dataarray.py
@@ -6,7 +6,7 @@ import warnings
 from collections.abc import Hashable
 from copy import deepcopy
 from textwrap import dedent
-from typing import Any, Final, cast
+from typing import Any, Final, Literal, cast
 
 import numpy as np
 import pandas as pd
@@ -3345,46 +3345,70 @@ class TestDataArray:
         arr = DataArray(s)
         assert "'a'" in repr(arr)  # should not error
 
+    @pytest.mark.parametrize("use_dask", [True, False])
+    @pytest.mark.parametrize("data", ["list", "array", True])
     @pytest.mark.parametrize("encoding", [True, False])
-    def test_to_and_from_dict(self, encoding) -> None:
+    def test_to_and_from_dict(
+        self, encoding: bool, data: bool | Literal["list", "array"], use_dask: bool
+    ) -> None:
+        if use_dask and not has_dask:
+            pytest.skip("requires dask")
+        encoding_data = {"bar": "spam"}
         array = DataArray(
             np.random.randn(2, 3), {"x": ["a", "b"]}, ["x", "y"], name="foo"
         )
-        array.encoding = {"bar": "spam"}
-        expected = {
+        array.encoding = encoding_data
+
+        return_data = array.to_numpy()
+        coords_data = np.array(["a", "b"])
+        if data == "list" or data is True:
+            return_data = return_data.tolist()
+            coords_data = coords_data.tolist()
+
+        expected: dict[str, Any] = {
             "name": "foo",
             "dims": ("x", "y"),
-            "data": array.values.tolist(),
+            "data": return_data,
             "attrs": {},
-            "coords": {"x": {"dims": ("x",), "data": ["a", "b"], "attrs": {}}},
+            "coords": {"x": {"dims": ("x",), "data": coords_data, "attrs": {}}},
         }
         if encoding:
-            expected["encoding"] = {"bar": "spam"}
-        actual = array.to_dict(encoding=encoding)
+            expected["encoding"] = encoding_data
+
+        if has_dask:
+            da = array.chunk()
+        else:
+            da = array
+
+        if data == "array" or data is False:
+            with raise_if_dask_computes():
+                actual = da.to_dict(encoding=encoding, data=data)
+        else:
+            actual = da.to_dict(encoding=encoding, data=data)
 
         # check that they are identical
-        assert expected == actual
+        np.testing.assert_equal(expected, actual)
 
         # check roundtrip
-        assert_identical(array, DataArray.from_dict(actual))
+        assert_identical(da, DataArray.from_dict(actual))
 
         # a more bare bones representation still roundtrips
         d = {
             "name": "foo",
             "dims": ("x", "y"),
-            "data": array.values.tolist(),
+            "data": da.values.tolist(),
             "coords": {"x": {"dims": "x", "data": ["a", "b"]}},
         }
-        assert_identical(array, DataArray.from_dict(d))
+        assert_identical(da, DataArray.from_dict(d))
 
         # and the most bare bones representation still roundtrips
-        d = {"name": "foo", "dims": ("x", "y"), "data": array.values}
-        assert_identical(array.drop_vars("x"), DataArray.from_dict(d))
+        d = {"name": "foo", "dims": ("x", "y"), "data": da.values}
+        assert_identical(da.drop_vars("x"), DataArray.from_dict(d))
 
         # missing a dims in the coords
         d = {
             "dims": ("x", "y"),
-            "data": array.values,
+            "data": da.values,
             "coords": {"x": {"data": ["a", "b"]}},
         }
         with pytest.raises(
@@ -3407,7 +3431,7 @@ class TestDataArray:
         endiantype = "<U1" if sys.byteorder == "little" else ">U1"
         expected_no_data["coords"]["x"].update({"dtype": endiantype, "shape": (2,)})
         expected_no_data.update({"dtype": "float64", "shape": (2, 3)})
-        actual_no_data = array.to_dict(data=False, encoding=encoding)
+        actual_no_data = da.to_dict(data=False, encoding=encoding)
         assert expected_no_data == actual_no_data
 
     def test_to_and_from_dict_with_time_dim(self) -> None:
@@ -3926,6 +3950,11 @@ class TestDataArray:
         actual = (self.dv > 10).xindexes["x"]
         assert expected is actual
 
+        # use mda for bitshift test as it's type int
+        actual = (self.mda << 2).xindexes["x"]
+        expected = self.mda.xindexes["x"]
+        assert expected is actual
+
     def test_binary_op_join_setting(self) -> None:
         dim = "x"
         align_type: Final = "outer"
diff --git a/xarray/tests/test_dataset.py b/xarray/tests/test_dataset.py
index 45286727..cc9220df 100644
--- a/xarray/tests/test_dataset.py
+++ b/xarray/tests/test_dataset.py
@@ -8,7 +8,7 @@ from collections.abc import Hashable
 from copy import copy, deepcopy
 from io import StringIO
 from textwrap import dedent
-from typing import Any
+from typing import Any, Literal
 
 import numpy as np
 import pandas as pd
@@ -4596,7 +4596,11 @@ class TestDataset:
         expected = df.apply(np.asarray)
         assert roundtripped.equals(expected)
 
-    def test_to_and_from_dict(self) -> None:
+    @pytest.mark.parametrize("encoding", [True, False])
+    @pytest.mark.parametrize("data", [True, "list", "array"])
+    def test_to_and_from_dict(
+        self, encoding: bool, data: bool | Literal["list", "array"]
+    ) -> None:
         # <xarray.Dataset>
         # Dimensions:  (t: 10)
         # Coordinates:
@@ -4617,14 +4621,25 @@ class TestDataset:
                 "b": {"dims": ("t",), "data": y.tolist(), "attrs": {}},
             },
         }
+        if encoding:
+            ds.t.encoding.update({"foo": "bar"})
+            expected["encoding"] = {}
+            expected["coords"]["t"]["encoding"] = ds.t.encoding
+            for vvs in ["a", "b"]:
+                expected["data_vars"][vvs]["encoding"] = {}
 
-        actual = ds.to_dict()
+        actual = ds.to_dict(data=data, encoding=encoding)
 
         # check that they are identical
-        assert expected == actual
+        np.testing.assert_equal(expected, actual)
 
         # check roundtrip
-        assert_identical(ds, Dataset.from_dict(actual))
+        ds_rt = Dataset.from_dict(actual)
+        assert_identical(ds, ds_rt)
+        if encoding:
+            assert set(ds_rt.variables) == set(ds.variables)
+            for vv in ds.variables:
+                np.testing.assert_equal(ds_rt[vv].encoding, ds[vv].encoding)
 
         # check the data=False option
         expected_no_data = expected.copy()
@@ -4635,14 +4650,18 @@ class TestDataset:
         expected_no_data["coords"]["t"].update({"dtype": endiantype, "shape": (10,)})
         expected_no_data["data_vars"]["a"].update({"dtype": "float64", "shape": (10,)})
         expected_no_data["data_vars"]["b"].update({"dtype": "float64", "shape": (10,)})
-        actual_no_data = ds.to_dict(data=False)
+        actual_no_data = ds.to_dict(data=False, encoding=encoding)
         assert expected_no_data == actual_no_data
 
         # verify coords are included roundtrip
         expected_ds = ds.set_coords("b")
-        actual2 = Dataset.from_dict(expected_ds.to_dict())
+        actual2 = Dataset.from_dict(expected_ds.to_dict(data=data, encoding=encoding))
 
         assert_identical(expected_ds, actual2)
+        if encoding:
+            assert set(expected_ds.variables) == set(actual2.variables)
+            for vv in ds.variables:
+                np.testing.assert_equal(expected_ds[vv].encoding, actual2[vv].encoding)
 
         # test some incomplete dicts:
         # this one has no attrs field, the dims are strings, and x, y are
@@ -4690,7 +4709,10 @@ class TestDataset:
         roundtripped = Dataset.from_dict(ds.to_dict())
         assert_identical(ds, roundtripped)
 
-    def test_to_and_from_dict_with_nan_nat(self) -> None:
+    @pytest.mark.parametrize("data", [True, "list", "array"])
+    def test_to_and_from_dict_with_nan_nat(
+        self, data: bool | Literal["list", "array"]
+    ) -> None:
         x = np.random.randn(10, 3)
         y = np.random.randn(10, 3)
         y[2] = np.nan
@@ -4706,7 +4728,7 @@ class TestDataset:
                 "lat": ("lat", lat),
             }
         )
-        roundtripped = Dataset.from_dict(ds.to_dict())
+        roundtripped = Dataset.from_dict(ds.to_dict(data=data))
         assert_identical(ds, roundtripped)
 
     def test_to_dict_with_numpy_attrs(self) -> None:
diff --git a/xarray/tests/test_groupby.py b/xarray/tests/test_groupby.py
index 4488bda3..48a9c17d 100644
--- a/xarray/tests/test_groupby.py
+++ b/xarray/tests/test_groupby.py
@@ -808,6 +808,34 @@ def test_groupby_math_more() -> None:
         ds + ds.groupby("time.month")
 
 
+def test_groupby_math_bitshift() -> None:
+    # create new dataset of int's only
+    ds = Dataset(
+        {
+            "x": ("index", np.ones(4, dtype=int)),
+            "y": ("index", np.ones(4, dtype=int) * -1),
+            "level": ("index", [1, 1, 2, 2]),
+            "index": [0, 1, 2, 3],
+        }
+    )
+    shift = DataArray([1, 2, 1], [("level", [1, 2, 8])])
+
+    left_expected = Dataset(
+        {
+            "x": ("index", [2, 2, 4, 4]),
+            "y": ("index", [-2, -2, -4, -4]),
+            "level": ("index", [2, 2, 8, 8]),
+            "index": [0, 1, 2, 3],
+        }
+    )
+
+    left_actual = (ds.groupby("level") << shift).reset_coords(names="level")
+    assert_equal(left_expected, left_actual)
+
+    right_actual = (left_expected.groupby("level") >> shift).reset_coords(names="level")
+    assert_equal(ds, right_actual)
+
+
 @pytest.mark.parametrize("use_flox", [True, False])
 def test_groupby_bins_cut_kwargs(use_flox: bool) -> None:
     da = xr.DataArray(np.arange(12).reshape(6, 2), dims=("x", "y"))
diff --git a/xarray/tests/test_variable.py b/xarray/tests/test_variable.py
index b92db16e..bef5efc1 100644
--- a/xarray/tests/test_variable.py
+++ b/xarray/tests/test_variable.py
@@ -344,9 +344,10 @@ class VariableSubclassobjects(ABC):
         assert v[0] == pd.Period("2000", freq="B")
         assert "Period('2000-01-03', 'B')" in repr(v)
 
-    def test_1d_math(self):
-        x = 1.0 * np.arange(5)
-        y = np.ones(5)
+    @pytest.mark.parametrize("dtype", [float, int])
+    def test_1d_math(self, dtype: np.typing.DTypeLike) -> None:
+        x = np.arange(5, dtype=dtype)
+        y = np.ones(5, dtype=dtype)
 
         # should we need `.to_base_variable()`?
         # probably a break that `+v` changes type?
@@ -360,11 +361,18 @@ class VariableSubclassobjects(ABC):
         assert_identical(base_v, v + 0)
         assert_identical(base_v, 0 + v)
         assert_identical(base_v, v * 1)
+        if dtype is int:
+            assert_identical(base_v, v << 0)
+            assert_array_equal(v << 3, x << 3)
+            assert_array_equal(v >> 2, x >> 2)
         # binary ops with numpy arrays
         assert_array_equal((v * x).values, x**2)
-        assert_array_equal((x * v).values, x**2)
+        assert_array_equal((x * v).values, x**2)  # type: ignore[attr-defined] # TODO: Fix mypy thinking numpy takes priority, GH7780
         assert_array_equal(v - y, v - 1)
         assert_array_equal(y - v, 1 - v)
+        if dtype is int:
+            assert_array_equal(v << x, x << x)
+            assert_array_equal(v >> x, x >> x)
         # verify attributes are dropped
         v2 = self.cls(["x"], x, {"units": "meters"})
         with set_options(keep_attrs=False):
@@ -378,10 +386,10 @@ class VariableSubclassobjects(ABC):
         # something complicated
         assert_array_equal((v**2 * w - 1 + x).values, x**2 * y - 1 + x)
         # make sure dtype is preserved (for Index objects)
-        assert float == (+v).dtype
-        assert float == (+v).values.dtype
-        assert float == (0 + v).dtype
-        assert float == (0 + v).values.dtype
+        assert dtype == (+v).dtype
+        assert dtype == (+v).values.dtype
+        assert dtype == (0 + v).dtype
+        assert dtype == (0 + v).values.dtype
         # check types of returned data
         assert isinstance(+v, Variable)
         assert not isinstance(+v, IndexVariable)