spot-sdk-python 2.0.0b12__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 (38) hide show
  1. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/PKG-INFO +12 -14
  2. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/README.md +1 -1
  3. spot_sdk_python-2.1.0b1/pyproject.toml +52 -0
  4. spot_sdk_python-2.1.0b1/pyproject.toml.orig +43 -0
  5. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/email.py +16 -6
  6. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/knowledge.py +3 -2
  7. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/mime.py +296 -61
  8. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/workflow.py +9 -1
  9. spot_sdk_python-2.0.0b12/pyproject.toml +0 -38
  10. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/__init__.py +0 -0
  11. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/_json.py +0 -0
  12. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/app.py +0 -0
  13. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/artifacts.py +0 -0
  14. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/clients.py +0 -0
  15. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/config.py +0 -0
  16. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/correlation.py +0 -0
  17. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/errors.py +0 -0
  18. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/knowledge_tags.py +0 -0
  19. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/llm.py +0 -0
  20. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/logging.py +0 -0
  21. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/manifest.py +0 -0
  22. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/model_input.py +0 -0
  23. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/orchestrator.py +0 -0
  24. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/proxy.py +0 -0
  25. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/py.typed +0 -0
  26. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/retriever.py +0 -0
  27. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/settings.py +0 -0
  28. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/signals.py +0 -0
  29. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/testing/README.md +0 -0
  30. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/testing/__init__.py +0 -0
  31. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/testing/contract.py +0 -0
  32. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/testing/fake_knowledge_client.py +0 -0
  33. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/testing/fake_llm.py +0 -0
  34. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/testing/fake_spot.py +0 -0
  35. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/testing/settings.py +0 -0
  36. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/testing/views.py +0 -0
  37. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/urls.py +0 -0
  38. {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/verdict.py +0 -0
@@ -1,25 +1,24 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spot-sdk-python
3
- Version: 2.0.0b12
3
+ Version: 2.1.0b1
4
4
  Summary: SPOT plugin SDK: the plugin contract, the plugin runtime, its clients and test helpers
5
- License: Apache-2.0
6
5
  Author: SPOT Project
7
- Author-email: spot@sonn.lu
8
- Requires-Python: >=3.11,<3.16
9
- Classifier: License :: OSI Approved :: Apache Software License
6
+ Author-email: SPOT Project <spot@sonn.lu>
7
+ License-Expression: Apache-2.0
10
8
  Classifier: Programming Language :: Python :: 3
11
9
  Classifier: Programming Language :: Python :: 3.11
12
10
  Classifier: Programming Language :: Python :: 3.12
13
11
  Classifier: Programming Language :: Python :: 3.13
14
12
  Classifier: Programming Language :: Python :: 3.14
15
13
  Classifier: Programming Language :: Python :: 3.15
16
- Requires-Dist: fastapi (>=0.133.0,<1)
17
- Requires-Dist: httpx2 (>=2.13.1,<3)
18
- Requires-Dist: idna (>=3.11,<4)
19
- Requires-Dist: pydantic (>=2.10,<3.0)
20
- Requires-Dist: pydantic-settings (>=2.0.0,<3.0.0)
21
- Requires-Dist: selectolax (>=1.0,<1.1)
22
- Requires-Dist: starlette (>=1.7.0,<2)
14
+ Requires-Dist: pydantic>=2.10,<3
15
+ Requires-Dist: pydantic-settings>=2.0.0,<3
16
+ Requires-Dist: httpx2>=2.13.1,<3
17
+ Requires-Dist: selectolax>=1.0,<1.1
18
+ Requires-Dist: idna>=3.11,<4
19
+ Requires-Dist: fastapi>=0.133.0,<1
20
+ Requires-Dist: starlette>=1.7.0,<2
21
+ Requires-Python: >=3.11, <3.16
23
22
  Project-URL: Homepage, https://sonn.lu
24
23
  Description-Content-Type: text/markdown
25
24
 
@@ -51,7 +50,7 @@ plugins share:
51
50
  ## Install
52
51
 
53
52
  ```bash
54
- pip install spot-sdk-python==2.0.0b12
53
+ pip install spot-sdk-python==2.0.0
55
54
  ```
56
55
 
57
56
  Python 3.11 to 3.15. Version 2 implements plugin contract 2, for
@@ -116,4 +115,3 @@ token=...)`.
116
115
  ## License
117
116
 
118
117
  Apache License 2.0.
119
-
@@ -26,7 +26,7 @@ plugins share:
26
26
  ## Install
27
27
 
28
28
  ```bash
