diff --git a/_plotly_utils/basevalidators.py b/_plotly_utils/basevalidators.py index c4d40f9e178..5e68afab7fb 100644 --- a/_plotly_utils/basevalidators.py +++ b/_plotly_utils/basevalidators.py @@ -1735,12 +1735,15 @@ class SubplotidValidator(BaseValidator): "dflt" ], "otherOpts": [ + "arrayOk", "regex" ] } """ - 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,40 +1758,55 @@ 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 = """\ 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) + 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): + if not isinstance(value, str): + return value, False + match = fullmatch(self.regex, value) if not match: - is_valid = False + return value, False else: digit_str = match.group(1) if len(digit_str) > 0 and int(digit_str) == 0: - is_valid = False + return value, False 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, True else: - is_valid = True + return value, True - if not is_valid: - self.raise_invalid_val(v) - return v + if v is None: + pass + elif self.array_ok and is_simple_array(v): + values = [] + 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: + v, success = coerce(v) + if not success: + self.raise_invalid_val(self.base) + return v class FlaglistValidator(BaseValidator): diff --git a/tests/test_plotly_utils/validators/test_subplotid_validator.py b/tests/test_plotly_utils/validators/test_subplotid_validator.py index 85ff0573e08..09e57c0ee99 100644 --- a/tests/test_plotly_utils/validators/test_subplotid_validator.py +++ b/tests/test_plotly_utils/validators/test_subplotid_validator.py @@ -11,15 +11,29 @@ 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 +# 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): @@ -43,3 +57,55 @@ 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"], ["legend", "legend2"]], +) +def test_acceptance_aok(val, validator_aok): + v = validator_aok.validate_coerce(val) + if isinstance(val, tuple): + assert val == tuple(v) + else: + 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): + 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_aok(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