Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/turbohtml/clean/_linkify.py: 82%

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

248 statements  

1""" 

2Turn URLs, email addresses and phone numbers in HTML into links, the way bleach.linkify did. 

3 

4bleach is the only library that shipped an HTML-aware linkifier, and it is end of life, so this is its replacement. 

5The typed layer compiles the options and parses the input with turbohtml's WHATWG tree builder. The C core snapshots 

6eligible text and anchors, scans each text run, invokes callbacks in document order, and mutates the tree. Text inside 

7an existing ``<a>``, a raw-text element (``<script>``/``<style>``), or a caller's ``skip_tags`` stays unchanged. 

8""" 

9 

10from __future__ import annotations 

11 

12import copy 

13import functools 

14from collections.abc import Mapping 

15from collections.abc import Set as AbstractSet 

16from dataclasses import dataclass, field 

17from enum import Enum 

18from typing import TYPE_CHECKING, Final, TypeAlias, TypeVar, cast 

19 

20from turbohtml._html import ( 

21 _linkify_apply, 

22 _linkify_find, 

23 _linkify_fold, 

24 _linkify_has, 

25 _linkify_nofollow, 

26 _linkify_target_blank, 

27 _phone_config_compile, 

28 _phone_e164, 

29 _phone_number_check, 

30 _phone_number_format, 

31 _phone_parse, 

32 _phone_regions, 

33 parse_fragment, 

34) 

35 

36if TYPE_CHECKING: 

37 from collections.abc import Callable, Iterable 

38 from typing import Self 

39 

40 from turbohtml._html import Node, _PhoneConfig 

41 

42_NODE_T = TypeVar("_NODE_T", bound="Node") 

43 

44# The ``scheme://host`` schemes autolinked when a config registers none: the fixed set linkify-it recognizes, so a typo 

45# scheme or a ``javascript://`` payload stays plain text. A ``Linkify.schemes`` restricts to its own set (bleach), while 

46# a ``LinkDetector``'s ``schemes`` extends this one; the low-level scanner without an allowlist stays permissive. 

47_DEFAULT_URL_SCHEMES: Final = ("ftp", "http", "https") 

48 

49 

50class PhoneType(Enum): 

51 """The line type a numbering plan assigns a number; ``UNKNOWN`` appears only in possible mode.""" 

52 

53 FIXED_LINE = "fixed_line" 

54 MOBILE = "mobile" 

55 FIXED_LINE_OR_MOBILE = "fixed_line_or_mobile" 

56 TOLL_FREE = "toll_free" 

57 PREMIUM_RATE = "premium_rate" 

58 SHARED_COST = "shared_cost" 

59 VOIP = "voip" 

60 PERSONAL_NUMBER = "personal_number" 

61 PAGER = "pager" 

62 UAN = "uan" 

63 VOICEMAIL = "voicemail" 

64 UNKNOWN = "unknown" 

65 

66 

67class PhoneFormat(Enum): 

68 """The ways :meth:`PhoneNumber.format` writes a number.""" 

69 

70 E164 = "e164" 

71 """``+16502530000``: the calling code and national significant number, no separators and no extension.""" 

72 INTERNATIONAL = "international" 

73 """``+1 650-253-0000``: the calling code, then the national number in its plan's grouping.""" 

74 NATIONAL = "national" 

75 """``(650) 253-0000``: the national number as dialed within its region, national prefix included.""" 

76 RFC3966 = "rfc3966" 

77 """``tel:+1-650-253-0000``: the RFC 3966 URI, hyphens between groups and ``;ext=`` for an extension.""" 

78 

79 

80_PHONE_FORMATS: Final = (PhoneFormat.E164, PhoneFormat.INTERNATIONAL, PhoneFormat.NATIONAL, PhoneFormat.RFC3966) 

81 

82 

83class PhoneGrouping(Enum): 

84 """How closely the written digit groups must follow the number's format.""" 

85 

86 ANY = "any" 

87 """The groups may fall anywhere.""" 

88 STRICT = "strict" 

89 """Each group of the number's format, or of an alternate format of its calling code, occurs in the text in order; 

90 ``415 6667777`` passes, ``41 566 67777`` does not.""" 

91 EXACT = "exact" 

92 """The written groups are the format's groups, or the whole national number unbroken; ``415 6667777`` does not 

93 pass.""" 

94 

95 

96_PHONE_GROUPINGS: Final = (PhoneGrouping.ANY, PhoneGrouping.STRICT, PhoneGrouping.EXACT) 

