spot-sdk-python 2.0.0b13__tar.gz → 2.1.0b1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b1}/PKG-INFO +2 -2
  2. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b1}/README.md +1 -1
  3. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b1}/pyproject.toml +1 -1
  4. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b1}/pyproject.toml.orig +1 -1
  5. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b1}/spot_sdk/email.py +16 -6
  6. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b1}/spot_sdk/mime.py +168 -57
  7. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b1}/spot_sdk/__init__.py +0 -0
  8. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b1}/spot_sdk/_json.py +0 -0
  9. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b1}/spot_sdk/app.py +0 -0
  10. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b1}/spot_sdk/artifacts.py +0 -0
  11. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b1}/spot_sdk/clients.py +0 -0
  12. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b1}/spot_sdk/config.py +0 -0
  13. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b1}/spot_sdk/correlation.py +0 -0
  14. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b1}/spot_sdk/errors.py +0 -0
  15. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b1}/spot_sdk/knowledge.py +0 -0
  16. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b1}/spot_sdk/knowledge_tags.py +0 -0
  17. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b1}/spot_sdk/llm.py +0 -0
  18. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b1}/spot_sdk/logging.py +0 -0
  19. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b1}/spot_sdk/manifest.py +0 -0
  20. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b1}/spot_sdk/model_input.py +0 -0
  21. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b1}/spot_sdk/orchestrator.py +0 -0
  22. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b1}/spot_sdk/proxy.py +0 -0
  23. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b1}/spot_sdk/py.typed +0 -0
  24. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b1}/spot_sdk/retriever.py +0 -0
  25. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b1}/spot_sdk/settings.py +0 -0
  26. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b1}/spot_sdk/signals.py +0 -0
  27. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b1}/spot_sdk/testing/README.md +0 -0
  28. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b1}/spot_sdk/testing/__init__.py +0 -0
  29. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b1}/spot_sdk/testing/contract.py +0 -0
  30. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b1}/spot_sdk/testing/fake_knowledge_client.py +0 -0
  31. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b1}/spot_sdk/testing/fake_llm.py +0 -0
  32. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b1}/spot_sdk/testing/fake_spot.py +0 -0
  33. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b1}/spot_sdk/testing/settings.py +0 -0
  34. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b1}/spot_sdk/testing/views.py +0 -0
  35. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b1}/spot_sdk/urls.py +0 -0
  36. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b1}/spot_sdk/verdict.py +0 -0
  37. {spot_sdk_python-2.0.0b13 → spot_sdk_python-2.1.0b1}/spot_sdk/workflow.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spot-sdk-python
3
- Version: 2.0.0b13
3
+ Version: 2.1.0b1
4
4
  Summary: SPOT plugin SDK: the plugin contract, the plugin runtime, its clients and test helpers
5
5
  Author: SPOT Project
6
6
  Author-email: SPOT Project <spot@sonn.lu>
@@ -50,7 +50,7 @@ plugins share:
50
50
  ## Install
51
51
 
52
52
  ```bash
53
- pip install spot-sdk-python==2.0.0b13
53
+ pip install spot-sdk-python==2.0.0
54
54
  ```
55
55
 
56
56
  Python 3.11 to 3.15. Version 2 implements plugin contract 2, for
@@ -26,7 +26,7 @@ plugins share:
26
26
  ## Install
27
27
 
28
28
  ```bash
29
- pip install spot-sdk-python==2.0.0b13
29
+ pip install spot-sdk-python==2.0.0
30
30
  ```
31
31
 
32
32
  Python 3.11 to 3.15. Version 2 implements plugin contract 2, for
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "spot-sdk-python"
7
- version = "2.0.0b13"
7
+ version = "2.1.0b1"
8
8
  description = "SPOT plugin SDK: the plugin contract, the plugin runtime, its clients and test helpers"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "spot-sdk-python"
7
- version = "2.0.0b13"
7
+ version = "2.1.0b1"
8
8
  description = "SPOT plugin SDK: the plugin contract, the plugin runtime, its clients and test helpers"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -26,9 +26,14 @@ class Address(BaseModel):
26
26
  default="",
27
27
  description="As browsers read a host (WHATWG URL Standard): UTS 46-mapped"
28
28
  " (lower-cased...), A-labels decoded, no trailing dots; one that can be"
