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.
@@ -0,0 +1,386 @@
1
+ """
2
+ CLI for chunk and health commands.
3
+
4
+ Author: Vasiliy Zdanovskiy
5
+ email: vasilyvz@gmail.com
6
+ """
7
+
8
+ import argparse
9
+ import asyncio
10
+ import base64
11
+ import json
12
+ import sys
13
+ from pathlib import Path
14
+ from typing import Any, Awaitable, Callable, Dict, Optional
15
+
16
+ from svo_client import SvoChunkerClient
17
+ from svo_client.client import build_file_chunk_params
18
+ from svo_client.config import ClientConfig
19
+ from svo_client.constants import MAX_CLI_FILE_CHUNK_UPLOAD_BYTES
20
+ from svo_client.exceptions import (
21
+ SvoChunkingError,
22
+ SvoClientError,
23
+ SvoConnectionError,
24
+ SvoTimeoutError,
25
+ SvoValidationError,
26
+ )
27
+
28
+
29
+ def create_parser() -> argparse.ArgumentParser:
30
+ parser = argparse.ArgumentParser(
31
+ description="SVO Chunker Client - Chunk text using SVO Chunker service",
32
+ formatter_class=argparse.RawDescriptionHelpFormatter,
33
+ )
34
+ parser.add_argument("--protocol", choices=["http", "https", "mtls"], default=None)
35
+ parser.add_argument("--host", default=None)
36
+ parser.add_argument("--port", type=int, default=None)
37
+ parser.add_argument("--token-header", default=None)
38
+ parser.add_argument("--token", default=None)
39
+ parser.add_argument("--cert", default=None)
40
+ parser.add_argument("--key", default=None)
41
+ parser.add_argument("--ca", default=None)
42
+ parser.add_argument("--check-hostname", action="store_true", default=None)
43
+ parser.add_argument(
44
+ "--timeout",
45
+ type=float,
46
+ default=None,
47
+ help="Command timeout in seconds. Omit for default (300). Use with --no-timeout to disable.",
48
+ )
49
+ parser.add_argument(
50
+ "--no-timeout",
51
+ action="store_true",
52
+ help="No timeout; wait until server completes (e.g. for very large texts).",
53
+ )
54
+ parser.add_argument("--config", default=None)
55
+ parser.add_argument("--window", type=int, default=3)
56
+ parser.add_argument(
57
+ "--use-sv",
58
+ action="store_true",
59
+ default=False,
60
+ help="Run SV (sentence-verb) chunking stage. Default: False.",
61
+ )
62
+ parser.add_argument(
63
+ "--aggregation-mode",
64
+ choices=["sentence", "none", "paragraph"],
65
+ default=None,
66
+ help="Optional aggregation mode sent to chunk/file-chunk.",
67
+ )
68
+ parser.add_argument(
69
+ "--chunk-set",
70
+ default=None,
71
+ help=(
72
+ "Optional chunk_set preset (server config.chunk_sets). "
73
+ "Known presets: plain_text, technical_text, docstring, scientific_text, "
74
+ "fiction, chat, log. Other names are forwarded as-is; the server validates "
75
+ "unknown names."
76
+ ),
77
+ )
78
+ parser.add_argument(
79
+ "--respect-structural-blocks",
80
+ action=argparse.BooleanOptionalAction,
81
+ default=None,
82
+ help=(
83
+ "Respect structural block boundaries during aggregation. "
84
+ "Use --no-respect-structural-blocks to disable."
85
+ ),
86
+ )
87
+ parser.add_argument("--language", default=None)
88
+ parser.add_argument("--source-id", default=None)
89
+ parser.add_argument("--project", default=None)
90
+ parser.add_argument("--unit-id", default=None)
91
+ parser.add_argument("--chunking-version", default="1.0")
92
+ parser.add_argument("--input", "-i", default=None)
93
+ parser.add_argument(
94
+ "--output",
95
+ "-o",
96
+ default=None,
97
+ help="Output file path for chunk result (required for chunk command)",
98
+ )
99
+ parser.add_argument("--format", choices=["json", "pretty"], default="pretty")
100
+ parser.add_argument(
101
+ "--filter-name",
102
+ default=None,
103
+ help=(
104
+ "Optional filter registry name for file-chunk (server may infer from "
105
+ "extension when omitted)"
106
+ ),
107
+ )
108
+ parser.add_argument(
109
+ "--type",
110
+ "--chunk-type",
111
+ dest="chunk_type",
112
+ default=None,
113
+ help=(
114
+ "Optional semantic/document metadata type sent as request field 'type' "
115
+ "(for example DocBlock, Draft, Message, Log)."
116
+ ),
117
+ )
118
+ parser.add_argument(
119
+ "--no-download",
120
+ action="store_true",
121
+ help=(
122
+ "With file-chunk: do not download the outbound JSON artifact; "
123
+ "print RPC result metadata to stdout."
124
+ ),
125
+ )
126
+ parser.add_argument(
127
+ "--verbose",
128
+ "-v",
129
+ action="store_true",
130
+ help=(
131
+ "With file-chunk: print WebSocket job status lines to stderr (stdout "
132
+ "JSON contract unchanged)."
133
+ ),
134
+ )
135
+ parser.add_argument(
136
+ "command",
137
+ nargs="?",
138
+ choices=["chunk", "health", "file-chunk"],
139
+ default="chunk",
140
+ )
141
+ return parser
142
+
143
+
144
+ async def chunk_command(
145
+ client: SvoChunkerClient, text: str, args: argparse.Namespace
146
+ ) -> None:
147
+ chunks = await client.chunk(
148
+ text=text,
149
+ window=args.window,
150
+ language=args.language,
151
+ source_id=args.source_id,
152
+ project=args.project,
153
+ unit_id=args.unit_id,
154
+ chunking_version=args.chunking_version,
155
+ use_sv=getattr(args, "use_sv", False),
156
+ chunk_type=args.chunk_type,
157
+ aggregation_mode=args.aggregation_mode,
158
+ respect_structural_blocks=args.respect_structural_blocks,
159
+ chunk_set=getattr(args, "chunk_set", None),
160
+ )
161
+ if args.format == "json":
162
+ output = json.dumps(chunks, ensure_ascii=False, indent=2)
163
+ else:
164
+ lines = [f"Chunked {len(chunks)} chunks:\n"]
165
+ for i, chunk in enumerate(chunks, 1):
166
+ lines.append(f"Chunk {i}:")
167
+ lines.append(f" UUID: {chunk.get('uuid')}")
168
+ lines.append(f" Type: {chunk.get('type')}")
169
+ lines.append(f" Language: {chunk.get('language')}")
170
+ lines.append(f" Body length: {len(chunk.get('body', ''))}")
171
+ lines.append(f" Text preview: {chunk.get('text', '')[:100]}...")
172
+ lines.append("")
173
+ output = "\n".join(lines)
174
+ output_path = Path(args.output)
175
+ output_path.write_text(output, encoding="utf-8")
176
+ written_back = output_path.read_text(encoding="utf-8")
177
+ if written_back != output:
178
+ raise IOError(f"Output verification failed for {output_path}")
179
+
180
+
181
+ async def health_command(client: SvoChunkerClient) -> None:
182
+ health = await client.health()
183
+ print(json.dumps(health, ensure_ascii=False, indent=2))
184
+
185
+
186
+ def _file_chunk_ws_status_line(msg: Dict[str, Any]) -> Optional[str]:
187
+ """Build a short stderr line from adapter ``status_hook`` payloads, or None."""
188
+ if not isinstance(msg, dict) or not msg:
189
+ return None
190
+ parts: list[str] = []
191
+ ev = msg.get("event") or msg.get("type")
192
+ if ev:
193
+ parts.append(str(ev))
194
+ prog = msg.get("progress_percent")
195
+ if prog is None:
196
+ prog = msg.get("progress")
197
+ if prog is not None:
198
+ parts.append(f"{prog}%")
199
+ desc = msg.get("description") or msg.get("message")
200
+ step = msg.get("step")
201
+ if isinstance(desc, str) and desc.strip():
202
+ parts.append(desc.strip()[:160])
203
+ elif isinstance(step, str) and step.strip():
204
+ parts.append(step.strip()[:160])
205
+ if not parts:
206
+ return None
207
+ return "file-chunk: " + " — ".join(parts)
208
+
209
+
210
+ async def file_chunk_command(
211
+ client: SvoChunkerClient, args: argparse.Namespace
212
+ ) -> None:
213
+ """Server ``file_chunk`` RPC: optional filter, then optional artifact download."""
214
+ input_path = Path(args.input)
215
+ raw_bytes = input_path.read_bytes()
216
+ if len(raw_bytes) > MAX_CLI_FILE_CHUNK_UPLOAD_BYTES:
217
+ print(
218
+ f"Error: file too large for base64 JSON-RPC upload "
219
+ f"({len(raw_bytes)} bytes, limit {MAX_CLI_FILE_CHUNK_UPLOAD_BYTES}). "
220
+ "Use transfer_id upload or multipart when supported by your deployment.",
221
+ file=sys.stderr,
222
+ )
223
+ raise SystemExit(1)
224
+ encoded = base64.b64encode(raw_bytes).decode("ascii")
225
+
226
+ params = build_file_chunk_params(
227
+ filename=input_path.name,
228
+ file_content_base64=encoded,
229
+ filter_name=args.filter_name,
230
+ window=args.window,
231
+ chunk_type=args.chunk_type,
232
+ language=args.language,
233
+ source_id=args.source_id,
234
+ project=args.project,
235
+ unit_id=args.unit_id,
236
+ chunking_version=args.chunking_version,
237
+ use_sv=getattr(args, "use_sv", False),
238
+ aggregation_mode=args.aggregation_mode,
239
+ respect_structural_blocks=args.respect_structural_blocks,
240
+ chunk_set=getattr(args, "chunk_set", None),
241
+ )
242
+
243
+ print(
244
+ "Waiting for server (no CLI progress on stdout; see server logs). "
245
+ "Use --verbose for WebSocket status lines on stderr.",
246
+ file=sys.stderr,
247
+ flush=True,
248
+ )
249
+
250
+ verbose_hook: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None
251
+ if getattr(args, "verbose", False):
252
+
253
+ async def _verbose_status_hook(msg: Dict[str, Any]) -> None:
254
+ line = _file_chunk_ws_status_line(msg)
255
+ if line:
256
+ print(line, file=sys.stderr, flush=True)
257
+
258
+ verbose_hook = _verbose_status_hook
259
+
260
+ download_path = None if args.no_download else str(Path(args.output))
261
+ data = await client.file_chunk(
262
+ params,
263
+ download_path=download_path,
264
+ status_hook=verbose_hook,
265
+ )
266
+
267
+ if args.no_download:
268
+ if args.format == "json":
269
+ print(json.dumps(data, ensure_ascii=False, indent=2))
270
+ else:
271
+ print(f"transfer_id: {data.get('transfer_id')}")
272
+ print(f"filename: {data.get('filename')}")
273
+ print(f"filter_name: {data.get('filter_name')}")
274
+ meta = data.get("metadata")
275
+ if isinstance(meta, dict):
276
+ for k, v in meta.items():
277
+ print(f"{k}: {v}")
278
+ return
279
+
280
+ if args.format == "json":
281
+ print(
282
+ json.dumps(
283
+ {"saved": args.output, "result": data},
284
+ ensure_ascii=False,
285
+ indent=2,
286
+ )
287
+ )
288
+ else:
289
+ print(f"Artifact written to {args.output}")
290
+ print(f"transfer_id={data.get('transfer_id')}")
291
+ print(f"filter_name={data.get('filter_name')}")
292
+
293
+
294
+ async def main_async() -> int:
295
+ parser = create_parser()
296
+ args = parser.parse_args()
297
+
298
+ if args.command == "file-chunk":
299
+ if not args.input:
300
+ parser.error("argument --input/-i is required for file-chunk command")
301
+ if not args.no_download and not args.output:
302
+ parser.error(
303
+ "argument --output/-o is required for file-chunk unless "
304
+ "--no-download is set"
305
+ )
306
+ if args.no_timeout is False and args.timeout is None:
307
+ args.no_timeout = True
308
+ elif args.command != "health" and not args.output:
309
+ parser.error(
310
+ "argument --output/-o is required for chunk command "
311
+ "to save retrieval result"
312
+ )
313
+
314
+ cli_args = {}
315
+ for key, value in vars(args).items():
316
+ if key in (
317
+ "command",
318
+ "input",
319
+ "output",
320
+ "format",
321
+ "window",
322
+ "language",
323
+ "source_id",
324
+ "project",
325
+ "unit_id",
326
+ "chunking_version",
327
+ "aggregation_mode",
328
+ "chunk_set",
329
+ "respect_structural_blocks",
330
+ "config",
331
+ "use_sv",
332
+ "no_timeout",
333
+ "filter_name",
334
+ "chunk_type",
335
+ "no_download",
336
+ "verbose",
337
+ ):
338
+ continue
339
+ if key == "timeout" and args.no_timeout:
340
+ cli_args["timeout"] = 0 # no timeout
341
+ continue
342
+ if value is not None:
343
+ cli_args[key.replace("-", "_")] = value
344
+
345
+ config_manager = ClientConfig(config_file=args.config, cli_args=cli_args)
346
+ client_config = config_manager.get_client_config()
347
+
348
+ try:
349
+ async with SvoChunkerClient(**client_config) as client:
350
+ if args.command == "health":
351
+ await health_command(client)
352
+ elif args.command == "file-chunk":
353
+ await file_chunk_command(client, args)
354
+ else:
355
+ if args.input:
356
+ text = Path(args.input).read_text(encoding="utf-8")
357
+ else:
358
+ text = sys.stdin.read()
359
+ if not text.strip():
360
+ print("Error: Empty input text", file=sys.stderr)
361
+ return 1
362
+ await chunk_command(client, text, args)
363
+ except (
364
+ SvoClientError,
365
+ SvoChunkingError,
366
+ SvoConnectionError,
367
+ SvoTimeoutError,
368
+ SvoValidationError,
369
+ ) as e:
370
+ print(f"Error: {e}", file=sys.stderr)
371
+ return 1
372
+ except KeyboardInterrupt:
373
+ print("\nInterrupted", file=sys.stderr)
374
+ return 130
375
+ except Exception as e:
376
+ print(f"Unexpected error: {e}", file=sys.stderr)
377
+ return 1
378
+ return 0
379
+
380
+
381
+ def cli_entry_point() -> None:
382
+ sys.exit(asyncio.run(main_async()))
383
+
384
+
385
+ if __name__ == "__main__":
386
+ cli_entry_point()