97# PhoneType members in the order the C recognizer numbers them (enum th_phone_type). 

98_PHONE_TYPES: Final = ( 

99 PhoneType.FIXED_LINE, 

100 PhoneType.MOBILE, 

101 PhoneType.TOLL_FREE, 

102 PhoneType.PREMIUM_RATE, 

103 PhoneType.SHARED_COST, 

104 PhoneType.PERSONAL_NUMBER, 

105 PhoneType.VOIP, 

106 PhoneType.PAGER, 

107 PhoneType.UAN, 

108 PhoneType.VOICEMAIL, 

109 PhoneType.FIXED_LINE_OR_MOBILE, 

110 PhoneType.UNKNOWN, 

111) 

112 

113#: Words that mark the digits right after them as an identifier rather than a phone number. 

114DEFAULT_PHONE_LABELS: Final = ( 

115 "account", 

116 "acct", 

117 "asin", 

118 "bic", 

119 "doi", 

120 "ean", 

121 "iban", 

122 "invoice", 

123 "isbn", 

124 "issn", 

125 "order", 

126 "postcode", 

127 "ref", 

128 "reference", 

129 "serial", 

130 "sku", 

131 "ticket", 

132 "tracking", 

133 "upc", 

134 "vat", 

135 "zip", 

136) 

137 

138 

139@dataclass(frozen=True, slots=True, init=False) 

140class PhoneNumbers: 

141 """ 

142 Phone-number detection settings for :class:`Linkify` and :class:`LinkDetector`. 

143 

144 ``regions`` are the ordered fallback regions for a number written without ``+`` (empty finds ``+`` numbers only); 

145 order decides which national reading wins, so any ordered iterable of ``str`` is accepted (a list, a tuple, a 

146 ``deque``, a generator, a caller's own iterable) and a bare string, a set or a mapping is a ``TypeError``, a 

147 runtime rule the ``Iterable[str]`` annotation cannot express. ``ignore_numbers_after`` are the words that mark 

148 the digits right after them as an identifier, not a number, as far as the groups joined without whitespace reach: 

149 ``Order 12345`` and ``Order 650-253-0000`` are skipped, the phone in ``Order 12345, 650-253-0000`` is still found, 

150 and ``Phone no. 650-253-0000`` links because ``no`` is not in the default list. Words are ASCII letters, compared 

151 case-folded against the word immediately before the digits and stored sorted; ``()`` disables the rule. 

152 

153 :param regions: the ordered fallback regions, ISO 3166-1 alpha-2 codes (``"US"``). 

154 :param require_valid: link only numbers the region's numbering plan assigns; False links every number of a 

155 possible length, with type ``UNKNOWN`` and possibly no region. 

156 :param require_separators: a bare digit run with no ``+``, separators or international prefix is not a number. 

157 :param skip_card_numbers: a Luhn-valid payment-card shape is not a number. 

158 :param require_national_prefix: a number written without ``+`` must carry the national prefix its number format 

159 writes (``20 7946 0958`` is not a British number, ``020 7946 0958`` is); False links it the way people dial 

160 locally. Applies with ``require_valid``. 

161 :param collapse_whitespace: read a run of HTML whitespace (tab, newline, form feed, carriage return, space) as 

162 the single space it renders as, so a number a source formatter broke across a line still reads as one. 

163 :param grouping: how closely the written digit groups must follow the number's format. Needs ``require_valid``. 

164 :param types: link only numbers of these resolved types; ``None`` links every type. Needs ``require_valid``. 

165 :param ignore_numbers_after: words that mark the digits right after them as an identifier. 

166 """ 

167 

168 regions: tuple[str, ...] 

169 require_valid: bool 

170 require_separators: bool 

171 skip_card_numbers: bool 

172 require_national_prefix: bool 

173 collapse_whitespace: bool 

174 grouping: PhoneGrouping 

175 types: frozenset[PhoneType] | None 

176 ignore_numbers_after: tuple[str, ...] 

177 _config: _PhoneConfig = field(init=False, repr=False, compare=False) 

178 

179 def __init__( # ruff:ignore[too-many-arguments] # one keyword per setting, the dataclass field list 

180 self, 

181 *, 

182 regions: Iterable[str] = (), 

183 require_valid: bool = True, 

184 require_separators: bool = False, 

185 skip_card_numbers: bool = True, 

186 require_national_prefix: bool = True, 

187 collapse_whitespace: bool = False, 

188 grouping: PhoneGrouping = PhoneGrouping.ANY, 

189 types: Iterable[PhoneType] | None = None, 

190 ignore_numbers_after: Iterable[str] = DEFAULT_PHONE_LABELS, 

191 ) -> None: 

