Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/idna/core.py: 14%

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

373 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_STATUS_VALID, _STATUS_MAPPED, _STATUS_DEVIATION, _STATUS_IGNORED = b"VMDI" 

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

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

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

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

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

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

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

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

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

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

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

27 

28 

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

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

31 if intranges_contain(cp, ranges): 

32 return jt 

33 return None 

34 

35 

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

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

38_ErrorCode = Literal[ 

39 "input_too_long", 

40 "label_too_long", 

41 "domain_too_long", 

42 "empty_label", 

43 "empty_domain", 

44 "not_nfc", 

45 "hyphen_3_4", 

46 "hyphen_start_end", 

47 "leading_combiner", 

48 "disallowed_codepoint", 

49 "contextj", 

50 "contexto", 

51 "unknown_codepoint", 

52 "bidi_rule_1", 

53 "bidi_rule_2", 

54 "bidi_rule_3", 

55 "bidi_rule_4", 

56 "bidi_rule_5", 

57 "bidi_rule_6", 

58 "bidi_unknown_direction", 

59 "invalid_alabel", 

60 "non_canonical_alabel", 

61 "invalid_ascii", 

62 "invalid_utf8", 

63 "uts46_disallowed", 

64 "uts46_std3", 

65 "unsupported_errors", 

66] 

67 

68 

69class IDNAError(UnicodeError): 

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

71 

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

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

74 need to parse the message: 

75 

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

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

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

79 between releases. 

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

81 was being validated; 

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

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

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

85 

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

87 """ 

88 

89 code: str | None 

90 text: str | None 

91 codepoint: int | None 

92 position: int | None 

93 

94 def __init__( 

95 self, 

96 *args: object, 

97 code: _ErrorCode | None = None, 

98 text: str | None = None, 

99 codepoint: int | None = None, 

100 position: int | None = None, 

101 ) -> None: 

102 super().__init__(*args) 

103 self.code = code 

104 self.text = text 

105 self.codepoint = codepoint 

106 self.position = position 

107 

108 

109class IDNABidiError(IDNAError): 

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

111 

112 

113class InvalidCodepoint(IDNAError): 

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

115 

116 

117class InvalidCodepointContext(IDNAError): 

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

119 

120 

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

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

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

124 raise ValueError("Unknown character in unicodedata") 

125 return v 

126 

127 

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

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

130 

131 

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

133 return s.encode("punycode") 

134 

135 

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

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

138 

139 

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

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

142 

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

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

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

146 length is measured in octets). 

147 

148 :param label: The label to check. 

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

150 ``False``. 

151 """ 

152 return len(label) <= 63 

153 

154 

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

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

157 

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

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

160 

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

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

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

164 ``False``. 

165 """ 

166 return len(domain) <= (254 if trailing_dot else 253) 

167 

168 

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

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

171 

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

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

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

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

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

177 

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

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

180 contains no RTL characters. 

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

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

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

184 """ 

185 if len(label) > _max_input_length: 

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

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

188 bidi_label = False 

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

190 direction = unicodedata.bidirectional(cp) 

191 if direction == "": 

192 # String likely comes from a newer version of Unicode 

193 raise IDNABidiError( 

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

195 code="bidi_unknown_direction", 

196 text=label, 

197 codepoint=ord(cp), 

198 position=idx, 

199 ) 

200 if direction in _bidi_rtl_categories: 

201 bidi_label = True 

202 if not bidi_label and not check_ltr: 

203 return True 

204 

205 # Bidi rule 1 

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

207 if direction in _bidi_rtl_first: 

208 rtl = True 

209 elif direction == "L": 

210 rtl = False 

211 else: 

212 raise IDNABidiError( 

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

214 code="bidi_rule_1", 

215 text=label, 

216 codepoint=ord(label[0]), 

217 position=1, 

218 ) 

219 

220 valid_ending = False 

221 ending_idx = 1 

222 number_type: str | None = None 

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

224 direction = unicodedata.bidirectional(cp) 

225 

226 if rtl: 

227 # Bidi rule 2 

228 if direction not in _bidi_rtl_allowed: 

