Repository: burnash/gspread

Issue:
Add deprecation warning for colors changing from dict -> hex
See #1270

Can this be done in a way where users can change their code to remove the deprecation warnings? Like in #1264

I'm not sure

Discussion:

Patch:
diff --git a/gspread/utils.py b/gspread/utils.py
index 6886a31..a1aebba 100644
--- a/gspread/utils.py
+++ b/gspread/utils.py
@@ -12,6 +12,7 @@ from collections.abc import Sequence
 from functools import wraps
 from itertools import chain
 from math import inf
+from typing import Mapping
 from urllib.parse import quote as uquote
 
 from google.auth.credentials import Credentials as Credentials
@@ -767,6 +768,89 @@ def combined_merge_values(worksheet_metadata, values):
     return new_values
 
 
+def convert_hex_to_colors_dict(hex_color: str) -> Mapping[str, float]:
+    """Convert a hex color code to RGB color values.
+
+    :param str hex_color: Hex color code in the format "#RRGGBB".
+
+    :returns: Dict containing the color's red, green and blue values between 0 and 1.
+    :rtype: dict
+
+    :raises:
+        ValueError: If the input hex string is not in the correct format or length.
+
+    Examples:
+        >>> convert_hex_to_colors_dict("#3300CC")
+        {'red': 0.2, 'green': 0.0, 'blue': 0.8}
+
+        >>> convert_hex_to_colors_dict("#30C")
+        {'red': 0.2, 'green': 0.0, 'blue': 0.8}
+
+    """
+    hex_color = hex_color.lstrip("#")
+
+    # Google API ColorStyle Reference:
+    # "The alpha value in the Color object isn't generally supported."
+    # https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/other#colorstyle
+    if len(hex_color) == 8:
+        hex_color = hex_color[:-2]
+
+    # Expand 3 character hex.
+    if len(hex_color) == 3:
+        hex_color = "".join([char * 2 for char in hex_color])
+
+    if len(hex_color) != 6:
+        raise ValueError("Hex color code must be in the format '#RRGGBB'.")
+
+    try:
+        rgb_color = {
+            "red": int(hex_color[0:2], 16) / 255,
+            "green": int(hex_color[2:4], 16) / 255,
+            "blue": int(hex_color[4:6], 16) / 255,
+        }
+
+        return rgb_color
+    except ValueError as ex:
+        raise ValueError(f"Invalid character in hex color string: #{hex_color}") from ex
+
+
+def convert_colors_to_hex_value(
+    red: float = 0.0, green: float = 0.0, blue: float = 0.0
+) -> str:
+    """Convert RGB color values to a hex color code.
+
+    :param float red: Red color value (0-1).
+    :param float green: Green color value (0-1).
+    :param float blue: Blue color value (0-1).
+
+    :returns: Hex color code in the format "#RRGGBB".
+    :rtype: str
+
+    :raises:
+        ValueError: If any color value is out of the accepted range (0-1).
+
+    Example:
+
+        >>> convert_colors_to_hex_value(0.2, 0, 0.8)
+        '#3300CC'
+
+        >>> convert_colors_to_hex_value(green=0.5)
+        '#008000'
+    """
+
+    def to_hex(value: float) -> str:
+        """
+        Convert an integer to a 2-digit uppercase hex string.
+        """
+        hex_value = hex(round(value * 255))[2:]
+        return hex_value.upper().zfill(2)
+
+    if any(value < 0 or value > 1 for value in (red, green, blue)):
+        raise ValueError("Color value out of accepted range 0-1.")
+
+    return f"#{to_hex(red)}{to_hex(green)}{to_hex(blue)}"
+
+
 if __name__ == "__main__":
     import doctest
 