192 """Raise on any mistake here, never at scan time.""" 

193 for name, flag in ( 

194 ("require_valid", require_valid), 

195 ("require_separators", require_separators), 

196 ("skip_card_numbers", skip_card_numbers), 

197 ("require_national_prefix", require_national_prefix), 

198 ("collapse_whitespace", collapse_whitespace), 

199 ): 

200 if not isinstance(flag, bool): 

201 msg = f"{name} must be bool" 

202 raise TypeError(msg) 

203 if not isinstance(grouping, PhoneGrouping): 

204 msg = "grouping must be a PhoneGrouping" 

205 raise TypeError(msg) 

206 if grouping is not PhoneGrouping.ANY and not require_valid: 

207 msg = "grouping needs require_valid=True" 

208 raise ValueError(msg) 

209 wanted = None 

210 if types is not None: 

211 wanted = frozenset(types) 

212 if not wanted or any(not isinstance(member, PhoneType) for member in wanted): 

213 msg = "types must be a non-empty iterable of PhoneType members" 

214 raise TypeError(msg) if wanted else ValueError(msg) 

215 if PhoneType.UNKNOWN in wanted: 

216 msg = "types cannot include PhoneType.UNKNOWN; it is what possible mode reports" 

217 raise ValueError(msg) 

218 if not require_valid: 

219 msg = "types needs require_valid=True" 

220 raise ValueError(msg) 

221 object.__setattr__(self, "regions", _phone_regions(_ordered_strings(regions, "regions"))) 

222 object.__setattr__(self, "require_valid", require_valid) 

223 object.__setattr__(self, "require_separators", require_separators) 

224 object.__setattr__(self, "skip_card_numbers", skip_card_numbers) 

225 object.__setattr__(self, "require_national_prefix", require_national_prefix) 

226 object.__setattr__(self, "collapse_whitespace", collapse_whitespace) 

227 object.__setattr__(self, "grouping", grouping) 

228 object.__setattr__(self, "types", wanted) 

229 object.__setattr__( 

230 self, 

231 "ignore_numbers_after", 

232 _linkify_fold(_ordered_strings(ignore_numbers_after, "ignore_numbers_after"), "ignore_numbers_after", 3), 

233 ) 

234 # compiled once here, where a mistake raises, and handed to every scanner and parse that takes the settings 

235 object.__setattr__(self, "_config", _compile_settings(self, PhoneNumber)) 

236 

237 def __reduce__(self) -> tuple[Callable[[], PhoneNumbers], tuple[()]]: 

238 # the compiled configuration is a native object, so a copy or a pickle rebuilds from the settings 

239 return functools.partial( 

240 PhoneNumbers, 

241 regions=self.regions, 

242 require_valid=self.require_valid, 

243 require_separators=self.require_separators, 

244 skip_card_numbers=self.skip_card_numbers, 

245 require_national_prefix=self.require_national_prefix, 

246 collapse_whitespace=self.collapse_whitespace, 

247 grouping=self.grouping, 

248 types=self.types, 

249 ignore_numbers_after=self.ignore_numbers_after, 

250 ), () 

251 

252 

253def _ordered_strings(values: Iterable[str], name: str) -> tuple[str, ...]: 

254 """Reject a bare string, a set or a mapping: it has no order to preserve.""" 

255 if isinstance(values, (str, bytes, AbstractSet, Mapping)): 

256 msg = f"{name} must be an ordered iterable of str, not {type(values).__name__}" 

257 raise TypeError(msg) 

258 items = tuple(values) 

259 if any(not isinstance(item, str) for item in items): 

260 msg = f"{name} entries must be str" 

261 raise TypeError(msg) 

262 return items 

263 

264 

265@dataclass(frozen=True, slots=True) 

266class PhoneNumber: 

267 """ 

268 A detected number, in the form the tables produced it. 

269 

270 ``international_number`` is ``+`` followed by the country code and the national significant number with no 

271 separators and no extension, the E.164 layout and, when it fits E.164, the value the ``tel:`` href carries. 

272 ``e164`` is the same string when it fits ITU E.164's 15-digit limit and ``None`` otherwise: the pinned metadata 

273 declares longer valid national services (DE, ID, JP, KR, NG and UY, up to 19 digits with the country code), and 

274 those link with RFC 3966's local form, ``tel:200000000000000;phone-context=+49``, since a global ``tel:`` number 

275 must be E.164; an extension goes first among the parameters, as the RFC orders them. 

276 

277 :param country_code: the calling code, ``1`` for ``+1``. 

278 :param national_number: the national significant number, ASCII digits with the leading zeros the plan keeps. 

279 :param extension: the extension digits, or ``None``. 

280 :param region: the region whose plan assigns the number (``"US"``, ``"001"`` for a non-geographic code), or 

281 ``None`` when possible mode could not tell. 

282 :param type: the resolved line type. 

283 """ 

