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]