Coverage for /pythoncovmergedfiles/medio/medio/src/idna/idna/core.py: 96%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

377 statements  

1from __future__ import annotations 

2 

3import bisect 

4import re 

5import unicodedata 

6import warnings 

7from typing import Literal 

8 

9from . import idnadata 

10from .intranges import intranges_contain 

11 

12_virama_combining_class = 9 

13_alabel_prefix = b"xn--" 

14_max_input_length = 1024 

15_max_domain_length = 253 # RFC 1035 octets, excluding any trailing dot 

16_STATUS_VALID, _STATUS_MAPPED, _STATUS_DEVIATION, _STATUS_IGNORED = b"VMDI" 

17_unicode_dots_re = re.compile("[\u002e\u3002\uff0e\uff61]") 

18_std3_disallowed_re = re.compile("[\x00-\x2c\x2f\x3a-\x40A-Z\x5b-\x60\x7b-\x7f]") 

19_ldh_label_re = re.compile(r"(?!..--)[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\Z") 

20_bidi_rtl_first = frozenset({"R", "AL"}) 

21_bidi_rtl_categories = frozenset({"R", "AL", "AN"}) 

22_bidi_rtl_allowed = frozenset({"R", "AL", "AN", "EN", "ES", "CS", "ET", "ON", "BN", "NSM"}) 

23_bidi_rtl_valid_ending = frozenset({"R", "AL", "EN", "AN"}) 

24_bidi_rtl_numeric = frozenset({"AN", "EN"}) 

25_bidi_ltr_allowed = frozenset({"L", "EN", "ES", "CS", "ET", "ON", "BN", "NSM"}) 

26_bidi_ltr_valid_ending = frozenset({"L", "EN"}) 

27_bidi_joiner_l_or_d = frozenset({"L", "D"}) 

28_bidi_joiner_r_or_d = frozenset({"R", "D"}) 

29 

30 

31def _joining_type(cp: int) -> str | None: 

32 for jt, ranges in idnadata.joining_types.items(): 

33 if intranges_contain(cp, ranges): 

34 return jt 

35 return None 

36 

37 

38# Machine-readable identifiers for the rule an :class:`IDNAError` reports. 

39# These strings are stable and documented; exception message wording is not. 

40_ErrorCode = Literal[ 

41 "input_too_long", 

42 "label_too_long", 

43 "domain_too_long", 

44 "empty_label", 

45 "empty_domain", 

46 "not_nfc", 

47 "hyphen_3_4", 

48 "hyphen_start_end", 

49 "leading_combiner", 

50 "disallowed_codepoint", 

51 "contextj", 

52 "contexto", 

53 "unknown_codepoint", 

54 "bidi_rule_1", 

55 "bidi_rule_2", 

56 "bidi_rule_3", 

57 "bidi_rule_4", 

58 "bidi_rule_5", 

59 "bidi_rule_6", 

60 "bidi_unknown_direction", 

61 "invalid_alabel", 

62 "non_canonical_alabel", 

63 "invalid_ascii", 

64 "invalid_utf8", 

65 "uts46_disallowed", 

66 "uts46_std3", 

67 "unsupported_errors", 

68] 

69 

70 

71class IDNAError(UnicodeError): 

72 """Base exception for all IDNA-encoding related problems. 

73 

74 ``str(err)`` is a human-readable description of the failure. The 

75 exception also carries machine-readable attributes so callers do not 

76 need to parse the message: 

77 

78 * ``code`` -- a short, stable identifier for the rule that failed, such 

79 as ``"disallowed_codepoint"`` or ``"bidi_rule_2"``. Message wording, 

80 by contrast, may change between releases. 

81 * ``text`` -- the label (or, for UTS #46 processing, the domain) that 

82 was being validated; 

83 * ``codepoint`` -- the offending codepoint, as an ``int``; 

84 * ``position`` -- the 1-based index of the offending character within 

85 ``text``, matching the position quoted in the message. 

86 

87 Each is ``None`` when it does not apply. 

88 """ 

89 

90 code: str | None 

91 text: str | None 

92 codepoint: int | None 

93 position: int | None 

94 

95 def __init__( 

96 self, 

97 *args: object, 

98 code: _ErrorCode | None = None, 

99 text: str | None = None, 

100 codepoint: int | None = None, 

101 position: int | None = None, 

102 ) -> None: 

103 super().__init__(*args) 

104 self.code = code 

105 self.text = text 

106 self.codepoint = codepoint 

107 self.position = position 

108 

109 

110class IDNABidiError(IDNAError): 