284 

285 country_code: int 

286 national_number: str 

287 extension: str | None 

288 region: str | None 

289 type: PhoneType 

290 

291 def __post_init__(self) -> None: 

292 """Reject a value the tables never produce.""" 

293 if type(self.country_code) is not int or type(self.national_number) is not str: 

294 msg = "country_code must be int and national_number must be str" 

295 raise TypeError(msg) 

296 if (self.extension is not None and type(self.extension) is not str) or ( 

297 self.region is not None and type(self.region) is not str 

298 ): 

299 msg = "extension and region must be str or None" 

300 raise TypeError(msg) 

301 if not isinstance(self.type, PhoneType): 

302 msg = "type must be a PhoneType" 

303 raise TypeError(msg) 

304 if self.extension is not None and not ( 

305 0 < len(self.extension) <= 20 and self.extension.isascii() and self.extension.isdigit() 

306 ): 

307 msg = "extension must be 1-20 ASCII digits" 

308 raise ValueError(msg) 

309 _phone_number_check(self.country_code, self.national_number, self.region, _PHONE_TYPES.index(self.type)) 

310 

311 @classmethod 

312 def parse(cls, text: str, *, regions: Iterable[str] = (), require_valid: bool = True) -> Self: 

313 """ 

314 Read a string that holds one phone number. 

315 

316 The number starts at the first ``+`` or digit, so a ``tel:`` scheme or a word before it (``Tel: 650-253-0000``) 

317 is skipped; characters that are neither digits, letters nor ``#`` are dropped from the end, and a second 

318 number after ``/x`` is cut off. The remainder must be digits, separators and ASCII letters, with an extension 

319 in any written form at the end, the auto-dialling ``650-253-0000,,1234`` and ``650-253-0000;1234`` included. 

320 Three or more letters make a vanity number whose letters are keypad digits (``1-800-FLOWERS``); fewer are 

321 dropped. An RFC 3966 local number reads through its ``phone-context`` (``tel:2530000;phone-context=+1650``), 

322 and ``;isub=`` ends the number. A number written without ``+`` is read with each of ``regions`` in turn, and 

323 unlike detection it needs neither separators nor the national prefix its format writes, and a payment-card 

324 shape is not refused. A string over 250 characters is no number. 

325 

326 :param text: the text holding the number. 

327 :param regions: the ordered fallback regions for a number written without ``+``. 

328 :param require_valid: the number must be one the plan assigns; False accepts any possible length. 

329 :returns: the number. 

330 :raises ValueError: when the text does not hold one such number. 

331 """ 

332 if not isinstance(text, str): 

333 msg = "text must be str" 

334 raise TypeError(msg) 

335 if ( 

336 number := _phone_parse( 

337 _parse_config(cls, _ordered_strings(regions, "regions"), require_valid=require_valid), text 

338 ) 

339 ) is None: 

340 msg = f"{text!r} is not a phone number" 

341 raise ValueError(msg) 

342 return cast("Self", number) # the native factory built cls, which the binding's annotation cannot say 

343 

344 @classmethod 

345 def _from_native(cls, *fields: object) -> PhoneNumber: 

346 # the recognizer resolved (country_code, national_number, extension, region, type) from the same tables the 

347 # check would consult, so the frozen instance is filled without it 

348 number = object.__new__(cls) 

349 for name, value in zip(("country_code", "national_number", "extension", "region", "type"), fields, strict=True): 

350 object.__setattr__(number, name, value) # ruff:ignore[unnecessary-dunder-call] # bypasses the frozen guard 

351 return number 

352 

353 @property 

354 def international_number(self) -> str: 

355 """The ``+`` number with no separators, what the ``tel:`` href carries.""" 

356 return f"+{self.country_code}{self.national_number}" 

357 

358 @property 

359 def e164(self) -> str | None: 

360 """The international number when it fits E.164's fifteen digits, else ``None``.""" 

361 return _phone_e164(self.country_code, self.national_number) 

362 

363 def format(self, style: PhoneFormat = PhoneFormat.INTERNATIONAL) -> str: 

