svo-client-dev 4.0.21__py3-none-any.whl
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.
- svo_client/__init__.py +75 -0
- svo_client/_version.py +3 -0
- svo_client/aiohttp_ws_max_message.py +50 -0
- svo_client/chunk_presets.py +36 -0
- svo_client/cli.py +225 -0
- svo_client/cli_chunk.py +386 -0
- svo_client/client.py +1104 -0
- svo_client/config.py +149 -0
- svo_client/connect_defaults.py +230 -0
- svo_client/constants.py +4 -0
- svo_client/core.py +330 -0
- svo_client/debug_ndjson.py +14 -0
- svo_client/exceptions.py +48 -0
- svo_client/file_processor.py +58 -0
- svo_client/transfer_precheck.py +91 -0
- svo_client_dev-4.0.21.dist-info/METADATA +363 -0
- svo_client_dev-4.0.21.dist-info/RECORD +20 -0
- svo_client_dev-4.0.21.dist-info/WHEEL +5 -0
- svo_client_dev-4.0.21.dist-info/entry_points.txt +3 -0
- svo_client_dev-4.0.21.dist-info/top_level.txt +1 -0
svo_client/core.py
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core: adapter client. Schema/help from server, list commands, direct call.
|
|
3
|
+
|
|
4
|
+
Uses mcp_proxy_adapter JsonRpcClient. With
|
|
5
|
+
``execute_command_unified(..., auto_poll=True)`` (default in
|
|
6
|
+
:meth:`ClientCore.call`), the adapter opens a command session and waits for
|
|
7
|
+
completion on WebSocket ``/ws``. The server runs non-queued commands in process
|
|
8
|
+
and queued commands in an isolated worker; both return the same client shape.
|
|
9
|
+
|
|
10
|
+
Author: Vasiliy Zdanovskiy
|
|
11
|
+
email: vasilyvz@gmail.com
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Awaitable, Callable, Dict, List, Optional
|
|
17
|
+
|
|
18
|
+
from svo_client.aiohttp_ws_max_message import apply_aiohttp_ws_max_message_patch
|
|
19
|
+
|
|
20
|
+
apply_aiohttp_ws_max_message_patch()
|
|
21
|
+
|
|
22
|
+
from mcp_proxy_adapter.client.jsonrpc_client import JsonRpcClient # noqa: E402
|
|
23
|
+
|
|
24
|
+
from svo_client.exceptions import ( # noqa: E402
|
|
25
|
+
SvoConnectionError,
|
|
26
|
+
SvoTimeoutError,
|
|
27
|
+
SvoValidationError,
|
|
28
|
+
)
|
|
29
|
+
from svo_client.transfer_precheck import ( # noqa: E402
|
|
30
|
+
declared_download_size_bytes,
|
|
31
|
+
ensure_local_free_space_for_download,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# httpx requires a finite timeout; match mcp_proxy_adapter resolve_timeout_strict
|
|
35
|
+
# fallback when command timeout is disabled (no arbitrary 60s ceiling).
|
|
36
|
+
_JSONRPC_HTTP_NO_CEILING_SEC = 21_600_000.0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _normalize_unified_command_response(resp: Any) -> Any:
|
|
40
|
+
"""
|
|
41
|
+
Map JSON-RPC top-level ``error`` (e.g. -32602 Invalid params) into the same
|
|
42
|
+
in-band shape as ``result: {success: false, error: {...}}`` for callers that
|
|
43
|
+
expect a unified dict (queued chunk validation failures).
|
|
44
|
+
"""
|
|
45
|
+
if not isinstance(resp, dict) or "error" not in resp:
|
|
46
|
+
return resp
|
|
47
|
+
if "result" in resp:
|
|
48
|
+
err = resp.get("error")
|
|
49
|
+
if isinstance(err, dict):
|
|
50
|
+
out = dict(resp)
|
|
51
|
+
out["result"] = {"success": False, "error": err}
|
|
52
|
+
return out
|
|
53
|
+
return resp
|
|
54
|
+
err = resp.get("error")
|
|
55
|
+
if not isinstance(err, dict):
|
|
56
|
+
return resp
|
|
57
|
+
out = dict(resp)
|
|
58
|
+
out["result"] = {"success": False, "error": err}
|
|
59
|
+
return out
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _resolve_cert_path(path: Optional[str], base_dir: Optional[Path]) -> Optional[str]:
|
|
63
|
+
if not path:
|
|
64
|
+
return None
|
|
65
|
+
p = Path(path)
|
|
66
|
+
if base_dir and not p.is_absolute():
|
|
67
|
+
return str(base_dir / p)
|
|
68
|
+
return path
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ClientCore:
|
|
72
|
+
"""
|
|
73
|
+
Core: adapter client. get_help, list_commands, call(command_name, params).
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
protocol: str = "mtls",
|
|
79
|
+
host: str = "127.0.0.1",
|
|
80
|
+
port: int = 8009,
|
|
81
|
+
token_header: Optional[str] = None,
|
|
82
|
+
token: Optional[str] = None,
|
|
83
|
+
cert: Optional[str] = None,
|
|
84
|
+
key: Optional[str] = None,
|
|
85
|
+
ca: Optional[str] = None,
|
|
86
|
+
check_hostname: bool = False,
|
|
87
|
+
timeout: Optional[float] = 300.0,
|
|
88
|
+
certs_dir: Optional[str] = None,
|
|
89
|
+
):
|
|
90
|
+
base = Path(certs_dir) if certs_dir else None
|
|
91
|
+
cert_path = _resolve_cert_path(cert, base)
|
|
92
|
+
key_path = _resolve_cert_path(key, base)
|
|
93
|
+
ca_path = _resolve_cert_path(ca, base)
|
|
94
|
+
default_http = 60.0 if protocol in ("https", "mtls") else 30.0
|
|
95
|
+
# None or 0 = no command timeout (wait until completion)
|
|
96
|
+
self.timeout = None if timeout is None or timeout == 0 else timeout
|
|
97
|
+
# HTTP client must not expire before a long JSON-RPC response; align with
|
|
98
|
+
# mcp_proxy_adapter "no timeout" ceiling when command timeout is disabled.
|
|
99
|
+
if self.timeout is None:
|
|
100
|
+
http_timeout = _JSONRPC_HTTP_NO_CEILING_SEC
|
|
101
|
+
else:
|
|
102
|
+
http_timeout = max(default_http, float(self.timeout))
|
|
103
|
+
|
|
104
|
+
self._client: JsonRpcClient = JsonRpcClient(
|
|
105
|
+
protocol=protocol,
|
|
106
|
+
host=host,
|
|
107
|
+
port=port,
|
|
108
|
+
token_header=token_header,
|
|
109
|
+
token=token,
|
|
110
|
+
cert=cert_path,
|
|
111
|
+
key=key_path,
|
|
112
|
+
ca=ca_path,
|
|
113
|
+
check_hostname=check_hostname,
|
|
114
|
+
timeout=http_timeout,
|
|
115
|
+
)
|
|
116
|
+
# region agent log
|
|
117
|
+
from svo_client.debug_ndjson import agent_debug_log
|
|
118
|
+
|
|
119
|
+
agent_debug_log(
|
|
120
|
+
"H1",
|
|
121
|
+
"svo_client/core.py:ClientCore.__init__",
|
|
122
|
+
"JsonRpcClient constructed",
|
|
123
|
+
{
|
|
124
|
+
"protocol": protocol,
|
|
125
|
+
"host": host,
|
|
126
|
+
"port": port,
|
|
127
|
+
"has_client_cert": bool(cert_path),
|
|
128
|
+
"has_client_key": bool(key_path),
|
|
129
|
+
"has_ca": bool(ca_path),
|
|
130
|
+
"jsonrpc_http_timeout_sec": http_timeout,
|
|
131
|
+
"execute_command_unified_timeout": self.timeout,
|
|
132
|
+
"adapter_ws_wait_timeout_passed": (
|
|
133
|
+
0 if self.timeout is None else self.timeout
|
|
134
|
+
),
|
|
135
|
+
},
|
|
136
|
+
)
|
|
137
|
+
# endregion
|
|
138
|
+
|
|
139
|
+
async def get_help(self, command_name: Optional[str] = None) -> Dict[str, Any]:
|
|
140
|
+
try:
|
|
141
|
+
return await self._client.help(command_name=command_name)
|
|
142
|
+
except Exception as e:
|
|
143
|
+
raise SvoConnectionError(f"Help failed: {e}") from e
|
|
144
|
+
|
|
145
|
+
def _help_data(self, help_result: Dict[str, Any]) -> Dict[str, Any]:
|
|
146
|
+
if not isinstance(help_result, dict):
|
|
147
|
+
return {}
|
|
148
|
+
return help_result.get("data", help_result)
|
|
149
|
+
|
|
150
|
+
async def list_commands(self) -> List[str]:
|
|
151
|
+
help_result = await self.get_help()
|
|
152
|
+
data = self._help_data(help_result)
|
|
153
|
+
commands = data.get("commands", {})
|
|
154
|
+
if isinstance(commands, dict):
|
|
155
|
+
return sorted(commands.keys())
|
|
156
|
+
if isinstance(commands, list):
|
|
157
|
+
return sorted(c for c in commands if isinstance(c, str))
|
|
158
|
+
return []
|
|
159
|
+
|
|
160
|
+
async def get_command_info(self, command_name: str) -> Optional[Dict[str, Any]]:
|
|
161
|
+
help_result = await self.get_help(command_name=command_name)
|
|
162
|
+
data = self._help_data(help_result)
|
|
163
|
+
if not data:
|
|
164
|
+
return None
|
|
165
|
+
if data.get("name") or data.get("schema"):
|
|
166
|
+
return data
|
|
167
|
+
commands = data.get("commands", {})
|
|
168
|
+
if isinstance(commands, dict) and command_name in commands:
|
|
169
|
+
return commands[command_name]
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
async def call(
|
|
173
|
+
self,
|
|
174
|
+
command_name: str,
|
|
175
|
+
params: Optional[Dict[str, Any]] = None,
|
|
176
|
+
*,
|
|
177
|
+
status_hook: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None,
|
|
178
|
+
) -> Dict[str, Any]:
|
|
179
|
+
"""Execute command; completion and result via WebSocket when ``job_id`` is returned.
|
|
180
|
+
|
|
181
|
+
Matches adapter queue semantics (including post-sync-cap handoff to the
|
|
182
|
+
queue): the initial response may be a minimal queue envelope; the adapter
|
|
183
|
+
still completes the job over ``/ws`` when *auto_poll* is enabled inside
|
|
184
|
+
``execute_command_unified``.
|
|
185
|
+
|
|
186
|
+
Optional *status_hook* is forwarded to the adapter's
|
|
187
|
+
``execute_command_unified`` (invoked for the initial queue response and each
|
|
188
|
+
inbound WebSocket message while waiting for the terminal job event). Hooks
|
|
189
|
+
may observe fields such as ``queued_after_timeout`` if the server includes them.
|
|
190
|
+
"""
|
|
191
|
+
try:
|
|
192
|
+
# Adapter maps None -> default 60s WS wait; 0 means no timeout (see
|
|
193
|
+
# mcp_proxy_adapter execute_command_unified / wait_for_job_via_websocket).
|
|
194
|
+
adapter_timeout = 0 if self.timeout is None else self.timeout
|
|
195
|
+
raw = await self._client.execute_command_unified(
|
|
196
|
+
command=command_name,
|
|
197
|
+
params=params or {},
|
|
198
|
+
auto_poll=True, # adapter waits via WebSocket for job_id, no HTTP polling
|
|
199
|
+
timeout=adapter_timeout,
|
|
200
|
+
status_hook=status_hook,
|
|
201
|
+
)
|
|
202
|
+
return _normalize_unified_command_response(raw)
|
|
203
|
+
except asyncio.TimeoutError as e:
|
|
204
|
+
msg = (
|
|
205
|
+
f"Command '{command_name}' timed out (no completion within {self.timeout}s)"
|
|
206
|
+
if self.timeout is not None
|
|
207
|
+
else f"Command '{command_name}' timed out"
|
|
208
|
+
)
|
|
209
|
+
raise SvoTimeoutError(msg) from e
|
|
210
|
+
except Exception as e:
|
|
211
|
+
# region agent log
|
|
212
|
+
from svo_client.debug_ndjson import agent_debug_log
|
|
213
|
+
|
|
214
|
+
cause = getattr(e, "__cause__", None)
|
|
215
|
+
agent_debug_log(
|
|
216
|
+
"H3",
|
|
217
|
+
"svo_client/core.py:ClientCore.call",
|
|
218
|
+
"execute_command_unified failed",
|
|
219
|
+
{
|
|
220
|
+
"command_name": command_name,
|
|
221
|
+
"exc_type": type(e).__name__,
|
|
222
|
+
"exc_repr": repr(e),
|
|
223
|
+
"cause_type": type(cause).__name__ if cause is not None else None,
|
|
224
|
+
"cause_repr": repr(cause) if cause is not None else None,
|
|
225
|
+
},
|
|
226
|
+
)
|
|
227
|
+
# endregion
|
|
228
|
+
raise SvoConnectionError(
|
|
229
|
+
f"Command '{command_name}' failed: {e}", remote_error=e
|
|
230
|
+
) from e
|
|
231
|
+
|
|
232
|
+
async def health(self) -> Dict[str, Any]:
|
|
233
|
+
try:
|
|
234
|
+
return await self._client.health()
|
|
235
|
+
except Exception as e:
|
|
236
|
+
raise SvoConnectionError(f"Health check failed: {e}") from e
|
|
237
|
+
|
|
238
|
+
async def get_openapi_schema(self) -> Dict[str, Any]:
|
|
239
|
+
"""Fetch ``/openapi.json`` via the same adapter transport as JSON-RPC."""
|
|
240
|
+
try:
|
|
241
|
+
return await self._client.get_openapi_schema()
|
|
242
|
+
except Exception as e:
|
|
243
|
+
raise SvoConnectionError(f"OpenAPI schema fetch failed: {e}") from e
|
|
244
|
+
|
|
245
|
+
async def download_transfer_to_path(
|
|
246
|
+
self,
|
|
247
|
+
transfer_id: str,
|
|
248
|
+
destination_path: str,
|
|
249
|
+
*,
|
|
250
|
+
acknowledge_after_download: bool = True,
|
|
251
|
+
) -> Any:
|
|
252
|
+
"""Download an outbound artifact by adapter transfer id.
|
|
253
|
+
|
|
254
|
+
Before starting the download, loads the declared ``size_bytes`` from
|
|
255
|
+
``get_download_session`` and refuses to call ``download_file`` if the
|
|
256
|
+
destination parent directory has insufficient free space (g1).
|
|
257
|
+
|
|
258
|
+
After a successful ``download_file``, calls JSON-RPC ``transfer_ack`` with
|
|
259
|
+
the receipt digest so the adapter can mark the session consumed and, when
|
|
260
|
+
the download session was created with ``delete_source_on_ack``, remove the
|
|
261
|
+
staged source file (g1 / t3 atomic 05).
|
|
262
|
+
"""
|
|
263
|
+
try:
|
|
264
|
+
need = await declared_download_size_bytes(self._client, transfer_id)
|
|
265
|
+
ensure_local_free_space_for_download(
|
|
266
|
+
destination_path=destination_path,
|
|
267
|
+
declared_size_bytes=need,
|
|
268
|
+
)
|
|
269
|
+
except SvoValidationError:
|
|
270
|
+
raise
|
|
271
|
+
try:
|
|
272
|
+
receipt = await self._client.download_file(transfer_id, destination_path)
|
|
273
|
+
except Exception as e:
|
|
274
|
+
raise SvoConnectionError(f"Download failed: {e}") from e
|
|
275
|
+
if acknowledge_after_download:
|
|
276
|
+
await self._acknowledge_outbound_download(receipt)
|
|
277
|
+
return receipt
|
|
278
|
+
|
|
279
|
+
async def _acknowledge_outbound_download(self, receipt: Any) -> None:
|
|
280
|
+
"""Post-download ``transfer_ack`` via the same JsonRpcClient (adapter JSON-RPC)."""
|
|
281
|
+
tid = getattr(receipt, "transfer_id", None)
|
|
282
|
+
algo = getattr(receipt, "checksum_algorithm", None) or "sha256"
|
|
283
|
+
digest = getattr(receipt, "checksum_value", None)
|
|
284
|
+
if not isinstance(tid, str) or not tid.strip():
|
|
285
|
+
raise SvoConnectionError(
|
|
286
|
+
"Download receipt missing transfer_id; cannot transfer_ack"
|
|
287
|
+
)
|
|
288
|
+
if not isinstance(digest, str) or not digest.strip():
|
|
289
|
+
raise SvoConnectionError(
|
|
290
|
+
"Download receipt missing checksum_value; cannot transfer_ack"
|
|
291
|
+
)
|
|
292
|
+
params: Dict[str, Any] = {
|
|
293
|
+
"transfer_id": tid.strip(),
|
|
294
|
+
"checksum_algorithm": str(algo).strip().lower(),
|
|
295
|
+
"checksum_value": digest.strip().lower(),
|
|
296
|
+
}
|
|
297
|
+
try:
|
|
298
|
+
response = await self._client.jsonrpc_call("transfer_ack", params)
|
|
299
|
+
except Exception as e:
|
|
300
|
+
raise SvoConnectionError(f"transfer_ack failed: {e}") from e
|
|
301
|
+
if isinstance(response, dict) and response.get("error") is not None:
|
|
302
|
+
err = response["error"]
|
|
303
|
+
msg = (
|
|
304
|
+
err.get("message", "transfer_ack error")
|
|
305
|
+
if isinstance(err, dict)
|
|
306
|
+
else str(err)
|
|
307
|
+
)
|
|
308
|
+
raise SvoConnectionError(f"transfer_ack failed: {msg}")
|
|
309
|
+
data = response.get("result") if isinstance(response, dict) else None
|
|
310
|
+
if not isinstance(data, dict) or not data.get("success"):
|
|
311
|
+
raise SvoConnectionError("transfer_ack did not return success")
|
|
312
|
+
|
|
313
|
+
def open_ws_channel(
|
|
314
|
+
self,
|
|
315
|
+
receive_timeout: float = 60.0,
|
|
316
|
+
heartbeat: float = 30.0,
|
|
317
|
+
):
|
|
318
|
+
from mcp_proxy_adapter.client.jsonrpc_client import (
|
|
319
|
+
open_bidirectional_ws_channel,
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
return open_bidirectional_ws_channel(
|
|
323
|
+
self._client,
|
|
324
|
+
receive_timeout=receive_timeout,
|
|
325
|
+
heartbeat=heartbeat,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
@property
|
|
329
|
+
def adapter_client(self) -> JsonRpcClient:
|
|
330
|
+
return self._client
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Optional NDJSON debug hook for agent instrumentation; no-op by default."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def agent_debug_log(
|
|
9
|
+
_hypothesis_id: str,
|
|
10
|
+
_location: str,
|
|
11
|
+
_message: str,
|
|
12
|
+
_data: dict[str, Any],
|
|
13
|
+
) -> None:
|
|
14
|
+
"""No-op unless debug instrumentation replaces this hook."""
|
svo_client/exceptions.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Exceptions for SVO Chunker client.
|
|
3
|
+
|
|
4
|
+
Author: Vasiliy Zdanovskiy
|
|
5
|
+
email: vasilyvz@gmail.com
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SvoClientError(Exception):
|
|
10
|
+
"""Base exception for SVO client errors."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SvoConnectionError(SvoClientError):
|
|
14
|
+
"""Raised when connection to server or WebSocket fails."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, message, *, remote_error=None):
|
|
17
|
+
super().__init__(message)
|
|
18
|
+
self.remote_error = remote_error
|
|
19
|
+
self.details = getattr(remote_error, "details", None)
|
|
20
|
+
self.error = getattr(remote_error, "error", None)
|
|
21
|
+
self.code = (
|
|
22
|
+
self.details.get("code")
|
|
23
|
+
if isinstance(self.details, dict) and "code" in self.details
|
|
24
|
+
else getattr(remote_error, "code", None)
|
|
25
|
+
)
|
|
26
|
+
self.data = (
|
|
27
|
+
self.details.get("data") if isinstance(self.details, dict) else None
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SvoTimeoutError(SvoClientError):
|
|
32
|
+
"""Raised when request or WebSocket wait times out."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SvoValidationError(SvoClientError):
|
|
36
|
+
"""Raised when request validation fails."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SvoChunkingError(SvoClientError):
|
|
40
|
+
"""Raised when chunking operation fails."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, message, *, error=None):
|
|
43
|
+
super().__init__(message)
|
|
44
|
+
self.error = error
|
|
45
|
+
self.code = error.get("code") if isinstance(error, dict) else None
|
|
46
|
+
self.details = (
|
|
47
|
+
error.get("details", error.get("data")) if isinstance(error, dict) else None
|
|
48
|
+
)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""
|
|
2
|
+
File processing helpers for SVO Chunker client.
|
|
3
|
+
|
|
4
|
+
Canonical path: single server ``file_chunk`` RPC via :func:`process_file_via_file_chunk`.
|
|
5
|
+
|
|
6
|
+
Author: Vasiliy Zdanovskiy
|
|
7
|
+
email: vasilyvz@gmail.com
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from typing import Any, Dict, Optional
|
|
11
|
+
|
|
12
|
+
from svo_client.client import (
|
|
13
|
+
SvoChunkerClient,
|
|
14
|
+
build_file_chunk_params,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def process_file_via_file_chunk(
|
|
19
|
+
client: SvoChunkerClient,
|
|
20
|
+
*,
|
|
21
|
+
filepath: Optional[str] = None,
|
|
22
|
+
filename: Optional[str] = None,
|
|
23
|
+
file_content_base64: Optional[str] = None,
|
|
24
|
+
transfer_id: Optional[str] = None,
|
|
25
|
+
filter_name: Optional[str] = None,
|
|
26
|
+
window: int = 3,
|
|
27
|
+
chunk_type: Optional[str] = None,
|
|
28
|
+
language: Optional[str] = None,
|
|
29
|
+
source_id: Optional[str] = None,
|
|
30
|
+
project: Optional[str] = None,
|
|
31
|
+
unit_id: Optional[str] = None,
|
|
32
|
+
chunking_version: str = "1.0",
|
|
33
|
+
use_sv: bool = False,
|
|
34
|
+
aggregation_mode: Optional[str] = None,
|
|
35
|
+
respect_structural_blocks: Optional[bool] = None,
|
|
36
|
+
chunk_set: Optional[str] = None,
|
|
37
|
+
download_path: Optional[str] = None,
|
|
38
|
+
) -> Dict[str, Any]:
|
|
39
|
+
"""Call server ``file_chunk`` with built parameters (single RPC, optional download)."""
|
|
40
|
+
params = build_file_chunk_params(
|
|
41
|
+
filepath=filepath,
|
|
42
|
+
filename=filename,
|
|
43
|
+
file_content_base64=file_content_base64,
|
|
44
|
+
transfer_id=transfer_id,
|
|
45
|
+
filter_name=filter_name,
|
|
46
|
+
window=window,
|
|
47
|
+
chunk_type=chunk_type,
|
|
48
|
+
language=language,
|
|
49
|
+
source_id=source_id,
|
|
50
|
+
project=project,
|
|
51
|
+
unit_id=unit_id,
|
|
52
|
+
chunking_version=chunking_version,
|
|
53
|
+
use_sv=use_sv,
|
|
54
|
+
aggregation_mode=aggregation_mode,
|
|
55
|
+
respect_structural_blocks=respect_structural_blocks,
|
|
56
|
+
chunk_set=chunk_set,
|
|
57
|
+
)
|
|
58
|
+
return await client.file_chunk(params, download_path=download_path)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Declared transfer size and local free-space checks before adapter download.
|
|
2
|
+
|
|
3
|
+
g1: the receiving side must not start the HTTP download if free space is insufficient.
|
|
4
|
+
Uses the adapter ``get_download_session`` response (``size_bytes``) and
|
|
5
|
+
``shutil.disk_usage`` on the destination file's parent directory.
|
|
6
|
+
|
|
7
|
+
Server-side preflight toward the vectorization service is not implemented in this
|
|
8
|
+
repository; that remains the embedding/adapter stack's responsibility when it exposes
|
|
9
|
+
a comparable hook.
|
|
10
|
+
|
|
11
|
+
Author: Vasiliy Zdanovskiy
|
|
12
|
+
email: vasilyvz@gmail.com
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import shutil
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Mapping, Optional, Union
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def coerce_transfer_size_bytes(raw: Any) -> int:
|
|
23
|
+
"""Parse ``size_bytes`` from a transfer session payload (int or decimal string)."""
|
|
24
|
+
if isinstance(raw, bool) or raw is None:
|
|
25
|
+
return 0
|
|
26
|
+
if isinstance(raw, int):
|
|
27
|
+
return raw if raw > 0 else 0
|
|
28
|
+
if isinstance(raw, str):
|
|
29
|
+
s = raw.strip()
|
|
30
|
+
if not s:
|
|
31
|
+
return 0
|
|
32
|
+
try:
|
|
33
|
+
v = int(s, 10)
|
|
34
|
+
except ValueError:
|
|
35
|
+
return 0
|
|
36
|
+
return v if v > 0 else 0
|
|
37
|
+
return 0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def free_bytes_for_path(destination_path: Union[str, Path]) -> int:
|
|
41
|
+
"""Free space on the filesystem that will hold *destination_path* (parent directory)."""
|
|
42
|
+
p = Path(destination_path).expanduser()
|
|
43
|
+
try:
|
|
44
|
+
parent = p.resolve().parent
|
|
45
|
+
except OSError:
|
|
46
|
+
parent = p.parent
|
|
47
|
+
usage = shutil.disk_usage(str(parent))
|
|
48
|
+
return int(usage.free)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def ensure_local_free_space_for_download(
|
|
52
|
+
*,
|
|
53
|
+
destination_path: Union[str, Path],
|
|
54
|
+
declared_size_bytes: Optional[int],
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Raise ``SvoValidationError`` if the parent dir has less free space than declared.
|
|
57
|
+
|
|
58
|
+
Conservative: requires ``free >= declared_size_bytes`` (no partial existing-file
|
|
59
|
+
accounting). g1: a positive declared size from the transfer session is required
|
|
60
|
+
before starting the download; missing, zero, or negative values fail closed.
|
|
61
|
+
"""
|
|
62
|
+
from svo_client.exceptions import SvoValidationError
|
|
63
|
+
|
|
64
|
+
if declared_size_bytes is None or declared_size_bytes < 1:
|
|
65
|
+
raise SvoValidationError(
|
|
66
|
+
"Declared transfer size is missing or invalid; cannot verify free space "
|
|
67
|
+
"before download (g1). get_download_session must return a positive "
|
|
68
|
+
"size_bytes."
|
|
69
|
+
)
|
|
70
|
+
free = free_bytes_for_path(destination_path)
|
|
71
|
+
if free < declared_size_bytes:
|
|
72
|
+
dest = Path(destination_path).expanduser()
|
|
73
|
+
raise SvoValidationError(
|
|
74
|
+
"Insufficient disk space for declared transfer size: "
|
|
75
|
+
f"need {declared_size_bytes} bytes, "
|
|
76
|
+
f"available {free} bytes on filesystem for {dest.parent}"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def declared_download_size_bytes(
|
|
81
|
+
adapter_client: Any,
|
|
82
|
+
transfer_id: str,
|
|
83
|
+
) -> int:
|
|
84
|
+
"""Return declared ``size_bytes`` from ``JsonRpcClient.get_download_session``."""
|
|
85
|
+
get_session = getattr(adapter_client, "get_download_session", None)
|
|
86
|
+
if not callable(get_session):
|
|
87
|
+
return 0
|
|
88
|
+
data = await get_session(transfer_id)
|
|
89
|
+
if not isinstance(data, Mapping):
|
|
90
|
+
return 0
|
|
91
|
+
return coerce_transfer_size_bytes(data.get("size_bytes"))
|