111 """Exception when bidirectional requirements are not satisfied""" 

112 

113 

114class InvalidCodepoint(IDNAError): 

115 """Exception when a disallowed or unallocated codepoint is used""" 

116 

117 

118class InvalidCodepointContext(IDNAError): 

119 """Exception when the codepoint is not valid in the context it is used""" 

120 

121 

122def _combining_class(cp: int) -> int: 

123 v = unicodedata.combining(chr(cp)) 

124 if v == 0 and not unicodedata.name(chr(cp)): 

125 raise ValueError("Unknown character in unicodedata") 

126 return v 

127 

128 

129def _is_script(cp: str, script: str) -> bool: 

130 return intranges_contain(ord(cp), idnadata.scripts[script]) 

131 

132 

133def _punycode(s: str) -> bytes: 

134 return s.encode("punycode") 

135 

136 

137def _unot(s: int) -> str: 

138 return f"U+{s:04X}" 

139 

140 

141def valid_label_length(label: bytes | str) -> bool: 

142 """Check that a label does not exceed the maximum permitted length. 

143 

144 Per :rfc:`1035` (and :rfc:`5891` §4.2.4) a DNS label must not exceed 

145 63 octets. The argument may be either a :class:`str` (a U-label, where 

146 length is measured in characters) or :class:`bytes` (an A-label, where 

147 length is measured in octets). 

148 

149 :param label: The label to check. 

150 :returns: ``True`` if the label is within the length limit, otherwise 

151 ``False``. 

152 """ 

153 return len(label) <= 63 

154 

155 

156def valid_string_length(domain: bytes | str, trailing_dot: bool) -> bool: 

157 """Check that a full domain name does not exceed the maximum length. 

158 

159 Per :rfc:`1035`, a domain name is limited to 253 octets when no trailing 

160 dot is present, or 254 octets when one is included. 

161 

162 :param domain: The full (possibly multi-label) domain name. 

163 :param trailing_dot: ``True`` if ``domain`` includes a trailing ``.``. 

164 :returns: ``True`` if the domain is within the length limit, otherwise 

165 ``False``. 

166 """ 

167 return len(domain) <= _max_domain_length + trailing_dot 

168 

169 

170def check_bidi(label: str, check_ltr: bool = False) -> bool: 

171 """Validate the Bidi Rule from :rfc:`5893` for a single label. 

172 

173 The Bidi Rule constrains how bidirectional characters (Hebrew, Arabic, 

174 etc.) may appear within a label. By default the check is only applied 

175 when the label contains at least one right-to-left character (Unicode 

176 bidirectional categories ``R``, ``AL``, or ``AN``); set ``check_ltr`` 

177 to ``True`` to apply it to LTR-only labels as well. 

178 

179 :param label: The label to validate, as a Unicode string. 

180 :param check_ltr: If ``True``, apply the rules even when the label 

181 contains no RTL characters. 

182 :returns: ``True`` if the label satisfies the Bidi Rule. 

183 :raises IDNABidiError: If any of Bidi Rule conditions 1-6 are violated, 

184 or if the directional category of a codepoint cannot be determined. 

185 """ 

186 if len(label) > _max_input_length: 

187 raise IDNAError("Label too long", code="input_too_long") 

188 # Bidi rules should only be applied if string contains RTL characters 

189 bidi_label = False 

190 for idx, cp in enumerate(label, 1): 

191 direction = unicodedata.bidirectional(cp) 

192 if direction == "": 

193 # String likely comes from a newer version of Unicode 

194 raise IDNABidiError( 

195 f"Unknown directionality in label {label!r} at position {idx}", 

196 code="bidi_unknown_direction", 

197 text=label, 

198 codepoint=ord(cp), 

199 position=idx, 

200 ) 

201 if direction in _bidi_rtl_categories: 

202 bidi_label = True 

203 if not bidi_label and not check_ltr: 

204 return True 

205 

206 # Bidi rule 1 

207 direction = unicodedata.bidirectional(label[0]) 

208 if direction in _bidi_rtl_first: 

209 rtl = True 

210 elif direction == "L": 

211 rtl = False 

212 else: 

213 raise IDNABidiError( 

214 f"First codepoint in label {label!r} must be directionality L, R or AL", 

215 code="bidi_rule_1", 

216 text=label, 

217 codepoint=ord(label[0]), 

218 position=1, 

219 ) 

220 

221 valid_ending = False 

222 ending_idx = 1 

223 number_type: str | None = None 

224 for idx, cp in enumerate(label, 1): 

225 direction = unicodedata.bidirectional(cp) 

