Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix invalid colors in a list #4964

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@
All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](http://semver.org/).

## [6.0.1] - 2025-01-13

### Added
- Support for HSL colors and Named colors. [#4957](https://github.com/plotly/plotly.py/issues/4957)
- Function to converge from HSL to RGB: `hsl_to_rgb`.

### Fixed
- Fix a bug `validate_colors` that caused invalid colors within lists to pass without raising errors [#4957](https://github.com/plotly/plotly.py/issues/4957)


## [6.0.0rc0] - 2024-11-27

### Added
Expand Down
104 changes: 102 additions & 2 deletions packages/python/plotly/_plotly_utils/colors/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,22 @@
],
}

PLOTLY_NAMED_COLORS = {
"red": (255, 0, 0),
"blue": (0, 0, 255),
"green": (0, 255, 0),
"yellow": (255, 255, 0),
"black": (0, 0, 0),
"white": (255, 255, 255),
"purple": (128, 0, 128),
"orange": (255, 165, 0),
"pink": (255, 192, 203),
"brown": (165, 42, 42),
"gold": (255, 215, 0),
"silver": (192, 192, 192),
"gray": (128, 128, 128),
}


def color_parser(colors, function):
"""
Expand Down Expand Up @@ -322,12 +338,15 @@ def validate_colors(colors, colortype="tuple"):
# color in the plotly colorscale. In resolving this issue we
# will be removing the immediate line below
colors = [colors_list[0]] + [colors_list[-1]]
elif "rgb" in colors or "#" in colors:
elif "rgb" in colors or "#" in colors or "hsl" in colors:
colors = [colors]
elif colors in PLOTLY_NAMED_COLORS:
colors = [PLOTLY_NAMED_COLORS[colors]]

else:
raise exceptions.PlotlyError(
"If your colors variable is a string, it must be a "
"Plotly scale, an rgb color or a hex color."
"Plotly scale, a named color, an rgb color, a hex color, or an hsl color."
)

elif isinstance(colors, tuple):
Expand Down Expand Up @@ -355,6 +374,28 @@ def validate_colors(colors, colortype="tuple"):

colors[j] = each_color

if "hsl" in each_color:
try:
each_color = color_parser(each_color, hsl_to_rgb)
except ValueError as e:
raise exceptions.PlotlyError(f"Invalid HSL color: {each_color}. {str(e)}")
each_color = color_parser(each_color, unconvert_from_RGB_255)
colors[j] = each_color

elif isinstance(colors, list):
validated_colors = []
for each_color in colors:
if each_color in NAMED_COLORS:
validated_colors.append(NAMED_COLORS[each_color])
elif "rgb" in each_color or "#" in each_color or "hsl" in each_color:
validated_colors.append(each_color)
else:
raise exceptions.PlotlyError(
f"Invalid color in list: {each_color}. Each color must be a valid "
"Plotly scale, named color, rgb color, hex color, or hsl color."
)
colors = validated_colors

if isinstance(each_color, tuple):
for value in each_color:
if value > 1.0:
Expand Down Expand Up @@ -766,6 +807,65 @@ def hex_to_rgb(value):
for i in range(0, hex_total_length, rgb_section_length)
)

def hsl_to_rgb(value):
"""
Converts an HSL color to RGB

:param (string) value: HSL color string

:rtype (tuple) (r_value, g_value, b_value): tuple of rgb values
"""

def _hue_to_rgb(p, q, t):
"""
Helper function for hsl_to_rgb
"""
if t < 0:
t += 1
if t > 1:
t -= 1
if t < 1 / 6:
return p + (q - p) * 6 * t
if t < 1 / 2:
return q
if t < 2 / 3:
return p + (q - p) * (2 / 3 - t) * 6
return p


value = value.replace(" ", "").lower()

# Validate the format
if not value.startswith("hsl(") or not value.endswith(")"):
raise ValueError(f"Invalid HSL format: {value}. Expected format: 'hsl(h, s%, l%)'")

try:
# Parsing the hsl string (usually of the form "hsl(h, s%, l%)")
h, s, l = value.lstrip("hsl(").rstrip(")%").split(",")
h, s, l = float(h) / 360, float(s.strip("%")) / 100, float(l.strip("%")) / 100
except Exception:
raise ValueError(f"Malformed HSL string: {value}")

if not (0 <= s <= 1 and 0 <= l <= 1):
raise ValueError(f"Saturation and lightness must be between 0% and 100%. Got: s={s*100}%, l={l*100}%")

if not (0 <= h <= 1):
raise ValueError(f"Hue must be between 0 and 360. Got: {h*360}")


# Convert HSL to RGB
if s == 0: # Achromatic
r = g = b = l

else:
q = l * (1 + s) if l < 0.5 else l + s - l * s
p = 2 * l - q
r = _hue_to_rgb(p, q, h + 1 / 3)
g = _hue_to_rgb(p, q, h)
b = _hue_to_rgb(p, q, h - 1 / 3)

return (round(r * 255), round(g * 255), round(b * 255))


def colorscale_to_colors(colorscale):
"""
Expand Down
46 changes: 23 additions & 23 deletions packages/python/plotly/plotly/graph_objs/layout/_title.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,13 @@ def automargin(self):
margins. If `yref='paper'` then the margin will expand to
ensure that the title doesn’t overlap with the edges of the
container. If `yref='container'` then the margins will ensure
that the title doesn’t overlap with the plot area, tick labels,
and axis titles. If `automargin=true` and the margins need to
be expanded, then y will be set to a default 1 and yanchor will
be set to an appropriate default to ensure that minimal margin
space is needed. Note that when `yref='paper'`, only 1 or 0 are
allowed y values. Invalid values will be reset to the default
1.
that the title doesn’t overlap with the plot area, tick
labels, and axis titles. If `automargin=true` and the margins
need to be expanded, then y will be set to a default 1 and
yanchor will be set to an appropriate default to ensure that
minimal margin space is needed. Note that when `yref='paper'`,
only 1 or 0 are allowed y values. Invalid values will be reset
to the default 1.

The 'automargin' property must be specified as a bool
(either True, or False)
Expand Down Expand Up @@ -365,14 +365,14 @@ def _prop_descriptions(self):
figure margins. If `yref='paper'` then the margin will
expand to ensure that the title doesn’t overlap with
the edges of the container. If `yref='container'` then
the margins will ensure that the title doesn’t overlap
with the plot area, tick labels, and axis titles. If
`automargin=true` and the margins need to be expanded,
then y will be set to a default 1 and yanchor will be
set to an appropriate default to ensure that minimal
margin space is needed. Note that when `yref='paper'`,
only 1 or 0 are allowed y values. Invalid values will
be reset to the default 1.
the margins will ensure that the title doesn’t
overlap with the plot area, tick labels, and axis
titles. If `automargin=true` and the margins need to be
expanded, then y will be set to a default 1 and yanchor
will be set to an appropriate default to ensure that
minimal margin space is needed. Note that when
`yref='paper'`, only 1 or 0 are allowed y values.
Invalid values will be reset to the default 1.
font
Sets the title font.
pad
Expand Down Expand Up @@ -449,14 +449,14 @@ def __init__(
figure margins. If `yref='paper'` then the margin will
expand to ensure that the title doesn’t overlap with
the edges of the container. If `yref='container'` then
the margins will ensure that the title doesn’t overlap
with the plot area, tick labels, and axis titles. If
`automargin=true` and the margins need to be expanded,
then y will be set to a default 1 and yanchor will be
set to an appropriate default to ensure that minimal
margin space is needed. Note that when `yref='paper'`,
only 1 or 0 are allowed y values. Invalid values will
be reset to the default 1.
the margins will ensure that the title doesn’t
overlap with the plot area, tick labels, and axis
titles. If `automargin=true` and the margins need to be
expanded, then y will be set to a default 1 and yanchor
will be set to an appropriate default to ensure that
minimal margin space is needed. Note that when
`yref='paper'`, only 1 or 0 are allowed y values.
Invalid values will be reset to the default 1.
font
Sets the title font.
pad
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -237,3 +237,65 @@ def test_n_colors(self):
]

self.assertEqual(generated_colorscale, expected_colorscale)

def test_hsl_support(self):
# Test valid HSL input
valid_hsl = "hsl(240, 100%, 50%)" # Blue
expected_rgb = (0, 0, 255)
self.assertEqual(colors.hsl_to_rgb(valid_hsl), expected_rgb)

# Test HSL input with spaces
valid_hsl_with_spaces = "hsl( 120 , 100% , 25% )" # Dark Green
expected_rgb_with_spaces = (0, 64, 0)
self.assertEqual(
colors.hsl_to_rgb(valid_hsl_with_spaces), expected_rgb_with_spaces
)

# Test malformed HSL input
invalid_hsl = "hsl(120, 100, 50%)" # Missing '%'
with self.assertRaises(ValueError):
colors.hsl_to_rgb(invalid_hsl)

# Test HSL with out-of-range values
out_of_range_hsl = "hsl(400, 120%, 50%)"
with self.assertRaises(ValueError):
colors.hsl_to_rgb(out_of_range_hsl)

# Test validate_colors with a list containing HSL
colors_list = ["hsl(0, 100%, 50%)", "hsl(120, 100%, 25%)"]
expected_colors = [(255, 0, 0), (0, 64, 0)]
self.assertEqual(
colors.validate_colors(colors_list, colortype="rgb"), expected_colors
)

# Test validate_colors with an invalid HSL in the list
invalid_colors_list = ["hsl(0, 100%, 50%)", "hsl(120, 100, 25%)"]
with self.assertRaises(PlotlyError):
colors.validate_colors(invalid_colors_list)

def test_named_colors(self):
# Test valid named color
valid_named_color = "red"
self.assertEqual(
colors.validate_colors(valid_named_color),
[valid_named_color],
"Valid named color 'red' should pass validation.",
)

# Test invalid named color
invalid_named_color = "notacolor"
with self.assertRaisesRegex(PlotlyError, "Invalid named color: notacolor"):
colors.validate_colors(invalid_named_color)

# Test list of valid named colors
valid_named_colors_list = ["red", "blue", "green"]
self.assertEqual(
colors.validate_colors(valid_named_colors_list),
valid_named_colors_list,
"Valid named colors list should pass validation.",
)

# Test list with an invalid named color
mixed_named_colors_list = ["red", "invalidcolor", "blue"]
with self.assertRaisesRegex(PlotlyError, "Invalid named color: invalidcolor"):
colors.validate_colors(mixed_named_colors_list)