364 """ 

365 Write the number in one of the four layouts. 

366 

367 A number past E.164's fifteen digits takes RFC 3966's local form (``tel:200000000000000;phone-context=+49``) 

368 in :attr:`PhoneFormat.RFC3966`, since a global ``tel:`` number must be E.164. 

369 

370 The grouping comes from the number formats of the calling code's main region (``+1`` numbers group the 

371 North American way whatever their region), chosen by the number's leading digits and length; a number no 

372 format covers is written as its bare national significant number. An extension follows the region's 

373 preferred marker (``ext.`` by default, ``;ext=`` for :attr:`PhoneFormat.RFC3966`) and is left out of 

374 :attr:`PhoneFormat.E164`. 

375 

376 :param style: the layout to write. 

377 :returns: the formatted number, ASCII. 

378 """ 

379 if not isinstance(style, PhoneFormat): 

380 msg = "style must be a PhoneFormat" 

381 raise TypeError(msg) 

382 return _phone_number_format( 

383 self.country_code, self.national_number, self.extension, _PHONE_FORMATS.index(style) 

384 ) 

385 

386 

387@functools.lru_cache(maxsize=32) 

388def _parse_config(number_type: type[PhoneNumber], regions: tuple[str, ...], *, require_valid: bool) -> _PhoneConfig: 

389 # keyed on the raw regions so a repeated parse call pays a cache lookup, not a settings object and its compile 

390 return _compile_settings( 

391 PhoneNumbers( 

392 regions=regions, 

393 require_valid=require_valid, 

394 skip_card_numbers=False, 

395 require_national_prefix=False, 

396 ignore_numbers_after=(), 

397 ), 

398 number_type, 

399 parsing=True, 

400 ) 

401 

402 

403def _compile_settings(phones: PhoneNumbers, number_type: type[PhoneNumber], *, parsing: bool = False) -> _PhoneConfig: 

404 return _phone_config_compile(( 

405 phones.regions, 

406 phones.require_valid, 

407 phones.require_separators, 

408 phones.skip_card_numbers, 

409 phones.require_national_prefix, 

410 phones.collapse_whitespace, 

411 _PHONE_GROUPINGS.index(phones.grouping), 

412 phones.types, 

413 phones.ignore_numbers_after, 

414 number_type, 

415 _PHONE_TYPES, 

416 parsing, 

417 )) 

418 

419 

420class LinkCandidate: 

421 """ 

422 A link handed to each callback to mutate or veto. 

423 

424 A callback returns the link to keep it, or ``None`` to drop the anchor: a detected link stays plain text, an 

425 existing one is unwrapped to its contents. 

426 

427 :param url: the link's ``href``. 

428 :param text: the visible link text the reader sees. 

429 :param attrs: extra attributes to put on the ``<a>`` (``rel``, ``target``, ``class``, ...). 

430 :param existing: True when reprocessing an ``<a>`` already in the input, False for a freshly detected link. 

431 :param phone: the detected number when the link is a phone number found in plain text; ``None`` for a URL, an 

432 email, and every existing anchor whatever its ``href``. 

433 """ 

434 

435 __slots__ = ("attrs", "existing", "phone", "text", "url") 

436 

437 def __init__( 

438 self, 

439 url: str, 

440 text: str, 

441 attrs: dict[str, str] | None = None, 

442 *, 

443 existing: bool = False, 

444 phone: PhoneNumber | None = None, 

445 ) -> None: 

446 """Create a link.""" 

447 self.url = url 

448 self.text = text 

449 self.attrs = attrs if attrs is not None else {} 

450 self.existing = existing 

451 self.phone = phone 

452 

453 

454# A callback receives the generated :class:`LinkCandidate` and returns it to keep the link, or ``None`` to leave the 

455# text bare. 

456Callback: TypeAlias = "Callable[[LinkCandidate], LinkCandidate | None]" 

457 

458 

459#: The callbacks linkify applies when a caller passes none, matching bleach's default. 

460def nofollow(link: LinkCandidate) -> LinkCandidate: 

461 """ 

462 Add ``rel="nofollow"`` to a web link so search engines skip it, leaving ``mailto:`` and other links alone. 

463 

464 :param link: the link to adjust. 

465 :returns: the link, with ``nofollow`` added when it is a web link; never ``None``. 

466 """ 

467 return _linkify_nofollow(link) 

468 

469 

470def target_blank(link: LinkCandidate) -> LinkCandidate: 