226 

227 if rtl: 

228 # Bidi rule 2 

229 if direction not in _bidi_rtl_allowed: 

230 raise IDNABidiError( 

231 f"Invalid direction for codepoint at position {idx} in a right-to-left label", 

232 code="bidi_rule_2", 

233 text=label, 

234 codepoint=ord(cp), 

235 position=idx, 

236 ) 

237 # Bidi rule 3 

238 if direction in _bidi_rtl_valid_ending: 

239 valid_ending = True 

240 ending_idx = idx 

241 elif direction != "NSM": 

242 valid_ending = False 

243 ending_idx = idx 

244 # Bidi rule 4 

245 if direction in _bidi_rtl_numeric: 

246 if not number_type: 

247 number_type = direction 

248 elif number_type != direction: 

249 raise IDNABidiError( 

250 "Can not mix numeral types in a right-to-left label", 

251 code="bidi_rule_4", 

252 text=label, 

253 codepoint=ord(cp), 

254 position=idx, 

255 ) 

256 else: 

257 # Bidi rule 5 

258 if direction not in _bidi_ltr_allowed: 

259 raise IDNABidiError( 

260 f"Invalid direction for codepoint at position {idx} in a left-to-right label", 

261 code="bidi_rule_5", 

262 text=label, 

263 codepoint=ord(cp), 

264 position=idx, 

265 ) 

266 # Bidi rule 6 

267 if direction in _bidi_ltr_valid_ending: 

268 valid_ending = True 

269 ending_idx = idx 

270 elif direction != "NSM": 

271 valid_ending = False 

272 ending_idx = idx 

273 

274 if not valid_ending: 

275 # Rules 3 and 6 concern the last character that is not a 

276 # non-spacing mark, which is what ``ending_idx`` tracks. 

277 raise IDNABidiError( 

278 "Label ends with illegal codepoint directionality", 

279 code="bidi_rule_3" if rtl else "bidi_rule_6", 

280 text=label, 

281 codepoint=ord(label[ending_idx - 1]), 

282 position=ending_idx, 

283 ) 

284 

285 return True 

286 

287 

288def check_initial_combiner(label: str) -> bool: 

289 """Reject labels that begin with a combining mark. 

290 

291 Per :rfc:`5891` §4.2.3.2 a label must not start with a character of 

292 Unicode general category ``M`` (Mark). 

293 

294 :param label: The label to check. 

295 :returns: ``True`` if the first character is not a combining mark. 

296 :raises IDNAError: If the label begins with a combining character. 

297 """ 

298 if label and unicodedata.category(label[0])[0] == "M": 

299 raise IDNAError( 

300 "Label begins with an illegal combining character", 

301 code="leading_combiner", 

302 text=label, 

303 codepoint=ord(label[0]), 

304 position=1, 

305 ) 

306 return True 

307 

308 

309def check_hyphen_ok(label: str) -> bool: 

310 """Validate the hyphen restrictions for a label. 

311 

312 Per :rfc:`5891` §4.2.3.1 a label must not start or end with a hyphen 

313 (``U+002D``), and must not have hyphens in both the third and fourth 

314 positions (the prefix reserved for A-labels). 

315 

316 :param label: The label to check. 

317 :returns: ``True`` if the hyphen restrictions are satisfied. 

318 :raises IDNAError: If any of the hyphen restrictions are violated. 

319 """ 

320 if label[2:4] == "--": 

321 raise IDNAError("Label has disallowed hyphens in 3rd and 4th position", code="hyphen_3_4") 

322 if label.startswith("-") or label.endswith("-"): 

323 raise IDNAError("Label must not start or end with a hyphen", code="hyphen_start_end") 

324 return True 

325 

326 

327def check_nfc(label: str) -> None: 

328 """Require that a label is in Unicode Normalization Form C. 

329 

330 :param label: The label to check. 

331 :raises IDNAError: If ``label`` differs from its NFC normalisation. 

332 """ 

333 if len(label) > _max_input_length: 

334 raise IDNAError("Label too long", code="input_too_long") 

335 if unicodedata.normalize("NFC", label) != label: 

336 raise IDNAError("Label must be in Normalization Form C", code="not_nfc") 

337 

338 

339def valid_contextj(label: str, pos: int) -> bool: 

