Repository: joke2k/faker

Issue:
Improve color provider
see: https://github.com/davidmerfield/randomColor



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
@@ -2,6 +2,7 @@
 from __future__ import unicode_literals
 from collections import OrderedDict
 
+from .color import RandomColor
 from .. import BaseProvider
 
 localized = True
@@ -180,3 +181,18 @@ def rgb_color(self):
     def rgb_css_color(self):
         return 'rgb(%s)' % ','.join(
             map(str, (self.random_int(0, 255) for _ in range(3))))
+
+    def color(self, hue=None, luminosity=None, color_format='hex'):
+        """
+        Creates a color in specified format
+
+        :param hue: monochrome, red, orange, yellow, green, blue, purple, pink, a number
+                    from 0 to 360, or a tuple/list of 2 numbers from 0 to 360
+        :param luminosity: bright, dark, light, or random
+        :param color_format: hsv, hsl, rgb, or hex with hex being default
+        :return: color in the specified format
+        """
+
+        return RandomColor(self.generator).generate(
+            hue=hue, luminosity=luminosity, color_format=color_format,
+        )
diff --git a/faker/providers/color/color.py b/faker/providers/color/color.py
new file mode 100644
--- /dev/null
+++ b/faker/providers/color/color.py
@@ -0,0 +1,280 @@
+"""
+Code adapted from:
+- https://github.com/davidmerfield/randomColor  (CC0)
+- https://github.com/kevinwuhoo/randomcolor-py  (MIT License)
+
+Additional reference from:
+- https://en.wikipedia.org/wiki/HSL_and_HSV
+"""
+
+import colorsys
+import math
+import random
+import sys
+
+import six
+
+
+COLOR_MAP = {
+    'monochrome': {
+        'hue_range': [0, 0],
+        'lower_bounds': [
+            [0, 0], [100, 0],
+        ],
+    },
+    'red': {
+        'hue_range': [-26, 18],
+        'lower_bounds': [
+            [20, 100], [30, 92], [40, 89],
+            [50, 85], [60, 78], [70, 70],
+            [80, 60], [90, 55], [100, 50],
+        ],
+    },
+    'orange': {
+        'hue_range': [19, 46],
+        'lower_bounds': [
+            [20, 100], [30, 93], [40, 88], [50, 86],
+            [60, 85], [70, 70], [100, 70],
+        ],
+    },
+    'yellow': {
+        'hue_range': [47, 62],
+        'lower_bounds': [
+            [25, 100], [40, 94], [50, 89], [60, 86],
+            [70, 84], [80, 82], [90, 80], [100, 75],
+        ],
+    },
+    'green': {
+        'hue_range': [63, 178],
+        'lower_bounds': [
+            [30, 100], [40, 90], [50, 85], [60, 81],
+            [70, 74], [80, 64], [90, 50], [100, 40],
+        ],
+    },
+    'blue': {
+        'hue_range': [179, 257],
+        'lower_bounds': [
+            [20, 100], [30, 86], [40, 80],
+            [50, 74], [60, 60], [70, 52],
+            [80, 44], [90, 39], [100, 35],
+        ],
+    },
+    'purple': {
+        'hue_range': [258, 282],
+        'lower_bounds': [
+            [20, 100], [30, 87], [40, 79],
+            [50, 70], [60, 65], [70, 59],
+            [80, 52], [90, 45], [100, 42],
+        ],
+    },
+    'pink': {
+        'hue_range': [283, 334],
+        'lower_bounds': [
+            [20, 100], [30, 90], [40, 86], [60, 84],
+            [80, 80], [90, 75], [100, 73],
+        ],
+    },
+}
+
+
+class RandomColor(object):
+
+    def __init__(self, generator=None, seed=None):
+        self.colormap = COLOR_MAP
+
+        # Option to specify a seed was not removed so this class
+        # can still be tested independently w/o generators
+        if generator:
+            self.random = generator.random
+        else:
+            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 = color_attrs['lower_bounds']
+            s_min = lower_bounds[0][0]
+            s_max = lower_bounds[-1][0]
+
+            b_min = lower_bounds[-1][1]
+            b_max = lower_bounds[0][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=None, luminosity=None, color_format='hex'):
+        # 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 we return the HSB color in the desired format
+        return self.set_format([h, s, b], color_format)
+
+    def pick_hue(self, hue):
+        hue_range = self.get_hue_range(hue)
+        hue = self.random_within(hue_range)
+
+        # Instead of storing red as two separate ranges,
+        # we group them, using negative numbers
+        if hue < 0:
+            hue += 360
+
+        return hue
+
+    def pick_saturation(self, hue, hue_name, luminosity):
+        if luminosity == 'random':
+            return self.random_within([0, 100])
+
+        if hue_name == 'monochrome':
+            return 0
+
+        saturation_range = self.get_saturation_range(hue)
+
+        s_min = saturation_range[0]
+        s_max = saturation_range[1]
+
+        if luminosity == 'bright':
+            s_min = 55
+        elif luminosity == 'dark':
+            s_min = s_max - 10
+        elif luminosity == 'light':
+            s_max = 55
+
+        return self.random_within([s_min, s_max])
+
+    def pick_brightness(self, h, s, luminosity):
+        b_min = self.get_minimum_brightness(h, s)
+        b_max = 100
+
+        if luminosity == 'dark':
+            b_max = b_min + 20
+        elif luminosity == 'light':
+            b_min = (b_max + b_min) / 2
+        elif luminosity == 'random':
+            b_min = 0
+            b_max = 100
+
+        return self.random_within([b_min, b_max])
+
+    def set_format(self, hsv, color_format):
+        if color_format == 'hsv':
+            color = 'hsv({}, {}, {})'.format(*hsv)
+
+        elif color_format == 'hsl':
+            hsl = self.hsv_to_hsl(hsv)
+            color = 'hsl({}, {}, {})'.format(*hsl)
+
+        elif color_format == 'rgb':
+            rgb = self.hsv_to_rgb(hsv)
+            color = 'rgb({}, {}, {})'.format(*rgb)
+
+        else:
+            rgb = self.hsv_to_rgb(hsv)
+            color = '#{:02x}{:02x}{:02x}'.format(*rgb)
+
+        return color
+
+    def get_minimum_brightness(self, h, s):
+        lower_bounds = self.get_color_info(h)['lower_bounds']
+
+        for i in range(len(lower_bounds) - 1):
+            s1 = lower_bounds[i][0]
+            v1 = lower_bounds[i][1]
+
+            s2 = lower_bounds[i + 1][0]
+            v2 = lower_bounds[i + 1][1]
+
+            if s1 <= s <= s2:
+                m = (v2 - v1) / (s2 - s1)
+                b = v1 - m * s1
+
+                return m * s + b
+
+        return 0
+
+    def get_hue_range(self, color_input):
+        if isinstance(color_input, (int, float)) and 0 <= color_input <= 360:
+            color_input = int(color_input)
+            return [color_input, color_input]
+
+        elif isinstance(color_input, six.string_types) and color_input in self.colormap:
+            return self.colormap[color_input]['hue_range']
+
+        elif color_input is None:
+            return [0, 360]
+
+        try:
+            v1, v2 = color_input
+            v1 = int(v1)
+            v2 = int(v2)
+        except (ValueError, TypeError):
+            msg = 'Hue must be a valid string, numeric type, or a tuple/list of 2 numeric types.'
+            raise TypeError(msg)
+        else:
+            if v2 < v1:
+                v1, v2 = v2, v1
+            if v1 < 0:
+                v1 = 0
+            if v2 > 360:
+                v2 = 360
+            return [v1, v2]
+
+    def get_saturation_range(self, hue):
+        return self.get_color_info(hue)['saturation_range']
+
+    def get_color_info(self, hue):
+        # Maps red colors to make picking hue easier
+        if 334 <= hue <= 360:
+            hue -= 360
+
+        for color_name, color in self.colormap.items():
+            if color['hue_range'][0] <= hue <= color['hue_range'][1]:
+                return self.colormap[color_name]
+        else:
+            raise ValueError('Value of hue `%s` is invalid.' % hue)
+
+    def random_within(self, r):
+        return self.random.randint(int(r[0]), int(r[1]))
+
+    @classmethod
+    def hsv_to_rgb(cls, hsv):
+        """
+        Converts HSV to RGB
+
+        :param hsv: 3-tuple of h, s, and v values
+        :return: 3-tuple of r, g, and b values
+        """
+        h, s, v = hsv
+        h = 1 if h == 0 else h
+        h = 359 if h == 360 else h
+
+        h = float(h)/360
+        s = float(s)/100
+        v = float(v)/100
+
+        rgb = colorsys.hsv_to_rgb(h, s, v)
+        return (int(c * 255) for c in rgb)
+
+    @classmethod
+    def hsv_to_hsl(cls, hsv):
+        """
+        Converts HSV to HSL
+
+        :param hsv: 3-tuple of h, s, and v values
+        :return: 3-tuple of h, s, and l values
+        """
+        h, s, v = hsv
+
+        s = float(s)/100
+        v = float(v)/100
+        l = 0.5 * v * (2 - s)   # noqa: E741
+
+        if l in [0, 1]:
+            s = 0
+        else:
+            s = v * s / (1 - math.fabs(2 * l - 1))
+        return (int(c) for c in [h, s * 100, 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
@@ -1,8 +1,11 @@
 import unittest
+import re
 from re import search
 from faker import Faker
 
+from faker.providers.color import RandomColor
 from faker.providers.color.hy_AM import Provider as HyAmProvider
+import six
 from six import string_types
 
 
@@ -45,6 +48,206 @@ def test_rgb_css_color(self):
         assert maxval <= 255
         assert minval >= 0
 
+    def test_color(self):
+        baseline_random_color = RandomColor(seed=4761)
+        expected = [baseline_random_color.generate() for _ in range(10000)]
+
+        # The `color` provider method should behave like the `generate`
+        # method of a standalone RandomColor instance for a given seed
+        self.factory.seed(4761)
+        colors = [self.factory.color() for _ in range(10000)]
+        assert colors == expected
+
+
+class TestRandomColor(unittest.TestCase):
+
+    seed = 4761
+    hsv_color_pattern = re.compile(
+        r'^hsv\('
+        r'(?P<h>\d|[1-9]\d|[1-3]\d{2}), '
+        r'(?P
\d|[1-9]\d|100), '
+        r'(?P<v>\d|[1-9]\d|100)\)$',
+    )
+    hsl_color_pattern = re.compile(
+        r'^hsl\('
+        r'(?P<h>\d|[1-9]\d|[1-3]\d{2}), '
+        r'(?P
\d|[1-9]\d|[1-3]\d{2}), '
+        r'(?P<l>\d|[1-9]\d|[1-3]\d{2})\)$',
+    )
+    rgb_color_pattern = re.compile(
+        r'^rgb\('
+        r'(?P<r>\d|[1-9]\d|[1-3]\d{2}), '
+        r'(?P<g>\d|[1-9]\d|[1-3]\d{2}), '
+        r'(?P<b>\d|[1-9]\d|[1-3]\d{2})\)$',
+    )
+    hex_color_pattern = re.compile(r'^#[0-9a-f]{6}$')
+
+    def setUp(self):
+        self.random_color = RandomColor(seed=self.seed)
+
+    def test_color_format_hsv(self):
+        for _ in range(10000):
+            hsv_color = self.random_color.generate(color_format='hsv')
+            match = self.hsv_color_pattern.match(hsv_color)
+            assert match
+            groupdict = match.groupdict()
+            assert 0 <= int(groupdict['h']) <= 360
+            assert 0 <= int(groupdict['s']) <= 100
+            assert 0 <= int(groupdict['v']) <= 100
+
+    def test_color_format_hsl(self):
+        for _ in range(10000):
+            hsl_color = self.random_color.generate(color_format='hsl')
+            match = self.hsl_color_pattern.match(hsl_color)
+            assert match
+            groupdict = match.groupdict()
+            assert 0 <= int(groupdict['h']) <= 360
+            assert 0 <= int(groupdict['s']) <= 100
+            assert 0 <= int(groupdict['l']) <= 100
+
+    def test_color_format_rgb(self):
+        for _ in range(10000):
+            rgb_color = self.random_color.generate(color_format='rgb')
+            match = self.rgb_color_pattern.match(rgb_color)
+            assert match
+            groupdict = match.groupdict()
+            assert 0 <= int(groupdict['r']) <= 255
+            assert 0 <= int(groupdict['g']) <= 255
+            assert 0 <= int(groupdict['b']) <= 255
+
+    def test_color_format_hex(self):
+        for _ in range(10000):
+            hex_color = self.random_color.generate(color_format='hex')
+            assert self.hex_color_pattern.match(hex_color)
+
+    def test_color_format_unspecified(self):
+        for _ in range(10000):
+            color = self.random_color.generate()
+            assert self.hex_color_pattern.match(color)
+
+    def test_hue_word(self):
+        if six.PY2:
+            expected = ['#f2f2f2', '#6b6b6b', '#939393', '#5e5e5e', '#aaaaaa']
+        else:
+            expected = ['#cecece', '#ededed', '#efefef', '#bcbcbc', '#777777']
+        colors = [self.random_color.generate(hue='monochrome') for _ in range(5)]
+        assert colors == expected
+
+        if six.PY2:
+            expected = ['#c46542', '#d8495f', '#c42c09', '#dd4b68', '#c6135e']
+        else:
+            expected = ['#ef0b31', '#f2b7ab', '#f74c55', '#a53822', '#8e3712']
+        colors = [self.random_color.generate(hue='red') for _ in range(5)]
+        assert colors == expected
+
+        if six.PY2:
+            expected = ['#fcda9f', '#ffa566', '#b55609', '#c9761e', '#fcd9c7']
+        else:
+            expected = ['#f98313', '#ddb77e', '#f9c413', '#f4ce81', '#ddae71']
+        colors = [self.random_color.generate(hue='orange') for _ in range(5)]
+        assert colors == expected
+
+        if six.PY2:
+            expected = ['#f2f75b', '#d8cb38', '#efe3a5', '#dbc053', '#eae096']
+        else:
+            expected = ['#dbe04e', '#efc621', '#fff65b', '#ceaf27', '#fcf9ae']
+        colors = [self.random_color.generate(hue='yellow') for _ in range(5)]
+        assert colors == expected
+
+        if six.PY2:
+            expected = ['#54d387', '#b1d15c', '#c0f78a', '#27b278', '#1bc43a']
+        else:
+            expected = ['#05876f', '#57e095', '#50ceaa', '#e4f7a0', '#698909']
+        colors = [self.random_color.generate(hue='green') for _ in range(5)]
+        assert colors == expected
+
+        if six.PY2:
+            expected = ['#0b577c', '#7ad3d3', '#4884ce', '#bae8f2', '#79cafc']
+        else:
+            expected = ['#2b839b', '#a4d3e8', '#3d2caa', '#3859a0', '#52349e']
+        colors = [self.random_color.generate(hue='blue') for _ in range(5)]
+        assert colors == expected
+
+        if six.PY2:
+            expected = ['#d2a7e5', '#a519fc', '#8b0ece', '#b17fe2', '#a949dd']
+        else:
+            expected = ['#a074e8', '#6122bf', '#9f76cc', '#250570', '#3c1599']
+        colors = [self.random_color.generate(hue='purple') for _ in range(5)]
+        assert colors == expected
+
+        if six.PY2:
+            expected = ['#f453c4', '#db81ac', '#f99fc9', '#e23fff', '#bb68cc']
+        else:
+            expected = ['#c605c6', '#fcc4ec', '#d979f7', '#ce108c', '#d3289d']
+        colors = [self.random_color.generate(hue='pink') for _ in range(5)]
+        assert colors == expected
+
+    def test_hue_tuple_beyond_limits(self):
+        baseline_random_color = RandomColor(seed=self.seed)
+        expected = [baseline_random_color.generate(hue=[0, 360]) for _ in range(1000)]
+
+        # Using a tuple with values not between 0 and 360 should yield the same results
+        # as using a tuple with clamped values for a given seed
+        colors = [self.random_color.generate(hue=[-100, 4500]) for _ in range(1000)]
+        assert colors == expected
+
+    def test_hue_tuple_inverted_values(self):
+        baseline_random_color = RandomColor(seed=self.seed)
+        expected = [baseline_random_color.generate(hue=[45, 75]) for _ in range(1000)]
+
+        # Using a tuple with inverted values should yield the same results
+        # as using the correctly ordered tuple for a given seed
+        colors = [self.random_color.generate(hue=[75, 45]) for _ in range(1000)]
+        assert colors == expected
+
+    def test_hue_invalid(self):
+        invalid_values = [
+            'invalid value',    # Unsupported string
+            [1, 2, 3],          # List with incorrect number of elements of valid data types
+            ['ab', 1],          # List with correct number of elements with invalid data types
+            self,               # Any other garbage
+        ]
+
+        for invalid_value in invalid_values:
+            with self.assertRaises(TypeError):
+                self.random_color.generate(hue=invalid_value)
+
+    def test_luminosity_word(self):
+        if six.PY2:
+            expected = ['#7c000a', '#018748', '#dd8a0d', '#000068', '#7c9308']
+        else:
+            expected = ['#2b7700', '#073c8c', '#d813aa', '#01961a', '#ce840e']
+        colors = [self.random_color.generate(luminosity='dark') for _ in range(5)]
+        assert colors == expected
+
+        if six.PY2:
+            expected = ['#11f7c9', '#3eafc9', '#6ad4f2', '#125582', '#e55098']
+        else:
+            expected = ['#16b5ff', '#6266ef', '#fc4e3f', '#b2ff70', '#a30424']
+        colors = [self.random_color.generate(luminosity='bright') for _ in range(5)]
+        assert colors == expected
+
+        if six.PY2:
+            expected = ['#ffafb5', '#fcc1a1', '#b7fcab', '#6df280', '#f599f7']
+        else:
+            expected = ['#f276a1', '#fcec94', '#aaffe5', '#ffbd7f', '#98f9dc']
+        colors = [self.random_color.generate(luminosity='light') for _ in range(5)]
+        assert colors == expected
+
+        if six.PY2:
+            expected = ['#11140f', '#7f7674', '#262116', '#376d20', '#535e53']
+        else:
+            expected = ['#070603', '#99a2a3', '#10a85c', '#3f4f0c', '#004f1c']
+        colors = [self.random_color.generate(luminosity='random') for _ in range(5)]
+        assert colors == expected
+
+    def test_luminosity_invalid(self):
+        baseline_random_color = RandomColor(seed=self.seed)
+        expected = [baseline_random_color.generate() for _ in range(1000)]
+
+        colors = [self.random_color.generate(luminosity='invalid_value') for _ in range(1000)]
+        assert colors == expected
+
 
 class TestHyAM(unittest.TestCase):
     """ Tests colors in the hy_AM locale """