29
- pip install spot-sdk-python==2.0.0b12
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
@@ -0,0 +1,52 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.12.22,<0.13"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "spot-sdk-python"
7
+ version = "2.1.0b1"
8
+ description = "SPOT plugin SDK: the plugin contract, the plugin runtime, its clients and test helpers"
9
+ readme = "README.md"
10
+ license = "Apache-2.0"
11
+ requires-python = ">=3.11,<3.16"
12
+ classifiers = [
13
+ "Programming Language :: Python :: 3",
14
+ "Programming Language :: Python :: 3.11",
15
+ "Programming Language :: Python :: 3.12",
16
+ "Programming Language :: Python :: 3.13",
17
+ "Programming Language :: Python :: 3.14",
18
+ "Programming Language :: Python :: 3.15",
19
+ ]
20
+ dependencies = [
21
+ "pydantic>=2.10,<3",
22
+ "pydantic-settings>=2.0.0,<3",
23
+ "httpx2>=2.13.1,<3",
24
+ "selectolax>=1.0,<1.1",
25
+ "idna>=3.11,<4",
26
+ "fastapi>=0.133.0,<1",
27
+ "starlette>=1.7.0,<2",
28
+ ]
29
+
30
+ [[project.authors]]
31
+ name = "SPOT Project"
32
+ email = "spot@sonn.lu"
33
+
34
+ [project.urls]
35
+ Homepage = "https://sonn.lu"
36
+
37
+ [tool.uv.build-backend]
38
+ module-name = "spot_sdk"
39
+ module-root = ""
40
+
41
+ [tool.ruff]
42
+ target-version = "py311"
43
+
44
+ [tool.ruff.lint]
45
+ select = [
46
+ "E",
47
+ "F",
48
+ "I",
49
+ "N",
50
+ "W",
51
+ ]
52
+ ignore = ["E501"]
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.12.22,<0.13"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "spot-sdk-python"
7
+ version = "2.1.0b1"
8
+ description = "SPOT plugin SDK: the plugin contract, the plugin runtime, its clients and test helpers"
9
+ readme = "README.md"
10
+ license = "Apache-2.0"
11
+ authors = [{ name = "SPOT Project", email = "spot@sonn.lu" }]
12
+ requires-python = ">=3.11,<3.16"
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3.13",
18
+ "Programming Language :: Python :: 3.14",
19
+ "Programming Language :: Python :: 3.15",
20
+ ]
21
+ dependencies = [
22
+ "pydantic>=2.10,<3",
23
+ "pydantic-settings>=2.0.0,<3",
24
+ "httpx2>=2.13.1,<3",
25
+ "selectolax>=1.0,<1.1",
26
+ "idna>=3.11,<4",
27
+ "fastapi>=0.133.0,<1",
28
+ "starlette>=1.7.0,<2",
29
+ ]
30
+
31
+ [project.urls]
32
+ Homepage = "https://sonn.lu"
33
+
34
+ [tool.uv.build-backend]
35
+ module-name = "spot_sdk"
36
+ module-root = ""
37
+
38
+ [tool.ruff]
39
+ target-version = "py311"
40
+
41
+ [tool.ruff.lint]
42
+ select = ["E", "F", "I", "N", "W"]
43
+ ignore = ["E501"] # Line too long
@@ -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"
@@ -64,8 +64,9 @@ class KnowledgeDocument(BaseModel):
64
64
  id: str = Field(
65
65
  ...,
66
66
  description=(
67
- "Stable id, unique within its source (e.g. 'employee:alice@co.com'). "
68
- "Upsert is idempotent on (source, id)."
67
+ "Stable id, unique within its source, e.g. a key of the source's own or a "
68
+ "keyed hash. It appears in URL paths and logs, so it never holds personal "
69
+ "data such as an email address. Upsert is idempotent on (source, id)."
69
70
  ),
70
71
  )