diff --git a/gspread/worksheet.py b/gspread/worksheet.py
index b8ea30d..9f61c9d 100644
--- a/gspread/worksheet.py
+++ b/gspread/worksheet.py
@@ -7,6 +7,7 @@ This module contains common worksheets' models.
 """
 
 import warnings
+from typing import Union
 
 from .cell import Cell
 from .exceptions import GSpreadException
@@ -25,6 +26,8 @@ from .utils import (
     cast_to_a1_notation,
     cell_list_to_rect,
     combined_merge_values,
+    convert_colors_to_hex_value,
+    convert_hex_to_colors_dict,
     fill_gaps,
     filter_dict_values,
     finditem,
@@ -203,8 +206,23 @@ class Worksheet:
         """Tab color style. Dict with RGB color values.
         If any of R, G, B are 0, they will not be present in the dict.
         """
+        warnings.warn(
+            DEPRECATION_WARNING_TEMPLATE.format(
+                v_deprecated="6.0.0",
+                msg_deprecated="""color format will change to hex format "#RRGGBB".
+                To suppress warning, use "get_tab_color()" and convert back to dict format, use gspread.utils.convert_hex_to_colors_dict.
+                However, we recommend changing your code to use hex format.""",
+            )
+        )
         return self._properties.get("tabColorStyle", {}).get("rgbColor", None)
 
+    def get_tab_color(self) -> Union[str, None]:
+        """Tab color style in hex format. String."""
+        tab_color = self._properties.get("tabColorStyle", {}).get("rgbColor", None)
+        if tab_color is None:
+            return None
+        return convert_colors_to_hex_value(**tab_color)
+
     def _get_sheet_property(self, property, default_value):
         """return a property of this worksheet or default value if not found"""
         meta = self.spreadsheet.fetch_sheet_metadata()
@@ -1459,12 +1477,24 @@ class Worksheet:
         self._properties["title"] = title
         return response
 
-    def update_tab_color(self, color: dict):
+    def update_tab_color(self, color: Union[dict, str]):
         """Changes the worksheet's tab color.
         Use clear_tab_color() to remove the color.
 
         :param dict color: The red, green and blue values of the color, between 0 and 1.
         """
+
+        if isinstance(color, str):
+            color = convert_hex_to_colors_dict(color)
+        else:
+            warnings.warn(
+                message=DEPRECATION_WARNING_TEMPLATE.format(
+                    v_deprecated="6.0.0",
+                    msg_deprecated="""color format will change to hex format "#RRGGBB".
+                    To suppress this warning, first convert color to hex with "gspread.utils.convert_colors_to_hex_value(color)""",
+                )
+            )
+
         red, green, blue = color["red"], color["green"], color["blue"]
         body = {
             "requests": [


Test patch:
diff --git a/tests/utils_test.py b/tests/utils_test.py
index 727ed8f..a85b84e 100644
--- a/tests/utils_test.py
+++ b/tests/utils_test.py
@@ -260,6 +260,44 @@ class UtilsTest(unittest.TestCase):
 
         self.assertEqual(actual_combine, expected_combine)
 
+    def test_convert_colors_to_hex_value(self):
+        color = {"red": 1, "green": 0.5, "blue": 0}
+        expected_hex = "#FF8000"
+
+        # successful convert from colors
+        hex = utils.convert_colors_to_hex_value(**color)
+        self.assertEqual(hex, expected_hex)
+
+        # successful convert from partial input
+        hex = utils.convert_colors_to_hex_value(green=1)
+        self.assertEqual(hex, "#00FF00")
+
+        # throw ValueError on color values out of range (0-1)
+        with self.assertRaises(ValueError):
+            utils.convert_colors_to_hex_value(1.23, 0, -50)
+
+    def test_convert_hex_to_color(self):
+        hexcolor = "#FF7F00"
+        expected_color = {"red": 1, "green": 0.49803922, "blue": 0}
+
+        # successful convert from hex to color
+        rgbcolor = utils.convert_hex_to_colors_dict(hexcolor)
+        for key, rgbvalue in rgbcolor.items():
+            self.assertAlmostEqual(rgbvalue, expected_color[key])
+
+        # successful ignore alpha
+        rgbcolor = utils.convert_hex_to_colors_dict(f"{hexcolor}42")
+        for key, rgbvalue in rgbcolor.items():
+            self.assertAlmostEqual(rgbvalue, expected_color[key])
+
+        # raise ValueError on invalid hex length
+        with self.assertRaises(ValueError):
+            utils.convert_hex_to_colors_dict("123456abcdef")
+
+        # raise ValueError on invalid hex characters
+        with self.assertRaises(ValueError):
+            utils.convert_hex_to_colors_dict("axbcde")
+
     def test_fill_gaps(self):
         """test fill_gaps function"""
         matrix = [
diff --git a/tests/worksheet_test.py b/tests/worksheet_test.py
index 875bbba..b73fc5e 100644
--- a/tests/worksheet_test.py
+++ b/tests/worksheet_test.py
@@ -239,6 +239,7 @@ class WorksheetTest(GspreadTest):
             .get("rgbColor", None)
         )
         color_param_before = self.sheet.tab_color
+        color_hex_before = self.sheet.get_tab_color()
 
         self.sheet.update_tab_color(pink_color)
 
@@ -249,14 +250,17 @@ class WorksheetTest(GspreadTest):
             .get("rgbColor", None)
         )
         color_param_after = self.sheet.tab_color
+        color_hex_after = self.sheet.get_tab_color()
 
         # params are set to whatever the user sets them to
         # google returns the closest 8-bit value
         # so these are different
         self.assertEqual(color_before, None)
         self.assertEqual(color_param_before, None)
+        self.assertEqual(color_hex_before, None)
         self.assertEqual(color_after, pink_color_from_google)
         self.assertEqual(color_param_after, pink_color)
+        self.assertEqual(color_hex_after, "#FF0080")
 
     @pytest.mark.vcr()
     def test_clear_tab_color(self):