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()