Preliminary support for structured exception responses
diff --git a/HISTORY.md b/HISTORY.md index 3309c1f..4377458 100644 --- a/HISTORY.md +++ b/HISTORY.md
@@ -1,5 +1,10 @@ # History +## Unreleased + +- Added machine-readable attributes to exceptions, allowing UIs + to provide more tailored errors. + ## 3.18 (2026-06-02) - When decoding a domain, add a `display` argument that will pass
diff --git a/idna/__init__.py b/idna/__init__.py index cfdc030..722f46a 100644 --- a/idna/__init__.py +++ b/idna/__init__.py
@@ -1,4 +1,5 @@ from .core import ( + ErrorCode, IDNABidiError, IDNAError, InvalidCodepoint, @@ -23,6 +24,7 @@ __all__ = [ "__version__", + "ErrorCode", "IDNABidiError", "IDNAError", "InvalidCodepoint",
diff --git a/idna/core.py b/idna/core.py index 1ccbd1f..3e83753 100644 --- a/idna/core.py +++ b/idna/core.py
@@ -1,4 +1,5 @@ import bisect +import enum import re import unicodedata import warnings @@ -32,8 +33,66 @@ return None +class ErrorCode(enum.Enum): + """Stable, machine-readable identifiers for IDNA validation failures. + + Carried by :class:`IDNAError` as its ``code`` attribute. Member values + are stable strings that serialize cleanly into logs and JSON; exception + message wording, by contrast, is not contractual and may change between + releases. + """ + + LABEL_TOO_LONG = "label_too_long" + DOMAIN_TOO_LONG = "domain_too_long" + EMPTY_LABEL = "empty_label" + EMPTY_DOMAIN = "empty_domain" + NOT_NFC = "not_nfc" + HYPHEN_34 = "hyphen_3_4" + HYPHEN_ENDS = "hyphen_starts_or_ends" + LEADING_COMBINER = "leading_combiner" + DISALLOWED_CODEPOINT = "disallowed_codepoint" + INVALID_CONTEXTJ = "invalid_contextj" + INVALID_CONTEXTO = "invalid_contexto" + BIDI_RULE_1 = "bidi_rule_1" + BIDI_RULE_2 = "bidi_rule_2" + BIDI_RULE_3 = "bidi_rule_3" + BIDI_RULE_4 = "bidi_rule_4" + BIDI_RULE_5 = "bidi_rule_5" + BIDI_RULE_6 = "bidi_rule_6" + BIDI_UNKNOWN_DIRECTIONALITY = "bidi_unknown_directionality" + INVALID_ALABEL = "invalid_alabel" + INVALID_ASCII = "invalid_ascii" + UTS46_DISALLOWED = "uts46_disallowed" + + class IDNAError(UnicodeError): - """Base exception for all IDNA-encoding related problems""" + """Base exception for all IDNA-encoding related problems. + + :param message: Human-readable description of the failure. This is the + only positional argument, so ``str(err)`` behaves as before. + :param code: Machine-readable :class:`ErrorCode` identifying the rule + that failed. + :param label: The label (or, for whole-domain checks such as UTS #46 + remapping, the input string) that ``position`` refers to. + :param codepoint: The offending codepoint, as an integer. + :param position: 1-based character position within ``label``, matching + the positions quoted in messages. + """ + + def __init__( + self, + message: str, + *, + code: Optional[ErrorCode] = None, + label: Optional[str] = None, + codepoint: Optional[int] = None, + position: Optional[int] = None, + ) -> None: + super().__init__(message) + self.code = code + self.label = label + self.codepoint = codepoint + self.position = position class IDNABidiError(IDNAError): @@ -113,14 +172,20 @@ or if the directional category of a codepoint cannot be determined. """ if len(label) > _max_input_length: - raise IDNAError("Label too long") + raise IDNAError("Label too long", code=ErrorCode.LABEL_TOO_LONG) # Bidi rules should only be applied if string contains RTL characters bidi_label = False for idx, cp in enumerate(label, 1): direction = unicodedata.bidirectional(cp) if direction == "": # String likely comes from a newer version of Unicode - raise IDNABidiError(f"Unknown directionality in label {label!r} at position {idx}") + raise IDNABidiError( + f"Unknown directionality in label {label!r} at position {idx}", + code=ErrorCode.BIDI_UNKNOWN_DIRECTIONALITY, + label=label, + codepoint=ord(cp), + position=idx, + ) if direction in _bidi_rtl_categories: bidi_label = True if not bidi_label and not check_ltr: @@ -133,7 +198,13 @@ elif direction == "L": rtl = False else: - raise IDNABidiError(f"First codepoint in label {label!r} must be directionality L, R or AL") + raise IDNABidiError( + f"First codepoint in label {label!r} must be directionality L, R or AL", + code=ErrorCode.BIDI_RULE_1, + label=label, + codepoint=ord(label[0]), + position=1, + ) valid_ending = False number_type: Optional[str] = None @@ -143,7 +214,13 @@ if rtl: # Bidi rule 2 if direction not in _bidi_rtl_allowed: - raise IDNABidiError(f"Invalid direction for codepoint at position {idx} in a right-to-left label") + raise IDNABidiError( + f"Invalid direction for codepoint at position {idx} in a right-to-left label", + code=ErrorCode.BIDI_RULE_2, + label=label, + codepoint=ord(cp), + position=idx, + ) # Bidi rule 3 if direction in _bidi_rtl_valid_ending: valid_ending = True @@ -154,11 +231,23 @@ if not number_type: number_type = direction elif number_type != direction: - raise IDNABidiError("Can not mix numeral types in a right-to-left label") + raise IDNABidiError( + "Can not mix numeral types in a right-to-left label", + code=ErrorCode.BIDI_RULE_4, + label=label, + codepoint=ord(cp), + position=idx, + ) else: # Bidi rule 5 if direction not in _bidi_ltr_allowed: - raise IDNABidiError(f"Invalid direction for codepoint at position {idx} in a left-to-right label") + raise IDNABidiError( + f"Invalid direction for codepoint at position {idx} in a left-to-right label", + code=ErrorCode.BIDI_RULE_5, + label=label, + codepoint=ord(cp), + position=idx, + ) # Bidi rule 6 if direction in _bidi_ltr_valid_ending: valid_ending = True @@ -166,7 +255,11 @@ valid_ending = False if not valid_ending: - raise IDNABidiError("Label ends with illegal codepoint directionality") + raise IDNABidiError( + "Label ends with illegal codepoint directionality", + code=ErrorCode.BIDI_RULE_3 if rtl else ErrorCode.BIDI_RULE_6, + label=label, + ) return True @@ -182,7 +275,13 @@ :raises IDNAError: If the label begins with a combining character. """ if unicodedata.category(label[0])[0] == "M": - raise IDNAError("Label begins with an illegal combining character") + raise IDNAError( + "Label begins with an illegal combining character", + code=ErrorCode.LEADING_COMBINER, + label=label, + codepoint=ord(label[0]), + position=1, + ) return True @@ -198,9 +297,17 @@ :raises IDNAError: If any of the hyphen restrictions are violated. """ if label[2:4] == "--": - raise IDNAError("Label has disallowed hyphens in 3rd and 4th position") + raise IDNAError( + "Label has disallowed hyphens in 3rd and 4th position", + code=ErrorCode.HYPHEN_34, + label=label, + ) if label[0] == "-" or label[-1] == "-": - raise IDNAError("Label must not start or end with a hyphen") + raise IDNAError( + "Label must not start or end with a hyphen", + code=ErrorCode.HYPHEN_ENDS, + label=label, + ) return True @@ -211,9 +318,13 @@ :raises IDNAError: If ``label`` differs from its NFC normalisation. """ if len(label) > _max_input_length: - raise IDNAError("Label too long") + raise IDNAError("Label too long", code=ErrorCode.LABEL_TOO_LONG) if unicodedata.normalize("NFC", label) != label: - raise IDNAError("Label must be in Normalization Form C") + raise IDNAError( + "Label must be in Normalization Form C", + code=ErrorCode.NOT_NFC, + label=label, + ) def valid_contextj(label: str, pos: int) -> bool: @@ -233,7 +344,7 @@ :raises IDNAError: If ``label`` exceeds the defensive input length limit. """ if len(label) > _max_input_length: - raise IDNAError("Label too long") + raise IDNAError("Label too long", code=ErrorCode.LABEL_TOO_LONG) cp_value = ord(label[pos]) if cp_value == 0x200C: @@ -286,7 +397,7 @@ :raises IDNAError: If ``label`` exceeds the defensive input length limit. """ if len(label) > _max_input_length: - raise IDNAError("Label too long") + raise IDNAError("Label too long", code=ErrorCode.LABEL_TOO_LONG) cp_value = ord(label[pos]) if cp_value == 0x00B7: @@ -338,16 +449,16 @@ :raises IDNABidiError: If the Bidi Rule is violated. """ if len(label) > _max_input_length: - raise IDNAError("Label too long") + raise IDNAError("Label too long", code=ErrorCode.LABEL_TOO_LONG) if isinstance(label, (bytes, bytearray)): label = label.decode("utf-8") if len(label) == 0: - raise IDNAError("Empty Label") + raise IDNAError("Empty Label", code=ErrorCode.EMPTY_LABEL) # Reject on domain length rather than label length so support some UTS 46 # use cases, still reducing processing of label contextual rules if not valid_string_length(label, trailing_dot=True): - raise IDNAError("Label too long") + raise IDNAError("Label too long", code=ErrorCode.LABEL_TOO_LONG, label=label) check_nfc(label) check_hyphen_ok(label) @@ -360,16 +471,38 @@ if intranges_contain(cp_value, idnadata.codepoint_classes["CONTEXTJ"]): try: if not valid_contextj(label, pos): - raise InvalidCodepointContext(f"Joiner {_unot(cp_value)} not allowed at position {pos + 1} in {label!r}") + raise InvalidCodepointContext( + f"Joiner {_unot(cp_value)} not allowed at position {pos + 1} in {label!r}", + code=ErrorCode.INVALID_CONTEXTJ, + label=label, + codepoint=cp_value, + position=pos + 1, + ) except ValueError as err: raise IDNAError( - f"Unknown codepoint adjacent to joiner {_unot(cp_value)} at position {pos + 1} in {label!r}" + f"Unknown codepoint adjacent to joiner {_unot(cp_value)} at position {pos + 1} in {label!r}", + code=ErrorCode.INVALID_CONTEXTJ, + label=label, + codepoint=cp_value, + position=pos + 1, ) from err elif intranges_contain(cp_value, idnadata.codepoint_classes["CONTEXTO"]): if not valid_contexto(label, pos): - raise InvalidCodepointContext(f"Codepoint {_unot(cp_value)} not allowed at position {pos + 1} in {label!r}") + raise InvalidCodepointContext( + f"Codepoint {_unot(cp_value)} not allowed at position {pos + 1} in {label!r}", + code=ErrorCode.INVALID_CONTEXTO, + label=label, + codepoint=cp_value, + position=pos + 1, + ) else: - raise InvalidCodepoint(f"Codepoint {_unot(cp_value)} at position {pos + 1} of {label!r} not allowed") + raise InvalidCodepoint( + f"Codepoint {_unot(cp_value)} at position {pos + 1} of {label!r} not allowed", + code=ErrorCode.DISALLOWED_CODEPOINT, + label=label, + codepoint=cp_value, + position=pos + 1, + ) check_bidi(label) @@ -388,7 +521,7 @@ exceeds 63 octets. """ if len(label) > _max_input_length: - raise IDNAError("Label too long") + raise IDNAError("Label too long", code=ErrorCode.LABEL_TOO_LONG, label=label) try: label_bytes = label.encode("ascii") except UnicodeEncodeError: @@ -396,14 +529,14 @@ else: ulabel(label_bytes) if not valid_label_length(label_bytes): - raise IDNAError("Label too long") + raise IDNAError("Label too long", code=ErrorCode.LABEL_TOO_LONG, label=label) return label_bytes check_label(label) label_bytes = _alabel_prefix + _punycode(label) if not valid_label_length(label_bytes): - raise IDNAError("Label too long") + raise IDNAError("Label too long", code=ErrorCode.LABEL_TOO_LONG, label=label) return label_bytes @@ -422,7 +555,7 @@ :raises IDNAError: If the label is malformed or fails validation. """ if len(label) > _max_input_length: - raise IDNAError("Label too long") + raise IDNAError("Label too long", code=ErrorCode.LABEL_TOO_LONG) if not isinstance(label, (bytes, bytearray)): try: label_bytes = label.encode("ascii") @@ -436,9 +569,12 @@ if label_bytes.startswith(_alabel_prefix): label_bytes = label_bytes[len(_alabel_prefix) :] if not label_bytes: - raise IDNAError("Malformed A-label, no Punycode eligible content found") + raise IDNAError( + "Malformed A-label, no Punycode eligible content found", + code=ErrorCode.INVALID_ALABEL, + ) if label_bytes.endswith(b"-"): - raise IDNAError("A-label must not end with a hyphen") + raise IDNAError("A-label must not end with a hyphen", code=ErrorCode.INVALID_ALABEL) else: check_label(label_bytes) return label_bytes.decode("ascii") @@ -446,7 +582,7 @@ try: label = label_bytes.decode("punycode") except UnicodeError as err: - raise IDNAError("Invalid A-label") from err + raise IDNAError("Invalid A-label", code=ErrorCode.INVALID_ALABEL) from err check_label(label) return label @@ -472,7 +608,7 @@ :raises IDNAError: If ``domain`` exceeds the defensive input length limit. """ if len(domain) > _max_input_length: - raise IDNAError("Domain too long") + raise IDNAError("Domain too long", code=ErrorCode.DOMAIN_TOO_LONG) from .uts46data import uts46_replacements, uts46_starts, uts46_statuses output = "" @@ -502,7 +638,13 @@ elif status == "I": continue else: - raise InvalidCodepoint(f"Codepoint {_unot(code_point)} not allowed at position {pos + 1} in {domain!r}") + raise InvalidCodepoint( + f"Codepoint {_unot(code_point)} not allowed at position {pos + 1} in {domain!r}", + code=ErrorCode.UTS46_DISALLOWED, + label=domain, + codepoint=code_point, + position=pos + 1, + ) return unicodedata.normalize("NFC", output) @@ -546,22 +688,25 @@ try: s = str(s, "ascii") except (UnicodeDecodeError, TypeError) as err: - raise IDNAError("should pass a unicode string to the function rather than a byte string.") from err + raise IDNAError( + "should pass a unicode string to the function rather than a byte string.", + code=ErrorCode.INVALID_ASCII, + ) from err if len(s) > _max_input_length: - raise IDNAError("Domain too long") + raise IDNAError("Domain too long", code=ErrorCode.DOMAIN_TOO_LONG) if uts46: s = uts46_remap(s, std3_rules, transitional) # Reject inputs that exceed the maximum DNS domain length up-front # to avoid expensive computation on long inputs. if not valid_string_length(s, trailing_dot=True): - raise IDNAError("Domain too long") + raise IDNAError("Domain too long", code=ErrorCode.DOMAIN_TOO_LONG) trailing_dot = False result = [] labels = s.split(".") if strict else _unicode_dots_re.split(s) if not labels or labels == [""]: - raise IDNAError("Empty domain") + raise IDNAError("Empty domain", code=ErrorCode.EMPTY_DOMAIN) if labels[-1] == "": del labels[-1] trailing_dot = True @@ -570,12 +715,12 @@ if s: result.append(s) else: - raise IDNAError("Empty label") + raise IDNAError("Empty label", code=ErrorCode.EMPTY_LABEL, label=label) if trailing_dot: result.append(b"") s = b".".join(result) if not valid_string_length(s, trailing_dot): - raise IDNAError("Domain too long") + raise IDNAError("Domain too long", code=ErrorCode.DOMAIN_TOO_LONG) return s @@ -614,20 +759,20 @@ try: s = str(s, "ascii") except (UnicodeDecodeError, TypeError) as err: - raise IDNAError("Invalid ASCII in A-label") from err + raise IDNAError("Invalid ASCII in A-label", code=ErrorCode.INVALID_ASCII) from err if len(s) > _max_input_length: - raise IDNAError("Domain too long") + raise IDNAError("Domain too long", code=ErrorCode.DOMAIN_TOO_LONG) if uts46: s = uts46_remap(s, std3_rules, False) # Reject inputs that exceed the maximum DNS domain length up-front # to avoid expensive computation on long inputs. if not valid_string_length(s, trailing_dot=True): - raise IDNAError("Domain too long") + raise IDNAError("Domain too long", code=ErrorCode.DOMAIN_TOO_LONG) trailing_dot = False result = [] labels = s.split(".") if strict else _unicode_dots_re.split(s) if not labels or labels == [""]: - raise IDNAError("Empty domain") + raise IDNAError("Empty domain", code=ErrorCode.EMPTY_DOMAIN) if not labels[-1]: del labels[-1] trailing_dot = True @@ -642,7 +787,7 @@ if u: result.append(u) else: - raise IDNAError("Empty label") + raise IDNAError("Empty label", code=ErrorCode.EMPTY_LABEL, label=label) if trailing_dot: result.append("") return ".".join(result)
diff --git a/tests/test_idna_error_codes.py b/tests/test_idna_error_codes.py new file mode 100644 index 0000000..baf0282 --- /dev/null +++ b/tests/test_idna_error_codes.py
@@ -0,0 +1,101 @@ +"""Tests for machine-readable error codes and structured exception attributes.""" + +import ast +import pickle +import unittest + +import idna +import idna.core + + +class ErrorCodeTests(unittest.TestCase): + def test_every_error_code_is_raisable(self): + triggers = { + idna.ErrorCode.LABEL_TOO_LONG: lambda: idna.alabel("a" * 64), + idna.ErrorCode.DOMAIN_TOO_LONG: lambda: idna.encode("a" * 300), + idna.ErrorCode.EMPTY_LABEL: lambda: idna.encode("a..b"), + idna.ErrorCode.EMPTY_DOMAIN: lambda: idna.encode(""), + idna.ErrorCode.NOT_NFC: lambda: idna.alabel("e\u0301xample"), + idna.ErrorCode.HYPHEN_34: lambda: idna.alabel("ab--cd"), + idna.ErrorCode.HYPHEN_ENDS: lambda: idna.alabel("-abc"), + idna.ErrorCode.LEADING_COMBINER: lambda: idna.alabel("\u0301abc"), + idna.ErrorCode.DISALLOWED_CODEPOINT: lambda: idna.alabel("abc\u0141"), + idna.ErrorCode.INVALID_CONTEXTJ: lambda: idna.alabel("a\u200cb"), + idna.ErrorCode.INVALID_CONTEXTO: lambda: idna.alabel("a\u00b7b"), + idna.ErrorCode.BIDI_RULE_1: lambda: idna.alabel("0\u05d0"), + idna.ErrorCode.BIDI_RULE_2: lambda: idna.alabel("\u05d0a"), + idna.ErrorCode.BIDI_RULE_3: lambda: idna.check_bidi("\u05d0+"), + idna.ErrorCode.BIDI_RULE_4: lambda: idna.check_bidi("\u05d0\u06600"), + idna.ErrorCode.BIDI_RULE_5: lambda: idna.alabel("a\u05d0"), + idna.ErrorCode.BIDI_RULE_6: lambda: idna.check_bidi("a+", check_ltr=True), + idna.ErrorCode.BIDI_UNKNOWN_DIRECTIONALITY: lambda: idna.check_bidi("\u0378"), + idna.ErrorCode.INVALID_ALABEL: lambda: idna.ulabel("xn--"), + idna.ErrorCode.INVALID_ASCII: lambda: idna.encode(b"\xff"), + idna.ErrorCode.UTS46_DISALLOWED: lambda: idna.uts46_remap("\u0080"), + } + self.assertEqual(set(triggers), set(idna.ErrorCode)) + for expected_code, trigger in triggers.items(): + with self.subTest(code=expected_code): + with self.assertRaises(idna.IDNAError) as ctx: + trigger() + self.assertIs(ctx.exception.code, expected_code) + + def test_error_code_values_are_unique(self): + values = [code.value for code in idna.ErrorCode] + self.assertEqual(len(values), len(set(values))) + + def test_structured_attributes(self): + with self.assertRaises(idna.InvalidCodepoint) as ctx: + idna.alabel("abc\u0141") + err = ctx.exception + self.assertIs(err.code, idna.ErrorCode.DISALLOWED_CODEPOINT) + self.assertEqual(err.label, "abc\u0141") + self.assertEqual(err.codepoint, 0x141) + self.assertEqual(err.position, 4) + self.assertIn("U+0141", str(err)) + self.assertIn("position 4", str(err)) + + def test_attributes_default_to_none(self): + err = idna.IDNAError("just a message") + self.assertIsNone(err.code) + self.assertIsNone(err.label) + self.assertIsNone(err.codepoint) + self.assertIsNone(err.position) + self.assertEqual(str(err), "just a message") + + def test_message_remains_sole_positional_argument(self): + with self.assertRaises(idna.IDNAError) as ctx: + idna.encode("a..b") + self.assertEqual(ctx.exception.args, ("Empty Label",)) + + def test_attributes_survive_pickle(self): + with self.assertRaises(idna.InvalidCodepoint) as ctx: + idna.alabel("abc\u0141") + err = pickle.loads(pickle.dumps(ctx.exception)) + self.assertIs(err.code, idna.ErrorCode.DISALLOWED_CODEPOINT) + self.assertEqual(err.label, "abc\u0141") + self.assertEqual(err.codepoint, 0x141) + self.assertEqual(err.position, 4) + self.assertEqual(str(err), str(ctx.exception)) + + def test_every_raise_site_passes_a_code(self): + """Guard against new raise sites regressing to code-less exceptions.""" + exception_names = {"IDNAError", "IDNABidiError", "InvalidCodepoint", "InvalidCodepointContext"} + source_path = idna.core.__file__ + assert source_path is not None + with open(source_path, encoding="utf-8") as f: + tree = ast.parse(f.read()) + missing = [] + for node in ast.walk(tree): + if not (isinstance(node, ast.Raise) and isinstance(node.exc, ast.Call)): + continue + func = node.exc.func + if not (isinstance(func, ast.Name) and func.id in exception_names): + continue + if not any(keyword.arg == "code" for keyword in node.exc.keywords): + missing.append(f"{func.id} at core.py:{node.lineno}") + self.assertEqual(missing, []) + + +if __name__ == "__main__": + unittest.main()