229 raise IDNABidiError( 

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

231 code="bidi_rule_2", 

232 text=label, 

233 codepoint=ord(cp), 

234 position=idx, 

235 ) 

236 # Bidi rule 3 

237 if direction in _bidi_rtl_valid_ending: 

238 valid_ending = True 

239 ending_idx = idx 

240 elif direction != "NSM": 

241 valid_ending = False 

242 ending_idx = idx 

243 # Bidi rule 4 

244 if direction in _bidi_rtl_numeric: 

245 if not number_type: 

246 number_type = direction 

247 elif number_type != direction: 

248 raise IDNABidiError( 

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

250 code="bidi_rule_4", 

251 text=label, 

252 codepoint=ord(cp), 

253 position=idx, 

254 ) 

255 else: 

256 # Bidi rule 5 

257 if direction not in _bidi_ltr_allowed: 

258 raise IDNABidiError( 

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

260 code="bidi_rule_5", 

261 text=label, 

262 codepoint=ord(cp), 

263 position=idx, 

264 ) 

265 # Bidi rule 6 

266 if direction in _bidi_ltr_valid_ending: 

267 valid_ending = True 

268 ending_idx = idx 

269 elif direction != "NSM": 

270 valid_ending = False 

271 ending_idx = idx 

272 

273 if not valid_ending: 

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

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

276 raise IDNABidiError( 

277 "Label ends with illegal codepoint directionality", 

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

279 text=label, 

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

281 position=ending_idx, 

282 ) 

283 

284 return True 

285 

286 

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

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

289 

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

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

292 

293 :param label: The label to check. 

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

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

296 """ 

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

298 raise IDNAError( 

299 "Label begins with an illegal combining character", 

300 code="leading_combiner", 

301 text=label, 

302 codepoint=ord(label[0]), 

303 position=1, 

304 ) 

305 return True 

306 

307 

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

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

310 

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

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

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

314 

315 :param label: The label to check. 

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

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

318 """ 

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

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

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

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

323 return True 

324 

325 

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

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

328 

329 :param label: The label to check. 

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

331 """ 

332 if len(label) > _max_input_length: 

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

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

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

336 

337 

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

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

340 

341 These rules govern the contextual use of the joiner codepoints 

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

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

344 

345 :param label: The label containing the codepoint. 

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

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

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

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

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

351 determining its combining class. 

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

353 """ 

354 if len(label) > _max_input_length: 

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

356 cp_value = ord(label[pos]) 

357 

358 if cp_value == 0x200C: 

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

360 return True 

361 

362 ok = False 

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

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

365 if joining_type == "T": 

366 continue 

367 if joining_type in _bidi_joiner_l_or_d: 

368 ok = True 

369 break 

370 break 

371 

372 if not ok: 

373 return False 

374 

375 ok = False 

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

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

378 if joining_type == "T": 

379 continue 

380 if joining_type in _bidi_joiner_r_or_d: 

381 ok = True 

382 break 

383 break 

384 return ok 

385 

386 if cp_value == 0x200D: 

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

388 

389 return False 

390 

391 

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

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

394 

395 Covers the contextual rules for codepoints such as MIDDLE DOT 

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

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

398 

399 :param label: The label containing the codepoint. 

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

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

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

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

404 recognised CONTEXTO codepoint). 

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

406 """ 

407 if len(label) > _max_input_length: 

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

409 cp_value = ord(label[pos]) 

410 

411 if cp_value == 0x00B7: 

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

413 

414 if cp_value == 0x0375: 

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

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

417 return False 

418 

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

420 if pos > 0: 

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

422 return False 

423 

424 if cp_value == 0x30FB: 

425 for cp in label: 

426 if cp == "\u30fb": 

427 continue 

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

429 return True 

430 return False 

431 

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

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

434 

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

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

437 

438 return False 

439 

440 

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

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

443 

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

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

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

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

448 (:func:`check_bidi`). 

449 

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

451 is decoded as UTF-8 first. 

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

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

454 UNASSIGNED codepoint. 

455 :raises InvalidCodepointContext: If a CONTEXTJ or CONTEXTO codepoint 

456 is not valid in its context. 

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

458 """ 

459 if len(label) > _max_input_length: 

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

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

462 try: 

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