29
- " no DNS name only lower-cased: over 1 012 characters or a label over 252,"
30
- " invisible ones aside (DNS allows 253 and 63, and NFC composes at most"
31
- " 4 code points into one)",
29
+ " no DNS name only lower-cased: over 1 012 characters, a label over 252"
30
+ " or over 63 combining marks in a row as UTS 46 maps them, invisible ones"
31
+ " aside (DNS allows"
32
+ " 253 and 63, NFC composes at most 4 code points into one, and an"
33
+ " A-label spells each mark with a character at least); one whose"
34
+ " A-labels not yet decoded do not fit in the 32 768 characters of"
35
+ " distinct A-labels a message decodes keeps all its A-labels encoded, as"
36
+ " UTS 46 maps them (host_limit)",
32
37
  )
33
38
 
34
39
 
@@ -83,9 +88,14 @@ class Url(BaseModel):
83
88
  " reads it: percent-decoded, UTS 46-mapped (lower-cased...), A-labels"
84
89
  " decoded when UTS 46 leaves them as they are, no trailing dots; one"
85
90
  " browsers refuse is kept as read, one that can be no DNS name only"
86
- " lower-cased: over 1 012 characters or a label over 252, invisible ones"
87
- " aside (DNS allows 253 and 63, and NFC composes at most 4 code points"
88
- ' into one); "" when none'
91
+ " lower-cased: over 1 012 characters, a label over 252 or over 63"
92
+ " combining marks in a row as UTS 46 maps them, invisible ones aside (DNS"
93
+ " allows 253 and 63,"
94
+ " NFC composes at most 4 code points into one, and an A-label spells"
95
+ " each mark with a character at least); one whose A-labels not yet"
96
+ " decoded do not fit in the 32 768 characters of distinct A-labels a"
97
+ " message decodes keeps all its A-labels encoded, as UTS 46 maps them"
98
+ ' (host_limit); "" when none'
89
99
  )
90
100
  found_in: Literal["text", "html_href", "html_text", "header"] = Field(
91
101
  description="html_text: written in the HTML's visible text, not in an attribute"
@@ -18,10 +18,12 @@ Anyone can send a message, so the parser never fails on its content:
18
18
  - the work is bounded. A part keeps at most ``MAX_HEADERS`` headers, at most
19
19
  ``MAX_PARTS`` parts are read, nesting deeper than ``MAX_DEPTH`` is not, a
20
20
  boundary longer than ``MAX_BOUNDARY_LENGTH`` is malformed, text, HTML and
21
- header values are cut at ``max_text_bytes``, and at most ``MAX_URLS`` URLs
22
- are kept. Each bound hit is an anomaly; the ones that leave content out set
23
- ``EmailView.truncated``. At most ``MAX_ANOMALIES`` anomalies are recorded,
24
- ``MAX_ANOMALIES_PER_CODE`` of each code, but always the first of each;
21
+ header values are cut at ``max_text_bytes``, at most ``MAX_URLS`` URLs are
22
+ kept, and the hosts decode at most ``MAX_A_LABEL_CHARACTERS`` characters of
23
+ distinct A-labels. Each bound hit is an anomaly; the ones that leave content
24
+ out set ``EmailView.truncated``. At most ``MAX_ANOMALIES`` anomalies are
25
+ recorded, ``MAX_ANOMALIES_PER_CODE`` of each code, but always the first of
26
+ each;
25
27
  - the view is bounded: its JSON takes at most ``MAX_VIEW_BYTES``, the sum of
26
28
  the budgets of its lists and long strings (see :func:`parse`).
27
29
 
@@ -87,6 +89,12 @@ MAX_BOUNDARY_LENGTH = 1_000
87
89
  MAX_ANOMALIES = 1_000
88
90
  MAX_ANOMALIES_PER_CODE = 100
89
91
  MAX_URLS = 10_000
92
+ # The characters of distinct A-labels a message's hosts decode: Python decodes
93
+ # Punycode a character at a time, and a message whose links each held other
94
+ # A-labels took half the CPU bound (sdk#28). Each A-label is decoded once a
95
+ # message; 32 768 characters are 520 A-labels of the longest, 63 characters,
96
+ # and decoding them takes about 60 ms.
97
+ MAX_A_LABEL_CHARACTERS = 1 << 15
90
98
  # The view's budgets, in bytes of its JSON as model_dump_json(by_alias=True)
91
99
  # writes it: UTF-8, escapes included. A list keeps its first entries within
92
100
  # its budget (the headers each one that fits), a string its start; each cut is
@@ -181,7 +189,12 @@ HTML_HIDDEN_TEXT = "html_hidden_text"
181
189
  """Text a reader may see was left out of visible_text: hidden by an inline style,
182
190
  the hidden attribute or the browser's own style (a closed <dialog>, a
183
191
  <datalist>), in SVG outside its text elements, or inside <head>, <iframe>,
184
- <noembed>, <noframes> or <template>."""
192
+ <noembed>, <noframes> or <template>. The detail is how many characters it
193
+ hid, counted as visible_text counts its own (the characters that render as
194
+ nothing removed, whitespace collapsed; not the separators between its nodes
195
+ and lines), over all the part's hidden text; the text itself is never in the
196
+ view. There is no detail when some hidden text could not be counted, a
197
+ template whose content is not read: the amount is unknown."""
185
198
  TRANSFER_ENCODING_JUNK = "transfer_encoding_junk"
186
199
  """A base64 body goes on after padding, which decoders read differently. The
187
200
  data ends at padding that ends a quantum, as RFC 2045 allows, and the rest is
@@ -209,6 +222,13 @@ BIDI_CONTROL = "bidi_control"
209
222
  to U+202E, U+2066 to U+2069), which reorder what a reader sees: the