340 """Validate the CONTEXTJ rules from :rfc:`5892` Appendix A. 

341 

342 These rules govern the contextual use of the joiner codepoints 

343 ``U+200C`` (ZERO WIDTH NON-JOINER, Appendix A.1) and ``U+200D`` 

344 (ZERO WIDTH JOINER, Appendix A.2) within a label. 

345 

346 :param label: The label containing the codepoint. 

347 :param pos: Index of the joiner codepoint within ``label``. 

348 :returns: ``True`` if the codepoint at ``pos`` satisfies its CONTEXTJ 

349 rule, ``False`` otherwise (including when the codepoint at 

350 ``pos`` is not a recognised joiner). 

351 :raises ValueError: If an adjacent codepoint has no Unicode name when 

352 determining its combining class. 

353 :raises IDNAError: If ``label`` exceeds the defensive input length limit. 

354 """ 

355 if len(label) > _max_input_length: 

356 raise IDNAError("Label too long", code="input_too_long") 

357 cp_value = ord(label[pos]) 

358 

359 if cp_value == 0x200C: 

360 if pos > 0 and _combining_class(ord(label[pos - 1])) == _virama_combining_class: 

361 return True 

362 

363 ok = False 

364 for i in range(pos - 1, -1, -1): 

365 joining_type = _joining_type(ord(label[i])) 

366 if joining_type == "T": 

367 continue 

368 if joining_type in _bidi_joiner_l_or_d: 

369 ok = True 

370 break 

371 break 

372 

373 if not ok: 

374 return False 

375 

376 ok = False 

377 for i in range(pos + 1, len(label)): 

378 joining_type = _joining_type(ord(label[i])) 

379 if joining_type == "T": 

380 continue 

381 if joining_type in _bidi_joiner_r_or_d: 

382 ok = True 

383 break 

384 break 

385 return ok 

386 

387 if cp_value == 0x200D: 

388 return pos > 0 and _combining_class(ord(label[pos - 1])) == _virama_combining_class 

389 

390 return False 

391 

392 

393def valid_contexto(label: str, pos: int, exception: bool = False) -> bool: 

394 """Validate the CONTEXTO rules from :rfc:`5892` Appendix A. 

395 

396 Covers the contextual rules for codepoints such as MIDDLE DOT 

397 (``U+00B7``), Greek lower numeral sign, Hebrew punctuation, Katakana 

398 middle dot, and the Arabic-Indic / Extended Arabic-Indic digit ranges. 

399 

400 :param label: The label containing the codepoint. 

401 :param pos: Index of the codepoint within ``label``. 

402 :param exception: Reserved for forward compatibility; currently unused. 

403 :returns: ``True`` if the codepoint at ``pos`` satisfies its CONTEXTO 

404 rule, ``False`` otherwise (including when the codepoint is not a 

405 recognised CONTEXTO codepoint). 

406 :raises IDNAError: If ``label`` exceeds the defensive input length limit. 

407 """ 

408 if len(label) > _max_input_length: 

409 raise IDNAError("Label too long", code="input_too_long") 

410 cp_value = ord(label[pos]) 

411 

412 if cp_value == 0x00B7: 

413 return 0 < pos < len(label) - 1 and ord(label[pos - 1]) == 0x006C and ord(label[pos + 1]) == 0x006C 

414 

415 if cp_value == 0x0375: 

416 if pos < len(label) - 1 and len(label) > 1: 

417 return _is_script(label[pos + 1], "Greek") 

418 return False 

419 

420 if cp_value in {0x05F3, 0x05F4}: 

421 if pos > 0: 

422 return _is_script(label[pos - 1], "Hebrew") 

423 return False 

424 

425 if cp_value == 0x30FB: 

426 for cp in label: 

427 if cp == "\u30fb": 

428 continue 

429 if _is_script(cp, "Hiragana") or _is_script(cp, "Katakana") or _is_script(cp, "Han"): 

430 return True 

431 return False 

432 

433 if 0x660 <= cp_value <= 0x669: 

434 return not any(0x6F0 <= ord(cp) <= 0x06F9 for cp in label) 

435 

436 if 0x6F0 <= cp_value <= 0x6F9: 

437 return not any(0x660 <= ord(cp) <= 0x0669 for cp in label) 

438 

439 return False 

440 

441 

442def check_label(label: str | bytes | bytearray) -> None: 

