From 1d665ba230c5fb13f4e2230c53f0f46a9c66abcf Mon Sep 17 00:00:00 2001 From: My-Tien Nguyen Date: Mon, 12 Jan 2026 14:24:28 +0100 Subject: [PATCH 1/6] Add array_ok to SubplotidValidator, used in pie.legend --- _plotly_utils/basevalidators.py | 51 +++++++++++++------ .../validators/test_subplotid_validator.py | 46 +++++++++++++++++ 2 files changed, 81 insertions(+), 16 deletions(-) diff --git a/_plotly_utils/basevalidators.py b/_plotly_utils/basevalidators.py index c4d40f9e178..ec98dfa3f51 100644 --- a/_plotly_utils/basevalidators.py +++ b/_plotly_utils/basevalidators.py @@ -1740,7 +1740,7 @@ class SubplotidValidator(BaseValidator): } """ - def __init__(self, plotly_name, parent_name, dflt=None, regex=None, **kwargs): + def __init__(self, plotly_name, parent_name, dflt=None, regex=None, array_ok=False, **kwargs): if dflt is None and regex is None: raise ValueError("One or both of regex and deflt must be specified") @@ -1755,6 +1755,7 @@ def __init__(self, plotly_name, parent_name, dflt=None, regex=None, **kwargs): self.base = re.match(r"/\^(\w+)", regex).group(1) self.regex = self.base + r"(\d*)" + self.array_ok = array_ok def description(self): desc = """\ @@ -1763,31 +1764,49 @@ def description(self): optionally followed by an integer >= 1 (e.g. '{base}', '{base}1', '{base}2', '{base}3', etc.) """.format(plotly_name=self.plotly_name, base=self.base) + + desc = """\ + The '{plotly_name}' property is an identifier of a particular + subplot, of type '{base}', that may be specified as: + - the string '{base}' + optionally followed by an integer >= 1 + (e.g. '{base}', '{base}1', '{base}2', '{base}3', etc.)""".format(plotly_name=self.plotly_name, base=self.base) + if self.array_ok: + desc += """ + - A tuple or list of the above""" return desc def validate_coerce(self, v): - if v is None: - pass - elif not isinstance(v, str): - self.raise_invalid_val(v) - else: - # match = re.fullmatch(self.regex, v) - match = fullmatch(self.regex, v) + def coerce(value, invalid_els): + if not isinstance(value, str): + invalid_els.append(value) + return value + match = fullmatch(self.regex, value) if not match: - is_valid = False + invalid_els.append(value) + return value else: digit_str = match.group(1) if len(digit_str) > 0 and int(digit_str) == 0: - is_valid = False + invalid_els.append(value) + return value elif len(digit_str) > 0 and int(digit_str) == 1: - # Remove 1 suffix (e.g. x1 -> x) - v = self.base - is_valid = True + return self.base else: - is_valid = True + return value - if not is_valid: - self.raise_invalid_val(v) + if v is None: + pass + elif self.array_ok and is_simple_array(v): + invalid_els = [] + v = [e for e in v if coerce(e, invalid_els)] + if invalid_els: + self.raise_invalid_elements(invalid_els[:10]) + else: + invalid_els = [] + v = coerce(v, invalid_els) + if invalid_els: + self.raise_invalid_val(self.base) return v diff --git a/tests/test_plotly_utils/validators/test_subplotid_validator.py b/tests/test_plotly_utils/validators/test_subplotid_validator.py index 85ff0573e08..ac270081755 100644 --- a/tests/test_plotly_utils/validators/test_subplotid_validator.py +++ b/tests/test_plotly_utils/validators/test_subplotid_validator.py @@ -10,11 +10,16 @@ def validator(): return SubplotidValidator("prop", "parent", dflt="geo") +@pytest.fixture() +def validator_aok(): + return SubplotidValidator("prop", "parent", dflt="legend", array_ok=True) # Tests +# Array not ok (default) # Acceptance + @pytest.mark.parametrize("val", ["geo"] + ["geo%d" % i for i in range(2, 10)]) def test_acceptance(val, validator): assert validator.validate_coerce(val) == val @@ -43,3 +48,44 @@ def test_rejection_value(val, validator): validator.validate_coerce(val) assert "Invalid value" in str(validation_failure.value) + +# Array ok + +# Acceptance + +@pytest.mark.parametrize("val", ["legend2", ["legend", "legend2"], ("legend1", "legend2")]) +def test_acceptance(val, validator_aok): + v = validator_aok.validate_coerce(val) + if isinstance(val, tuple): + assert val == tuple(v) + else: + assert val == v + + +# Rejection by type +@pytest.mark.parametrize("val", [23, [2, 3], {}, set(), np_inf(), np_nan()]) +def test_rejection_type(val, validator_aok): + with pytest.raises(ValueError) as validation_failure: + validator_aok.validate_coerce(val) + + failure_msg = str(validation_failure.value) + assert "Invalid value" in failure_msg or "Invalid elements" in failure_msg + + +# Rejection by value +@pytest.mark.parametrize( + "val", + [ + "", # Cannot be empty + "bogus", # Must begin with 'geo' + "legend0", # If followed by a number the number must be > 1, + ["", "legend"], + ("bogus", "legend2") + ], +) +def test_rejection_value(val, validator_aok): + with pytest.raises(ValueError) as validation_failure: + validator_aok.validate_coerce(val) + + failure_msg = str(validation_failure.value) + assert "Invalid value" in failure_msg or "Invalid elements" in failure_msg \ No newline at end of file From 40532e458420ff2e86df2ea21deb2290b2f57aea Mon Sep 17 00:00:00 2001 From: My-Tien Nguyen Date: Mon, 12 Jan 2026 14:34:10 +0100 Subject: [PATCH 2/6] Fix names of test cases (forgot to rename them after copy-paste) --- .../validators/test_subplotid_validator.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_plotly_utils/validators/test_subplotid_validator.py b/tests/test_plotly_utils/validators/test_subplotid_validator.py index ac270081755..5ef985bf690 100644 --- a/tests/test_plotly_utils/validators/test_subplotid_validator.py +++ b/tests/test_plotly_utils/validators/test_subplotid_validator.py @@ -54,7 +54,7 @@ def test_rejection_value(val, validator): # Acceptance @pytest.mark.parametrize("val", ["legend2", ["legend", "legend2"], ("legend1", "legend2")]) -def test_acceptance(val, validator_aok): +def test_acceptance_aok(val, validator_aok): v = validator_aok.validate_coerce(val) if isinstance(val, tuple): assert val == tuple(v) @@ -64,7 +64,7 @@ def test_acceptance(val, validator_aok): # Rejection by type @pytest.mark.parametrize("val", [23, [2, 3], {}, set(), np_inf(), np_nan()]) -def test_rejection_type(val, validator_aok): +def test_rejection_type_aok(val, validator_aok): with pytest.raises(ValueError) as validation_failure: validator_aok.validate_coerce(val) @@ -83,7 +83,7 @@ def test_rejection_type(val, validator_aok): ("bogus", "legend2") ], ) -def test_rejection_value(val, validator_aok): +def test_rejection_value_aok(val, validator_aok): with pytest.raises(ValueError) as validation_failure: validator_aok.validate_coerce(val) From 3a50e457809132e38e1bd6a6af551e65e306b838 Mon Sep 17 00:00:00 2001 From: My-Tien Nguyen Date: Mon, 12 Jan 2026 14:53:37 +0100 Subject: [PATCH 3/6] Run ruff format after adding support for arrays in subplotid validator. --- _plotly_utils/basevalidators.py | 8 ++++++-- .../validators/test_subplotid_validator.py | 13 ++++++++++--- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/_plotly_utils/basevalidators.py b/_plotly_utils/basevalidators.py index ec98dfa3f51..44b0b91b74b 100644 --- a/_plotly_utils/basevalidators.py +++ b/_plotly_utils/basevalidators.py @@ -1740,7 +1740,9 @@ class SubplotidValidator(BaseValidator): } """ - def __init__(self, plotly_name, parent_name, dflt=None, regex=None, array_ok=False, **kwargs): + def __init__( + self, plotly_name, parent_name, dflt=None, regex=None, array_ok=False, **kwargs + ): if dflt is None and regex is None: raise ValueError("One or both of regex and deflt must be specified") @@ -1770,7 +1772,9 @@ def description(self): subplot, of type '{base}', that may be specified as: - the string '{base}' optionally followed by an integer >= 1 - (e.g. '{base}', '{base}1', '{base}2', '{base}3', etc.)""".format(plotly_name=self.plotly_name, base=self.base) + (e.g. '{base}', '{base}1', '{base}2', '{base}3', etc.)""".format( + plotly_name=self.plotly_name, base=self.base + ) if self.array_ok: desc += """ - A tuple or list of the above""" diff --git a/tests/test_plotly_utils/validators/test_subplotid_validator.py b/tests/test_plotly_utils/validators/test_subplotid_validator.py index 5ef985bf690..fc9daea2ba3 100644 --- a/tests/test_plotly_utils/validators/test_subplotid_validator.py +++ b/tests/test_plotly_utils/validators/test_subplotid_validator.py @@ -10,16 +10,19 @@ def validator(): return SubplotidValidator("prop", "parent", dflt="geo") + @pytest.fixture() def validator_aok(): return SubplotidValidator("prop", "parent", dflt="legend", array_ok=True) + # Tests # Array not ok (default) # Acceptance + @pytest.mark.parametrize("val", ["geo"] + ["geo%d" % i for i in range(2, 10)]) def test_acceptance(val, validator): assert validator.validate_coerce(val) == val @@ -49,11 +52,15 @@ def test_rejection_value(val, validator): assert "Invalid value" in str(validation_failure.value) + # Array ok # Acceptance -@pytest.mark.parametrize("val", ["legend2", ["legend", "legend2"], ("legend1", "legend2")]) + +@pytest.mark.parametrize( + "val", ["legend2", ["legend", "legend2"], ("legend1", "legend2")] +) def test_acceptance_aok(val, validator_aok): v = validator_aok.validate_coerce(val) if isinstance(val, tuple): @@ -80,7 +87,7 @@ def test_rejection_type_aok(val, validator_aok): "bogus", # Must begin with 'geo' "legend0", # If followed by a number the number must be > 1, ["", "legend"], - ("bogus", "legend2") + ("bogus", "legend2"), ], ) def test_rejection_value_aok(val, validator_aok): @@ -88,4 +95,4 @@ def test_rejection_value_aok(val, validator_aok): validator_aok.validate_coerce(val) failure_msg = str(validation_failure.value) - assert "Invalid value" in failure_msg or "Invalid elements" in failure_msg \ No newline at end of file + assert "Invalid value" in failure_msg or "Invalid elements" in failure_msg From d1993cca5d22081bc5a12de83641653da1f71074 Mon Sep 17 00:00:00 2001 From: My-Tien Nguyen Date: Tue, 13 Jan 2026 11:19:37 +0100 Subject: [PATCH 4/6] Fix docstring and description of SubplotidValidator - added missing arrayOk in docstring - removed replaced description - Fixed indentation --- _plotly_utils/basevalidators.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/_plotly_utils/basevalidators.py b/_plotly_utils/basevalidators.py index 44b0b91b74b..6d7d7273d85 100644 --- a/_plotly_utils/basevalidators.py +++ b/_plotly_utils/basevalidators.py @@ -1735,6 +1735,7 @@ class SubplotidValidator(BaseValidator): "dflt" ], "otherOpts": [ + "arrayOk", "regex" ] } @@ -1762,17 +1763,9 @@ def __init__( def description(self): desc = """\ The '{plotly_name}' property is an identifier of a particular - subplot, of type '{base}', that may be specified as the string '{base}' - optionally followed by an integer >= 1 - (e.g. '{base}', '{base}1', '{base}2', '{base}3', etc.) - """.format(plotly_name=self.plotly_name, base=self.base) - - desc = """\ - The '{plotly_name}' property is an identifier of a particular subplot, of type '{base}', that may be specified as: - - the string '{base}' - optionally followed by an integer >= 1 - (e.g. '{base}', '{base}1', '{base}2', '{base}3', etc.)""".format( + - the string '{base}' optionally followed by an integer >= 1 + (e.g. '{base}', '{base}1', '{base}2', '{base}3', etc.)""".format( plotly_name=self.plotly_name, base=self.base ) if self.array_ok: From b809b46090845dbce8f2f91b946ab9cdf8957faf Mon Sep 17 00:00:00 2001 From: My-Tien Nguyen Date: Tue, 13 Jan 2026 11:34:54 +0100 Subject: [PATCH 5/6] changed helper function in SubplotidValidator - return success instead of writing into passed array --- _plotly_utils/basevalidators.py | 34 +++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/_plotly_utils/basevalidators.py b/_plotly_utils/basevalidators.py index 6d7d7273d85..5e68afab7fb 100644 --- a/_plotly_utils/basevalidators.py +++ b/_plotly_utils/basevalidators.py @@ -1774,37 +1774,39 @@ def description(self): return desc def validate_coerce(self, v): - def coerce(value, invalid_els): + def coerce(value): if not isinstance(value, str): - invalid_els.append(value) - return value + return value, False match = fullmatch(self.regex, value) if not match: - invalid_els.append(value) - return value + return value, False else: digit_str = match.group(1) if len(digit_str) > 0 and int(digit_str) == 0: - invalid_els.append(value) - return value + return value, False elif len(digit_str) > 0 and int(digit_str) == 1: - return self.base + return self.base, True else: - return value + return value, True if v is None: pass elif self.array_ok and is_simple_array(v): + values = [] invalid_els = [] - v = [e for e in v if coerce(e, invalid_els)] - if invalid_els: + for e in v: + coerced_e, success = coerce(e) + values.append(coerced_e) + if not success: + invalid_els.append(coerced_e) + if len(invalid_els) > 0: self.raise_invalid_elements(invalid_els[:10]) + return values else: - invalid_els = [] - v = coerce(v, invalid_els) - if invalid_els: - self.raise_invalid_val(self.base) - return v + v, success = coerce(v) + if not success: + self.raise_invalid_val(self.base) + return v class FlaglistValidator(BaseValidator): From cef18a9d95d22614e5e660ba6722645defaf7fe1 Mon Sep 17 00:00:00 2001 From: My-Tien Nguyen Date: Tue, 13 Jan 2026 11:36:48 +0100 Subject: [PATCH 6/6] Separate test for coercion of geo1 to geo and legend1 to legend --- .../validators/test_subplotid_validator.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/tests/test_plotly_utils/validators/test_subplotid_validator.py b/tests/test_plotly_utils/validators/test_subplotid_validator.py index fc9daea2ba3..09e57c0ee99 100644 --- a/tests/test_plotly_utils/validators/test_subplotid_validator.py +++ b/tests/test_plotly_utils/validators/test_subplotid_validator.py @@ -28,6 +28,12 @@ def test_acceptance(val, validator): assert validator.validate_coerce(val) == val +# Coercion from {base}1 to {base} +def test_coerce(validator): + v = validator.validate_coerce("geo1") + assert ("geo") == v + + # Rejection by type @pytest.mark.parametrize("val", [23, [], {}, set(), np_inf(), np_nan()]) def test_rejection_type(val, validator): @@ -59,7 +65,8 @@ def test_rejection_value(val, validator): @pytest.mark.parametrize( - "val", ["legend2", ["legend", "legend2"], ("legend1", "legend2")] + "val", + ["legend2", ["legend", "legend2"], ["legend", "legend2"]], ) def test_acceptance_aok(val, validator_aok): v = validator_aok.validate_coerce(val) @@ -69,6 +76,12 @@ def test_acceptance_aok(val, validator_aok): assert val == v +# Coercion from {base}1 to {base} +def test_coerce_aok(validator_aok): + v = validator_aok.validate_coerce(("legend1", "legend2")) + assert ("legend", "legend2") == tuple(v) + + # Rejection by type @pytest.mark.parametrize("val", [23, [2, 3], {}, set(), np_inf(), np_nan()]) def test_rejection_type_aok(val, validator_aok):