471 """ 

472 Open a web link in a new tab, stripping a stale ``target`` from a non-web link so it cannot leak through. 

473 

474 :param link: the link to adjust. 

475 :returns: the link, with ``target`` set on a web link or cleared on a non-web link; never ``None``. 

476 """ 

477 return _linkify_target_blank(link) 

478 

479 

480DEFAULT_CALLBACKS: Final = (nofollow,) 

481 

482 

483@dataclass(frozen=True) 

484class Linkify: 

485 """ 

486 An immutable, thread-safe description of how :func:`linkify` and :class:`Linker` find and rewrite links. 

487 

488 Build one and reuse it across threads. 

489 

490 :param callbacks: callables run on each detected link to adjust or veto it (defaults to ``DEFAULT_CALLBACKS``). 

491 :param skip_tags: tags whose text is left untouched, such as ``pre`` and ``code``. 

492 :param parse_email: also autolink email addresses as ``mailto:`` links, bare or already written as a 

493 ``mailto:`` URI. 

494 :param process_existing: run the callbacks over ``<a>`` tags already present, not only freshly detected links. 

495 :param extra_tlds: top-level domains that make a bare domain a link, on top of the built-in IANA table. 

496 :param schemes: the exact set of ``scheme://`` URL schemes that autolink; ``None`` keeps the built-in 

497 ``http``/``https``/``ftp`` default, so a typo scheme or a ``javascript://`` payload stays plain text. A bare 

498 domain is always treated as ``http`` and is governed by the TLD table, not ``schemes``. 

499 :param phones: also link phone numbers as ``tel:`` links, per these :class:`PhoneNumbers` settings; ``None`` 

500 leaves digits alone. A ``tel:`` URI already written in the text links as itself. 

501 """ 

502 

503 callbacks: Iterable[Callback] = DEFAULT_CALLBACKS 

504 skip_tags: Iterable[str] | None = None 

505 parse_email: bool = False 

506 process_existing: bool = False 

507 extra_tlds: Iterable[str] | None = None 

508 schemes: Iterable[str] | None = None 

509 phones: PhoneNumbers | None = None 

510 

511 

512class Linker: 

513 """ 

514 A reusable linkifier; build it once from a :class:`Linkify` configuration and call :meth:`linkify` per document. 

515 

516 :param options: the configuration to apply; None uses ``DEFAULT_CALLBACKS`` and detects nothing else. 

517 """ 

518 

519 def __init__(self, options: Linkify | None = None) -> None: 

520 """Compile a configuration into the form the walk consumes.""" 

521 config = options if options is not None else Linkify() 

522 self.callbacks = tuple(config.callbacks) 

523 self.skip_tags = _linkify_fold(config.skip_tags, "skip_tags", 0) if config.skip_tags is not None else () 

524 self.parse_email = config.parse_email 

525 self.process_existing = config.process_existing 

526 self.extra_tlds = _linkify_fold(config.extra_tlds, "extra_tlds", 0) if config.extra_tlds else () 

527 self.url_schemes = ( 

528 _linkify_fold(config.schemes, "schemes", 0) if config.schemes is not None else _DEFAULT_URL_SCHEMES 

529 ) 

530 self.phones = config.phones 

531 self._phone_config = _compile_phones(config.phones) 

532 

533 def linkify(self, text: str | Node) -> str: 

534 """ 

535 Linkify HTML, leaving everything but eligible text runs untouched. 

536 

537 :param text: the HTML to linkify, or an already parsed node (see :meth:`linkify_node`), which skips the parse 

538 and links a copy so the node is left as it was. 

539 :returns: the linkified HTML. 

540 """ 

541 root = parse_fragment(text) if isinstance(text, str) else copy.deepcopy(text) 

542 return self.linkify_node(root).inner_html 

543 

544 def linkify_node(self, node: _NODE_T) -> _NODE_T: 

545 """ 

546 Linkify an already parsed subtree in place and return the node, so it chains. 

547 

548 This is the tree form for a pipeline that parses once, runs several transforms, and serializes once. The 

549 walk is the one :meth:`linkify` runs after parsing: it links only in text the reader sees, never inside an 

550 existing ``<a>``, a raw-text element, or a tag in ``Linkify.skip_tags``, and it rewrites the tree under the 

551 document's lock on the free-threaded build. 

552 

553 :param node: the element or document whose text runs to link. 

554 :returns: the same node, now holding the links. 

555 :raises TypeError: if ``node`` is not a node. 

556 """ 

557 if isinstance(node, str): 

558 msg = "linkify_node takes a parsed node; pass a str to linkify instead" 

559 raise TypeError(msg) 

