diff --git a/CHANGELOG.md b/CHANGELOG.md index 9503651a32f..92e95165c42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). - Fix issue with per-point marker color for hover labels in `scattergl`, `quiver` traces [[#8027](https://github.com/plotly/plotly.js/pull/8027)] - Update `maplibre-gl` to v6 to address [CVE-2026-85061](https://github.com/advisories/GHSA-jrc7-96c5-q579) [[#8035](https://github.com/plotly/plotly.js/pull/8035)] - Note: Safari 15, Chrome 56, Firefox 51 and later are now required for map traces +- Update `hex_to_rgb` function to raise error for invalid-length hex codes, and emit warning for hex codes containing alpha [[#5729](https://github.com/plotly/plotly.py/pull/5729)], with thanks to @dylanpulver for the contribution! ## [7.0.0] - 2026-08-25 diff --git a/_plotly_utils/colors/__init__.py b/_plotly_utils/colors/__init__.py index e3b5cfa3205..a9689101e6b 100644 --- a/_plotly_utils/colors/__init__.py +++ b/_plotly_utils/colors/__init__.py @@ -76,6 +76,7 @@ import decimal from numbers import Number +from warnings import warn from _plotly_utils import exceptions @@ -763,15 +764,29 @@ def hex_to_rgb(value): '#FFF' --> (255, 255, 255) """ + + input_value = value value = value.lstrip("#") if len(value) == 3: value = "".join(c * 2 for c in value) - hex_total_length = len(value) - rgb_section_length = hex_total_length // 3 - return tuple( - int(value[i : i + rgb_section_length], 16) - for i in range(0, hex_total_length, rgb_section_length) - ) + elif len(value) == 4: + warn( + "4-character hex color provided; 4th character will be ignored." + "got {!r}".format(input_value) + ) + value = "".join(c * 2 for c in value)[:6] + elif len(value) == 8: + warn( + "8-character hex color provided; last two characters will be ignored." + "got {!r}".format(input_value) + ) + value = value[:6] + elif len(value) != 6: + raise ValueError( + "hex color must be 3 or 6 hex digits, optionally prefixed with " + "'#'; got {!r}".format(input_value) + ) + return tuple(int(value[i : i + 2], 16) for i in range(0, 6, 2)) def colorscale_to_colors(colorscale): diff --git a/tests/test_plotly_utils/colors/test_color_conversions.py b/tests/test_plotly_utils/colors/test_color_conversions.py index 6513b7c3233..8e28734b045 100644 --- a/tests/test_plotly_utils/colors/test_color_conversions.py +++ b/tests/test_plotly_utils/colors/test_color_conversions.py @@ -1,3 +1,5 @@ +import pytest + from _plotly_utils.colors import ( find_intermediate_color, hex_to_rgb, @@ -21,6 +23,26 @@ def test_hex_to_rgb_shorthand_3_digit(): assert hex_to_rgb("#00f") == (0, 0, 255) +@pytest.mark.parametrize("value", ["#abcd", "eb4d", "#12345678", "b24fa3d1"]) +def test_hex_to_rgb_warns_on_4_and_8_digits(value): + warning_must_contain = "4-char" if len(value.lstrip("#")) == 4 else "8-char" + with pytest.warns(UserWarning, match=warning_must_contain): + hex_to_rgb(value) + + +@pytest.mark.parametrize("value", ["#12345", "#1", "#1234567", "", "#"]) +def test_hex_to_rgb_rejects_other_lengths(value): + # The section width was len // 3, so "#12345" returned a 5-tuple rather + # than raising. + with pytest.raises(ValueError, match="3 or 6 hex digits"): + hex_to_rgb(value) + + +def test_hex_to_rgb_accepts_missing_hash(): + assert hex_to_rgb("aabbcc") == (170, 187, 204) + assert hex_to_rgb("abc") == (170, 187, 204) + + def test_label_rgb_formats_tuple(): assert label_rgb((255, 0, 0)) == "rgb(255, 0, 0)" assert label_rgb((1, 2, 3)) == "rgb(1, 2, 3)"