Repository: joke2k/faker

Issue:
RGB/HSV/HSL color tuples from `color` provider
* Faker version: 19.3.1
* OS: any

With #104/#1055 the `color` provider learned how to generate colors with certain characteristics like "dark" or from a given hue. 

As noted by @malefice in the previous PR, the faked values are only available as strings. Often a numeric format is more suitable as input for other data-generation or computation. I am personally looking for this functionality to generate placeholder-images with similar colors and [pycairo](https://pycairo.readthedocs.io/en/latest/reference/patterns.html#class-solidpattern-pattern) works with RGB as float. 

With a little refactoring it should be easy to reuse the logic already implemented in `RandomColor` to provide int/float tuples for pseudo-random colors.

### Expected behavior

Instead of adding another `color_format` argument and `color()` having non-string return values, additional fakes could provide the color tuples using the same `hue` and `luminosity` kwargs as `color()`.

```
>>> from faker import Faker
>>> fake = Faker()
>>> fake.color(hue='red')
'#931929'
>>> fake.color_rgb(hue='red')
(247, 127, 118)
>>> fake.color_rgb_float(hue='red')
(0.98, 0.68355, 0.6566)
>>> fake.color_hsv(hue='red')
(334, 91, 78)
>>> fake.color_hsl(hue='red')
(340, 57, 60)
```

### Feature overlap and further changes...

The current color provider only generates strings.

There is however some overlap with `color()` and `rgb_css_color()`, that probably should be refactored/deprecated in a separate step, i.e.:

```
>>> fake.color(hue='blue', color_format='rgb')
'rgb(25, 211, 232)'
>>> fake.rgb_css_color()
'rgb(117,96,199)'
```




Discussion:

Resolving patch:
diff --git a/faker/providers/color/__init__.py b/faker/providers/color/__init__.py
--- a/faker/providers/color/__init__.py
+++ b/faker/providers/color/__init__.py
@@ -1,5 +1,6 @@
 from collections import OrderedDict
-from typing import Dict, Optional
+from functools import cached_property
+from typing import Dict, Optional, Tuple
 
 from ...typing import HueType
 from .. import BaseProvider, ElementsType
@@ -198,6 +199,10 @@ def rgb_css_color(self) -> str:
         """Generate a color formatted as a CSS rgb() function."""
         return f"rgb({self.random_int(0, 255)},{self.random_int(0, 255)},{self.random_int(0, 255)})"
 
+    @cached_property
+    def _random_color(self):
+        return RandomColor(self.generator)
+
     def color(
         self,
         hue: Optional[HueType] = None,
@@ -238,8 +243,40 @@ def color(
         :sample: hue=135, luminosity='dark', color_format='hsv'
         :sample: hue=(300, 20), luminosity='random', color_format='hsl'
         """
-        return RandomColor(self.generator).generate(
+        return self._random_color.generate(
             hue=hue,
             luminosity=luminosity,
             color_format=color_format,
         )
+
+    def color_rgb(
+        self,
+        hue: Optional[HueType] = None,
+        luminosity: Optional[str] = None,
+    ) -> Tuple[int, int, int]:
+        """Generate a RGB color tuple of integers in a human-friendly way."""
+        return self._random_color.generate_rgb(hue=hue, luminosity=luminosity)
+
+    def color_rgb_float(
+        self,
+        hue: Optional[HueType] = None,
+        luminosity: Optional[str] = None,
+    ) -> Tuple[float, float, float]:
+        """Generate a RGB color tuple of floats in a human-friendly way."""
+        return self._random_color.generate_rgb_float(hue=hue, luminosity=luminosity)
+
+    def color_hsl(
+        self,
+        hue: Optional[HueType] = None,
+        luminosity: Optional[str] = None,
+    ) -> Tuple[int, int, int]:
+        """Generate a HSL color tuple in a human-friendly way."""
+        return self._random_color.generate_hsl(hue=hue, luminosity=luminosity)
+
+    def color_hsv(
+        self,
+        hue: Optional[HueType] = None,
+        luminosity: Optional[str] = None,
+    ) -> Tuple[int, int, int]:
+        """Generate a HSV color tuple in a human-friendly way."""
+        return self._random_color.generate_hsv(hue=hue, luminosity=luminosity)
diff --git a/faker/providers/color/color.py b/faker/providers/color/color.py
--- a/faker/providers/color/color.py
+++ b/faker/providers/color/color.py
@@ -16,13 +16,16 @@
 import random
 import sys
 
-from typing import TYPE_CHECKING, Dict, Hashable, Optional, Sequence, Tuple
+from typing import TYPE_CHECKING, Dict, Hashable, Literal, Optional, Sequence, Tuple
 
 if TYPE_CHECKING:
     from ...factory import Generator
 
 from ...typing import HueType
 
+ColorFormat = Literal["hex", "hsl", "hsv", "rgb"]
+
+
 COLOR_MAP: Dict[str, Dict[str, Sequence[Tuple[int, int]]]] = {
     "monochrome": {
         "hue_range": [(0, 0)],
@@ -144,37 +147,64 @@ def __init__(self, generator: Optional["Generator"] = None, seed: Optional[Hasha
             self.seed = seed if seed else random.randint(0, sys.maxsize)
             self.random = random.Random(self.seed)
 
-        for color_name, color_attrs in self.colormap.items():
-            lower_bounds: Sequence[Tuple[int, int]] = color_attrs["lower_bounds"]
-            s_min, b_max = lower_bounds[0]
-            s_max, b_min = lower_bounds[-1]
-
-            self.colormap[color_name]["saturation_range"] = [(s_min, s_max)]
-            self.colormap[color_name]["brightness_range"] = [(b_min, b_max)]
-
     def generate(
         self,
         hue: Optional[HueType] = None,
         luminosity: Optional[str] = None,
-        color_format: str = "hex",
+        color_format: ColorFormat = "hex",
     ) -> str:
-        """Generate a color.
+        """Generate and format a color.
 
         Whenever :meth:`color() <faker.providers.color.Provider.color>` is
         called, the arguments used are simply passed into this method, and this
         method handles the rest.
         """
+        # Generate HSV color tuple from picked hue and luminosity
+        hsv = self.generate_hsv(hue=hue, luminosity=luminosity)
+
+        # Return the HSB/V color in the desired string format
+        return self.set_format(hsv, color_format)
+
+    def generate_hsv(
+        self,
+        hue: Optional[HueType] = None,
+        luminosity: Optional[str] = None,
+    ) -> Tuple[int, int, int]:
+        """Generate a HSV color tuple."""
         # First we pick a hue (H)
         h = self.pick_hue(hue)
 
         # Then use H to determine saturation (S)
         s = self.pick_saturation(h, hue, luminosity)
 
-        # Then use S and H to determine brightness (B).
-        b = self.pick_brightness(h, s, luminosity)
+        # Then use S and H to determine brightness/value (B/V).
+        v = self.pick_brightness(h, s, luminosity)
 
-        # Then we return the HSB color in the desired format
-        return self.set_format((h, s, b), color_format)
+        return h, s, v
+
+    def generate_rgb(
+        self,
+        hue: Optional[HueType] = None,
+        luminosity: Optional[str] = None,
+    ) -> Tuple[int, int, int]:
+        """Generate a RGB color tuple of integers."""
+        return self.hsv_to_rgb(self.generate_hsv(hue=hue, luminosity=luminosity))
+
+    def generate_rgb_float(
+        self,
+        hue: Optional[HueType] = None,
+        luminosity: Optional[str] = None,
+    ) -> Tuple[float, float, float]:
+        """Generate a RGB color tuple of floats."""
+        return self.hsv_to_rgb_float(self.generate_hsv(hue=hue, luminosity=luminosity))
+
+    def generate_hsl(
+        self,
+        hue: Optional[HueType] = None,
+        luminosity: Optional[str] = None,
+    ) -> Tuple[int, int, int]:
+        """Generate a HSL color tuple."""
+        return self.hsv_to_hsl(self.generate_hsv(hue=hue, luminosity=luminosity))
 
     def pick_hue(self, hue: Optional[HueType]) -> int:
         """Return a numerical hue value."""
@@ -226,7 +256,7 @@ def pick_brightness(self, h: int, s: int, luminosity: Optional[str]) -> int:
 
         return self.random_within((b_min, b_max))
 
-    def set_format(self, hsv: Tuple[int, int, int], color_format: str) -> str:
+    def set_format(self, hsv: Tuple[int, int, int], color_format: ColorFormat) -> str:
         """Handle conversion of HSV values into desired format."""
         if color_format == "hsv":
             color = f"hsv({hsv[0]}, {hsv[1]}, {hsv[2]})"
@@ -265,11 +295,11 @@ def get_hue_range(self, color_input: Optional[HueType]) -> Tuple[int, int]:
         """Return the hue range for a given ``color_input``."""
         if isinstance(color_input, (int, float)) and 0 <= color_input <= 360:
             color_input = int(color_input)
-            return (color_input, color_input)
+            return color_input, color_input
         elif isinstance(color_input, str) and color_input in self.colormap:
             return self.colormap[color_input]["hue_range"][0]
         elif color_input is None:
-            return (0, 360)
+            return 0, 360
 
         if isinstance(color_input, list):
             color_input = tuple(color_input)
@@ -285,12 +315,13 @@ def get_hue_range(self, color_input: Optional[HueType]) -> Tuple[int, int]:
                 v1, v2 = v2, v1
             v1 = max(v1, 0)
             v2 = min(v2, 360)
-            return (v1, v2)
+            return v1, v2
         raise TypeError("Hue must be a valid string, numeric type, or a tuple/list of 2 numeric types.")
 
     def get_saturation_range(self, hue: int) -> Tuple[int, int]:
         """Return the saturation range for a given numerical ``hue`` value."""
-        return self.get_color_info(hue)["saturation_range"][0]
+        saturation_bounds = [s for s, v in self.get_color_info(hue)["lower_bounds"]]
+        return min(saturation_bounds), max(saturation_bounds)
 
     def get_color_info(self, hue: int) -> Dict[str, Sequence[Tuple[int, int]]]:
         """Return the color info for a given numerical ``hue`` value."""
@@ -310,18 +341,27 @@ def random_within(self, r: Sequence[int]) -> int:
         return self.random.randint(int(r[0]), int(r[1]))
 
     @classmethod
-    def hsv_to_rgb(cls, hsv: Tuple[int, int, int]) -> Tuple[int, int, int]:
+    def hsv_to_rgb_float(cls, hsv: Tuple[int, int, int]) -> Tuple[float, float, float]:
         """Convert HSV to RGB.
 
         This method expects ``hsv`` to be a 3-tuple of H, S, and V values, and
-        it will return a 3-tuple of the equivalent R, G, and B values.
+        it will return a 3-tuple of the equivalent R, G, and B float values.
         """
         h, s, v = hsv
         h = max(h, 1)
         h = min(h, 359)
 
-        r, g, b = colorsys.hsv_to_rgb(h / 360, s / 100, v / 100)
-        return (int(r * 255), int(g * 255), int(b * 255))
+        return colorsys.hsv_to_rgb(h / 360, s / 100, v / 100)
+
+    @classmethod
+    def hsv_to_rgb(cls, hsv: Tuple[int, int, int]) -> Tuple[int, int, int]:
+        """Convert HSV to RGB.
+
+        This method expects ``hsv`` to be a 3-tuple of H, S, and V values, and
+        it will return a 3-tuple of the equivalent R, G, and B integer values.
+        """
+        r, g, b = cls.hsv_to_rgb_float(hsv)
+        return int(r * 255), int(g * 255), int(b * 255)
 
     @classmethod
     def hsv_to_hsl(cls, hsv: Tuple[int, int, int]) -> Tuple[int, int, int]:
@@ -334,7 +374,7 @@ def hsv_to_hsl(cls, hsv: Tuple[int, int, int]) -> Tuple[int, int, int]:
 
         s_: float = s / 100.0
         v_: float = v / 100.0
-        l = 0.5 * (v_) * (2 - s_)  # noqa: E741
+        l = 0.5 * v_ * (2 - s_)  # noqa: E741
 
         s_ = 0.0 if l in [0, 1] else v_ * s_ / (1 - math.fabs(2 * l - 1))
-        return (int(h), int(s_ * 100), int(l * 100))
+        return int(h), int(s_ * 100), int(l * 100)


Test patch:
diff --git a/tests/providers/test_color.py b/tests/providers/test_color.py
--- a/tests/providers/test_color.py
+++ b/tests/providers/test_color.py
@@ -47,15 +47,42 @@ def test_rgb_css_color(self, faker, num_samples):
             assert 0 <= b <= 255
 
     def test_color(self, faker, num_samples):
-        baseline_random_color = RandomColor(seed=4761)
-        expected = [baseline_random_color.generate() for _ in range(num_samples)]
+        random_color = self._seed_instances(faker, 4761)
+        expected = [random_color.generate() for _ in range(num_samples)]
 
         # The `color` provider method should behave like the `generate`
         # method of a standalone RandomColor instance for a given seed
-        faker.seed_instance(4761)
         colors = [faker.color() for _ in range(num_samples)]
         assert colors == expected
 
+    def _seed_instances(self, faker, seed):
+        faker.seed_instance(seed)
+        return RandomColor(seed=seed)
+
+    def test_color_rgb(self, faker, num_samples):
+        random_color = self._seed_instances(faker, 4761)
+        expected = [random_color.generate_rgb() for _ in range(num_samples)]
+        colors = [faker.color_rgb() for _ in range(num_samples)]
+        assert colors == expected
+
+    def test_color_rgb_float(self, faker, num_samples):
+        random_color = self._seed_instances(faker, 4761)
+        expected = [random_color.generate_rgb_float() for _ in range(num_samples)]
+        colors = [faker.color_rgb_float() for _ in range(num_samples)]
+        assert colors == expected
+
+    def test_color_hsl(self, faker, num_samples):
+        random_color = self._seed_instances(faker, 4761)
+        expected = [random_color.generate_hsl() for _ in range(num_samples)]
+        colors = [faker.color_hsl() for _ in range(num_samples)]
+        assert colors == expected
+
+    def test_color_hsv(self, faker, num_samples):
+        random_color = self._seed_instances(faker, 4761)
+        expected = [random_color.generate_hsv() for _ in range(num_samples)]
+        colors = [faker.color_hsv() for _ in range(num_samples)]
+        assert colors == expected
+
 
 class TestRandomColor:
     """Test RandomColor class"""
@@ -116,6 +143,42 @@ def test_color_format_unspecified(self, num_samples):
             color = self.random_color.generate()
             assert self.hex_color_pattern.fullmatch(color)
 
+    def test_rgb(self, num_samples):
+        for _ in range(num_samples):
+            value = self.random_color.generate_rgb()
+            assert len(value) == 3
+            for i in range(3):
+                assert isinstance(value[i], int)
+                assert 0 <= value[i] <= 255
+
+    def test_rgb_float(self, num_samples):
+        for _ in range(num_samples):
+            value = self.random_color.generate_rgb_float()
+            assert len(value) == 3
+            for i in range(3):
+                assert isinstance(value[i], float)
+                assert 0 <= value[i] <= 1
+
+    def test_hsl(self, num_samples):
+        for _ in range(num_samples):
+            value = self.random_color.generate_hsl()
+            assert len(value) == 3
+            for i in range(3):
+                assert isinstance(value[i], int)
+            assert 0 <= value[0] <= 360
+            assert 0 <= value[1] <= 100
+            assert 0 <= value[2] <= 100
+
+    def test_hsv(self, num_samples):
+        for _ in range(num_samples):
+            value = self.random_color.generate_hsl()
+            assert len(value) == 3
+            for i in range(3):
+                assert isinstance(value[i], int)
+            assert 0 <= value[0] <= 360
+            assert 0 <= value[1] <= 100
+            assert 0 <= value[2] <= 100
+
     def test_hue_integer(self):
         # HSV format is used, because whatever hue value supplied must be present in the output
         for hue in range(360):