443 """Run the full set of IDNA 2008 validity checks on a single label. 

444 

445 Applies, in order: NFC normalisation (:func:`check_nfc`), hyphen 

446 restrictions (:func:`check_hyphen_ok`), the no-leading-combiner rule 

447 (:func:`check_initial_combiner`), per-codepoint validity (PVALID, 

448 CONTEXTJ, CONTEXTO classes from :rfc:`5892`), and the Bidi Rule 

449 (:func:`check_bidi`). 

450 

451 :param label: The label to validate. ``bytes`` or ``bytearray`` input 

452 is decoded as UTF-8 first. 

453 :raises IDNAError: If the label is empty or fails a structural rule. 

454 :raises InvalidCodepoint: If the label contains a DISALLOWED or 

455 UNASSIGNED codepoint. 

456 :raises InvalidCodepointContext: If a CONTEXTJ or CONTEXTO codepoint 

457 is not valid in its context. 

458 :raises IDNABidiError: If the Bidi Rule is violated. 

459 """ 

460 if len(label) > _max_input_length: 

461 raise IDNAError("Label too long", code="input_too_long") 

462 if isinstance(label, (bytes, bytearray)): 

463 try: 

464 label = label.decode("utf-8") 

465 except UnicodeDecodeError as err: 

466 raise IDNAError("Invalid UTF-8 in label", code="invalid_utf8") from err 

467 if len(label) == 0: 

468 raise IDNAError("Empty Label", code="empty_label") 

469 if _ldh_label_re.match(label): 

470 return 

471 

472 # Check against the domain length rather than the label length to 

473 # support some UTS #46 use cases, while still bounding the work done 

474 # by the label contextual rules below. 

475 if not valid_string_length(label, trailing_dot=True): 

476 raise IDNAError("Label too long", code="label_too_long") 

477 

478 check_nfc(label) 

479 check_hyphen_ok(label) 

480 check_initial_combiner(label) 

481 

482 for pos, cp in enumerate(label): 

483 cp_value = ord(cp) 

484 if intranges_contain(cp_value, idnadata.codepoint_classes["PVALID"]): 

485 continue 

486 if intranges_contain(cp_value, idnadata.codepoint_classes["CONTEXTJ"]): 

487 try: 

488 contextj_ok = valid_contextj(label, pos) 

489 except ValueError as err: 

490 raise IDNAError( 

491 f"Unknown codepoint adjacent to joiner {_unot(cp_value)} at position {pos + 1} in {label!r}", 

492 code="unknown_codepoint", 

493 text=label, 

494 codepoint=cp_value, 

495 position=pos + 1, 

496 ) from err 

497 if not contextj_ok: 

498 raise InvalidCodepointContext( 

499 f"Joiner {_unot(cp_value)} not allowed at position {pos + 1} in {label!r}", 

500 code="contextj", 

501 text=label, 

502 codepoint=cp_value, 

503 position=pos + 1, 

504 ) 

505 elif intranges_contain(cp_value, idnadata.codepoint_classes["CONTEXTO"]): 

506 if not valid_contexto(label, pos): 

507 raise InvalidCodepointContext( 

508 f"Codepoint {_unot(cp_value)} not allowed at position {pos + 1} in {label!r}", 

509 code="contexto", 

510 text=label, 

511 codepoint=cp_value, 

512 position=pos + 1, 

513 ) 

514 else: 

515 raise InvalidCodepoint( 

516 f"Codepoint {_unot(cp_value)} at position {pos + 1} of {label!r} not allowed", 

517 code="disallowed_codepoint", 

518 text=label, 

519 codepoint=cp_value, 

520 position=pos + 1, 

521 ) 

522 

523 check_bidi(label) 

524 

525 

526def alabel(label: str) -> bytes: 

527 """Convert a single U-label into its A-label form. 

528 

529 The result is the ASCII-Compatible Encoding (ACE) form per :rfc:`5891` 

530 §4: the label is validated, Punycode-encoded, and prefixed with 

531 ``xn--``. Pure ASCII labels that are already valid IDNA labels are 

532 returned unchanged (as :class:`bytes`). 

533 

534 :param label: The label to convert, as a Unicode string. 

535 :returns: The A-label as ASCII-encoded :class:`bytes`. 

536 :raises IDNAError: If the label is invalid or the resulting A-label 

537 exceeds 63 octets. 

538 """ 

539 if len(label) > _max_input_length: 

540 raise IDNAError("Label too long", code="input_too_long") 

541 try: 

542 label_bytes = label.encode("ascii") 

543 except UnicodeEncodeError: 

544 pass 

545 else: 

546 ulabel(label_bytes) 

547 if not valid_label_length(label_bytes): 

548 raise IDNAError("Label too long", code="label_too_long") 

549 return label_bytes 

550 

551 check_label(label) 

552 label_bytes = _alabel_prefix + _punycode(label) 

553 

554 if not valid_label_length(label_bytes): 

555 raise IDNAError("Label too long", code="label_too_long") 

556 

