spot-sdk-python 2.0.0b11__tar.gz → 2.0.0b12__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.0b12}/PKG-INFO +2 -2
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b12}/README.md +1 -1
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b12}/pyproject.toml +1 -1
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b12}/spot_sdk/__init__.py +3 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b12}/spot_sdk/app.py +6 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b12}/spot_sdk/knowledge.py +21 -7
- spot_sdk_python-2.0.0b12/spot_sdk/proxy.py +94 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b12}/spot_sdk/testing/fake_knowledge_client.py +38 -12
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b12}/spot_sdk/_json.py +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b12}/spot_sdk/artifacts.py +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b12}/spot_sdk/clients.py +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b12}/spot_sdk/config.py +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b12}/spot_sdk/correlation.py +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b12}/spot_sdk/email.py +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b12}/spot_sdk/errors.py +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b12}/spot_sdk/knowledge_tags.py +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b12}/spot_sdk/llm.py +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b12}/spot_sdk/logging.py +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b12}/spot_sdk/manifest.py +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b12}/spot_sdk/mime.py +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b12}/spot_sdk/model_input.py +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b12}/spot_sdk/orchestrator.py +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b12}/spot_sdk/py.typed +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b12}/spot_sdk/retriever.py +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b12}/spot_sdk/settings.py +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b12}/spot_sdk/signals.py +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b12}/spot_sdk/testing/README.md +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b12}/spot_sdk/testing/__init__.py +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b12}/spot_sdk/testing/contract.py +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b12}/spot_sdk/testing/fake_llm.py +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b12}/spot_sdk/testing/fake_spot.py +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b12}/spot_sdk/testing/settings.py +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b12}/spot_sdk/testing/views.py +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b12}/spot_sdk/urls.py +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b12}/spot_sdk/verdict.py +0 -0
- {spot_sdk_python-2.0.0b11 → spot_sdk_python-2.0.0b12}/spot_sdk/workflow.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: spot-sdk-python
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.0b12
|
|
4
4
|
Summary: SPOT plugin SDK: the plugin contract, the plugin runtime, its clients and test helpers
|
|
5
5
|
License: Apache-2.0
|
|
6
6
|
Author: SPOT Project
|
|
@@ -51,7 +51,7 @@ plugins share:
|
|
|
51
51
|
## Install
|
|
52
52
|
|
|
53
53
|
```bash
|
|
54
|
-
pip install spot-sdk-python==2.0.
|
|
54
|
+
pip install spot-sdk-python==2.0.0b12
|
|
55
55
|
```
|
|
56
56
|
|
|
57
57
|
Python 3.11 to 3.15. Version 2 implements plugin contract 2, for
|
|
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
|
|
|
4
4
|
|
|
5
5
|
[tool.poetry]
|
|
6
6
|
name = "spot-sdk-python"
|
|
7
|
-
version = "2.0.
|
|
7
|
+
version = "2.0.0b12"
|
|
8
8
|
description = "SPOT plugin SDK: the plugin contract, the plugin runtime, its clients and test helpers"
|
|
9
9
|
authors = ["SPOT Project <spot@sonn.lu>"]
|
|
10
10
|
license = "Apache-2.0"
|
|
@@ -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,8 @@ 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. 'employee:alice@co.com'). "
|
|
68
|
+
"Upsert is idempotent on (source, id)."
|
|
69
69
|
),
|
|
70
70
|
)
|
|
71
71
|
content: str = Field(
|
|
@@ -88,7 +88,10 @@ class KnowledgeDocument(BaseModel):
|
|
|
88
88
|
)
|
|
89
89
|
source: str = Field(
|
|
90
90
|
default="",
|
|
91
|
-
description=
|
|
91
|
+
description=(
|
|
92
|
+
"The provider that wrote it: its plugin id, set by the store for a "
|
|
93
|
+
"plugin's write."
|
|
94
|
+
),
|
|
92
95
|
)
|
|
93
96
|
updated_at: datetime | None = None
|
|
94
97
|
expires_at: datetime | None = Field(
|
|
@@ -103,6 +106,16 @@ class KnowledgeDocument(BaseModel):
|
|
|
103
106
|
)
|
|
104
107
|
|
|
105
108
|
|
|
109
|
+
def _documents_path(source: str) -> str:
|
|
110
|
+
"""The store's path of a source's documents, the source quoted."""
|
|
111
|
+
return f"/sources/{quote(source, safe='')}/documents"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _document_path(source: str, doc_id: str) -> str:
|
|
115
|
+
"""The store's path of one of a source's documents, both parts quoted."""
|
|
116
|
+
return f"{_documents_path(source)}/{quote(doc_id, safe='')}"
|
|
117
|
+
|
|
118
|
+
|
|
106
119
|
class _QueryAnswer(BaseModel):
|
|
107
120
|
"""The store's answer to ``POST /query``."""
|
|
108
121
|
|
|
@@ -256,8 +269,10 @@ class KnowledgeClient:
|
|
|
256
269
|
await self._call("POST", "/bulk-upsert", json=payload)
|
|
257
270
|
|
|
258
271
|
async def delete(self, doc_id: str) -> None:
|
|
259
|
-
"""Delete
|
|
260
|
-
|
|
272
|
+
"""Delete one of the plugin's own documents, by its id within the plugin's source."""
|
|
273
|
+
if not self.source:
|
|
274
|
+
raise ValueError("delete needs the plugin's id: set SPOT_PLUGIN_ID")
|
|
275
|
+
await self._call("DELETE", _document_path(self.source, doc_id))
|
|
261
276
|
|
|
262
277
|
async def replace_source(self, docs: list[KnowledgeDocument]) -> None:
|
|
263
278
|
"""Make ``docs`` the plugin's whole set of documents.
|
|
@@ -269,8 +284,7 @@ class KnowledgeClient:
|
|
|
269
284
|
if not self.source:
|
|
270
285
|
raise ValueError("replace_source needs the plugin's id: set SPOT_PLUGIN_ID")
|
|
271
286
|
payload = {"documents": [d.model_dump(mode="json") for d in docs]}
|
|
272
|
-
|
|
273
|
-
await self._call("PUT", path, json=payload)
|
|
287
|
+
await self._call("PUT", _documents_path(self.source), json=payload)
|
|
274
288
|
|
|
275
289
|
# ------------------------------------------------------------------ #
|
|
276
290
|
# Read side: analyzers
|
|
@@ -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.0b12}/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)."""
|
|
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
|
|
File without changes
|
|
File without changes
|