71
72
  content: str = Field(
@@ -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
 
@@ -38,7 +40,9 @@ import base64
38
40
  import binascii
39
41
  import codecs
40
42
  import email
43
+ import functools
41
44
  import hashlib
45
+ import ipaddress
42
46
  import json
43
47
  import math
44
48
  import re
@@ -54,8 +58,8 @@ from email.utils import decode_params, getaddresses, parsedate_to_datetime, unqu
54
58
  from html import unescape as unescape_html
55
59
  from itertools import chain
56
60
  from typing import Any, Literal, NamedTuple, TypeAlias, TypeVar, cast
61
+ from urllib.parse import SplitResult
57
62
  from urllib.parse import unquote as unquote_url
58
- from urllib.parse import urlsplit
59
63
  from uuid import UUID
60
64
 
61
65
  import idna
@@ -85,6 +89,12 @@ MAX_BOUNDARY_LENGTH = 1_000
85
89
  MAX_ANOMALIES = 1_000
86
90
  MAX_ANOMALIES_PER_CODE = 100
87
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
88
98
  # The view's budgets, in bytes of its JSON as model_dump_json(by_alias=True)
89
99
  # writes it: UTF-8, escapes included. A list keeps its first entries within
90
100
  # its budget (the headers each one that fits), a string its start; each cut is
@@ -179,7 +189,12 @@ HTML_HIDDEN_TEXT = "html_hidden_text"
179
189
  """Text a reader may see was left out of visible_text: hidden by an inline style,
180
190
  the hidden attribute or the browser's own style (a closed <dialog>, a
181
191
  <datalist>), in SVG outside its text elements, or inside <head>, <iframe>,
182
- <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."""
183
198
  TRANSFER_ENCODING_JUNK = "transfer_encoding_junk"
184
199
  """A base64 body goes on after padding, which decoders read differently. The
185
200
  data ends at padding that ends a quantum, as RFC 2045 allows, and the rest is
@@ -207,6 +222,13 @@ BIDI_CONTROL = "bidi_control"
207
222
  to U+202E, U+2066 to U+2069), which reorder what a reader sees: the
208
223
  right-to-left-override file name. Noted where INVISIBLE_CHARACTERS is, with how
209
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."""
210
232
  ANOMALY_LIMIT = "anomaly_limit"
211
233
  """More than MAX_ANOMALIES anomalies were met: the later ones are not recorded."""
212
234
  ANOMALY_CODES = (
@@ -229,6 +251,7 @@ ANOMALY_CODES = (
229
251
  URL_LIMIT,
230
252
  INVISIBLE_CHARACTERS,
231
253
  BIDI_CONTROL,
254
+ HOST_LIMIT,
232
255
  ANOMALY_LIMIT,
233
256
  )
234
257
  """Every anomaly code, in the order above."""
@@ -304,6 +327,9 @@ _HIDDEN_TAGS = frozenset(
304
327
  # Leaving out a script, a style or the title is ordinary; leaving out other
305
328
  # text hides what a reader may see.
306
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)
307
333
  # Template content is parsed again from its serialisation, only in documents
308
334
  # with at most this many template tags: Lexbor serialises nested templates
309
335
  # recursively, about 100 bytes of stack each, and 1 500 crashed a thread with
@@ -519,6 +545,38 @@ _MAX_LABEL = 4 * 63
519
545
  _MAX_A_LABEL = 63
520
546
  # The most characters idna maps a call (from 3.17).
521
547
  _IDNA_INPUT = 1024
548
+ # NFC reorders a run of combining marks (of a canonical combining class
549
+ # other than 0) in time quadratic in it. A label with more marks in a
550
+ # row than an A-label holds characters maps to no DNS label: NFC composes at
551
+ # most 3 of them into the character before (U+1F8F), and the 61 or more left
552
+ # are code points an A-label spells with a character each at least, after its
553
+ # "xn--": 65 characters.
554
+ _MAX_MARKS = _MAX_A_LABEL
555
+ _MARK_RUN = re.compile(rb"(?<![^\x00])[^\x00]{%d}" % (_MAX_MARKS + 1))
556
+ # The characters UTS 46 maps to other marks than their own class counts (idna
557
+ # 3.20, Unicode 14.0 and 15.1): to two, to marks though they are of class 0,
558
+ # or to an iota, no mark.
559
+ _MARK_READINGS = str.maketrans(
560
+ {
561
+ "\u0344": "\u0308\u0301",
562
+ "\u0345": "\u03b9",
563
+ "\u0f73": "\u0f71\u0f72",
564
+ "\u0f75": "\u0f71\u0f74",
565
+ "\u0f81": "\u0f71\u0f80",
566
+ "\uff9e": "\u3099",
567
+ "\uff9f": "\u309a",
568
+ }
569
+ )
570
+ # Python's urlsplit reads a URL without its leading C0 controls and spaces,
571
+ # and without its tabs and line breaks. Its netloc follows "//", after the
572
+ # scheme if any, up to the first of "/?#".
573
+ _URL_LEADING = "".join(map(chr, range(0x21)))
574
+ _URL_REMOVED = "\t\r\n"
575
+ _NETLOC = re.compile(r"(?:[A-Za-z][A-Za-z0-9+.-]*:)?//([^/?#]*)")
576
+ # What urlsplit refuses NFKC to make of a netloc's characters.
577
+ _URL_DELIMITERS = frozenset("/?#@:")
578
+ # An IPvFuture address, in brackets (RFC 3986).
579
+ _IPV_FUTURE = re.compile(r"v[a-fA-F0-9]+\..+")
522
580
 
523
581
 
524
582
  # A parameter as email.utils decodes it: a value, or an RFC 2231
@@ -601,11 +659,13 @@ def parse(
601
659
  nodes, left_out = _walk(root, budget=len(raw))
602
660
  _note_ambiguous_boundaries(nodes)
603
661
  fields, cut_headers = _fields(root, max_text_bytes)
662
+ punycode = _Punycode() # the A-labels of the addresses' and URLs' hosts
604
663
  read, cut_from = _header_fields(
605
664
  root.firsts,
606
665
  fields,
607
666
  {authserv_id.lower() for authserv_id in trusted_authserv_ids},
608
667
  max_text_bytes,
668
+ punycode,
609
669
  )
610
670
  given = {"source": _Cutter(), "envelope": _Cutter()}
611
671
  source = given["source"](source)
@@ -637,24 +697,26 @@ def parse(
637
697
  text = bodies.get("text/plain")
638
698
  html = bodies.get("text/html")
639
699
  cut_urls = _Cutter()
640
- 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)]
641
702
  shown: _Body | None = None # the body the visible text is read from
642
703
  visible_text = ""
643
704
  if text:
644
- 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))
645
706
  seen = _visible_body(text, text.content)