560 _linkify_apply( 

561 node, 

562 self.callbacks, 

563 self.parse_email, 

564 self.extra_tlds, 

565 self.url_schemes, 

566 self.process_existing, 

567 self.skip_tags, 

568 LinkCandidate, 

569 self._phone_config, 

570 ) 

571 return node 

572 

573 

574def linkify(text: str | Node, options: Linkify | None = None) -> str: 

575 """ 

576 Find URLs, email addresses and phone numbers in HTML and wrap them in ``<a>`` links. 

577 

578 Existing markup is left untouched. 

579 

580 :param text: the HTML to linkify, or an already parsed node (see :func:`linkify_node`), which skips the parse and 

581 links a copy so the node is left as it was. 

582 :param options: the configuration to apply; None uses ``DEFAULT_CALLBACKS`` and detects nothing else. 

583 :returns: the linkified HTML. 

584 """ 

585 return Linker(options).linkify(text) 

586 

587 

588def linkify_node(node: _NODE_T, options: Linkify | None = None) -> _NODE_T: 

589 """ 

590 Find URLs, email addresses and phone numbers in an already parsed subtree and wrap them in ``<a>`` links, in place. 

591 

592 The tree form for a pipeline that parses once and serializes once; see :meth:`Linker.linkify_node`. 

593 

594 :param node: the element or document whose text runs to link. 

595 :param options: the configuration to apply; None uses ``DEFAULT_CALLBACKS`` and detects nothing else. 

596 :returns: the same node, now holding the links. 

597 :raises TypeError: if ``node`` is not a node. 

598 """ 

599 return Linker(options).linkify_node(node) 

600 

601 

602class LinkSpan: 

603 """ 

604 One URL, email address or phone number found in a run of plain text. 

605 

606 :param start: the half-open start offset of the match in the scanned text. 

607 :param end: the half-open end offset of the match in the scanned text. 

608 :param text: the matched substring exactly as it appeared. 

609 :param url: the normalized ``href`` (``mailto:`` for an email, ``http://`` for a bare domain, the number's own 

610 ``tel:`` URI for a phone number and for a written ``tel:`` URI, the text itself for a ``scheme://`` or 

611 registered scheme-less URL). 

612 :param is_email: whether the match is an email address. 

613 :param phone: the detected number when the match is a phone number, else ``None``. 

614 """ 

615 

616 __slots__ = ("end", "is_email", "phone", "start", "text", "url") 

617 

618 def __init__( # ruff:ignore[too-many-arguments] # the five fields are the span's positional contract 

619 self, 

620 start: int, 

621 end: int, 

622 text: str, 

623 url: str, 

624 is_email: bool, # ruff:ignore[boolean-type-hint-positional-argument] # the pre-existing positional contract 

625 *, 

626 phone: PhoneNumber | None = None, 

627 ) -> None: 

628 """Create a link span.""" 

629 self.start = start 

630 self.end = end 

631 self.text = text 

632 self.url = url 

633 self.is_email = is_email 

634 self.phone = phone 

635 

636 def __repr__(self) -> str: 

637 """Render the span with its offsets and url, the way a debugger or a failing test wants to see it.""" 

638 phone = f", phone={self.phone!r}" if self.phone is not None else "" 

639 return f"LinkSpan(start={self.start}, end={self.end}, text={self.text!r}, url={self.url!r}{phone})" 

640 

641 def __eq__(self, other: object) -> bool: 

642 """Two spans are equal when every field matches; comparing to a non-span defers to the other operand.""" 

643 if not isinstance(other, LinkSpan): 

644 return NotImplemented 

645 return (self.start, self.end, self.text, self.url, self.is_email, self.phone) == ( 

646 other.start, 

647 other.end, 

648 other.text, 

649 other.url, 

650 other.is_email, 

651 other.phone, 

652 ) 

653 

654 __hash__ = None # a span carries offsets into one specific string, so it is not a stable dict key 

655 

656 

657def _span_from_match(text: str, span: tuple[int, int, int, str, PhoneNumber | None, bool]) -> LinkSpan: 

658 start, end, _kind, url, phone, is_email = span 

659 return LinkSpan(start, end, text[start:end], url, is_email, phone=phone) 

660 

661 

662class LinkDetector: 

