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

374 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_bidi_rtl_first = frozenset({"R", "AL"}) 

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

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

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

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

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

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

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

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

28 

29 

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

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

32 if intranges_contain(cp, ranges): 

33 return jt 

34 return None 

35 

36 

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

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

39_ErrorCode = Literal[ 

40 "input_too_long", 

41 "label_too_long", 

42 "domain_too_long", 

43 "empty_label", 

44 "empty_domain", 

45 "not_nfc", 

46 "hyphen_3_4", 

47 "hyphen_start_end", 

48 "leading_combiner", 

49 "disallowed_codepoint", 

50 "contextj", 

51 "contexto", 

52 "unknown_codepoint", 

53 "bidi_rule_1", 

54 "bidi_rule_2", 

55 "bidi_rule_3", 

56 "bidi_rule_4", 

57 "bidi_rule_5", 

58 "bidi_rule_6", 

59 "bidi_unknown_direction", 

60 "invalid_alabel", 

61 "non_canonical_alabel", 

62 "invalid_ascii", 

63 "invalid_utf8", 

64 "uts46_disallowed", 

65 "uts46_std3", 

66 "unsupported_errors", 

67] 

68 

69 

70class IDNAError(UnicodeError): 

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

72 

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

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

75 need to parse the message: 

76 

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

78 as ``"disallowed_codepoint"`` or ``"bidi_rule_2"``; the full list is 

79 documented in the README. Message wording, by contrast, may change 

80 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 

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

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

472 # by the label contextual rules below. 

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

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

475 

476 check_nfc(label) 

477 check_hyphen_ok(label) 

478 check_initial_combiner(label) 

479 

480 for pos, cp in enumerate(label): 

481 cp_value = ord(cp) 

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

483 continue 

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

485 try: 

486 contextj_ok = valid_contextj(label, pos) 

487 except ValueError as err: 

488 raise IDNAError( 

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

490 code="unknown_codepoint", 

491 text=label, 

492 codepoint=cp_value, 

493 position=pos + 1, 

494 ) from err 

495 if not contextj_ok: 

496 raise InvalidCodepointContext( 

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

498 code="contextj", 

499 text=label, 

500 codepoint=cp_value, 

501 position=pos + 1, 

502 ) 

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

504 if not valid_contexto(label, pos): 

505 raise InvalidCodepointContext( 

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

507 code="contexto", 

508 text=label, 

509 codepoint=cp_value, 

510 position=pos + 1, 

511 ) 

512 else: 

513 raise InvalidCodepoint( 

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

515 code="disallowed_codepoint", 

516 text=label, 

517 codepoint=cp_value, 

518 position=pos + 1, 

519 ) 

520 

521 check_bidi(label) 

522 

523 

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

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

526 

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

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

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

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

531 

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

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

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

535 exceeds 63 octets. 

536 """ 

537 if len(label) > _max_input_length: 

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

539 try: 

540 label_bytes = label.encode("ascii") 

541 except UnicodeEncodeError: 

542 pass 

543 else: 

544 ulabel(label_bytes) 

545 if not valid_label_length(label_bytes): 

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

547 return label_bytes 

548 

549 check_label(label) 

550 label_bytes = _alabel_prefix + _punycode(label) 

551 

552 if not valid_label_length(label_bytes): 

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

554 

555 return label_bytes 

556 

557 

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

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

560 

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

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

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

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

565 validated and returned as a Unicode string. 

566 

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

568 is treated as ASCII. 

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

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

571 """ 

572 if len(label) > _max_input_length: 

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

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

575 try: 

576 label_bytes = label.encode("ascii") 

577 except UnicodeEncodeError: 

578 check_label(label) 

579 return label 

580 else: 

581 label_bytes = bytes(label) 

582 if not label_bytes.isascii(): 

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

584 

585 label_bytes = label_bytes.lower() 

586 if label_bytes.startswith(_alabel_prefix): 

587 label_bytes = label_bytes[len(_alabel_prefix) :] 

588 if not label_bytes: 

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

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

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

592 else: 

593 check_label(label_bytes) 

594 return label_bytes.decode("ascii") 

595 

596 try: 

597 label = label_bytes.decode("punycode") 

598 except UnicodeError as err: 

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

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

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

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

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

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

605 if _punycode(label) != label_bytes: 

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

607 check_label(label) 

608 return label 

609 

610 

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

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

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

614 under ``UseSTD3ASCIIRules``.""" 

615 match = _std3_disallowed_re.search(text) 

616 if match: 

617 codepoint = ord(match.group()) 

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

619 raise InvalidCodepoint( 

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

621 code="uts46_std3", 

622 text=domain, 

623 codepoint=codepoint, 

624 position=position, 

625 ) 

626 

627 

628def _warn_transitional() -> None: 

629 warnings.warn( 

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

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

632 DeprecationWarning, 

633 stacklevel=3, 

634 ) 

635 

636 

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

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

639 

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

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

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

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

644 

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

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

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

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

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

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

651 characters are passed through. 

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

653 transitional processing in Unicode 15.1 and deviation (status 

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

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

656 in a future version. 

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

658 :raises InvalidCodepoint: If the domain contains a disallowed 

659 codepoint under the chosen rules. 

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

661 """ 

