spot-sdk-python 2.0.0b11__tar.gz → 2.0.0b13__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.0b11 → spot_sdk_python-2.0.0b13}/PKG-INFO +12 -14
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b13}/README.md +1 -1
- spot_sdk_python-2.0.0b13/pyproject.toml +52 -0
- spot_sdk_python-2.0.0b13/pyproject.toml.orig +43 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b13}/spot_sdk/__init__.py +3 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b13}/spot_sdk/app.py +6 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b13}/spot_sdk/knowledge.py +22 -7
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b13}/spot_sdk/mime.py +139 -15
- spot_sdk_python-2.0.0b13/spot_sdk/proxy.py +94 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b13}/spot_sdk/testing/fake_knowledge_client.py +38 -12
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b13}/spot_sdk/workflow.py +9 -1
- spot_sdk_python-2.0.0b11/pyproject.toml +0 -38
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b13}/spot_sdk/_json.py +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b13}/spot_sdk/artifacts.py +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b13}/spot_sdk/clients.py +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b13}/spot_sdk/config.py +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b13}/spot_sdk/correlation.py +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b13}/spot_sdk/email.py +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b13}/spot_sdk/errors.py +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b13}/spot_sdk/knowledge_tags.py +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b13}/spot_sdk/llm.py +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b13}/spot_sdk/logging.py +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b13}/spot_sdk/manifest.py +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b13}/spot_sdk/model_input.py +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b13}/spot_sdk/orchestrator.py +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b13}/spot_sdk/py.typed +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b13}/spot_sdk/retriever.py +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b13}/spot_sdk/settings.py +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b13}/spot_sdk/signals.py +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b13}/spot_sdk/testing/README.md +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b13}/spot_sdk/testing/__init__.py +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b13}/spot_sdk/testing/contract.py +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b13}/spot_sdk/testing/fake_llm.py +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b13}/spot_sdk/testing/fake_spot.py +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b13}/spot_sdk/testing/settings.py +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b13}/spot_sdk/testing/views.py +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b13}/spot_sdk/urls.py +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b13}/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.
|
|
3
|
+
Version: 2.0.0b13
|
|
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.0b13
|
|
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.0.0b13"
|
|
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.0.0b13"
|
|
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
|
|
@@ -25,6 +25,7 @@ from .orchestrator import (
|
|
|
25
25
|
RespondRequest,
|
|
26
26
|
StageOutcome,
|
|
27
27
|
)
|
|
28
|
+
from .proxy import PROXY_SECRET, apply_proxy_credentials
|
|
28
29
|
from .retriever import ActionReport, IngestionRequest, IngestionResult, IngestionStatus
|
|
29
30
|
from .settings import PluginSettings
|
|
30
31
|
from .verdict import AnalyzerVerdict, Score, Signal, VerdictClass
|
|
@@ -127,9 +128,11 @@ __all__ = [
|
|
|
127
128
|
"SettingType",
|
|
128
129
|
# Plugin runtime
|
|
129
130
|
"CorrelationMiddleware",
|
|
131
|
+
"PROXY_SECRET",
|
|
130
132
|
"PluginHealth",
|
|
131
133
|
"PluginSettings",
|
|
132
134
|
"SyncState",
|
|
135
|
+
"apply_proxy_credentials",
|
|
133
136
|
"create_plugin_app",
|
|
134
137
|
# Clients
|
|
135
138
|
"LLMClient",
|
|
@@ -58,6 +58,7 @@ from .correlation import HEADER, adopt
|
|
|
58
58
|
from .logging import get_logger
|
|
59
59
|
from .manifest import Manifest, PluginKind
|
|
60
60
|
from .orchestrator import AnalyzeRequest, RespondRequest
|
|
61
|
+
from .proxy import apply_proxy_credentials
|
|
61
62
|
from .settings import PluginSettings
|
|
62
63
|
from .verdict import AnalyzerVerdict
|
|
63
64
|
|
|
@@ -220,6 +221,10 @@ def create_plugin_app(
|
|
|
220
221
|
) -> FastAPI:
|
|
221
222
|
"""Build the plugin's app; the settings are read once, here.
|
|
222
223
|
|
|
224
|
+
First, the egress proxy's credentials, which core mounts as a secret
|
|
225
|
+
file, go back into the proxy variables (``apply_proxy_credentials``), so
|
|
226
|
+
the plugin's HTTP clients reach an authenticated proxy.
|
|
227
|
+
|
|
223
228
|
The manifest's kind needs its handler (analyzer: ``analyze``, responder:
|
|
224
229
|
``respond``, context provider: ``sync``; a mail retriever has none), and
|
|
225
230
|
each handler its kind, or ``ValueError`` is raised.
|
|
@@ -253,6 +258,7 @@ def create_plugin_app(
|
|
|
253
258
|
components of that document alone, so read it from this app: another
|
|
254
259
|
FastAPI app that includes its routes leaves their ``$ref`` dangling.
|
|
255
260
|
"""
|
|
261
|
+
apply_proxy_credentials()
|
|
256
262
|
_check_kind(manifest, {"analyze": analyze, "respond": respond, "sync": sync})
|
|
257
263
|
settings = settings_cls()
|
|
258
264
|
if not settings.spot_plugin_token.get_secret_value():
|
|
@@ -64,8 +64,9 @@ class KnowledgeDocument(BaseModel):
|
|
|
64
64
|
id: str = Field(
|
|
65
65
|
...,
|
|
66
66
|
description=(
|
|
67
|
-
"Stable unique
|
|
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(
|
|
@@ -88,7 +89,10 @@ class KnowledgeDocument(BaseModel):
|
|
|
88
89
|
)
|
|
89
90
|
source: str = Field(
|
|
90
91
|
default="",
|
|
91
|
-
description=
|
|
92
|
+
description=(
|
|
93
|
+
"The provider that wrote it: its plugin id, set by the store for a "
|
|
94
|
+
"plugin's write."
|
|
95
|
+
),
|
|
92
96
|
)
|
|
93
97
|
updated_at: datetime | None = None
|
|
94
98
|
expires_at: datetime | None = Field(
|
|
@@ -103,6 +107,16 @@ class KnowledgeDocument(BaseModel):
|
|
|
103
107
|
)
|
|
104
108
|
|
|
105
109
|
|
|
110
|
+
def _documents_path(source: str) -> str:
|
|
111
|
+
"""The store's path of a source's documents, the source quoted."""
|
|
112
|
+
return f"/sources/{quote(source, safe='')}/documents"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _document_path(source: str, doc_id: str) -> str:
|
|
116
|
+
"""The store's path of one of a source's documents, both parts quoted."""
|
|
117
|
+
return f"{_documents_path(source)}/{quote(doc_id, safe='')}"
|
|
118
|
+
|
|
119
|
+
|
|
106
120
|
class _QueryAnswer(BaseModel):
|
|
107
121
|
"""The store's answer to ``POST /query``."""
|
|
108
122
|
|
|
@@ -256,8 +270,10 @@ class KnowledgeClient:
|
|
|
256
270
|
await self._call("POST", "/bulk-upsert", json=payload)
|
|
257
271
|
|
|
258
272
|
async def delete(self, doc_id: str) -> None:
|
|
259
|
-
"""Delete
|
|
260
|
-
|
|
273
|
+
"""Delete one of the plugin's own documents, by its id within the plugin's source."""
|
|
274
|
+
if not self.source:
|
|
275
|
+
raise ValueError("delete needs the plugin's id: set SPOT_PLUGIN_ID")
|
|
276
|
+
await self._call("DELETE", _document_path(self.source, doc_id))
|
|
261
277
|
|
|
262
278
|
async def replace_source(self, docs: list[KnowledgeDocument]) -> None:
|
|
263
279
|
"""Make ``docs`` the plugin's whole set of documents.
|
|
@@ -269,8 +285,7 @@ class KnowledgeClient:
|
|
|
269
285
|
if not self.source:
|
|
270
286
|
raise ValueError("replace_source needs the plugin's id: set SPOT_PLUGIN_ID")
|
|
271
287
|
payload = {"documents": [d.model_dump(mode="json") for d in docs]}
|
|
272
|
-
|
|
273
|
-
await self._call("PUT", path, json=payload)
|
|
288
|
+
await self._call("PUT", _documents_path(self.source), json=payload)
|
|
274
289
|
|
|
275
290
|
# ------------------------------------------------------------------ #
|
|
276
291
|
# Read side: analyzers
|
|
@@ -38,7 +38,9 @@ import base64
|
|
|
38
38
|
import binascii
|
|
39
39
|
import codecs
|
|
40
40
|
import email
|
|
41
|
+
import functools
|
|
41
42
|
import hashlib
|
|
43
|
+
import ipaddress
|
|
42
44
|
import json
|
|
43
45
|
import math
|
|
44
46
|
import re
|
|
@@ -54,8 +56,8 @@ from email.utils import decode_params, getaddresses, parsedate_to_datetime, unqu
|
|
|
54
56
|
from html import unescape as unescape_html
|
|
55
57
|
from itertools import chain
|
|
56
58
|
from typing import Any, Literal, NamedTuple, TypeAlias, TypeVar, cast
|
|
59
|
+
from urllib.parse import SplitResult
|
|
57
60
|
from urllib.parse import unquote as unquote_url
|
|
58
|
-
from urllib.parse import urlsplit
|
|
59
61
|
from uuid import UUID
|
|
60
62
|
|
|
61
63
|
import idna
|
|
@@ -519,6 +521,38 @@ _MAX_LABEL = 4 * 63
|
|
|
519
521
|
_MAX_A_LABEL = 63
|
|
520
522
|
# The most characters idna maps a call (from 3.17).
|
|
521
523
|
_IDNA_INPUT = 1024
|
|
524
|
+
# NFC reorders a run of combining marks (of a canonical combining class
|
|
525
|
+
# other than 0) in time quadratic in it. A label with more marks in a
|
|
526
|
+
# row than an A-label holds characters maps to no DNS label: NFC composes at
|
|
527
|
+
# most 3 of them into the character before (U+1F8F), and the 61 or more left
|
|
528
|
+
# are code points an A-label spells with a character each at least, after its
|
|
529
|
+
# "xn--": 65 characters.
|
|
530
|
+
_MAX_MARKS = _MAX_A_LABEL
|
|
531
|
+
_MARK_RUN = re.compile(rb"(?<![^\x00])[^\x00]{%d}" % (_MAX_MARKS + 1))
|
|
532
|
+
# The characters UTS 46 maps to other marks than their own class counts (idna
|
|
533
|
+
# 3.20, Unicode 14.0 and 15.1): to two, to marks though they are of class 0,
|
|
534
|
+
# or to an iota, no mark.
|
|
535
|
+
_MARK_READINGS = str.maketrans(
|
|
536
|
+
{
|
|
537
|
+
"\u0344": "\u0308\u0301",
|
|
538
|
+
"\u0345": "\u03b9",
|
|
539
|
+
"\u0f73": "\u0f71\u0f72",
|
|
540
|
+
"\u0f75": "\u0f71\u0f74",
|
|
541
|
+
"\u0f81": "\u0f71\u0f80",
|
|
542
|
+
"\uff9e": "\u3099",
|
|
543
|
+
"\uff9f": "\u309a",
|
|
544
|
+
}
|
|
545
|
+
)
|
|
546
|
+
# Python's urlsplit reads a URL without its leading C0 controls and spaces,
|
|
547
|
+
# and without its tabs and line breaks. Its netloc follows "//", after the
|
|
548
|
+
# scheme if any, up to the first of "/?#".
|
|
549
|
+
_URL_LEADING = "".join(map(chr, range(0x21)))
|
|
550
|
+
_URL_REMOVED = "\t\r\n"
|
|
551
|
+
_NETLOC = re.compile(r"(?:[A-Za-z][A-Za-z0-9+.-]*:)?//([^/?#]*)")
|
|
552
|
+
# What urlsplit refuses NFKC to make of a netloc's characters.
|
|
553
|
+
_URL_DELIMITERS = frozenset("/?#@:")
|
|
554
|
+
# An IPvFuture address, in brackets (RFC 3986).
|
|
555
|
+
_IPV_FUTURE = re.compile(r"v[a-fA-F0-9]+\..+")
|
|
522
556
|
|
|
523
557
|
|
|
524
558
|
# A parameter as email.utils decodes it: a value, or an RFC 2231
|
|
@@ -1532,11 +1566,26 @@ def _domain(host: str) -> str:
|
|
|
1532
1566
|
A host that can be no DNS name (``_can_be_dns_name``) reaches no
|
|
1533
1567
|
resolver: it is only lower-cased, neither mapped nor decoded. That bounds
|
|
1534
1568
|
the work a host costs: CPython reorders combining marks for NFC in time
|
|
1535
|
-
quadratic in their run, and a run
|
|
1569
|
+
quadratic in their run, and a longer run than ``_MAX_MARKS`` makes no DNS
|
|
1570
|
+
name.
|
|
1536
1571
|
|
|
1537
1572
|
It reads the host, and does not decide whether a browser would accept it:
|
|
1538
1573
|
a label against the Bidi rule, or with a joiner out of context, is read
|
|
1539
|
-
all the same.
|
|
1574
|
+
all the same.
|
|
1575
|
+
|
|
1576
|
+
A host of at most ``_MAX_DOMAIN`` characters is read once while it stays
|
|
1577
|
+
among the last 256 the process read: a hostile message repeats one host
|
|
1578
|
+
up to MAX_URLS times (sdk#27). UTS 46 maps a character to at most 18, so
|
|
1579
|
+
the cache holds 19 MiB at most. A longer host is read each time: it is a
|
|
1580
|
+
DNS name only once its invisible characters or its trailing dots are
|
|
1581
|
+
removed."""
|
|
1582
|
+
if len(host) > _MAX_DOMAIN:
|
|
1583
|
+
return _read_domain(host)
|
|
1584
|
+
return _cached_domain(host)
|
|
1585
|
+
|
|
1586
|
+
|
|
1587
|
+
def _read_domain(host: str) -> str:
|
|
1588
|
+
"""``host`` read as ``_domain`` reads it, each time."""
|
|
1540
1589
|
host = host.rstrip(_DOT_CHARACTERS)
|
|
1541
1590
|
if not _can_be_dns_name(host):
|
|
1542
1591
|
return host.lower()
|
|
@@ -1549,19 +1598,37 @@ def _domain(host: str) -> str:
|
|
|
1549
1598
|
return ".".join(map(_decoded, labels))
|
|
1550
1599
|
|
|
1551
1600
|
|
|
1601
|
+
_cached_domain = functools.lru_cache(maxsize=256)(_read_domain)
|
|
1602
|
+
|
|
1603
|
+
|
|
1552
1604
|
def _can_be_dns_name(host: str) -> bool:
|
|
1553
1605
|
"""Whether ``host`` may map to a DNS name, counted on what a resolver could
|
|
1554
1606
|
see: without its invisible characters (``visible``, which holds all those
|
|
1555
1607
|
UTS 46 removes) and its trailing dots, it keeps at most ``_MAX_DOMAIN``
|
|
1556
|
-
characters,
|
|
1608
|
+
characters, each label at most ``_MAX_LABEL``, and at most ``_MAX_MARKS``
|
|
1609
|
+
combining marks in a row as UTS 46 maps them. UTS 46 maps any other
|
|
1557
1610
|
character to one or more, so the count never exceeds the mapped host's
|
|
1558
|
-
before NFC.
|
|
1611
|
+
before NFC. The joiners, the only invisible characters UTS 46 keeps, may
|
|
1612
|
+
split a run counted whole: no ASCII either, they leave the label as long
|
|
1613
|
+
in its A-label. It only counts: the host is read as UTS 46 maps it."""
|
|
1559
1614
|
counted = visible(host).rstrip(_DOT_CHARACTERS)
|
|
1560
|
-
return
|
|
1561
|
-
len(
|
|
1615
|
+
return (
|
|
1616
|
+
len(counted) <= _MAX_DOMAIN
|
|
1617
|
+
and all(len(label) <= _MAX_LABEL for label in _DOTS.split(counted))
|
|
1618
|
+
and not _too_many_marks(counted)
|
|
1562
1619
|
)
|
|
1563
1620
|
|
|
1564
1621
|
|
|
1622
|
+
def _too_many_marks(text: str) -> bool:
|
|
1623
|
+
"""Whether ``text`` holds more than ``_MAX_MARKS`` combining marks in a row,
|
|
1624
|
+
as UTS 46 maps them (``_MARK_READINGS``). In linear time: the classes, all
|
|
1625
|
+
under 256, are matched as bytes, from the start of each run."""
|
|
1626
|
+
if text.isascii():
|
|
1627
|
+
return False
|
|
1628
|
+
classes = bytes(map(unicodedata.combining, text.translate(_MARK_READINGS)))
|
|
1629
|
+
return _MARK_RUN.search(classes) is not None
|
|
1630
|
+
|
|
1631
|
+
|
|
1565
1632
|
def _mapped(label: str) -> str:
|
|
1566
1633
|
"""A label mapped as UTS 46 maps it; as written, lower-cased, when it does
|
|
1567
1634
|
not map."""
|
|
@@ -1878,19 +1945,76 @@ def url_host(text: str) -> str:
|
|
|
1878
1945
|
dropped. A label UTS 46 does not map stays as written, lower-cased, a
|
|
1879
1946
|
host browsers refuse (a space, a slash, a label against the Bidi rule) is
|
|
1880
1947
|
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
|
-
|
|
1948
|
+
never an exception. That is a host that keeps over 1 012 characters, a
|
|
1949
|
+
label over 252, or over 63 combining marks in a row, once its invisible
|
|
1950
|
+
characters are removed: a DNS name holds 253, a label 63, NFC composes at
|
|
1951
|
+
most 4 code points into one, and an A-label spells each code point that is
|
|
1952
|
+
not ASCII with a character at least. An A-label over 63 characters stays
|
|
1953
|
+
encoded.
|
|
1954
|
+
|
|
1955
|
+
The host is found as CPython 3.11's urlsplit reads it (3.13 also accepts
|
|
1956
|
+
an IPvFuture ``V``), its ``.hostname``, and none where urlsplit refuses the
|
|
1957
|
+
URL, but in linear time: urlsplit puts the netloc in NFKC to refuse a
|
|
1958
|
+
character NFKC turns into a delimiter (U+2100 is ``a/c``), in time
|
|
1959
|
+
quadratic in a run of combining marks.
|
|
1885
1960
|
"""
|
|
1886
1961
|
if text[:4].lower() == "www.":
|
|
1887
1962
|
text = "http://" + text
|
|
1963
|
+
# Browsers read a backslash as a slash in web URLs.
|
|
1964
|
+
host = _hostname(text.replace("\\", "/"))
|
|
1965
|
+
return _domain(_clean(unquote_url(host))) if host else ""
|
|
1966
|
+
|
|
1967
|
+
|
|
1968
|
+
def _hostname(url: str) -> str | None:
|
|
1969
|
+
"""``urlsplit(url).hostname``, as CPython 3.11's urlsplit reads it (3.13
|
|
1970
|
+
also accepts an IPvFuture ``V``); None where urlsplit refuses ``url``: a
|
|
1971
|
+
bracket without its pair, brackets misplaced or around no IPv6 or IPvFuture
|
|
1972
|
+
address (``_accepted_brackets``), or a character NFKC turns into a
|
|
1973
|
+
delimiter. That last is checked a character at a time: NFKC composes no
|
|
1974
|
+
delimiter, and the netloc's own ``@`` and ``:`` do not count."""
|
|
1975
|
+
netloc = _netloc(url)
|
|
1976
|
+
if ("[" in netloc) != ("]" in netloc):
|
|
1977
|
+
return None
|
|
1978
|
+
if "[" in netloc and not _accepted_brackets(netloc.rpartition("@")[2]):
|
|
1979
|
+
return None
|
|
1980
|
+
if any(
|
|
1981
|
+
not _URL_DELIMITERS.isdisjoint(unicodedata.normalize("NFKD", character))
|
|
1982
|
+
for character in set(netloc)
|
|
1983
|
+
if not character.isascii()
|
|
1984
|
+
):
|
|
1985
|
+
return None
|
|
1986
|
+
return SplitResult("", netloc, "", "", "").hostname
|
|
1987
|
+
|
|
1988
|
+
|
|
1989
|
+
def _netloc(url: str) -> str:
|
|
1990
|
+
"""The netloc urlsplit reads in ``url``, unchecked; "" when it has none."""
|
|
1991
|
+
url = url.lstrip(_URL_LEADING)
|
|
1992
|
+
for removed in _URL_REMOVED:
|
|
1993
|
+
url = url.replace(removed, "")
|
|
1994
|
+
found = _NETLOC.match(url)
|
|
1995
|
+
return found.group(1) if found else ""
|
|
1996
|
+
|
|
1997
|
+
|
|
1998
|
+
def _accepted_brackets(hostinfo: str) -> bool:
|
|
1999
|
+
"""Whether urlsplit accepts the brackets of a netloc whose ``hostinfo``
|
|
2000
|
+
follows its last ``@``: nothing before them, only a port after them, and an
|
|
2001
|
+
IPv6 or IPvFuture address in them. With the brackets in the userinfo, the
|
|
2002
|
+
host itself must be such an address."""
|
|
2003
|
+
before, bracket, bracketed = hostinfo.partition("[")
|
|
2004
|
+
if not bracket:
|
|
2005
|
+
host = before.partition(":")[0]
|
|
2006
|
+
elif before:
|
|
2007
|
+
return False
|
|
2008
|
+
else:
|
|
2009
|
+
host, _, port = bracketed.partition("]")
|
|
2010
|
+
if port and not port.startswith(":"):
|
|
2011
|
+
return False
|
|
2012
|
+
if host.startswith("v"):
|
|
2013
|
+
return _IPV_FUTURE.fullmatch(host) is not None
|
|
1888
2014
|
try:
|
|
1889
|
-
|
|
1890
|
-
host = urlsplit(text.replace("\\", "/")).hostname
|
|
2015
|
+
return ipaddress.ip_address(host).version == 6
|
|
1891
2016
|
except ValueError:
|
|
1892
|
-
return
|
|
1893
|
-
return _domain(_clean(unquote_url(host))) if host else ""
|
|
2017
|
+
return False
|
|
1894
2018
|
|
|
1895
2019
|
|
|
1896
2020
|
def _url(
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""The egress proxy's credentials, from a secret file.
|
|
2
|
+
|
|
3
|
+
Core never puts the proxy's user and password in a plugin's environment: it
|
|
4
|
+
forwards each proxy variable without them, and mounts them as the file
|
|
5
|
+
``/run/secrets/spot_proxy_credentials``, ``user:password`` as a URL carries
|
|
6
|
+
them (percent-encoded). :func:`apply_proxy_credentials`, which
|
|
7
|
+
:func:`spot_sdk.create_plugin_app` calls first, puts them back into the
|
|
8
|
+
process's proxy variables, so its HTTP clients reach the proxy
|
|
9
|
+
authenticated. They are never logged.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import unicodedata
|
|
16
|
+
from collections.abc import MutableMapping
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
19
|
+
|
|
20
|
+
from .settings import SECRETS_DIR
|
|
21
|
+
|
|
22
|
+
PROXY_SECRET = "spot_proxy_credentials"
|
|
23
|
+
"""The file, in the secrets directory, that holds the proxy's ``user:password``."""
|
|
24
|
+
|
|
25
|
+
PROXY_VARIABLES = (
|
|
26
|
+
"HTTP_PROXY",
|
|
27
|
+
"HTTPS_PROXY",
|
|
28
|
+
"ALL_PROXY",
|
|
29
|
+
"http_proxy",
|
|
30
|
+
"https_proxy",
|
|
31
|
+
"all_proxy",
|
|
32
|
+
)
|
|
33
|
+
"""The variables of the proxy URLs that HTTP clients read."""
|
|
34
|
+
|
|
35
|
+
# What a URL's user and password cannot hold as written: they end it.
|
|
36
|
+
_DELIMITERS = frozenset("/?#[]")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def apply_proxy_credentials(
|
|
40
|
+
secrets_dir: Path | None = None, environ: MutableMapping[str, str] | None = None
|
|
41
|
+
) -> bool:
|
|
42
|
+
"""Put the proxy's credentials into each proxy variable whose URL names no user.
|
|
43
|
+
|
|
44
|
+
The credentials are the first line of ``secrets_dir / PROXY_SECRET``
|
|
45
|
+
(by default in ``/run/secrets``), and they go into the proxy variables
|
|
46
|
+
of ``environ`` (by default the process's). Core writes the file only
|
|
47
|
+
when every proxy variable it forwards carried these same credentials,
|
|
48
|
+
so each gets them. A URL that already names a user keeps it. A value
|
|
49
|
+
without a scheme (``proxy.example:3128``) is left as it is: a client
|
|
50
|
+
that reads it reaches the proxy without them. Every variable is left as
|
|
51
|
+
it is when the file is empty.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Whether the file exists.
|
|
55
|
+
|
|
56
|
+
Raises:
|
|
57
|
+
ValueError: the file is not UTF-8, or its first line holds ``/``,
|
|
58
|
+
``?``, ``#``, ``[``, ``]`` or a control character, which a URL
|
|
59
|
+
carries percent-encoded; the error names the file, never what it
|
|
60
|
+
holds.
|
|
61
|
+
"""
|
|
62
|
+
path = (SECRETS_DIR if secrets_dir is None else secrets_dir) / PROXY_SECRET
|
|
63
|
+
if not path.is_file():
|
|
64
|
+
return False
|
|
65
|
+
credentials = _read_credentials(path)
|
|
66
|
+
if credentials is None:
|
|
67
|
+
# Raised here, where no local holds the file's content.
|
|
68
|
+
raise ValueError(f"{path} does not hold user:password as a URL carries them")
|
|
69
|
+
if not credentials:
|
|
70
|
+
return True
|
|
71
|
+
variables = os.environ if environ is None else environ
|
|
72
|
+
for name in PROXY_VARIABLES:
|
|
73
|
+
parts = urlsplit(variables.get(name, ""))
|
|
74
|
+
if parts.netloc and "@" not in parts.netloc:
|
|
75
|
+
netloc = f"{credentials}@{parts.netloc}"
|
|
76
|
+
variables[name] = urlunsplit(parts._replace(netloc=netloc))
|
|
77
|
+
return True
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _read_credentials(path: Path) -> str | None:
|
|
81
|
+
"""The file's first line, stripped; None when it is not UTF-8, or holds
|
|
82
|
+
``/``, ``?``, ``#``, ``[``, ``]`` or a control character, which a URL
|
|
83
|
+
carries percent-encoded."""
|
|
84
|
+
try:
|
|
85
|
+
lines = path.read_bytes().decode("utf-8").splitlines()
|
|
86
|
+
except UnicodeDecodeError:
|
|
87
|
+
return None
|
|
88
|
+
credentials = lines[0].strip() if lines else ""
|
|
89
|
+
if any(
|
|
90
|
+
char in _DELIMITERS or unicodedata.category(char) == "Cc"
|
|
91
|
+
for char in credentials
|
|
92
|
+
):
|
|
93
|
+
return None
|
|
94
|
+
return credentials
|
{spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b13}/spot_sdk/testing/fake_knowledge_client.py
RENAMED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"""In-memory fake of :class:`spot_sdk.knowledge.KnowledgeClient` for tests.
|
|
2
2
|
|
|
3
|
-
No HTTP, no embedding, no vector math — just a dict keyed by
|
|
4
|
-
|
|
3
|
+
No HTTP, no embedding, no vector math — just a dict keyed by
|
|
4
|
+
``(source, id)``, as a document's id is unique within its source, that
|
|
5
|
+
supports the same methods. ``fetch()`` does naive substring
|
|
5
6
|
matching over content + tag-expression filtering, which is enough for
|
|
6
7
|
plugin unit tests to assert that an analyzer reads documents out of the
|
|
7
8
|
store.
|
|
@@ -23,7 +24,8 @@ from __future__ import annotations
|
|
|
23
24
|
import re
|
|
24
25
|
from typing import TYPE_CHECKING
|
|
25
26
|
|
|
26
|
-
from ..
|
|
27
|
+
from ..clients import SpotAPIError
|
|
28
|
+
from ..knowledge import KnowledgeDocument, _document_path
|
|
27
29
|
from ..workflow import RetrievalLimits
|
|
28
30
|
|
|
29
31
|
if TYPE_CHECKING:
|
|
@@ -36,7 +38,7 @@ class FakeKnowledgeClient:
|
|
|
36
38
|
def __init__(
|
|
37
39
|
self, retrieval_limits: RetrievalLimits | None = None, *, source: str = ""
|
|
38
40
|
) -> None:
|
|
39
|
-
self._docs: dict[str, KnowledgeDocument] = {}
|
|
41
|
+
self._docs: dict[tuple[str, str], KnowledgeDocument] = {}
|
|
40
42
|
self.retrieval_limits = retrieval_limits or RetrievalLimits()
|
|
41
43
|
self.source = source
|
|
42
44
|
|
|
@@ -49,13 +51,34 @@ class FakeKnowledgeClient:
|
|
|
49
51
|
# --- write side ---------------------------------------------------- #
|
|
50
52
|
|
|
51
53
|
async def upsert(self, doc: KnowledgeDocument) -> None:
|
|
52
|
-
self.
|
|
54
|
+
await self.bulk_upsert([doc])
|
|
53
55
|
|
|
54
56
|
async def bulk_upsert(self, docs: list[KnowledgeDocument]) -> None:
|
|
57
|
+
"""Store the documents, as the store does for a plugin's write.
|
|
58
|
+
|
|
59
|
+
With a source, a batch holding another source's document is refused
|
|
60
|
+
whole, before anything is stored, and a document without one takes
|
|
61
|
+
the client's. Without a source (a core-like caller), the documents
|
|
62
|
+
are stored as given.
|
|
63
|
+
"""
|
|
64
|
+
if self.source:
|
|
65
|
+
self._refuse_other_sources(docs)
|
|
66
|
+
docs = [
|
|
67
|
+
d if d.source else d.model_copy(update={"source": self.source})
|
|
68
|
+
for d in docs
|
|
69
|
+
]
|
|
55
70
|
self.add(*docs)
|
|
56
71
|
|
|
57
72
|
async def delete(self, doc_id: str) -> None:
|
|
58
|
-
|
|
73
|
+
"""Delete the client's own document of this id; it needs a source, as the client does.
|
|
74
|
+
|
|
75
|
+
A document the source does not hold raises ``SpotAPIError`` 404, as
|
|
76
|
+
the store answers.
|
|
77
|
+
"""
|
|
78
|
+
if not self.source:
|
|
79
|
+
raise ValueError("delete needs a source: FakeKnowledgeClient(source=...)")
|
|
80
|
+
if self._docs.pop((self.source, doc_id), None) is None:
|
|
81
|
+
raise SpotAPIError("DELETE", _document_path(self.source, doc_id), 404)
|
|
59
82
|
|
|
60
83
|
async def replace_source(self, docs: list[KnowledgeDocument]) -> None:
|
|
61
84
|
"""Keep ``docs`` as the source's documents, as the store does: an empty
|
|
@@ -64,13 +87,16 @@ class FakeKnowledgeClient:
|
|
|
64
87
|
raise ValueError(
|
|
65
88
|
"replace_source needs a source: FakeKnowledgeClient(source=...)"
|
|
66
89
|
)
|
|
90
|
+
self._refuse_other_sources(docs)
|
|
91
|
+
for key in [key for key in self._docs if key[0] == self.source]:
|
|
92
|
+
del self._docs[key]
|
|
93
|
+
self.add(*(d.model_copy(update={"source": self.source}) for d in docs))
|
|
94
|
+
|
|
95
|
+
def _refuse_other_sources(self, docs: list[KnowledgeDocument]) -> None:
|
|
96
|
+
"""Refuse documents naming another source than the client's: the store answers 403."""
|
|
67
97
|
foreign = sorted({d.source for d in docs} - {"", self.source})
|
|
68
98
|
if foreign:
|
|
69
99
|
raise ValueError(f"documents of another source: {foreign}")
|
|
70
|
-
for doc_id in [i for i, d in self._docs.items() if d.source == self.source]:
|
|
71
|
-
del self._docs[doc_id]
|
|
72
|
-
for d in docs:
|
|
73
|
-
self._docs[d.id] = d.model_copy(update={"source": self.source})
|
|
74
100
|
|
|
75
101
|
# --- read side ----------------------------------------------------- #
|
|
76
102
|
|
|
@@ -107,9 +133,9 @@ class FakeKnowledgeClient:
|
|
|
107
133
|
# --- test helpers -------------------------------------------------- #
|
|
108
134
|
|
|
109
135
|
def add(self, *docs: KnowledgeDocument) -> None:
|
|
110
|
-
"""Store documents as they are, without awaiting (test helper)."""
|
|
136
|
+
"""Store documents as they are, by source and id, without awaiting (test helper)."""
|
|
111
137
|
for d in docs:
|
|
112
|
-
self._docs[d.id] = d
|
|
138
|
+
self._docs[d.source, d.id] = d
|
|
113
139
|
|
|
114
140
|
def all(self) -> list[KnowledgeDocument]:
|
|
115
141
|
"""Return all stored documents (test helper)."""
|
|
@@ -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.0b11"
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|