464 except UnicodeDecodeError as err: 

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

466 if len(label) == 0: 

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

468 

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

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

471 # by the label contextual rules below. 

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

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

474 

475 check_nfc(label) 

476 check_hyphen_ok(label) 

477 check_initial_combiner(label) 

478 

479 for pos, cp in enumerate(label): 

480 cp_value = ord(cp) 

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

482 continue 

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

484 try: 

485 contextj_ok = valid_contextj(label, pos) 

486 except ValueError as err: 

487 raise IDNAError( 

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

489 code="unknown_codepoint", 

490 text=label, 

491 codepoint=cp_value, 

492 position=pos + 1, 

493 ) from err 

494 if not contextj_ok: 

495 raise InvalidCodepointContext( 

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

497 code="contextj", 

498 text=label, 

499 codepoint=cp_value, 

500 position=pos + 1, 

501 ) 

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

503 if not valid_contexto(label, pos): 

504 raise InvalidCodepointContext( 

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

506 code="contexto", 

507 text=label, 

508 codepoint=cp_value, 

509 position=pos + 1, 

510 ) 

511 else: 

512 raise InvalidCodepoint( 

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

514 code="disallowed_codepoint", 

515 text=label, 

516 codepoint=cp_value, 

517 position=pos + 1, 

518 ) 

519 

520 check_bidi(label) 

521 

522 

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

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

525 

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

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

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

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

530 

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

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

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

534 exceeds 63 octets. 

535 """ 

536 if len(label) > _max_input_length: 

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

538 try: 

539 label_bytes = label.encode("ascii") 

540 except UnicodeEncodeError: 

541 pass 

542 else: 

543 ulabel(label_bytes) 

544 if not valid_label_length(label_bytes): 

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

546 return label_bytes 

547 

548 check_label(label) 

549 label_bytes = _alabel_prefix + _punycode(label) 

550 

551 if not valid_label_length(label_bytes): 

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

553 

554 return label_bytes 

555 

556 

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

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

559 

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

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

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

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

564 validated and returned as a Unicode string. 

565 

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

567 is treated as ASCII. 

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

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

570 """ 

571 if len(label) > _max_input_length: 

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

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

574 try: 

575 label_bytes = label.encode("ascii") 

576 except UnicodeEncodeError: 

577 check_label(label) 

578 return label 

579 else: 

580 label_bytes = bytes(label) 

581 if not label_bytes.isascii(): 

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

583 

584 label_bytes = label_bytes.lower() 

585 if label_bytes.startswith(_alabel_prefix): 

586 label_bytes = label_bytes[len(_alabel_prefix) :] 

587 if not label_bytes: 

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

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

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

591 else: 

592 check_label(label_bytes) 

593 return label_bytes.decode("ascii") 

594 

595 try: 

596 label = label_bytes.decode("punycode") 

597 except UnicodeError as err: 

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

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

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

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

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

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

604 if _punycode(label) != label_bytes: 

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

606 check_label(label) 

607 return label 

608 

609 

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

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

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

613 under ``UseSTD3ASCIIRules``.""" 

614 match = _std3_disallowed_re.search(text) 

615 if match: 

616 codepoint = ord(match.group()) 

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

618 raise InvalidCodepoint( 

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

620 code="uts46_std3", 

621 text=domain, 

622 codepoint=codepoint, 

623 position=position, 

624 ) 

625 

626 

627def _warn_transitional() -> None: 

628 warnings.warn( 

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

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

631 DeprecationWarning, 

632 stacklevel=3, 

633 ) 

634 

635 

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

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

638 

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

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

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

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

643 

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

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

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

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

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

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

650 characters are passed through. 

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

652 transitional processing in Unicode 15.1 and deviation (status 

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

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

655 in a future version. 

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

657 :raises InvalidCodepoint: If the domain contains a disallowed 

658 codepoint under the chosen rules. 

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

660 """ 

661 if transitional: 

662 _warn_transitional() 

663 if len(domain) > _max_input_length: 

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

665 if domain.isascii(): 

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

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

668 result = domain.lower() 

669 if std3_rules: 

670 _check_std3(result, domain, 0) 

671 return result 

