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