210
223
  right-to-left-override file name. Noted where INVISIBLE_CHARACTERS is, with how
211
224
  many as the detail; such a string gets both notes."""
225
+ HOST_LIMIT = "host_limit"
226
+ """The message's hosts hold more than MAX_A_LABEL_CHARACTERS characters of
227
+ distinct A-labels, read in the view's order (the from, reply_to, to and cc
228
+ domains, then the URLs, the first entry a list leaves out included): a host
229
+ whose A-labels not yet decoded do not fit in what is left keeps all its
230
+ A-labels encoded, as UTS 46 maps them. The other hosts are read as url_host
231
+ reads them, the From domain always."""
212
232
  ANOMALY_LIMIT = "anomaly_limit"
213
233
  """More than MAX_ANOMALIES anomalies were met: the later ones are not recorded."""
214
234
  ANOMALY_CODES = (
@@ -231,6 +251,7 @@ ANOMALY_CODES = (
231
251
  URL_LIMIT,
232
252
  INVISIBLE_CHARACTERS,
233
253
  BIDI_CONTROL,
254
+ HOST_LIMIT,
234
255
  ANOMALY_LIMIT,
235
256
  )
236
257
  """Every anomaly code, in the order above."""
@@ -306,6 +327,9 @@ _HIDDEN_TAGS = frozenset(
306
327
  # Leaving out a script, a style or the title is ordinary; leaving out other
307
328
  # text hides what a reader may see.
308
329
  _ORDINARY_HIDDEN_TAGS = frozenset({"script", "style", "title"})
330
+ # Elements whose text the pattern reader leaves out, though it hides text a
331
+ # reader may see: that text cannot be counted by pattern.
332
+ _UNREAD_BY_PATTERN = re.compile(r"<(?:iframe|noembed|noframes)\b", re.IGNORECASE)
309
333
  # Template content is parsed again from its serialisation, only in documents
310
334
  # with at most this many template tags: Lexbor serialises nested templates
311
335
  # recursively, about 100 bytes of stack each, and 1 500 crashed a thread with
@@ -635,11 +659,13 @@ def parse(
635
659
  nodes, left_out = _walk(root, budget=len(raw))
636
660
  _note_ambiguous_boundaries(nodes)
637
661
  fields, cut_headers = _fields(root, max_text_bytes)
662
+ punycode = _Punycode() # the A-labels of the addresses' and URLs' hosts
638
663
  read, cut_from = _header_fields(
639
664
  root.firsts,
640
665
  fields,
641
666
  {authserv_id.lower() for authserv_id in trusted_authserv_ids},
642
667
  max_text_bytes,
668
+ punycode,
643
669
  )
644
670
  given = {"source": _Cutter(), "envelope": _Cutter()}
645
671
  source = given["source"](source)
@@ -671,24 +697,26 @@ def parse(
671
697
  text = bodies.get("text/plain")
672
698
  html = bodies.get("text/html")
673
699
  cut_urls = _Cutter()
674
- found = [_header_urls(fields, cut_urls)] # the URLs, built as they are kept
700
+ # The URLs, built as they are kept.
701
+ found = [_header_urls(fields, cut_urls, punycode)]
675
702
  shown: _Body | None = None # the body the visible text is read from
676
703
  visible_text = ""
677
704
  if text:
678
- found.append(_urls_in(text.content, "text", text.part_id, cut_urls))
705
+ found.append(_urls_in(text.content, "text", text.part_id, cut_urls, punycode))
679
706
  seen = _visible_body(text, text.content)
680
707
  if seen.strip():
681
708
  shown, visible_text = text, seen
682
709
  if html:
683
710
  reader = _read_html(html.content)
684
- found.append(_html_urls(reader.links, html.part_id, cut_urls))
711
+ found.append(_html_urls(reader.links, html.part_id, cut_urls, punycode))
685
712
  html_text = reader.text
686
- found.append(_urls_in(html_text, "html_text", html.part_id, cut_urls))
713
+ found.append(_urls_in(html_text, "html_text", html.part_id, cut_urls, punycode))
687
714
  if shown is None:
688
715
  # Removing them may leave runs of spaces and empty lines.
689
716
  shown, visible_text = html, _lines(_visible_body(html, html_text))
690
717
  if reader.hidden_text:
691
- html.part.note(HTML_HIDDEN_TEXT)
718
+ amount = [] if reader.uncounted else [str(reader.hidden_characters)]
719
+ html.part.note(HTML_HIDDEN_TEXT, None, *amount)
692
720
  if reader.limit:
693
721
  html.part.note(HTML_LIMIT, None, reader.limit)
694
722
  if reader.unresolved:
@@ -718,6 +746,8 @@ def parse(
718
746
  )
719
747
  if left_out_url is not None or cut_urls.cut:
720
748
  notes.append((_Note(URL_LIMIT), "1"))
749
+ if punycode.spent:
750
+ notes.append((_Note(HOST_LIMIT), "1"))
721
751
  notes += [
722
752
  (_Note(code, f"url:{index}", (detail,)), None)
723
753
  for index, url in enumerate(urls)
@@ -1557,11 +1587,42 @@ def _unquote(value: str) -> str:
1557
1587
  return _QUOTED_PAIR.sub(r"\1", quoted.group(1)) if quoted else value
1558
1588
 
1559
1589
 
1560
- def _domain(host: str) -> str:
1590
+ class _Punycode:
1591
+ """The A-labels a message's hosts decode (``_decoded``): each once, and
1592
+ ``MAX_A_LABEL_CHARACTERS`` characters of them in all. A host whose
1593
+ A-labels not yet decoded do not fit in what is left keeps all its labels
1594
+ as UTS 46 maps them, and ``spent`` says one did."""
1595
+
1596
+ def __init__(self) -> None:
1597
+ self.left = MAX_A_LABEL_CHARACTERS
1598
+ self.spent = False
1599
+ self._read: dict[str, str] = {} # each A-label decoded, as read
1600
+
1601
+ def host(self, mapped: str) -> str:
1602
+ """``mapped``, a host as UTS 46 maps it (``_mapped_host``), with its
1603
+ A-labels decoded; as it is when the A-labels not yet decoded do not fit
1604
+ in what is left. Its labels are split at full stops, as UTS 46 splits a
1605
+ mapped domain."""
1606
+ if "xn--" not in mapped: # no A-label: a repeated host costs no loop
1607
+ return mapped
1608
+ labels = mapped.split(".")
1609
+ new = {
1610
+ label for label in labels if _is_a_label(label) and label not in self._read
1611
+ }
1612
+ size = sum(map(len, new))
1613
+ if size > self.left:
1614
+ self.spent = True
1615
+ return mapped
1616
+ self.left -= size
1617
+ self._read.update((label, _decoded(label)) for label in new)
1618
+ return ".".join(self._read.get(label, label) for label in labels)
1619
+
1620
+
1621
+ def _domain(host: str, punycode: _Punycode) -> str:
1561
1622
  """``host`` as browsers read it, as the WHATWG URL Standard has them do,
