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/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."""
@@ -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"))