ndi-cli 0.4.0__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.
ndi_cli/_docops.py ADDED
@@ -0,0 +1,443 @@
1
+ """Document operations: upload, parse, extract, split, classify, ground."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import collections
7
+ import json
8
+ import sys
9
+ from collections.abc import Callable
10
+ from concurrent.futures import ThreadPoolExecutor, as_completed
11
+ from pathlib import Path
12
+
13
+ from ndi_sdk.client import NdiClient
14
+ from ndi_sdk.models.document_ops import (
15
+ ClassifyClass,
16
+ ExtractCitationOptions,
17
+ GroundOptions,
18
+ GroundTarget,
19
+ ParseChunkingOptions,
20
+ ParseFiguresOptions,
21
+ ParseOutputOptions,
22
+ ParseSpreadsheetOptions,
23
+ SplitCategory,
24
+ SplitOutputOptions,
25
+ )
26
+ from ndi_sdk.models.jobs import Job
27
+
28
+ from ndi_cli import _render
29
+ from ndi_cli._jobs import wait_and_report
30
+ from ndi_cli._output import add_job_output_args, output_mode
31
+ from ndi_cli._sources import SourceError, collect_specs, resolve_source
32
+ from ndi_cli._tools import parse_pages
33
+
34
+ DEFAULT_PARALLEL = 4
35
+
36
+
37
+ def add_parsers(sub: argparse._SubParsersAction, common: argparse.ArgumentParser) -> None:
38
+ upload = sub.add_parser("upload", parents=[common], help="Stage a file for a document operation.")
39
+ upload.add_argument("file", help="Local file to stage.")
40
+ upload.add_argument("--ttl", type=int, default=None, dest="ttl_seconds", metavar="SECONDS")
41
+ upload.add_argument("--file-name", default=None, help="Override the uploaded name.")
42
+ upload.set_defaults(run=_run_upload, family="docops", needs_workspace=False, needs_client=True)
43
+
44
+ parse = sub.add_parser("parse", parents=[common], help="Turn a document into markdown, text, and blocks.")
45
+ _add_source_args(parse)
46
+ parse.add_argument("-p", "--pages", default=None, metavar="SPEC", help="1-based pages, e.g. 3 or 1-5,8.")
47
+ parse.add_argument("--mode", choices=("low", "medium", "high"), default="low")
48
+ parse.add_argument("--format", dest="formats", default=None, help="Comma list: markdown,text,blocks.")
49
+ parse.add_argument("--table-format", choices=("html", "markdown"), default="html")
50
+ parse.add_argument("--chunk", choices=("none", "page", "section"), default="none")
51
+ parse.add_argument("--figures", choices=("omit", "include", "describe"), default="describe")
52
+ parse.add_argument("--sheets", default=None, help="Comma-separated workbook sheet names.")
53
+ parse.add_argument("--password", default=None, help="PDF password. Never stored.")
54
+ parse.set_defaults(run=_run_parse, family="docops", needs_workspace=False, needs_client=True)
55
+
56
+ extract = sub.add_parser("extract", parents=[common], help="Pull structured data against a JSON Schema.")
57
+ _add_source_args(extract, required=False)
58
+ extract.add_argument("-s", "--schema", default=None, help="Schema file path or inline JSON object.")
59
+ extract.add_argument("--schema-id", default=None, help="Saved schema id.")
60
+ extract.add_argument("--instructions", default=None)
61
+ extract.add_argument("-p", "--pages", default=None, metavar="SPEC")
62
+ extract.add_argument("--no-citations", dest="citations", action="store_false")
63
+ extract.add_argument("--validate", action="store_true", help="Check the schema without creating a job.")
64
+ extract.set_defaults(run=_run_extract, family="docops", needs_workspace=False, needs_client=True)
65
+
66
+ split = sub.add_parser("split", parents=[common], help="Find logical sections of a document.")
67
+ _add_source_args(split)
68
+ _add_class_args(split)
69
+ split.add_argument("--unknown", choices=("include", "force", "error"), default="include")
70
+ split.add_argument("-p", "--pages", default=None, metavar="SPEC")
71
+ split.add_argument("--include-content", action="store_true")
72
+ split.set_defaults(run=_run_split, family="docops", needs_workspace=False, needs_client=True)
73
+
74
+ classify = sub.add_parser("classify", parents=[common], help="Label a document against your classes.")
75
+ _add_source_args(classify)
76
+ _add_class_args(classify)
77
+ classify.add_argument("--granularity", choices=("document", "page"), default="document")
78
+ classify.add_argument("--unknown", choices=("allow", "force_best"), default="allow")
79
+ classify.set_defaults(run=_run_classify, family="docops", needs_workspace=False, needs_client=True)
80
+
81
+ ground = sub.add_parser("ground", parents=[common], help="Locate quoted text back in the source.")
82
+ _add_source_args(ground)
83
+ ground.add_argument("--target", action="append", dest="targets", default=None, help="id=TEXT; repeatable.")
84
+ ground.add_argument("--targets", dest="targets_file", default=None, help="JSON array of targets.")
85
+ ground.add_argument("--max-matches", type=int, default=10)
86
+ ground.add_argument("--previews", action="store_true")
87
+ ground.set_defaults(run=_run_ground, family="docops", needs_workspace=False, needs_client=True)
88
+
89
+
90
+ def _add_source_args(parser: argparse.ArgumentParser, *, required: bool = True) -> None:
91
+ parser.add_argument("source", nargs=None if required else "?", help="File, directory, URL, ndi://, jobid://, ws://, or -.")
92
+ parser.add_argument("--file-name", default=None, help="Name for stdin bytes or a URL without a filename.")
93
+ parser.add_argument("-j", type=int, default=DEFAULT_PARALLEL, dest="parallel", help="Parallel jobs for a directory.")
94
+ add_job_output_args(parser)
95
+
96
+
97
+ def _add_class_args(parser: argparse.ArgumentParser) -> None:
98
+ parser.add_argument("--class", action="append", dest="classes", default=None, help="id:label[:description]; repeatable.")
99
+ parser.add_argument("--classes", dest="classes_file", default=None, help="JSON array of classes.")
100
+
101
+
102
+ def _run_upload(client: NdiClient, workspace_id: str, args: argparse.Namespace) -> int:
103
+ path = Path(args.file)
104
+ if not path.is_file():
105
+ print(f"error: file not found: {args.file}", file=sys.stderr)
106
+ return 2
107
+ upload = client.documents.create_upload(path, file_name=args.file_name or path.name, ttl_seconds=args.ttl_seconds)
108
+ print(f"ndi://upload/{upload.upload_id}")
109
+ print(f"upload {upload.upload_id} {upload.file_name} {upload.size_bytes}B", file=sys.stderr)
110
+ return 0
111
+
112
+
113
+ def _run_parse(client: NdiClient, workspace_id: str, args: argparse.Namespace) -> int:
114
+ formats = args.formats.split(",") if args.formats else None
115
+ sheets = [name.strip() for name in args.sheets.split(",") if name.strip()] if args.sheets else None
116
+ output = ParseOutputOptions(formats=formats or ["markdown", "blocks"], table_format=args.table_format)
117
+ return _run_over_sources(
118
+ client,
119
+ workspace_id,
120
+ args,
121
+ allow_parse_result=True,
122
+ submit=lambda source: client.documents.parse(
123
+ source,
124
+ page_ranges=_page_ranges(args.pages),
125
+ parse_mode=args.mode,
126
+ output=output,
127
+ figures=ParseFiguresOptions(mode=args.figures),
128
+ chunking=ParseChunkingOptions(strategy=args.chunk),
129
+ spreadsheet=ParseSpreadsheetOptions(sheets=sheets) if sheets is not None else None,
130
+ password=args.password,
131
+ ),
132
+ auto=_render.parse_job,
133
+ )
134
+
135
+
136
+ def _run_extract(client: NdiClient, workspace_id: str, args: argparse.Namespace) -> int:
137
+ schema, schema_id = _load_schema(args)
138
+ if args.validate:
139
+ result = client.documents.validate_extract_schema(json_schema=schema, schema_id=schema_id)
140
+ text = (
141
+ result.model_dump_json(indent=2, exclude_none=True)
142
+ if output_mode(args) == "json"
143
+ else _render.schema_validation(result)
144
+ )
145
+ print(text)
146
+ return 0 if result.valid else 1
147
+ if not args.source:
148
+ print("error: extract needs a SOURCE (or --validate)", file=sys.stderr)
149
+ return 2
150
+ return _run_over_sources(
151
+ client,
152
+ workspace_id,
153
+ args,
154
+ allow_parse_result=True,
155
+ submit=lambda source: client.documents.extract(
156
+ source,
157
+ json_schema=schema,
158
+ schema_id=schema_id,
159
+ instructions=args.instructions,
160
+ page_ranges=_page_ranges(args.pages),
161
+ citations=ExtractCitationOptions(enabled=args.citations),
162
+ ),
163
+ auto=_render.extract_job,
164
+ )
165
+
166
+
167
+ def _run_split(client: NdiClient, workspace_id: str, args: argparse.Namespace) -> int:
168
+ classes = _load_split_classes(args)
169
+ return _run_over_sources(
170
+ client,
171
+ workspace_id,
172
+ args,
173
+ allow_parse_result=True,
174
+ submit=lambda source: client.documents.split(
175
+ source,
176
+ classes=classes,
177
+ unknown_policy=args.unknown,
178
+ page_ranges=_page_ranges(args.pages),
179
+ output=SplitOutputOptions(include_content=args.include_content),
180
+ ),
181
+ auto=_render.split_job,
182
+ )
183
+
184
+
185
+ def _run_classify(client: NdiClient, workspace_id: str, args: argparse.Namespace) -> int:
186
+ classes = _load_classify_classes(args)
187
+ return _run_over_sources(
188
+ client,
189
+ workspace_id,
190
+ args,
191
+ allow_parse_result=False,
192
+ submit=lambda source: client.documents.classify(
193
+ source,
194
+ classes=classes,
195
+ granularity=args.granularity,
196
+ unknown_policy=args.unknown,
197
+ ),
198
+ auto=_render.classify_job,
199
+ )
200
+
201
+
202
+ def _run_ground(client: NdiClient, workspace_id: str, args: argparse.Namespace) -> int:
203
+ targets = _load_targets(args)
204
+ options = GroundOptions(max_matches=args.max_matches, include_previews=args.previews)
205
+ return _run_over_sources(
206
+ client,
207
+ workspace_id,
208
+ args,
209
+ allow_parse_result=True,
210
+ submit=lambda source: client.documents.ground(source, targets=targets, options=options),
211
+ auto=_render.ground_job,
212
+ )
213
+
214
+
215
+ def _run_over_sources(
216
+ client: NdiClient,
217
+ workspace_id: str,
218
+ args: argparse.Namespace,
219
+ *,
220
+ allow_parse_result: bool,
221
+ submit: Callable,
222
+ auto: Callable[[Job], str],
223
+ ) -> int:
224
+ try:
225
+ specs = collect_specs(args.source)
226
+ except SourceError as exc:
227
+ print(f"error: {exc}", file=sys.stderr)
228
+ return 2
229
+ if len(specs) == 1:
230
+ return _one(
231
+ client,
232
+ workspace_id,
233
+ args,
234
+ specs[0],
235
+ stem=_stem_for(specs[0]),
236
+ allow_parse_result=allow_parse_result,
237
+ submit=submit,
238
+ auto=auto,
239
+ )
240
+ if getattr(args, "save", None):
241
+ print("error: --save is for one input; use --out-dir for a directory", file=sys.stderr)
242
+ return 2
243
+ if getattr(args, "file_name", None):
244
+ # One name across many inputs would relabel every upload identically.
245
+ print("error: --file-name is for one input; it cannot name every file of a directory", file=sys.stderr)
246
+ return 2
247
+ stems = _out_stems(specs)
248
+ workers = max(1, args.parallel)
249
+ code = 0
250
+ with ThreadPoolExecutor(max_workers=workers) as pool:
251
+ futures = [
252
+ pool.submit(
253
+ _one,
254
+ client,
255
+ workspace_id,
256
+ args,
257
+ spec,
258
+ stem=stems[spec],
259
+ allow_parse_result=allow_parse_result,
260
+ submit=submit,
261
+ auto=auto,
262
+ )
263
+ for spec in specs
264
+ ]
265
+ for future in as_completed(futures):
266
+ result = future.result()
267
+ if result != 0:
268
+ code = result
269
+ return code
270
+
271
+
272
+ def _stem_for(spec: str) -> str:
273
+ return "stdin" if spec == "-" else Path(spec).stem
274
+
275
+
276
+ def _out_stems(specs: list[str]) -> dict[str, str]:
277
+ """Give every input its own ``--out-dir`` file name.
278
+
279
+ ``Path(spec).stem`` alone collides for ``a/report.pdf`` + ``b/report.csv``,
280
+ and the later job silently overwrites the earlier one's result — work that
281
+ was already submitted and paid for. Disambiguate with the parent directory
282
+ and the extension, then with a counter, so one input never destroys another.
283
+ """
284
+ counts = collections.Counter(_stem_for(spec) for spec in specs)
285
+ taken: set[str] = set()
286
+ stems: dict[str, str] = {}
287
+ for spec in specs:
288
+ base = _stem_for(spec)
289
+ if counts[base] > 1:
290
+ path = Path(spec)
291
+ parent = path.parent.name
292
+ suffix = path.suffix.lstrip(".")
293
+ base = "-".join(part for part in (parent, path.stem, suffix) if part)
294
+ candidate, index = base, 2
295
+ while candidate in taken:
296
+ candidate = f"{base}-{index}"
297
+ index += 1
298
+ taken.add(candidate)
299
+ stems[spec] = candidate
300
+ return stems
301
+
302
+
303
+ def _one(
304
+ client: NdiClient,
305
+ workspace_id: str,
306
+ args: argparse.Namespace,
307
+ spec: str,
308
+ *,
309
+ stem: str,
310
+ allow_parse_result: bool,
311
+ submit: Callable,
312
+ auto: Callable[[Job], str],
313
+ ) -> int:
314
+ try:
315
+ source = resolve_source(
316
+ spec,
317
+ client=client,
318
+ workspace_id=workspace_id or None,
319
+ file_name=args.file_name,
320
+ allow_parse_result=allow_parse_result,
321
+ )
322
+ job = submit(source)
323
+ except SourceError as exc:
324
+ print(f"error: {exc}", file=sys.stderr)
325
+ return 2
326
+ return wait_and_report(client, job, args, auto=auto, stem=stem)
327
+
328
+
329
+ def _page_ranges(spec: str | None):
330
+ pages = parse_pages(spec)
331
+ if not pages:
332
+ return None
333
+ from ndi_sdk.models.document_ops import PageRange
334
+
335
+ ranges = []
336
+ start = prev = pages[0]
337
+ for page in pages[1:]:
338
+ if page == prev + 1:
339
+ prev = page
340
+ continue
341
+ ranges.append(PageRange(start=start, end=prev))
342
+ start = prev = page
343
+ ranges.append(PageRange(start=start, end=prev))
344
+ return ranges
345
+
346
+
347
+ def _read_json_flag(path: str, flag: str):
348
+ """Read a JSON file named by a flag, turning every failure into a usage error.
349
+
350
+ ``read_text``/``json.loads`` raise OSError and JSONDecodeError. Only the
351
+ latter is a ValueError, so without this the CLI exits 1 with a traceback on
352
+ an ordinary path typo instead of the documented exit 2.
353
+ """
354
+ try:
355
+ raw = Path(path).read_text(encoding="utf-8")
356
+ except OSError as exc:
357
+ raise ValueError(f"cannot read {flag} {path!r}: {exc.strerror or exc}") from None
358
+ try:
359
+ return json.loads(raw)
360
+ except json.JSONDecodeError as exc:
361
+ raise ValueError(f"{flag} {path!r} is not valid JSON: {exc}") from None
362
+
363
+
364
+ def _load_schema(args: argparse.Namespace) -> tuple[dict | None, str | None]:
365
+ # Treat an empty flag value as absent so `--schema-id ''` is a usage error
366
+ # rather than an AttributeError on the other branch.
367
+ schema = args.schema.strip() if isinstance(args.schema, str) else args.schema
368
+ schema_id = args.schema_id.strip() if isinstance(args.schema_id, str) else args.schema_id
369
+ if not schema:
370
+ schema = None
371
+ if not schema_id:
372
+ schema_id = None
373
+ if (schema is None) == (schema_id is None):
374
+ raise ValueError("pass exactly one of -s/--schema or --schema-id")
375
+ if schema_id:
376
+ return None, schema_id
377
+ if schema.startswith("{"):
378
+ try:
379
+ parsed = json.loads(schema)
380
+ except json.JSONDecodeError as exc:
381
+ raise ValueError(f"-s/--schema is not valid JSON: {exc}") from None
382
+ else:
383
+ parsed = _read_json_flag(schema, "-s/--schema")
384
+ if not isinstance(parsed, dict):
385
+ raise ValueError(f"-s/--schema must be a JSON object, got {type(parsed).__name__}")
386
+ return parsed, None
387
+
388
+
389
+ def _load_split_classes(args: argparse.Namespace) -> list[SplitCategory]:
390
+ raw = _load_class_items(args)
391
+ return [SplitCategory.model_validate(item) if isinstance(item, dict) else _split_class_flag(item) for item in raw]
392
+
393
+
394
+ def _load_classify_classes(args: argparse.Namespace) -> list[ClassifyClass]:
395
+ raw = _load_class_items(args)
396
+ return [ClassifyClass.model_validate(item) if isinstance(item, dict) else _classify_class_flag(item) for item in raw]
397
+
398
+
399
+ def _load_targets(args: argparse.Namespace) -> list[GroundTarget]:
400
+ if args.targets_file:
401
+ items = _read_json_flag(args.targets_file, "--targets")
402
+ if not isinstance(items, list):
403
+ raise ValueError(f"--targets {args.targets_file!r} must hold a JSON array, got {type(items).__name__}")
404
+ return [GroundTarget.model_validate(item) for item in items]
405
+ if not args.targets:
406
+ raise ValueError("pass --target id=TEXT or --targets FILE.json")
407
+ return [_target_flag(item) for item in args.targets]
408
+
409
+
410
+ def _load_class_items(args: argparse.Namespace) -> list:
411
+ if args.classes_file:
412
+ items = _read_json_flag(args.classes_file, "--classes")
413
+ # Without this a JSON object iterates as its keys, and each key is then
414
+ # reported as a bogus `bad --class '<key>'` the user never typed.
415
+ if not isinstance(items, list):
416
+ raise ValueError(f"--classes {args.classes_file!r} must hold a JSON array, got {type(items).__name__}")
417
+ return items
418
+ if args.classes:
419
+ return args.classes
420
+ raise ValueError("pass --class id:label or --classes FILE.json")
421
+
422
+
423
+ def _split_class_flag(text: str) -> SplitCategory:
424
+ ident, _, rest = text.partition(":")
425
+ if not rest:
426
+ raise ValueError(f"bad --class {text!r}: use id:label[:description]")
427
+ label, _, description = rest.partition(":")
428
+ return SplitCategory(id=ident, label=label, description=description)
429
+
430
+
431
+ def _classify_class_flag(text: str) -> ClassifyClass:
432
+ ident, _, rest = text.partition(":")
433
+ if not rest:
434
+ raise ValueError(f"bad --class {text!r}: use id:label[:description]")
435
+ label, _, description = rest.partition(":")
436
+ return ClassifyClass(id=ident, label=label, description=description)
437
+
438
+
439
+ def _target_flag(text: str) -> GroundTarget:
440
+ ident, sep, body = text.partition("=")
441
+ if not sep or not body:
442
+ raise ValueError(f"bad --target {text!r}: use id=TEXT")
443
+ return GroundTarget(id=ident, text=body)
ndi_cli/_jobs.py ADDED
@@ -0,0 +1,106 @@
1
+ """Job inspect / list / cancel, plus the wait used by every job-returning verb."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ import uuid
8
+ from collections.abc import Callable
9
+
10
+ from ndi_sdk.client import NdiClient
11
+ from ndi_sdk.errors import JobFailedError, JobTimeoutError
12
+ from ndi_sdk.models.jobs import Job
13
+ from ndi_sdk.resources.jobs import check_terminal
14
+
15
+ from ndi_cli import _render
16
+ from ndi_cli._output import add_job_output_args, emit_text, output_mode, render_job
17
+
18
+
19
+ def add_parsers(sub: argparse._SubParsersAction, common: argparse.ArgumentParser) -> None:
20
+ job = sub.add_parser("job", parents=[common], help="Read one job by id.")
21
+ job.add_argument("job_id", help="Job UUID.")
22
+ add_job_output_args(job)
23
+ job.set_defaults(run=_run_get, family="job", needs_workspace=False, needs_client=True)
24
+
25
+ jobs = sub.add_parser("jobs", parents=[common], help="List recent jobs.")
26
+ jobs.add_argument("--kind", action="append", dest="kinds", default=None, help="Kind filter; repeatable.")
27
+ jobs.add_argument("--status", action="append", dest="statuses", default=None, help="Status filter; repeatable.")
28
+ jobs.add_argument("--limit", type=int, default=None, help="Page size.")
29
+ add_job_output_args(jobs)
30
+ jobs.set_defaults(run=_run_list, family="job", needs_workspace=False, needs_client=True)
31
+
32
+ cancel = sub.add_parser("cancel", parents=[common], help="Cancel a queued or running job.")
33
+ cancel.add_argument("job_id", help="Job UUID.")
34
+ cancel.add_argument("--reason", default=None, help="Optional cancellation reason.")
35
+ add_job_output_args(cancel)
36
+ cancel.set_defaults(run=_run_cancel, family="job", needs_workspace=False, needs_client=True)
37
+
38
+
39
+ def wait_and_report(
40
+ client: NdiClient,
41
+ job: Job,
42
+ args: argparse.Namespace,
43
+ *,
44
+ auto: Callable[[Job], str],
45
+ stem: str,
46
+ ) -> int:
47
+ print(f"job {job.job_id} {job.status}", file=sys.stderr)
48
+ if getattr(args, "async_submit", False):
49
+ print(job.job_id)
50
+ return 0
51
+ try:
52
+ if job.is_terminal:
53
+ check_terminal(job, raise_on_failure=True)
54
+ else:
55
+ job = client.jobs.wait(job.job_id, timeout=args.timeout)
56
+ except JobFailedError as exc:
57
+ code = f" [{exc.job.error.code}]" if exc.job.error and exc.job.error.code else ""
58
+ print(f"error:{code} {exc}", file=sys.stderr)
59
+ return 1
60
+ except JobTimeoutError as exc:
61
+ print(f"error: {exc}", file=sys.stderr)
62
+ print(exc.job.job_id)
63
+ return 1
64
+ print(f"job {job.job_id} {job.status}", file=sys.stderr)
65
+ return render_job(job, args, auto=auto, stem=stem)
66
+
67
+
68
+ def _job_uuid(value: str) -> str:
69
+ """Reject anything that is not a job UUID.
70
+
71
+ The id is interpolated straight into the request path, so a value holding
72
+ ``/`` or ``?`` addresses a different endpoint entirely.
73
+ """
74
+ try:
75
+ return str(uuid.UUID(value.strip()))
76
+ except (AttributeError, ValueError):
77
+ raise ValueError(f"bad job id: {value!r}") from None
78
+
79
+
80
+ def _run_get(client: NdiClient, workspace_id: str, args: argparse.Namespace) -> int:
81
+ job = client.jobs.get(_job_uuid(args.job_id))
82
+ return render_job(job, args, auto=lambda item: _render.job_line(item), stem="job")
83
+
84
+
85
+ def _run_list(client: NdiClient, workspace_id: str, args: argparse.Namespace) -> int:
86
+ page = client.jobs.list(
87
+ workspace_id=args.workspace or None,
88
+ kind=args.kinds,
89
+ status=args.statuses,
90
+ limit=args.limit,
91
+ )
92
+ mode = output_mode(args)
93
+ if mode == "json":
94
+ return emit_text(page.model_dump_json(indent=2, exclude_none=True), args, default_stem="jobs", suffix=".json")
95
+ if mode in ("md", "payload", "id"):
96
+ # A listing is not a job, so these shapes have nothing to render. Say so
97
+ # instead of silently printing the default listing.
98
+ print(f"error: -o {mode} is not available for `jobs`; use auto or json", file=sys.stderr)
99
+ return 2
100
+ # Route through emit_text so --save / --out-dir work here as they do elsewhere.
101
+ return emit_text(_render.job_page(page), args, default_stem="jobs", suffix=".txt")
102
+
103
+
104
+ def _run_cancel(client: NdiClient, workspace_id: str, args: argparse.Namespace) -> int:
105
+ job = client.jobs.cancel(_job_uuid(args.job_id), reason=args.reason)
106
+ return render_job(job, args, auto=lambda item: _render.job_line(item), stem="job")
ndi_cli/_local.py ADDED
@@ -0,0 +1,130 @@
1
+ """Worker-local transport for the existing CLI and SDK command implementations.
2
+
3
+ The socket is scoped to one shell activity. It carries typed commands, never an
4
+ API key or a caller-selected job/workspace. Every request also carries the
5
+ activity's connection token (``NDI_CLI_TOKEN``): the socket lives in a
6
+ same-UID-only directory, but worker processes share one UID, so the token is
7
+ what stops a command reaching a *sibling* activity's socket and running under
8
+ that job's scope. Rendering remains in ``_cli``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import socket
14
+ from typing import Annotated, Literal, TypeVar
15
+
16
+ from ndi_sdk.errors import ErrorBody, NdiConnectionError, NdiTimeoutError, error_class_for
17
+ from ndi_sdk.models.tools import (
18
+ FileMetadataRequest,
19
+ FolderMetadataRequest,
20
+ QaFileRequest,
21
+ ReadFileRequest,
22
+ RunSqlRequest,
23
+ SearchRequest,
24
+ )
25
+ from pydantic import BaseModel, Field, TypeAdapter
26
+
27
+ from ndi_cli.commands import WorkspaceCommand
28
+
29
+ SOCKET_ENV = "NDI_CLI_SOCKET"
30
+ TOKEN_ENV = "NDI_CLI_TOKEN"
31
+ MAX_MESSAGE_BYTES = 16 * 1024 * 1024
32
+ ModelT = TypeVar("ModelT", bound=BaseModel)
33
+
34
+
35
+ class FolderCommand(BaseModel):
36
+ command: Literal[WorkspaceCommand.FOLDER_METADATA] = WorkspaceCommand.FOLDER_METADATA
37
+ body: FolderMetadataRequest
38
+
39
+
40
+ class FileCommand(BaseModel):
41
+ command: Literal[WorkspaceCommand.FILE_METADATA] = WorkspaceCommand.FILE_METADATA
42
+ body: FileMetadataRequest
43
+
44
+
45
+ class ReadCommand(BaseModel):
46
+ command: Literal[WorkspaceCommand.READ_FILE] = WorkspaceCommand.READ_FILE
47
+ body: ReadFileRequest
48
+
49
+
50
+ class AskCommand(BaseModel):
51
+ command: Literal[WorkspaceCommand.ASK_FILE] = WorkspaceCommand.ASK_FILE
52
+ body: QaFileRequest
53
+
54
+
55
+ class SqlCommand(BaseModel):
56
+ command: Literal[WorkspaceCommand.RUN_SQL] = WorkspaceCommand.RUN_SQL
57
+ body: RunSqlRequest
58
+
59
+
60
+ class SearchCommand(BaseModel):
61
+ command: Literal[WorkspaceCommand.HYBRID_SEARCH] = WorkspaceCommand.HYBRID_SEARCH
62
+ body: SearchRequest
63
+
64
+
65
+ Command = Annotated[
66
+ FolderCommand | FileCommand | ReadCommand | AskCommand | SqlCommand | SearchCommand, Field(discriminator="command")
67
+ ]
68
+ COMMAND = TypeAdapter(Command)
69
+
70
+
71
+ class CommandEnvelope(BaseModel):
72
+ """One request line on the socket: the owning run's token plus its command."""
73
+
74
+ token: str
75
+ command: Command
76
+
77
+
78
+ class CommandSuccess(BaseModel):
79
+ kind: Literal["success"] = "success"
80
+ result_json: str
81
+
82
+
83
+ class CommandFailure(BaseModel):
84
+ kind: Literal["failure"] = "failure"
85
+ status: int
86
+ error: ErrorBody
87
+
88
+
89
+ REPLY = TypeAdapter(Annotated[CommandSuccess | CommandFailure, Field(discriminator="kind")])
90
+
91
+
92
+ class LocalTransport:
93
+ """Dispatch SDK tool requests to the activity that owns the shell subprocess."""
94
+
95
+ def __init__(self, path: str, timeout: float, token: str = "") -> None:
96
+ self.path = path
97
+ self.timeout = timeout
98
+ self.token = token
99
+
100
+ def request_model(self, model: type[ModelT], method: str, path: str, **kwargs: object) -> ModelT:
101
+ body = kwargs.get("body")
102
+ if method != "POST" or not isinstance(body, BaseModel):
103
+ raise ValueError("The internal CLI accepts workspace commands only.")
104
+ name = path.rsplit("/", 1)[-1]
105
+ command = WorkspaceCommand.ASK_FILE if name == "qa-file" else WorkspaceCommand(name)
106
+ request = CommandEnvelope(
107
+ token=self.token,
108
+ command=COMMAND.validate_python({"command": command, "body": body.model_dump(exclude_none=True)}),
109
+ )
110
+ try:
111
+ with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
112
+ connection.settimeout(self.timeout)
113
+ connection.connect(self.path)
114
+ connection.sendall(request.model_dump_json().encode() + b"\n")
115
+ with connection.makefile("rb") as stream:
116
+ # Match the HTTP transport: service/renderer budgets own
117
+ # truncation; the transport must preserve the complete result.
118
+ raw = stream.readline()
119
+ if not raw.endswith(b"\n"):
120
+ raise NdiConnectionError("The internal CLI returned an incomplete response.")
121
+ except TimeoutError as exc:
122
+ raise NdiTimeoutError(str(exc)) from exc
123
+ except OSError as exc:
124
+ raise NdiConnectionError("The internal CLI connection closed.") from exc
125
+ reply = REPLY.validate_json(raw)
126
+ if isinstance(reply, CommandFailure):
127
+ # The same typed exception the HTTP transport would raise for this
128
+ # status, so callers handle both transports identically.
129
+ raise error_class_for(reply.status)(status_code=reply.status, message=reply.error.message, body=reply.error)
130
+ return model.model_validate_json(reply.result_json)