672 

673 from .uts46data import uts46_replacements, uts46_starts, uts46_statuses 

674 

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

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

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

678 # to report a violation at its input position. 

679 output: list[str] = [] 

680 start = 0 

681 for pos, char in enumerate(domain): 

682 code_point = ord(char) 

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

684 status = uts46_statuses[i] 

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

686 # anything else disallowed. 

687 if status == _STATUS_VALID: 

688 continue 

689 if status == _STATUS_MAPPED: 

690 replacement = uts46_replacements[i] 

691 elif status == _STATUS_DEVIATION: 

692 continue 

693 elif status == _STATUS_IGNORED: 

694 replacement = None 

695 else: 

696 raise InvalidCodepoint( 

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

698 code="uts46_disallowed", 

699 text=domain, 

700 codepoint=code_point, 

701 position=pos + 1, 

702 ) 

703 if start < pos: 

704 run = domain[start:pos] 

705 if std3_rules: 

706 _check_std3(run, domain, start) 

707 output.append(run) 

708 if replacement: 

709 if std3_rules and _std3_disallowed_re.search(replacement): 

710 raise InvalidCodepoint( 

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

712 code="uts46_std3", 

713 text=domain, 

714 codepoint=code_point, 

715 position=pos + 1, 

716 ) 

717 output.append(replacement) 

718 start = pos + 1 

719 

720 if start == 0: 

721 if std3_rules: 

722 _check_std3(domain, domain, 0) 

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

724 tail = domain[start:] 

725 if std3_rules: 

726 _check_std3(tail, domain, start) 

727 output.append(tail) 

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

729 

730 

731def encode( 

732 s: str | bytes | bytearray, 

733 strict: bool = False, 

734 uts46: bool = False, 

735 std3_rules: bool = False, 

736 transitional: bool = False, 

737) -> bytes: 

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

739 

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

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

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

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

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

745 

746 :param s: The domain name to encode. 

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

748 separator. 

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

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

751 ``True``. 

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

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

754 future version. 

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

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

757 or exceeds the maximum domain length. 

758 """ 

759 if transitional: 

760 _warn_transitional() 

761 if not isinstance(s, str): 

762 try: 

763 s = str(s, "ascii") 

764 except (UnicodeDecodeError, TypeError) as err: 

765 raise IDNAError( 

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

767 ) from err 

768 if len(s) > _max_input_length: 

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

770 if uts46: 

771 s = uts46_remap(s, std3_rules) 

772 

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

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

775 

776 trailing_dot = False 

777 result = [] 

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

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

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

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

782 del labels[-1] 

783 trailing_dot = True 

784 for label in labels: 

785 s = alabel(label) 

786 if s: 

787 result.append(s) 

788 else: 

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

790 if trailing_dot: 

791 result.append(b"") 

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

793 if not valid_string_length(s, trailing_dot): 

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

795 return s 

796 

797 

798def decode( 

799 s: str | bytes | bytearray, 

800 strict: bool = False, 

801 uts46: bool = False, 

802 std3_rules: bool = False, 

803 display: bool = False, 

804) -> str: 

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

806 

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

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

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

810 :func:`uts46_remap`. 

811 

812 :param s: The domain name to decode. 

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

814 separator. 

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

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

817 ``True``. 

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

819 validation is passed through unchanged (lowercased) rather than 

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

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

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

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

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

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

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

827 invalid label, or is empty. 

828 """ 

829 if not isinstance(s, str): 

830 try: 

831 s = str(s, "ascii") 

832 except (UnicodeDecodeError, TypeError) as err: 

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

834 if len(s) > _max_input_length: 

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

836 if uts46: 

837 s = uts46_remap(s, std3_rules, False) 

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

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

840 trailing_dot = False 

841 result = [] 

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

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

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

845 if not labels[-1]: 

846 del labels[-1] 

847 trailing_dot = True 

848 for label in labels: 

849 try: 

850 u = ulabel(label) 

851 except IDNAError: 

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

853 u = label.lower() 

854 else: 

855 raise 

856 if u: 

857 result.append(u) 

858 else: 

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

860 if trailing_dot: 

861 result.append("") 

862 return ".".join(result)