557 return label_bytes 

558 

559 

560def ulabel(label: str | bytes | bytearray) -> str: 

561 """Convert a single A-label into its U-label form. 

562 

563 Performs the inverse of :func:`alabel`: an ``xn--``-prefixed label is 

564 Punycode-decoded and validated, and is rejected unless it is the 

565 canonical A-label for the decoded U-label (:rfc:`5891` §5.3). Labels 

566 that are already Unicode (or plain ASCII without the ACE prefix) are 

567 validated and returned as a Unicode string. 

568 

569 :param label: The label to convert. ``bytes`` or ``bytearray`` input 

570 is treated as ASCII. 

571 :returns: The U-label as a Unicode string. 

572 :raises IDNAError: If the label is malformed or fails validation. 

573 """ 

574 if len(label) > _max_input_length: 

575 raise IDNAError("Label too long", code="input_too_long") 

576 if not isinstance(label, (bytes, bytearray)): 

577 try: 

578 label_bytes = label.encode("ascii") 

579 except UnicodeEncodeError: 

580 check_label(label) 

581 return label 

582 else: 

583 label_bytes = bytes(label) 

584 if not label_bytes.isascii(): 

585 raise IDNAError("Invalid ASCII in A-label", code="invalid_ascii") 

586 

587 label_bytes = label_bytes.lower() 

588 if label_bytes.startswith(_alabel_prefix): 

589 label_bytes = label_bytes[len(_alabel_prefix) :] 

590 if not label_bytes: 

591 raise IDNAError("Malformed A-label, no Punycode eligible content found", code="invalid_alabel") 

592 if label_bytes.endswith(b"-"): 

593 raise IDNAError("A-label must not end with a hyphen", code="invalid_alabel") 

594 else: 

595 check_label(label_bytes) 

596 return label_bytes.decode("ascii") 

597 

598 try: 

599 label = label_bytes.decode("punycode") 

600 except UnicodeError as err: 

601 raise IDNAError("Invalid A-label", code="invalid_alabel") from err 

602 # RFC 5891 §5.3: the label is rejected unless re-encoding the decoded 

603 # form reproduces the (lowercased) input. This catches "fake A-labels" 

604 # (RFC 5890 §2.3.2.1) such as ``xn---bbk``, a non-canonical Punycode 

605 # spelling of ``xn--bbk`` that would otherwise decode to the same 

606 # U-label and so display identically to a different wire-format name. 

607 if _punycode(label) != label_bytes: 

608 raise IDNAError("A-label is not the canonical Punycode encoding of its U-label", code="non_canonical_alabel") 

609 check_label(label) 

610 return label 

611 

612 

613def _check_std3(text: str, domain: str, offset: int) -> None: 

614 """Raise if ``text``, a slice of ``domain`` starting at ``offset`` that 

615 UTS #46 mapping left unchanged, contains an ASCII character disallowed 

616 under ``UseSTD3ASCIIRules``.""" 

617 match = _std3_disallowed_re.search(text) 

618 if match: 

619 codepoint = ord(match.group()) 

620 position = offset + match.start() + 1 

621 raise InvalidCodepoint( 

622 f"Codepoint {_unot(codepoint)} not allowed at position {position} in {domain!r}", 

623 code="uts46_std3", 

624 text=domain, 

625 codepoint=codepoint, 

626 position=position, 

627 ) 

628 

629 

630def _warn_transitional() -> None: 

631 warnings.warn( 

632 "Transitional processing is deprecated in UTS #46 and has no effect. " 

633 "The transitional argument will be removed in a future version.", 

634 DeprecationWarning, 

635 stacklevel=3, 

636 ) 

637 

638 

639def uts46_remap(domain: str, std3_rules: bool = True, transitional: bool = False) -> str: 

640 """Apply the UTS #46 character mapping to a domain string. 

641 

642 Implements the mapping table from `UTS #46 §4 

643 <https://www.unicode.org/reports/tr46/>`_: each character is kept, 

644 replaced, or rejected based on its status (``V``, ``M``, ``D``, 

645 ``I``, ``X``). The result is returned in Normalisation Form C. 

646 

647 :param domain: The full domain name to remap. 

648 :param std3_rules: If ``True``, apply UTS #46's ``UseSTD3ASCIIRules``: 

649 after mapping, any ASCII character other than a lowercase letter, 

650 digit, hyphen or the label separator ``.`` is rejected, whether it 

651 appeared in the input or was produced by a mapping (e.g. U+FF01 

652 FULLWIDTH EXCLAMATION MARK maps to ``!``). If ``False``, such 

653 characters are passed through. 

654 :param transitional: Deprecated and ignored. UTS #46 deprecated 

655 transitional processing in Unicode 15.1 and deviation (status 

656 ``D``) codepoints are now always kept, so this has no effect 

657 beyond emitting a :class:`DeprecationWarning`. It will be removed 

658 in a future version. 

659 :returns: The remapped domain, in Normalisation Form C. 

660 :raises InvalidCodepoint: If the domain contains a disallowed 

661 codepoint under the chosen rules. 

662 :raises IDNAError: If ``domain`` exceeds the defensive input length limit. 

663 """ 