663 """ 

664 Find the links in plain text, configured once and reused per call. 

665 

666 Unlike :class:`Linker`, which rewrites HTML, a detector only *locates* links and hands back :class:`LinkSpan` 

667 objects, leaving the text untouched. 

668 

669 :param emails: detect email addresses, bare or written as a ``mailto:`` URI; the URI's span covers its own 

670 scheme, so it links as one whole. 

671 :param bare_domains: detect bare domains (``example.com``) with no explicit scheme. A host behind a written 

672 scheme needs no dot, so ``http://localhost:8000/`` and ``http://[::1]/`` are links either way. 

673 :param tlds: custom top-level domains accepted for bare-domain matching, on top of the IANA table. 

674 :param schemes: extra schemes to detect, both as scheme-less opaque URLs (``tel:``, ``bitcoin:``) and as 

675 ``scheme://`` authority URLs, on top of the built-in ``http``/``https``/``ftp`` set; an unregistered scheme 

676 such as ``javascript://`` or a typo like ``hppt://`` is not detected. 

677 :param phones: also detect phone numbers, per these :class:`PhoneNumbers` settings; ``None`` leaves digits 

678 alone. With phones on, a written ``tel:`` or ``tel://`` URI whose payload reads as a number is a phone link 

679 too: the span covers the URI as written and carries the number, whose own ``tel:`` URI is the ``url``. 

680 """ 

681 

682 def __init__( 

683 self, 

684 *, 

685 emails: bool = True, 

686 bare_domains: bool = True, 

687 tlds: Iterable[str] = (), 

688 schemes: Iterable[str] = (), 

689 phones: PhoneNumbers | None = None, 

690 ) -> None: 

691 """Build a reusable detector.""" 

692 self.emails = emails 

693 self.bare_domains = bare_domains 

694 self.phones = phones 

695 self._phone_config = _compile_phones(phones) 

696 self._tlds = _linkify_fold(tlds, "tlds", 1) 

697 self._registered = _linkify_fold(schemes, "schemes", 2) 

698 self._schemes = self._registered if phones is None else _linkify_fold((*self._registered, "tel"), "schemes", 0) 

699 self._url_schemes = _linkify_fold((*_DEFAULT_URL_SCHEMES, *self._registered), "schemes", 0) 

700 

701 def __reduce__(self) -> tuple[Callable[[], LinkDetector], tuple[()]]: 

702 # the compiled phone configuration is a native object, so a copy or a pickle rebuilds from the settings 

703 return functools.partial( 

704 LinkDetector, 

705 emails=self.emails, 

706 bare_domains=self.bare_domains, 

707 tlds=self._tlds, 

708 schemes=self._registered, 

709 phones=self.phones, 

710 ), () 

711 

712 def find(self, text: str, *, unique: bool = False) -> list[LinkSpan]: 

713 """ 

714 Find every link in a run of text. 

715 

716 :param text: the text to scan. 

717 :param unique: keep only the first span of each distinct ``url``, so text repeating one address yields it 

718 once; offsets then point at that first occurrence. 

719 :returns: every link as a :class:`LinkSpan`, in the order it appears. 

720 """ 

721 return [ 

722 _span_from_match(text, span) 

723 for span in _linkify_find( 

724 text, 

725 self.emails, 

726 self.bare_domains, 

727 self._tlds, 

728 self._schemes, 

729 self._url_schemes, 

730 self._phone_config, 

731 unique, 

732 ) 

733 ] 

734 

735 def has_link(self, text: str) -> bool: 

736 """ 

737 Test a run of text for any link, cheaper than :meth:`find` when only presence matters. 

738 

739 :param text: the text to scan. 

740 :returns: whether the text contains at least one link. 

741 """ 

742 return _linkify_has( 

743 text, self.emails, self.bare_domains, self._tlds, self._schemes, self._url_schemes, self._phone_config 

744 ) 

745 

746 

747def _compile_phones(phones: PhoneNumbers | None) -> _PhoneConfig | None: 

748 if phones is None: 

749 return None 

750 if not isinstance(phones, PhoneNumbers): 

751 msg = "phones must be PhoneNumbers or None" 

752 raise TypeError(msg) 

753 return phones._config # ruff:ignore[private-member-access] # the settings compiled themselves 

754 

755 

756__all__ = [ 

757 "DEFAULT_CALLBACKS", 

758 "DEFAULT_PHONE_LABELS", 

759 "Callback", 

760 "LinkCandidate", 

761 "LinkDetector", 

762 "LinkSpan", 

763 "Linker", 

764 "Linkify", 

765 "PhoneFormat", 

766 "PhoneGrouping", 

767 "PhoneNumber", 

768 "PhoneNumbers", 

769 "PhoneType", 

770 "linkify", 

771 "linkify_node", 

772 "nofollow", 

773 "target_blank", 

774]