646
707
  if seen.strip():
647
708
  shown, visible_text = text, seen
648
709
  if html:
649
710
  reader = _read_html(html.content)
650
- found.append(_html_urls(reader.links, html.part_id, cut_urls))
711
+ found.append(_html_urls(reader.links, html.part_id, cut_urls, punycode))
651
712
  html_text = reader.text
652
- 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))
653
714
  if shown is None:
654
715
  # Removing them may leave runs of spaces and empty lines.
655
716
  shown, visible_text = html, _lines(_visible_body(html, html_text))
656
717
  if reader.hidden_text:
657
- 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)
658
720
  if reader.limit:
659
721
  html.part.note(HTML_LIMIT, None, reader.limit)
660
722
  if reader.unresolved:
@@ -684,6 +746,8 @@ def parse(
684
746
  )
685
747
  if left_out_url is not None or cut_urls.cut:
686
748
  notes.append((_Note(URL_LIMIT), "1"))
749
+ if punycode.spent:
750
+ notes.append((_Note(HOST_LIMIT), "1"))
687
751
  notes += [
688
752
  (_Note(code, f"url:{index}", (detail,)), None)
689
753
  for index, url in enumerate(urls)
@@ -1523,45 +1587,115 @@ def _unquote(value: str) -> str:
1523
1587
  return _QUOTED_PAIR.sub(r"\1", quoted.group(1)) if quoted else value
1524
1588
 
1525
1589
 
1526
- 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:
1527
1622
  """``host`` as browsers read it, as the WHATWG URL Standard has them do,
1528
1623
  without its trailing dots: mapped as UTS 46 maps it (case, width, the
1529
- characters it ignores...), each A-label decoded (``_decoded``). A label
1530
- 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.
1531
1626
 
1532
1627
  A host that can be no DNS name (``_can_be_dns_name``) reaches no
1533
1628
  resolver: it is only lower-cased, neither mapped nor decoded. That bounds
1534
1629
  the work a host costs: CPython reorders combining marks for NFC in time
1535
- quadratic in their run, and a run ends at a dot.
1630
+ quadratic in their run, and a longer run than ``_MAX_MARKS`` makes no DNS
1631
+ name.
1536
1632
 
1537
1633
  It reads the host, and does not decide whether a browser would accept it:
1538
1634
  a label against the Bidi rule, or with a joiner out of context, is read
1539
- all the same."""
1635
+ all the same.
1636
+
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
1639
+ up to MAX_URLS times (sdk#27). UTS 46 maps a character to at most 18, so
1640
+ the cache holds 19 MiB at most. A longer host is mapped each time: it is a
1641
+ DNS name only once its invisible characters or its trailing dots are
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)
1651
+
1652
+
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."""
1540
1656
  host = host.rstrip(_DOT_CHARACTERS)
1541
1657
  if not _can_be_dns_name(host):
1542
- return host.lower()
1658
+ return None
1543
1659
  try:
1544
1660
  labels = _remapped(host).split(".")
1545
1661
  except ValueError: # a code point UTS 46 does not map: each label alone
1546
1662
  labels = [_mapped(label) for label in _DOTS.split(host)]
1547
1663
  while labels and not labels[-1]:
1548
1664
  labels.pop()
1549
- return ".".join(map(_decoded, labels))
1665
+ return ".".join(labels)
1666
+
1667
+
1668
+ _cached_mapped_host = functools.lru_cache(maxsize=256)(_mapped_host)
1550
1669
 
1551
1670
 
1552
1671
  def _can_be_dns_name(host: str) -> bool:
1553
1672
  """Whether ``host`` may map to a DNS name, counted on what a resolver could
1554
1673
  see: without its invisible characters (``visible``, which holds all those
1555
1674
  UTS 46 removes) and its trailing dots, it keeps at most ``_MAX_DOMAIN``
1556
- characters, and each label at most ``_MAX_LABEL``. UTS 46 maps any other
1675
+ characters, each label at most ``_MAX_LABEL``, and at most ``_MAX_MARKS``
1676
+ combining marks in a row as UTS 46 maps them. UTS 46 maps any other
1557
1677
  character to one or more, so the count never exceeds the mapped host's
1558
- before NFC. It only counts: the host is read as UTS 46 maps it."""
1678
+ before NFC. The joiners, the only invisible characters UTS 46 keeps, may
1679
+ split a run counted whole: no ASCII either, they leave the label as long
1680
+ in its A-label. It only counts: the host is read as UTS 46 maps it."""
1559
1681
  counted = visible(host).rstrip(_DOT_CHARACTERS)
1560
- return len(counted) <= _MAX_DOMAIN and all(
1561
- len(label) <= _MAX_LABEL for label in _DOTS.split(counted)
1682
+ return (
1683
+ len(counted) <= _MAX_DOMAIN
1684
+ and all(len(label) <= _MAX_LABEL for label in _DOTS.split(counted))
1685
+ and not _too_many_marks(counted)
1562
1686
  )
1563
1687
 
1564
1688
 
1689
+ def _too_many_marks(text: str) -> bool:
1690
+ """Whether ``text`` holds more than ``_MAX_MARKS`` combining marks in a row,
1691
+ as UTS 46 maps them (``_MARK_READINGS``). In linear time: the classes, all
1692
+ under 256, are matched as bytes, from the start of each run."""
1693
+ if text.isascii():
1694
+ return False
1695
+ classes = bytes(map(unicodedata.combining, text.translate(_MARK_READINGS)))
1696
+ return _MARK_RUN.search(classes) is not None
1697
+
1698
+
1565
1699
  def _mapped(label: str) -> str:
1566
1700
  """A label mapped as UTS 46 maps it; as written, lower-cased, when it does
1567
1701
  not map."""
@@ -1585,27 +1719,29 @@ def _remapped(text: str) -> str:
1585
1719
  return unicodedata.normalize("NFC", "".join(pieces))
1586
1720
 
1587
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
+
1588
1730
  def _decoded(label: str) -> str:
1589
- """A mapped label as browsers show it: an A-label decoded from Punycode
1590
- when what it decodes to is a label UTS 46 leaves as it is (its own
1591
- criterion: valid code points, in NFC), and not ASCII alone; any other
1592
- 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.
1593
1735
 
1594
1736
  So an A-label that decodes to what UTS 46 would change stays encoded: a
1595
1737
  fullwidth or a capital letter, a soft hyphen, a full stop, a code point
1596
1738
  it does not map. Mapping it would read a dead A-label as another host,
1597
1739
  a brand's own (``xn--mi7ca7anb7c`` is a fullwidth ``paypal``).
1598
1740
 
1599
- An A-label over ``_MAX_A_LABEL`` characters is a DNS label no resolver
1600
- reaches: it stays as it is. That bounds Python's Punycode decoding,
1601
- quadratic in the label.
1602
-
1603
1741
  There is no other check: IDNA 2008's CONTEXTO rules rescanned a label for
1604
1742
  each code point they concern, and UTS 46's joiner checks took 70 us a
1605
1743
  joiner on idna 3.20, so hostile labels cost seconds a message.
1606
1744
  """
1607
- if not label.startswith("xn--") or len(label) > _MAX_A_LABEL:
1608
- return label
1609
1745
  try:
1610
1746
  decoded = label[4:].encode("ascii").decode("punycode")
1611
1747
  if (
@@ -1618,9 +1754,10 @@ def _decoded(label: str) -> str:
1618
1754
  return label
1619
1755
 
1620
1756
 
1621
- def _addresses(value: str, cut: _Cutter) -> Iterator[Address]:
1622
- """The mailboxes of an address header, each string cut to its budget; a
1623
- 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."""
1624
1761
  mailboxes = getaddresses([value])
1625
1762
  if not mailboxes or not all("@" in address for _, address in mailboxes):
1626
1763
  value = value.strip()
@@ -1631,7 +1768,7 @@ def _addresses(value: str, cut: _Cutter) -> Iterator[Address]:
1631
1768
  yield Address(
1632
1769
  display_name=cut(_decode_words(name)),
1633
1770
  address=cut(address),
1634
- domain=cut(_domain(address.rpartition("@")[2])),
1771
+ domain=cut(_domain(address.rpartition("@")[2], punycode)),
1635
1772
  )
1636
1773
 
1637
1774
 
@@ -1662,12 +1799,14 @@ def _header_fields(
1662
1799
  fields: list[tuple[str, str]],
1663
1800
  trusted: set[str],
1664
1801
  limit: int,
1802
+ punycode: _Punycode,
1665
1803
  ) -> tuple[_HeaderFields, list[str]]:
1666
1804
  """The view's fields read from the first header of each name (``firsts``,
1667
1805
  the whole header block's, each value cut at ``limit`` bytes), and the
1668
1806
  authentication results from the trusted headers on top: within their
1669
1807
  budgets. Each string is cut to ``_STRING_BYTES``, the subject to
1670
- ``_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``.
1671
1810
 
1672
1811
  Also the headers a field was cut from, as ``header:`` subjects.
1673
1812
  """
@@ -1683,7 +1822,7 @@ def _header_fields(
1683
1822
  return first.get(name, ("", ""))[1]
1684
1823
 
1685
1824
  def addresses(name: str) -> list[Address]:
1686
- return cuts[name].listed(_addresses(header(name), cuts[name]))
1825
+ return cuts[name].listed(_addresses(header(name), cuts[name], punycode))
1687
1826
 
1688
1827
  def message_id(name: str) -> str | None:
1689
1828
  return cuts[name](header(name).strip()) or None
@@ -1691,7 +1830,7 @@ def _header_fields(
1691
1830
  authentication, authentication_cut = _authentication(fields, trusted)
1692
1831
  read = _HeaderFields(
1693
1832
  subject=cuts["subject"](_decode_words(header("subject")), _SUBJECT_BYTES),
1694
- from_=next(_addresses(header("from"), cuts["from"]), None),
1833
+ from_=next(_addresses(header("from"), cuts["from"], punycode), None),
1695
1834
  reply_to=addresses("reply-to"),
1696
1835
  to=addresses("to"),
1697
1836
  cc=addresses("cc"),
@@ -1866,6 +2005,12 @@ def _lines(text: str) -> str:
1866
2005
  return "\n".join(line for line in lines if line)
1867
2006
 
1868
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
+
1869
2014
  def url_host(text: str) -> str:
1870
2015
  """The host a browser would reach for ``text``; "" when there is none: the
1871
2016
  host ``parse`` records in ``Url.host``. A backslash reads as a slash, and
@@ -1878,19 +2023,82 @@ def url_host(text: str) -> str:
1878
2023
  dropped. A label UTS 46 does not map stays as written, lower-cased, a
1879
2024
  host browsers refuse (a space, a slash, a label against the Bidi rule) is
1880
2025
  kept as read, and a host that can be no DNS name is only lower-cased:
1881
- never an exception. That is a host that keeps over 1 012 characters, or a
1882
- label over 252, once its invisible characters are removed: a DNS name
1883
- holds 253, a label 63, and NFC composes at most 4 code points into one.
1884
- An A-label over 63 characters stays encoded.
2026
+ never an exception. That is a host that keeps over 1 012 characters, a
2027
+ label over 252, or over 63 combining marks in a row, once its invisible
2028
+ characters are removed: a DNS name holds 253, a label 63, NFC composes at
2029
+ most 4 code points into one, and an A-label spells each code point that is
2030
+ not ASCII with a character at least. An A-label over 63 characters stays
2031
+ encoded.
2032
+
2033
+ The host is found as CPython 3.11's urlsplit reads it (3.13 also accepts
2034
+ an IPvFuture ``V``), its ``.hostname``, and none where urlsplit refuses the
2035
+ URL, but in linear time: urlsplit puts the netloc in NFKC to refuse a
2036
+ character NFKC turns into a delimiter (U+2100 is ``a/c``), in time
2037
+ quadratic in a run of combining marks.
1885
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``."""
1886
2045
  if text[:4].lower() == "www.":
1887
2046
  text = "http://" + text
2047
+ # Browsers read a backslash as a slash in web URLs.
2048
+ host = _hostname(text.replace("\\", "/"))
2049
+ return _domain(_clean(unquote_url(host)), punycode) if host else ""
2050
+
2051
+
2052
+ def _hostname(url: str) -> str | None:
2053
+ """``urlsplit(url).hostname``, as CPython 3.11's urlsplit reads it (3.13
2054
+ also accepts an IPvFuture ``V``); None where urlsplit refuses ``url``: a
2055
+ bracket without its pair, brackets misplaced or around no IPv6 or IPvFuture
2056
+ address (``_accepted_brackets``), or a character NFKC turns into a
2057
+ delimiter. That last is checked a character at a time: NFKC composes no
2058
+ delimiter, and the netloc's own ``@`` and ``:`` do not count."""
2059
+ netloc = _netloc(url)
2060
+ if ("[" in netloc) != ("]" in netloc):
2061
+ return None
2062
+ if "[" in netloc and not _accepted_brackets(netloc.rpartition("@")[2]):
2063
+ return None
2064
+ if any(
2065
+ not _URL_DELIMITERS.isdisjoint(unicodedata.normalize("NFKD", character))
2066
+ for character in set(netloc)
2067
+ if not character.isascii()
2068
+ ):
2069
+ return None
2070
+ return SplitResult("", netloc, "", "", "").hostname
2071
+
2072
+
2073
+ def _netloc(url: str) -> str:
2074
+ """The netloc urlsplit reads in ``url``, unchecked; "" when it has none."""
2075
+ url = url.lstrip(_URL_LEADING)
2076
+ for removed in _URL_REMOVED:
2077
+ url = url.replace(removed, "")
2078
+ found = _NETLOC.match(url)
2079
+ return found.group(1) if found else ""
2080
+
2081
+
2082
+ def _accepted_brackets(hostinfo: str) -> bool:
2083
+ """Whether urlsplit accepts the brackets of a netloc whose ``hostinfo``
2084
+ follows its last ``@``: nothing before them, only a port after them, and an
2085
+ IPv6 or IPvFuture address in them. With the brackets in the userinfo, the
2086
+ host itself must be such an address."""
2087
+ before, bracket, bracketed = hostinfo.partition("[")
2088
+ if not bracket:
2089
+ host = before.partition(":")[0]
2090
+ elif before:
2091
+ return False
2092
+ else:
2093
+ host, _, port = bracketed.partition("]")
2094
+ if port and not port.startswith(":"):
2095
+ return False
2096
+ if host.startswith("v"):
2097
+ return _IPV_FUTURE.fullmatch(host) is not None
1888
2098
  try:
1889
- # Browsers read a backslash as a slash in web URLs.
1890
- host = urlsplit(text.replace("\\", "/")).hostname
2099
+ return ipaddress.ip_address(host).version == 6
1891
2100
  except ValueError:
1892
- return ""
1893
- return _domain(_clean(unquote_url(host))) if host else ""
2101
+ return False
1894
2102
 
1895
2103
 
1896
2104
  def _url(
@@ -1898,13 +2106,15 @@ def _url(
1898
2106
  found_in: Literal["text", "html_href", "html_text", "header"],
1899
2107
  part_id: str | None,
1900
2108
  cut: _Cutter,
2109
+ punycode: _Punycode,
1901
2110
  anchor_text: str | None = None,
1902
2111
  ) -> Url:
1903
2112
  """A URL as the view records it: cut to ``_URL_BYTES``, its host (of the
1904
- whole URL) and anchor text to ``_STRING_BYTES``."""
2113
+ whole URL, its A-labels decoded within ``punycode``) and anchor text to
2114
+ ``_STRING_BYTES``."""
1905
2115
  return Url(
1906
2116
  url=cut(url, _URL_BYTES),
1907
- host=cut(url_host(url)),
2117
+ host=cut(_url_host(url, punycode)),
1908
2118
  found_in=found_in,
1909
2119
  part_id=part_id,
1910
2120
  anchor_text=cut.optional(anchor_text),
@@ -1912,14 +2122,21 @@ def _url(
1912
2122
 
1913
2123
 
1914
2124
  def _urls_in(
1915
- 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,
1916
2130
  ) -> Iterator[Url]:
1917
2131
  """The ``http(s)://`` and ``www.`` URLs written in a text."""
1918
2132
  for match in _URL.finditer(text):
1919
- 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)
1920
2135
 
1921
2136
 
1922
- 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]:
1923
2140
  """The URLs of the List-Unsubscribe headers (RFC 2369)."""
1924
2141
  for name, value in fields:
1925
2142
  if name.lower() != "list-unsubscribe":
@@ -1927,13 +2144,15 @@ def _header_urls(fields: list[tuple[str, str]], cut: _Cutter) -> Iterator[Url]:
1927
2144
  for item in _ANGLE_BRACKETS.findall(value):
1928
2145
  url = "".join(item.split())
1929
2146
  if url:
1930
- yield _url(url, "header", None, cut)
2147
+ yield _url(url, "header", None, cut, punycode)
1931
2148
 
1932
2149
 
1933
- 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]:
1934
2153
  """The URLs of an HTML part's attributes."""
1935
2154
  for link in links:
1936
- 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)
1937
2156
 
1938
2157
 
1939
2158
  @dataclass(slots=True)
@@ -1993,19 +2212,27 @@ class _HtmlReader:
1993
2212
  the text of ``<textarea>``, ``<xmp>`` and ``<plaintext>`` as raw markup, so
1994
2213
  it is visible. Mail clients run no script, so ``<noscript>`` content is
1995
2214
  visible too. Stylesheets are not evaluated. Leaving out text other than a
1996
- script, a style or the title sets ``hidden_text``; URLs in hidden elements
1997
- are still collected. A hiding property whose value is known only when
1998
- 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``.
1999
2219
 
2000
2220
  Lexbor keeps a template's content out of the tree, so it is parsed again
2001
2221
  from the template's serialisation: when the document has at most
2002
2222
  ``_MAX_TEMPLATES`` template tags, and for the document's length in all.
2003
- 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.
2004
2229
  """
2005
2230
 
2006
2231
  def __init__(self, html: str) -> None:
2007
2232
  self.links: list[_Link] = []
2008
2233
  self.hidden_text = False
2234
+ self.hidden_characters = 0
2235
+ self.uncounted = False # hidden text that could not be counted
2009
2236
  self.unresolved: set[str] = set() # properties known only when rendering
2010
2237
  self._chunks: list[str] = []
2011
2238
  self._anchors: list[list[str]] = [] # the text of each open link
@@ -2088,7 +2315,9 @@ class _HtmlReader:
2088
2315
 
2089
2316
  def _text(self, text: str, style: _Style) -> None:
2090
2317
  if style.hides:
2091
- 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)
2092
2321
  else:
2093
2322
  # Only block elements break lines; a newline in the source is a space.
2094
2323
  self._emit(text.replace("\n", " "))
@@ -2100,7 +2329,7 @@ class _HtmlReader:
2100
2329
 
2101
2330
  def _template_content(self, template: LexborNode) -> list[LexborNode]:
2102
2331
  if self._budget <= 0:
2103
- self.hidden_text = True
2332
+ self.hidden_text = self.uncounted = True
2104
2333
  return []
2105
2334
  html = template.html or ""
2106
2335
  # Lexbor escapes ">" in attribute values: the first one ends the start tag.
@@ -2108,6 +2337,12 @@ class _HtmlReader:
2108
2337
  self._budget -= len(content)
2109
2338
  if _html_budget(content):
2110
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")))
2111
2346
  return []
2112
2347
  return _top_nodes(content)
2113
2348
 
@@ -15,6 +15,10 @@ ResponseMode = Literal["observe", "enforce"]
15
15
  DECISIVE_SIGNAL_PATTERN = r"^[a-z0-9_]+(\.[a-z0-9_]+)*\.([a-z0-9_]+|\*)$"
16
16
  """A signal name, or a prefix ending in ``.*``."""
17
17
 
18
+ # A workflow id goes into URLs, log lines and the commit messages of the config
19
+ # history: letters, digits, dots, underscores and hyphens only (sdk#26)
20
+ WORKFLOW_ID_PATTERN = r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"
21
+
18
22
 
19
23
  class FailureStrategy(StrEnum):
20
24
  """Strategy for handling analyzer failures."""
@@ -163,7 +167,11 @@ class VerdictPolicy(BaseModel):
163
167
  class Workflow(BaseModel):
164
168
  """Complete workflow configuration for email analysis."""
165
169
 
166
- id: str = Field(..., description="Workflow ID")
170
+ id: str = Field(
171
+ ...,
172
+ pattern=WORKFLOW_ID_PATTERN,
173
+ description="Workflow ID: a letter or digit, then up to 127 letters, digits, dots, underscores or hyphens",
174
+ )
167
175
  name: str = Field(..., description="Workflow name")
168
176
  version: int = Field(default=1, ge=1, description="Workflow version")
169
177
  description: str = Field(default="", description="Workflow description")
@@ -1,38 +0,0 @@
1
- [build-system]
2
- requires = ["poetry-core"]
3
- build-backend = "poetry.core.masonry.api"
4
-
5
- [tool.poetry]
6
- name = "spot-sdk-python"
7
- version = "2.0.0b12"
8
- description = "SPOT plugin SDK: the plugin contract, the plugin runtime, its clients and test helpers"
9
- authors = ["SPOT Project <spot@sonn.lu>"]
10
- license = "Apache-2.0"
11
- homepage = "https://sonn.lu"
12
- readme = "README.md"
13
- packages = [
14
- {include = "spot_sdk"}
15
- ]
16
-
17
- [tool.poetry.dependencies]
18
- python = ">=3.11,<3.16"
19
- pydantic = "^2.10"
20
- pydantic-settings = "^2.0.0"
21
- httpx2 = ">=2.13.1,<3"
22
- selectolax = ">=1.0,<1.1"
23
- idna = ">=3.11,<4"
24
- fastapi = ">=0.133.0,<1"
25
- starlette = ">=1.7.0,<2"
26
-
27
- [tool.poetry.group.dev.dependencies]
28
- pytest = "^9.0.3"
29
- pytest-asyncio = "^1.3.0"
30
- pytest-cov = "^4.1.0"
31
- mypy = "^1.5.0"
32
-
33
- [tool.ruff]
34
- target-version = "py311"
35
-
36
- [tool.ruff.lint]
37
- select = ["E", "F", "I", "N", "W"]
38
- ignore = ["E501"] # Line too long