664 if transitional: 

665 _warn_transitional() 

666 if len(domain) > _max_input_length: 

667 raise IDNAError("Domain too long", code="input_too_long") 

668 if domain.isascii(): 

669 # The only ASCII mapping in UTS #46 is upper- to lowercase, and 

670 # ASCII is invariant under NFC, so lowercasing is the whole job. 

671 result = domain.lower() 

672 if std3_rules: 

673 _check_std3(result, domain, 0) 

674 return result 

675 

676 from .uts46data import uts46_replacements, uts46_starts, uts46_statuses 

677 

678 # ``start`` marks the run of unchanged input not yet copied; a run is 

679 # only sliced out when a character must be replaced or dropped, so the 

680 # common no-change case makes no copy. STD3 is checked per output piece 

681 # to report a violation at its input position. 

682 output: list[str] = [] 

683 start = 0 

684 for pos, char in enumerate(domain): 

685 code_point = ord(char) 

686 i = code_point if code_point < 256 else bisect.bisect_right(uts46_starts, code_point) - 1 

687 status = uts46_statuses[i] 

688 # UTS #46 §4: V valid, D deviation (kept), M mapped, I ignored, 

689 # anything else disallowed. 

690 if status == _STATUS_VALID: 

691 continue 

692 if status == _STATUS_MAPPED: 

693 replacement = uts46_replacements[i] 

694 elif status == _STATUS_DEVIATION: 

695 continue 

696 elif status == _STATUS_IGNORED: 

697 replacement = None 

698 else: 

699 raise InvalidCodepoint( 

700 f"Codepoint {_unot(code_point)} not allowed at position {pos + 1} in {domain!r}", 

701 code="uts46_disallowed", 

702 text=domain, 

703 codepoint=code_point, 

704 position=pos + 1, 

705 ) 

706 if start < pos: 

707 run = domain[start:pos] 

708 if std3_rules: 

709 _check_std3(run, domain, start) 

710 output.append(run) 

711 if replacement: 

712 if std3_rules and _std3_disallowed_re.search(replacement): 

713 raise InvalidCodepoint( 

714 f"Codepoint {_unot(code_point)} not allowed at position {pos + 1} in {domain!r}", 

715 code="uts46_std3", 

716 text=domain, 

717 codepoint=code_point, 

718 position=pos + 1, 

719 ) 

720 output.append(replacement) 

721 start = pos + 1 

722 

723 if start == 0: 

724 if std3_rules: 

725 _check_std3(domain, domain, 0) 

726 return unicodedata.normalize("NFC", domain) 

727 tail = domain[start:] 

728 if std3_rules: 

729 _check_std3(tail, domain, start) 

730 output.append(tail) 

731 return unicodedata.normalize("NFC", "".join(output)) 

732 

733 

734def encode( 

735 s: str | bytes | bytearray, 

736 strict: bool = False, 

737 uts46: bool = False, 

738 std3_rules: bool = False, 

739 transitional: bool = False, 

740) -> bytes: 

741 """Encode a Unicode domain name into its ASCII (A-label) form. 

742 

743 Splits the input on label separators (only ``U+002E`` if ``strict`` is 

744 set; otherwise also IDEOGRAPHIC FULL STOP ``U+3002``, FULLWIDTH FULL 

745 STOP ``U+FF0E``, and HALFWIDTH IDEOGRAPHIC FULL STOP ``U+FF61``), 

746 encodes each label with :func:`alabel`, and rejoins them with ``.``. 

747 Optionally pre-processes the input through :func:`uts46_remap`. 

748 

749 :param s: The domain name to encode. 

750 :param strict: If ``True``, only ``U+002E`` is recognised as a label 

751 separator. 

752 :param uts46: If ``True``, apply UTS #46 mapping before encoding. 

753 :param std3_rules: Forwarded to :func:`uts46_remap` when ``uts46`` is 

754 ``True``. 

755 :param transitional: Deprecated and ignored (see :func:`uts46_remap`): 

756 emits a :class:`DeprecationWarning` and will be removed in a 

757 future version. 

758 :returns: The encoded domain as ASCII :class:`bytes`. 

759 :raises IDNAError: If the domain is empty, contains an invalid label, 

760 or exceeds the maximum domain length. 

761 """ 