662 if transitional: 

663 _warn_transitional() 

664 if len(domain) > _max_input_length: 

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

666 if domain.isascii(): 

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

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

669 result = domain.lower() 

670 if std3_rules: 

671 _check_std3(result, domain, 0) 

672 return result 

673 

674 from .uts46data import uts46_replacements, uts46_starts, uts46_statuses 

675 

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

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

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

679 # to report a violation at its input position. 

680 output: list[str] = [] 

681 start = 0 

682 for pos, char in enumerate(domain): 

683 code_point = ord(char) 

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

685 status = uts46_statuses[i] 

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

687 # anything else disallowed. 

688 if status == _STATUS_VALID: 

689 continue 

690 if status == _STATUS_MAPPED: 

691 replacement = uts46_replacements[i] 

692 elif status == _STATUS_DEVIATION: 

693 continue 

694 elif status == _STATUS_IGNORED: 

695 replacement = None 

696 else: 

697 raise InvalidCodepoint( 

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

699 code="uts46_disallowed", 

700 text=domain, 

701 codepoint=code_point, 

702 position=pos + 1, 

703 ) 

704 if start < pos: 

705 run = domain[start:pos] 

706 if std3_rules: 

707 _check_std3(run, domain, start) 

708 output.append(run) 

709 if replacement: 

710 if std3_rules and _std3_disallowed_re.search(replacement): 

711 raise InvalidCodepoint( 

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

713 code="uts46_std3", 

714 text=domain, 

715 codepoint=code_point, 

716 position=pos + 1, 

717 ) 

718 output.append(replacement) 

719 start = pos + 1 

720 

721 if start == 0: 

722 if std3_rules: 

723 _check_std3(domain, domain, 0) 

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

725 tail = domain[start:] 

726 if std3_rules: 

727 _check_std3(tail, domain, start) 

728 output.append(tail) 

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

730 

731 

732def encode( 

733 s: str | bytes | bytearray, 

734 strict: bool = False, 

735 uts46: bool = False, 

736 std3_rules: bool = False, 

737 transitional: bool = False, 

738) -> bytes: 

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

740 

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

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

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

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

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

746 

747 :param s: The domain name to encode. 

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

749 separator. 

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

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

752 ``True``. 

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

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

755 future version. 

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

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

758 or exceeds the maximum domain length. 

759 """ 

760 if transitional: 

761 _warn_transitional() 

762 if not isinstance(s, str): 

763 try: 

764 s = str(s, "ascii") 

765 except (UnicodeDecodeError, TypeError) as err: 

766 raise IDNAError( 

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

768 ) from err 

769 if len(s) > _max_input_length: 

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

771 if uts46: 

772 s = uts46_remap(s, std3_rules) 

773 

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

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

776 

777 trailing_dot = False 

778 result = [] 

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

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

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

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

783 del labels[-1] 

784 trailing_dot = True 

785 for label in labels: 

786 s = alabel(label) 

787 if s: 

788 result.append(s) 

789 else: 

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

791 if trailing_dot: 

792 result.append(b"") 

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

794 if not valid_string_length(s, trailing_dot): 

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

796 return s 

797 

798 

799def decode( 

800 s: str | bytes | bytearray, 

801 strict: bool = False, 

802 uts46: bool = False, 

803 std3_rules: bool = False, 

804 display: bool = False, 

805) -> str: 

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

807 

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

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

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

811 :func:`uts46_remap`. 

812 

813 :param s: The domain name to decode. 

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

815 separator. 

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

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

818 ``True``. 

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

820 validation is passed through unchanged (lowercased) rather than 

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

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

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

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

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

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

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

828 invalid label, or is empty. 

829 """ 

830 if not isinstance(s, str): 

831 try: 

832 s = str(s, "ascii") 

833 except (UnicodeDecodeError, TypeError) as err: 

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

835 if len(s) > _max_input_length: 

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

837 if uts46: 

838 s = uts46_remap(s, std3_rules, False) 

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

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

841 trailing_dot = False 

842 result = [] 

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

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

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

846 if not labels[-1]: 

847 del labels[-1] 

848 trailing_dot = True 

849 for label in labels: 

850 try: 

851 u = ulabel(label) 

852 except IDNAError: 

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

854 u = label.lower() 

855 else: 

856 raise 

857 if u: 

858 result.append(u) 

859 else: 

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

861 if trailing_dot: 

862 result.append("") 

863 return ".".join(result)