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.
- {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/PKG-INFO +12 -14
- {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/README.md +1 -1
- spot_sdk_python-2.1.0b1/pyproject.toml +52 -0
- spot_sdk_python-2.1.0b1/pyproject.toml.orig +43 -0
- {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/email.py +16 -6
- {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/knowledge.py +3 -2
- {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/mime.py +296 -61
- {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/workflow.py +9 -1
- spot_sdk_python-2.0.0b12/pyproject.toml +0 -38
- {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/__init__.py +0 -0
- {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/_json.py +0 -0
- {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/app.py +0 -0
- {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/artifacts.py +0 -0
- {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/clients.py +0 -0
- {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/config.py +0 -0
- {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/correlation.py +0 -0
- {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/errors.py +0 -0
- {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/knowledge_tags.py +0 -0
- {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/llm.py +0 -0
- {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/logging.py +0 -0
- {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/manifest.py +0 -0
- {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/model_input.py +0 -0
- {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/orchestrator.py +0 -0
- {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/proxy.py +0 -0
- {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/py.typed +0 -0
- {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/retriever.py +0 -0
- {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/settings.py +0 -0
- {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/signals.py +0 -0
- {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/testing/README.md +0 -0
- {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/testing/__init__.py +0 -0
- {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/testing/contract.py +0 -0
- {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/testing/fake_knowledge_client.py +0 -0
- {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/testing/fake_llm.py +0 -0
- {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/testing/fake_spot.py +0 -0
- {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/testing/settings.py +0 -0
- {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/testing/views.py +0 -0
- {spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/urls.py +0 -0
- {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.
|
|
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
|
-
|
|
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:
|
|
17
|
-
Requires-Dist:
|
|
18
|
-
Requires-Dist:
|
|
19
|
-
Requires-Dist:
|
|
20
|
-
Requires-Dist:
|
|
21
|
-
Requires-Dist:
|
|
22
|
-
Requires-Dist: starlette
|
|
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.
|
|
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
|
-
|
|
@@ -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
|
|
30
|
-
"
|
|
31
|
-
"
|
|
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
|
|
87
|
-
"
|
|
88
|
-
|
|
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
|
|
68
|
-
"
|
|
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``,
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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``)
|
|
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
|
|
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
|
|
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(
|
|
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,
|
|
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.
|
|
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
|
|
1561
|
-
len(
|
|
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:
|
|
1590
|
-
when what it decodes to is a label UTS 46 leaves as it is (its
|
|
1591
|
-
criterion: valid code points, in NFC), and not ASCII alone;
|
|
1592
|
-
|
|
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
|
|
1623
|
-
value that does not parse
|
|
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,
|
|
1882
|
-
label over 252,
|
|
1883
|
-
holds 253, a label 63,
|
|
1884
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
1997
|
-
|
|
1998
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{spot_sdk_python-2.0.0b12 → spot_sdk_python-2.1.0b1}/spot_sdk/testing/fake_knowledge_client.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|