762 if transitional: 

763 _warn_transitional() 

764 if not isinstance(s, str): 

765 try: 

766 s = str(s, "ascii") 

767 except (UnicodeDecodeError, TypeError) as err: 

768 raise IDNAError( 

769 "should pass a unicode string to the function rather than a byte string.", code="invalid_ascii" 

770 ) from err 

771 if len(s) > _max_input_length: 

772 raise IDNAError("Domain too long", code="input_too_long") 

773 if uts46: 

774 s = uts46_remap(s, std3_rules) 

775 

776 if not valid_string_length(s, trailing_dot=True): 

777 raise IDNAError("Domain too long", code="domain_too_long") 

778 

779 trailing_dot = False 

780 result = [] 

781 labels = s.split(".") if strict else _unicode_dots_re.split(s) 

782 if not labels or labels == [""]: 

783 raise IDNAError("Empty domain", code="empty_domain") 

784 if labels[-1] == "": 

785 del labels[-1] 

786 trailing_dot = True 

787 for label in labels: 

788 s = alabel(label) 

789 if s: 

790 result.append(s) 

791 else: 

792 raise IDNAError("Empty label", code="empty_label") 

793 if trailing_dot: 

794 result.append(b"") 

795 s = b".".join(result) 

796 if not valid_string_length(s, trailing_dot): 

797 raise IDNAError("Domain too long", code="domain_too_long") 

798 return s 

799 

800 

801def decode( 

802 s: str | bytes | bytearray, 

803 strict: bool = False, 

804 uts46: bool = False, 

805 std3_rules: bool = False, 

806 display: bool = False, 

807) -> str: 

808 """Decode an A-label-encoded domain name back to Unicode. 

809 

810 Splits the input on label separators (see :func:`encode` for the 

811 rules), decodes each label with :func:`ulabel`, and rejoins them 

812 with ``.``. Optionally pre-processes the input through 

813 :func:`uts46_remap`. 

814 

815 :param s: The domain name to decode. 

816 :param strict: If ``True``, only ``U+002E`` is recognised as a label 

817 separator. 

818 :param uts46: If ``True``, apply UTS #46 mapping before decoding. 

819 :param std3_rules: Forwarded to :func:`uts46_remap` when ``uts46`` is 

820 ``True``. 

821 :param display: If ``True``, any ``xn--`` label that fails IDNA 

822 validation is passed through unchanged (lowercased) rather than 

823 aborting the whole call. Intended for "decode for display" 

824 consumers (e.g. URL libraries, HTTP clients) that want to show 

825 the user the label as it appears on the wire when it cannot be 

826 rendered as Unicode. Matches the per-label recovery prescribed 

827 by UTS #46 §4 and the WHATWG URL "domain to Unicode" algorithm. 

828 :returns: The decoded domain as a Unicode string. 

829 :raises IDNAError: If the input is not valid ASCII, contains an 

830 invalid label, or is empty. 

831 """ 

832 if not isinstance(s, str): 

833 try: 

834 s = str(s, "ascii") 

835 except (UnicodeDecodeError, TypeError) as err: 

836 raise IDNAError("Invalid ASCII in A-label", code="invalid_ascii") from err 

837 if len(s) > _max_input_length: 

838 raise IDNAError("Domain too long", code="input_too_long") 

839 if uts46: 

840 s = uts46_remap(s, std3_rules, False) 

841 if not valid_string_length(s, trailing_dot=True): 

842 raise IDNAError("Domain too long", code="domain_too_long") 

843 trailing_dot = False 

844 result = [] 

845 labels = s.split(".") if strict else _unicode_dots_re.split(s) 

846 if not labels or labels == [""]: 

847 raise IDNAError("Empty domain", code="empty_domain") 

848 if not labels[-1]: 

849 del labels[-1] 

850 trailing_dot = True 

851 for label in labels: 

852 try: 

853 u = ulabel(label) 

854 except IDNAError: 

855 if display and label[:4].lower() == "xn--": 

856 u = label.lower() 

857 else: 

858 raise 

859 if u: 

860 result.append(u) 

861 else: 

862 raise IDNAError("Empty label", code="empty_label") 

863 if trailing_dot: 

864 result.append("") 

865 return ".".join(result)