1562
1623
  without its trailing dots: mapped as UTS 46 maps it (case, width, the
1563
- characters it ignores...), each A-label decoded (``_decoded``). A label
1564
- UTS 46 does not map stays as written, lower-cased.
1624
+ characters it ignores...), each A-label decoded (``_decoded``) within
1625
+ ``punycode``. A label UTS 46 does not map stays as written, lower-cased.
1565
1626
 
1566
1627
  A host that can be no DNS name (``_can_be_dns_name``) reaches no
1567
1628
  resolver: it is only lower-cased, neither mapped nor decoded. That bounds
@@ -1573,32 +1634,38 @@ def _domain(host: str) -> str:
1573
1634
  a label against the Bidi rule, or with a joiner out of context, is read
1574
1635
  all the same.
1575
1636
 
1576
- A host of at most ``_MAX_DOMAIN`` characters is read once while it stays
1577
- among the last 256 the process read: a hostile message repeats one host
1637
+ A host of at most ``_MAX_DOMAIN`` characters is mapped once while it stays
1638
+ among the last 256 the process mapped: a hostile message repeats one host
1578
1639
  up to MAX_URLS times (sdk#27). UTS 46 maps a character to at most 18, so
1579
- the cache holds 19 MiB at most. A longer host is read each time: it is a
1640
+ the cache holds 19 MiB at most. A longer host is mapped each time: it is a
1580
1641
  DNS name only once its invisible characters or its trailing dots are
1581
- removed."""
1582
- if len(host) > _MAX_DOMAIN:
1583
- return _read_domain(host)
1584
- return _cached_domain(host)
1642
+ removed. Its A-labels are decoded by ``punycode``, once a message: the
1643
+ cache cannot bound a message whose hosts each hold other A-labels
1644
+ (sdk#28)."""
1645
+ mapped = (
1646
+ _mapped_host(host) if len(host) > _MAX_DOMAIN else _cached_mapped_host(host)
1647
+ )
1648
+ if mapped is None:
1649
+ return host.rstrip(_DOT_CHARACTERS).lower()
1650
+ return punycode.host(mapped)
1585
1651
 
1586
1652
 
1587
- def _read_domain(host: str) -> str:
1588
- """``host`` read as ``_domain`` reads it, each time."""
1653
+ def _mapped_host(host: str) -> str | None:
1654
+ """``host`` as ``_domain`` maps it, each time, its A-labels not yet decoded;
1655
+ None when it can be no DNS name."""
1589
1656
  host = host.rstrip(_DOT_CHARACTERS)
1590
1657
  if not _can_be_dns_name(host):
1591
- return host.lower()
1658
+ return None
1592
1659
  try:
1593
1660
  labels = _remapped(host).split(".")
1594
1661
  except ValueError: # a code point UTS 46 does not map: each label alone
1595
1662
  labels = [_mapped(label) for label in _DOTS.split(host)]
1596
1663
  while labels and not labels[-1]:
1597
1664
  labels.pop()
1598
- return ".".join(map(_decoded, labels))
1665
+ return ".".join(labels)
1599
1666
 
1600
1667
 
1601
- _cached_domain = functools.lru_cache(maxsize=256)(_read_domain)
1668
+ _cached_mapped_host = functools.lru_cache(maxsize=256)(_mapped_host)
1602
1669
 
1603
1670
 
1604
1671
  def _can_be_dns_name(host: str) -> bool:
@@ -1652,27 +1719,29 @@ def _remapped(text: str) -> str:
1652
1719
  return unicodedata.normalize("NFC", "".join(pieces))
1653
1720
 
1654
1721
 
1722
+ def _is_a_label(label: str) -> bool:
1723
+ """Whether a mapped label is an A-label a resolver may reach: ``xn--``, and
1724
+ ``_MAX_A_LABEL`` characters at most. A longer one is a DNS label no
1725
+ resolver reaches: it stays as it is. That bounds Python's Punycode
1726
+ decoding, quadratic in the label."""
1727
+ return label.startswith("xn--") and len(label) <= _MAX_A_LABEL
1728
+
1729
+
1655
1730
  def _decoded(label: str) -> str:
1656
- """A mapped label as browsers show it: an A-label decoded from Punycode
1657
- when what it decodes to is a label UTS 46 leaves as it is (its own
1658
- criterion: valid code points, in NFC), and not ASCII alone; any other
1659
- label as it is.
1731
+ """A mapped A-label (``_is_a_label``) as browsers show it: decoded from
1732
+ Punycode when what it decodes to is a label UTS 46 leaves as it is (its
1733
+ own criterion: valid code points, in NFC), and not ASCII alone; else as
1734
+ it is.
1660
1735
 
1661
1736
  So an A-label that decodes to what UTS 46 would change stays encoded: a
1662
1737
  fullwidth or a capital letter, a soft hyphen, a full stop, a code point
1663
1738
  it does not map. Mapping it would read a dead A-label as another host,
1664
1739
  a brand's own (``xn--mi7ca7anb7c`` is a fullwidth ``paypal``).
1665
1740
 
1666
- An A-label over ``_MAX_A_LABEL`` characters is a DNS label no resolver
1667
- reaches: it stays as it is. That bounds Python's Punycode decoding,
1668
- quadratic in the label.
1669
-
1670
1741
  There is no other check: IDNA 2008's CONTEXTO rules rescanned a label for
1671
1742
  each code point they concern, and UTS 46's joiner checks took 70 us a
1672
1743
  joiner on idna 3.20, so hostile labels cost seconds a message.
1673
1744
  """
1674
- if not label.startswith("xn--") or len(label) > _MAX_A_LABEL:
1675
- return label
1676
1745
  try:
1677
1746
  decoded = label[4:].encode("ascii").decode("punycode")
1678
1747
  if (
@@ -1685,9 +1754,10 @@ def _decoded(label: str) -> str:
1685
1754
  return label
1686
1755
 
1687
1756
 
1688
- def _addresses(value: str, cut: _Cutter) -> Iterator[Address]:
1689
- """The mailboxes of an address header, each string cut to its budget; a
1690
- value that does not parse is kept whole."""
1757
+ def _addresses(value: str, cut: _Cutter, punycode: _Punycode) -> Iterator[Address]:
1758
+ """The mailboxes of an address header, each string cut to its budget, each
1759
+ domain's A-labels decoded within ``punycode``; a value that does not parse
1760
+ is kept whole."""
1691
1761
  mailboxes = getaddresses([value])
1692
1762
  if not mailboxes or not all("@" in address for _, address in mailboxes):
1693
1763
  value = value.strip()
@@ -1698,7 +1768,7 @@ def _addresses(value: str, cut: _Cutter) -> Iterator[Address]:
1698
1768
  yield Address(
1699
1769
  display_name=cut(_decode_words(name)),
1700
1770
  address=cut(address),
1701
- domain=cut(_domain(address.rpartition("@")[2])),
1771
+ domain=cut(_domain(address.rpartition("@")[2], punycode)),
1702
1772
  )
1703
1773
 
1704
1774
 
@@ -1729,12 +1799,14 @@ def _header_fields(
1729
1799
  fields: list[tuple[str, str]],
1730
1800
  trusted: set[str],
1731
1801
  limit: int,
1802
+ punycode: _Punycode,
1732
1803
  ) -> tuple[_HeaderFields, list[str]]:
1733
1804
  """The view's fields read from the first header of each name (``firsts``,
1734
1805
  the whole header block's, each value cut at ``limit`` bytes), and the
1735
1806
  authentication results from the trusted headers on top: within their
1736
1807
  budgets. Each string is cut to ``_STRING_BYTES``, the subject to
1737
- ``_SUBJECT_BYTES``, and each list to ``_LIST_BYTES``.
1808
+ ``_SUBJECT_BYTES``, and each list to ``_LIST_BYTES``; the addresses'
1809
+ A-labels are decoded within ``punycode``.
1738
1810
 
1739
1811
  Also the headers a field was cut from, as ``header:`` subjects.
1740
1812
  """
@@ -1750,7 +1822,7 @@ def _header_fields(
1750
1822
  return first.get(name, ("", ""))[1]
1751
1823
 
1752
1824
  def addresses(name: str) -> list[Address]:
1753
- return cuts[name].listed(_addresses(header(name), cuts[name]))
1825
+ return cuts[name].listed(_addresses(header(name), cuts[name], punycode))
1754
1826
 
1755
1827
  def message_id(name: str) -> str | None:
1756
1828
  return cuts[name](header(name).strip()) or None
@@ -1758,7 +1830,7 @@ def _header_fields(
1758
1830
  authentication, authentication_cut = _authentication(fields, trusted)
1759
1831
  read = _HeaderFields(
1760
1832
  subject=cuts["subject"](_decode_words(header("subject")), _SUBJECT_BYTES),
1761
- from_=next(_addresses(header("from"), cuts["from"]), None),
1833
+ from_=next(_addresses(header("from"), cuts["from"], punycode), None),
1762
1834
  reply_to=addresses("reply-to"),
1763
1835
  to=addresses("to"),
1764
1836
  cc=addresses("cc"),
@@ -1933,6 +2005,12 @@ def _lines(text: str) -> str:
1933
2005
  return "\n".join(line for line in lines if line)
1934
2006
 
1935
2007
 
2008
+ def _shown_length(text: str) -> int:
2009
+ """The characters ``text`` adds to ``visible_text``: those that render as
2010
+ nothing removed, its whitespace collapsed."""
2011
+ return len(" ".join(visible(text).split()))
2012
+
2013
+
1936
2014
  def url_host(text: str) -> str:
1937
2015
  """The host a browser would reach for ``text``; "" when there is none: the
1938
2016
  host ``parse`` records in ``Url.host``. A backslash reads as a slash, and
@@ -1958,11 +2036,17 @@ def url_host(text: str) -> str:
1958
2036
  character NFKC turns into a delimiter (U+2100 is ``a/c``), in time
1959
2037
  quadratic in a run of combining marks.
1960
2038
  """
2039
+ # A host alone holds far fewer A-label characters than a message decodes.
2040
+ return _url_host(text, _Punycode())
2041
+
2042
+
2043
+ def _url_host(text: str, punycode: _Punycode) -> str:
2044
+ """``url_host(text)``, its A-labels decoded within ``punycode``."""
1961
2045
  if text[:4].lower() == "www.":
1962
2046
  text = "http://" + text
1963
2047
  # Browsers read a backslash as a slash in web URLs.
1964
2048
  host = _hostname(text.replace("\\", "/"))
1965
- return _domain(_clean(unquote_url(host))) if host else ""
2049
+ return _domain(_clean(unquote_url(host)), punycode) if host else ""
1966
2050
 
1967
2051
 
1968
2052
  def _hostname(url: str) -> str | None:
@@ -2022,13 +2106,15 @@ def _url(
2022
2106
  found_in: Literal["text", "html_href", "html_text", "header"],
2023
2107
  part_id: str | None,
2024
2108
  cut: _Cutter,
2109
+ punycode: _Punycode,
2025
2110
  anchor_text: str | None = None,
2026
2111
  ) -> Url:
2027
2112
  """A URL as the view records it: cut to ``_URL_BYTES``, its host (of the
2028
- whole URL) and anchor text to ``_STRING_BYTES``."""
2113
+ whole URL, its A-labels decoded within ``punycode``) and anchor text to
2114
+ ``_STRING_BYTES``."""
2029
2115
  return Url(
2030
2116
  url=cut(url, _URL_BYTES),
2031
- host=cut(url_host(url)),
2117
+ host=cut(_url_host(url, punycode)),
2032
2118
  found_in=found_in,
2033
2119
  part_id=part_id,
2034
2120
  anchor_text=cut.optional(anchor_text),
@@ -2036,14 +2122,21 @@ def _url(
2036
2122
 
2037
2123
 
2038
2124
  def _urls_in(
2039
- text: str, found_in: Literal["text", "html_text"], part_id: str, cut: _Cutter
2125
+ text: str,
2126
+ found_in: Literal["text", "html_text"],
2127
+ part_id: str,
2128
+ cut: _Cutter,
2129
+ punycode: _Punycode,
2040
2130
  ) -> Iterator[Url]:
2041
2131
  """The ``http(s)://`` and ``www.`` URLs written in a text."""
2042
2132
  for match in _URL.finditer(text):
2043
- yield _url(match.group().rstrip(_URL_TRAILER), found_in, part_id, cut)
2133
+ url = match.group().rstrip(_URL_TRAILER)
2134
+ yield _url(url, found_in, part_id, cut, punycode)
2044
2135
 
2045
2136
 
2046
- def _header_urls(fields: list[tuple[str, str]], cut: _Cutter) -> Iterator[Url]:
2137
+ def _header_urls(
2138
+ fields: list[tuple[str, str]], cut: _Cutter, punycode: _Punycode
2139
+ ) -> Iterator[Url]:
2047
2140
  """The URLs of the List-Unsubscribe headers (RFC 2369)."""
2048
2141
  for name, value in fields:
2049
2142
  if name.lower() != "list-unsubscribe":
@@ -2051,13 +2144,15 @@ def _header_urls(fields: list[tuple[str, str]], cut: _Cutter) -> Iterator[Url]:
2051
2144
  for item in _ANGLE_BRACKETS.findall(value):
2052
2145
  url = "".join(item.split())
2053
2146
  if url:
2054
- yield _url(url, "header", None, cut)
2147
+ yield _url(url, "header", None, cut, punycode)
2055
2148
 
2056
2149
 
2057
- def _html_urls(links: list[_Link], part_id: str, cut: _Cutter) -> Iterator[Url]:
2150
+ def _html_urls(
2151
+ links: list[_Link], part_id: str, cut: _Cutter, punycode: _Punycode
2152
+ ) -> Iterator[Url]:
2058
2153
  """The URLs of an HTML part's attributes."""
2059
2154
  for link in links:
2060
- yield _url(link.url, "html_href", part_id, cut, link.anchor_text)
2155
+ yield _url(link.url, "html_href", part_id, cut, punycode, link.anchor_text)
2061
2156
 
2062
2157
 
2063
2158
  @dataclass(slots=True)
@@ -2117,19 +2212,27 @@ class _HtmlReader:
2117
2212
  the text of ``<textarea>``, ``<xmp>`` and ``<plaintext>`` as raw markup, so
2118
2213
  it is visible. Mail clients run no script, so ``<noscript>`` content is
2119
2214
  visible too. Stylesheets are not evaluated. Leaving out text other than a
2120
- script, a style or the title sets ``hidden_text``; URLs in hidden elements
2121
- are still collected. A hiding property whose value is known only when
2122
- rendering hides nothing, and is added to ``unresolved``.
2215
+ script, a style or the title sets ``hidden_text``, and adds what it holds
2216
+ to ``hidden_characters``, counted as ``visible_text`` counts. URLs in
2217
+ hidden elements are still collected. A hiding property whose value is
2218
+ known only when rendering hides nothing, and is added to ``unresolved``.
2123
2219
 
2124
2220
  Lexbor keeps a template's content out of the tree, so it is parsed again
2125
2221
  from the template's serialisation: when the document has at most
2126
2222
  ``_MAX_TEMPLATES`` template tags, and for the document's length in all.
2127
- Otherwise, a template's content is not read, and is taken to hide text.
2223
+ Otherwise, a template's content is not read, and is taken to hide text,
2224
+ which is not counted: ``uncounted``. The content of a template over the
2225
+ HTML budget once serialised is counted from its text read by pattern,
2226
+ which errs high when a ``<textarea>`` makes the rest read as raw text;
2227
+ with an ``<iframe>``, ``<noembed>`` or ``<noframes>``, whose text that
2228
+ reader leaves out, it is ``uncounted`` too.
2128
2229
  """
2129
2230
 
2130
2231
  def __init__(self, html: str) -> None:
2131
2232
  self.links: list[_Link] = []
2132
2233
  self.hidden_text = False
2234
+ self.hidden_characters = 0
2235
+ self.uncounted = False # hidden text that could not be counted
2133
2236
  self.unresolved: set[str] = set() # properties known only when rendering
2134
2237
  self._chunks: list[str] = []
2135
2238
  self._anchors: list[list[str]] = [] # the text of each open link
@@ -2212,7 +2315,9 @@ class _HtmlReader:
2212
2315
 
2213
2316
  def _text(self, text: str, style: _Style) -> None:
2214
2317
  if style.hides:
2215
- self.hidden_text |= not style.ordinary and bool(text.strip())
2318
+ if not style.ordinary and text.strip():
2319
+ self.hidden_text = True
2320
+ self.hidden_characters += _shown_length(text)
2216
2321
  else:
2217
2322
  # Only block elements break lines; a newline in the source is a space.
2218
2323
  self._emit(text.replace("\n", " "))
@@ -2224,7 +2329,7 @@ class _HtmlReader:
2224
2329
 
2225
2330
  def _template_content(self, template: LexborNode) -> list[LexborNode]:
2226
2331
  if self._budget <= 0:
2227
- self.hidden_text = True
2332
+ self.hidden_text = self.uncounted = True
2228
2333
  return []
2229
2334
  html = template.html or ""
2230
2335
  # Lexbor escapes ">" in attribute values: the first one ends the start tag.
@@ -2232,6 +2337,12 @@ class _HtmlReader:
2232
2337
  self._budget -= len(content)
2233
2338
  if _html_budget(content):
2234
2339
  self.hidden_text = True
2340
+ if _UNREAD_BY_PATTERN.search(content):
2341
+ self.uncounted = True
2342
+ else:
2343
+ text = _read_by_pattern(content)[0]
2344
+ # A line is a block: its characters, as a text node's.
2345
+ self.hidden_characters += sum(map(_shown_length, text.split("\n")))
2235
2346
  return []
2236
2347
  return _top_nodes(content)
2237
2348