nace-cli 0.1.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.
nace_cli/_docops.py ADDED
@@ -0,0 +1,414 @@
1
+ """Document operations (``parse``, ``split``, ``classify``, ``extract``, ``ground``) and uploads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ import uuid
8
+ from collections.abc import Callable
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from nace_sdk import (
13
+ MAX_WAIT_SECONDS,
14
+ DocumentJob,
15
+ NaceAPIError,
16
+ NaceClient,
17
+ NaceError,
18
+ OnConflict,
19
+ ParseResultSource,
20
+ Source,
21
+ UploadedFile,
22
+ UrlSource,
23
+ WorkspaceFileSource,
24
+ )
25
+ from nace_sdk._core.constants import MAX_IDEMPOTENCY_KEY_LENGTH
26
+ from nace_sdk._core.endpoints import url_file_name
27
+
28
+ from nace_cli._inputs import page_ranges, parse_options, read_json, read_secret
29
+ from nace_cli._jobs import add_wait_args, submit_and_report
30
+ from nace_cli._output import CommandFailed, add_job_output_args, to_json
31
+
32
+ SOURCE_HELP = """\
33
+ SOURCE is one of:
34
+ https://... a public link the document service downloads
35
+ (--file-name names the file when the link doesn't end in one)
36
+ a local file uploaded into your workspace first, then used as a workspace file
37
+ ws:<workspace_id>/<file_id> a file already in your workspace (what `nace upload` prints)
38
+ job:<job_id> a finished parse job's result, so it is not parsed again
39
+ (not for classify)
40
+ """
41
+
42
+ ON_CONFLICT_CHOICES = ("reject", "new_version")
43
+
44
+
45
+ def add_parsers(sub: argparse._SubParsersAction, common: argparse.ArgumentParser) -> None:
46
+ upload = sub.add_parser("upload", parents=[common], help="Upload a file into your workspace; prints its ws: source.")
47
+ upload.add_argument("file", help="Local file.")
48
+ upload.add_argument("--path", default=None, help="Path in the workspace (default: the file name).")
49
+ _add_on_conflict(upload)
50
+ upload.set_defaults(run=run_upload, family="client")
51
+
52
+ grant = sub.add_parser("upload-grant", parents=[common], help="Mint a one-use token another client can upload one file with.")
53
+ grant.add_argument("--path", default=None, help="Pin the workspace path the upload must use.")
54
+ grant.add_argument("--max-bytes", type=int, default=None, metavar="N", help="Largest file the grant accepts.")
55
+ grant.add_argument("--total-size-bytes", type=int, default=None, metavar="N", help="Exact size the file must have.")
56
+ grant.add_argument("--ttl-seconds", type=int, default=None, metavar="N", help="How long the grant stays valid.")
57
+ grant.set_defaults(run=run_upload_grant, family="client")
58
+
59
+ session = sub.add_parser("upload-session", parents=[common], help="Open a resumable upload in parts for a large file.")
60
+ session.add_argument("--path", required=True, help="Path of the file in the workspace.")
61
+ session.add_argument("--total-size-bytes", type=int, required=True, metavar="N", help="The file's exact size.")
62
+ session.add_argument("--ttl-seconds", type=int, default=None, metavar="N", help="How long the session accepts parts.")
63
+ _add_on_conflict(session)
64
+ session.add_argument("--idempotency-key", default=None, metavar="KEY", help="Retry-safe key; the same key returns the same session.")
65
+ session.set_defaults(run=run_upload_session, family="client")
66
+
67
+ parse = _operation(sub, common, "parse", "Convert a document to Markdown, text or layout blocks.")
68
+ parse.add_argument(
69
+ "--format",
70
+ dest="formats",
71
+ default=None,
72
+ help="Comma list of markdown,text,blocks (default: the API's markdown,blocks; stdout shows the Markdown).",
73
+ )
74
+ parse.add_argument(
75
+ "--password",
76
+ default=None,
77
+ metavar="VALUE",
78
+ help="An encrypted PDF's password: the text, @FILE, or - for stdin, typed hidden at a terminal (keeps it out of shell history).",
79
+ )
80
+ parse.set_defaults(run=run_parse)
81
+
82
+ split = _operation(sub, common, "split", "Split a multi-document file into the documents it contains.")
83
+ _add_class_args(split)
84
+ split.set_defaults(run=run_split)
85
+
86
+ classify = _operation(sub, common, "classify", "Classify a document, or each page, into your classes.")
87
+ _add_class_args(classify)
88
+ classify.add_argument("--granularity", choices=("document", "page"), default=None, help="One label for the file, or one per page.")
89
+ classify.set_defaults(run=run_classify)
90
+
91
+ extract = _operation(sub, common, "extract", "Extract structured fields with a JSON Schema, inline or saved.")
92
+ schema = extract.add_mutually_exclusive_group()
93
+ schema.add_argument("-s", "--schema", default=None, help="JSON Schema: a file path, @FILE, or inline JSON.")
94
+ schema.add_argument("--schema-id", default=None, metavar="ID", help="A saved schema (sch_...), at its latest version.")
95
+ extract.add_argument("--schema-version", type=int, default=None, metavar="N", help="With --schema-id, pin this version.")
96
+ extract.add_argument("--instructions", default=None, help="Extra guidance for the extraction.")
97
+ extract.set_defaults(run=run_extract)
98
+
99
+ ground = _operation(sub, common, "ground", "Find where each target text appears in a document.", pages=False)
100
+ ground.add_argument("--target", action="append", dest="targets", default=[], metavar="[ID=]TEXT", help="Repeatable; ids default to t1, t2, ...")
101
+ ground.add_argument("--targets", dest="targets_file", default=None, metavar="JSON", help="JSON array of targets.")
102
+ ground.add_argument(
103
+ "--semantic",
104
+ action="store_true",
105
+ help="Deprecated: match every target by meaning, unless it sets semantic itself. Fails on PDF, Word and PowerPoint.",
106
+ )
107
+ ground.set_defaults(run=run_ground)
108
+
109
+
110
+ def _add_on_conflict(parser: argparse.ArgumentParser) -> None:
111
+ # Unset means the document service's reject, for a local SOURCE too: a job reads its workspace file's
112
+ # current version when it starts, not when it is created, so new_version changes the input of jobs
113
+ # still queued on that file.
114
+ parser.add_argument(
115
+ "--on-conflict",
116
+ choices=ON_CONFLICT_CHOICES,
117
+ default=None,
118
+ help=(
119
+ "When a different file has the path: reject (default) fails with path_conflict; new_version adds a version, "
120
+ "which jobs on that file not yet started then read."
121
+ ),
122
+ )
123
+
124
+
125
+ def _operation(sub: argparse._SubParsersAction, common: argparse.ArgumentParser, name: str, help_text: str, *, pages: bool = True):
126
+ parser = sub.add_parser(
127
+ name, parents=[common], help=help_text, description=help_text, epilog=SOURCE_HELP, formatter_class=argparse.RawDescriptionHelpFormatter
128
+ )
129
+ parser.add_argument("source", help="https:// URL, local file, ws:<workspace>/<file>, or job:<parse job id>.")
130
+ parser.add_argument("--file-name", default=None, metavar="NAME", help="For an https:// SOURCE: the file's name; its extension picks the parser.")
131
+ parser.add_argument("--upload-path", default=None, metavar="PATH", help="For a local SOURCE: its path in the workspace (default: its name).")
132
+ _add_on_conflict(parser)
133
+ if pages:
134
+ # Ground reads whole documents: it has no page selection.
135
+ parser.add_argument("-p", "--pages", default=None, metavar="SPEC", help="1-based pages, e.g. 3 or 1-5,8 (not with a job: SOURCE).")
136
+ parser.add_argument(
137
+ "--mode",
138
+ dest="parse_mode",
139
+ choices=("low", "medium", "high"),
140
+ default=None,
141
+ help="Parse effort (default low); medium or high needs an original SOURCE, not job:.",
142
+ )
143
+ parser.add_argument(
144
+ "--option",
145
+ action="append",
146
+ dest="option_pairs",
147
+ default=[],
148
+ metavar="KEY=VALUE",
149
+ help="Any other request field; dotted keys nest, JSON values parse (output.formats='[\"text\"]').",
150
+ )
151
+ parser.add_argument("--options", default=None, metavar="JSON", help="Request fields as a JSON object (file, @FILE, inline); flags win.")
152
+ parser.add_argument("--wait-seconds", type=int, default=None, metavar="N", help="Hold the create open up to N seconds (max 60).")
153
+ parser.add_argument("--idempotency-key", default=None, metavar="KEY", help="Retry-safe key; the same key and body return the same job.")
154
+ add_wait_args(parser)
155
+ add_job_output_args(parser)
156
+ parser.set_defaults(family="client", operation=name)
157
+ return parser
158
+
159
+
160
+ def _add_class_args(parser: argparse.ArgumentParser) -> None:
161
+ parser.add_argument(
162
+ "--class",
163
+ action="append",
164
+ dest="class_specs",
165
+ default=[],
166
+ metavar="ID:LABEL[:DESCRIPTION]",
167
+ help="One class; repeatable.",
168
+ )
169
+ parser.add_argument("--classes", dest="classes_file", default=None, metavar="JSON", help="JSON array of classes.")
170
+
171
+
172
+ def _check_source_flags(args: argparse.Namespace) -> None:
173
+ """Refuse flag and SOURCE combinations the document service would refuse, before any upload or request."""
174
+ spec: str = args.source
175
+ if spec.lower().startswith("http://"):
176
+ raise ValueError("SOURCE must be an https:// URL: the document service refuses http://")
177
+ if args.file_name is not None and not spec.startswith("https://"):
178
+ raise ValueError("--file-name names the file at an https:// SOURCE; it doesn't apply here")
179
+ if (args.upload_path is not None or args.on_conflict is not None) and spec.startswith(("https://", "ws:", "job:")):
180
+ raise ValueError("--upload-path and --on-conflict apply to a local-file SOURCE only")
181
+ if not spec.startswith("job:"):
182
+ return
183
+ operation = args.operation
184
+ if operation == "classify":
185
+ raise ValueError("classify reads the original file: pass a URL, a local file or ws:, not job:")
186
+ if getattr(args, "pages", None) and operation in ("parse", "extract"):
187
+ raise ValueError(f"{operation} can't select pages of a job: SOURCE; drop -p, or pass the original file")
188
+ if getattr(args, "parse_mode", None) not in (None, "low"):
189
+ raise ValueError("--mode medium or high parses again, so it needs the original file, not job:")
190
+ if getattr(args, "password", None) is not None:
191
+ raise ValueError("--password is for the original file; a job: SOURCE is already parsed")
192
+
193
+
194
+ def resolve_source(client: NaceClient, args: argparse.Namespace) -> Source:
195
+ """``SOURCE`` as a request source, uploading a local file first."""
196
+ spec: str = args.source
197
+ if spec.startswith("https://"):
198
+ file_name = args.file_name
199
+ if file_name is None:
200
+ try:
201
+ file_name = url_file_name(spec)
202
+ except NaceError as exc:
203
+ raise NaceError(f"{exc} (or pass --file-name)") from None
204
+ return UrlSource(url=spec, file_name=file_name)
205
+ if spec.startswith("job:"):
206
+ return ParseResultSource(job_id=_uuid(spec[4:], spec))
207
+ if spec.startswith("ws:"):
208
+ workspace_id, sep, file_id = spec[3:].partition("/")
209
+ if not sep:
210
+ raise ValueError(f"expected ws:<workspace_id>/<file_id>, got {spec!r}")
211
+ return WorkspaceFileSource(workspace_id=_uuid(workspace_id, spec), file_id=_uuid(file_id, spec))
212
+ path = Path(spec)
213
+ if not path.is_file():
214
+ raise ValueError(f"no such file, and not a URL, ws: or job: source: {spec}")
215
+ print(f"uploading {path.name}", file=sys.stderr)
216
+ uploaded = upload_file(client, path, path=args.upload_path, on_conflict=args.on_conflict)
217
+ print(f"uploaded {workspace_source(uploaded)}", file=sys.stderr)
218
+ return uploaded.as_source()
219
+
220
+
221
+ def upload_file(client: NaceClient, file: Path, *, path: str | None, on_conflict: OnConflict | None) -> UploadedFile:
222
+ try:
223
+ return client.documents.upload(file, path=path, on_conflict=on_conflict)
224
+ except NaceAPIError:
225
+ raise
226
+ except NaceError as exc:
227
+ # Every request has gone out by now: the upload failed or is still assembling.
228
+ raise CommandFailed(str(exc)) from None
229
+
230
+
231
+ def _uuid(value: str, spec: str) -> str:
232
+ try:
233
+ return str(uuid.UUID(value))
234
+ except ValueError:
235
+ raise ValueError(f"not an id in {spec!r}: {value!r}") from None
236
+
237
+
238
+ def workspace_source(uploaded: UploadedFile) -> str:
239
+ return f"ws:{uploaded.workspace_id}/{uploaded.file_id}"
240
+
241
+
242
+ def _body(args: argparse.Namespace) -> dict[str, Any]:
243
+ """The request fields from ``--options``, then ``--option``, then the flags every operation shares."""
244
+ _check_source_flags(args)
245
+ base = read_json(args.options, what="--options") if args.options else {}
246
+ if not isinstance(base, dict):
247
+ raise ValueError("--options must be a JSON object")
248
+ body = parse_options(args.option_pairs, base)
249
+ if "source" in body:
250
+ raise ValueError("pass the source as SOURCE (with --file-name for a URL), not as a request field")
251
+ ranges = page_ranges(getattr(args, "pages", None))
252
+ if ranges is not None:
253
+ body["page_ranges"] = ranges
254
+ if getattr(args, "parse_mode", None):
255
+ body["parse_mode"] = args.parse_mode
256
+ return body
257
+
258
+
259
+ def _take(body: dict[str, Any], key: str, flag: Any, kind: type) -> Any:
260
+ """A value the SDK takes as an argument: the flag's, else the body field's (which is removed either way)."""
261
+ value = body.pop(key, None)
262
+ if flag is not None:
263
+ return flag
264
+ if value is not None and (not isinstance(value, kind) or isinstance(value, bool)):
265
+ raise ValueError(f"{key} must be a {kind.__name__}")
266
+ return value
267
+
268
+
269
+ def _classes(args: argparse.Namespace, body: dict[str, Any]) -> Any:
270
+ classes: list[dict[str, Any]] = []
271
+ if args.classes_file:
272
+ loaded = read_json(args.classes_file, what="--classes")
273
+ if not isinstance(loaded, list):
274
+ raise ValueError("--classes must be a JSON array")
275
+ classes.extend(loaded)
276
+ for spec in args.class_specs:
277
+ parts = spec.split(":", 2)
278
+ if len(parts) < 2 or not parts[0] or not parts[1]:
279
+ raise ValueError(f"--class needs ID:LABEL[:DESCRIPTION], got {spec!r}")
280
+ entry = {"id": parts[0], "label": parts[1]}
281
+ if len(parts) == 3 and parts[2]:
282
+ entry["description"] = parts[2]
283
+ classes.append(entry)
284
+ from_body = body.pop("classes", None)
285
+ if classes:
286
+ return classes
287
+ if not from_body:
288
+ raise ValueError("give at least one --class or --classes")
289
+ return from_body
290
+
291
+
292
+ def _submit(client: NaceClient, args: argparse.Namespace, body: dict[str, Any], **typed: Any) -> int:
293
+ """Send the operation: ``typed`` as the SDK's own arguments, every other field of ``body`` as given.
294
+
295
+ The run functions have already moved their typed fields (classes, schema, targets...) out of
296
+ ``body``, so ``extra_body``, which the SDK merges last, can't override them or the source.
297
+ """
298
+ wait_seconds = _take(body, "wait_seconds", args.wait_seconds, int)
299
+ idempotency_key = _take(body, "idempotency_key", args.idempotency_key, str)
300
+ # The SDK checks these too, but only at the create, after a local SOURCE was uploaded.
301
+ if wait_seconds is not None and not 0 <= wait_seconds <= MAX_WAIT_SECONDS:
302
+ raise ValueError(f"wait_seconds must be between 0 and {MAX_WAIT_SECONDS}")
303
+ if idempotency_key is not None and not 0 < len(idempotency_key) <= MAX_IDEMPOTENCY_KEY_LENGTH:
304
+ raise ValueError(f"the idempotency key must be 1 to {MAX_IDEMPOTENCY_KEY_LENGTH} characters")
305
+ source = resolve_source(client, args)
306
+ create: Callable[..., DocumentJob] = getattr(client.documents, args.operation)
307
+ job = create(source, wait_seconds=wait_seconds, idempotency_key=idempotency_key, extra_body=body or None, **typed)
308
+ return submit_and_report(client, job, args)
309
+
310
+
311
+ def run_upload(client: NaceClient, args: argparse.Namespace) -> int:
312
+ path = Path(args.file)
313
+ if not path.is_file():
314
+ raise ValueError(f"no such file: {args.file}")
315
+ uploaded = upload_file(client, path, path=args.path, on_conflict=args.on_conflict)
316
+ if args.json:
317
+ print(uploaded.model_dump_json(indent=2, exclude_none=True))
318
+ else:
319
+ print(workspace_source(uploaded))
320
+ return 0
321
+
322
+
323
+ def run_upload_grant(client: NaceClient, args: argparse.Namespace) -> int:
324
+ grant = client.documents.create_upload_grant(
325
+ path=args.path, max_bytes=args.max_bytes, total_size_bytes=args.total_size_bytes, ttl_seconds=args.ttl_seconds
326
+ )
327
+ print(grant.model_dump_json(indent=2))
328
+ print("the token uploads one file into your workspace: hand it only to whoever uploads that file", file=sys.stderr)
329
+ return 0
330
+
331
+
332
+ def run_upload_session(client: NaceClient, args: argparse.Namespace) -> int:
333
+ session = client.documents.create_upload_session(
334
+ path=args.path,
335
+ total_size_bytes=args.total_size_bytes,
336
+ ttl_seconds=args.ttl_seconds,
337
+ on_conflict=args.on_conflict,
338
+ idempotency_key=args.idempotency_key,
339
+ )
340
+ print(to_json(session.model_dump(mode="json")))
341
+ print("session_token writes into your workspace: hand it only to whoever uploads the parts", file=sys.stderr)
342
+ return 0
343
+
344
+
345
+ def run_parse(client: NaceClient, args: argparse.Namespace) -> int:
346
+ body = _body(args)
347
+ if args.formats:
348
+ output = body.get("output")
349
+ formats = [item.strip() for item in args.formats.split(",") if item.strip()]
350
+ body["output"] = {**(output if isinstance(output, dict) else {}), "formats": formats}
351
+ if args.password is not None:
352
+ body["password"] = read_secret(args.password, prompt="PDF password: ")
353
+ return _submit(client, args, body)
354
+
355
+
356
+ def run_split(client: NaceClient, args: argparse.Namespace) -> int:
357
+ body = _body(args)
358
+ return _submit(client, args, body, classes=_classes(args, body))
359
+
360
+
361
+ def run_classify(client: NaceClient, args: argparse.Namespace) -> int:
362
+ body = _body(args)
363
+ if args.granularity:
364
+ body["granularity"] = args.granularity
365
+ return _submit(client, args, body, classes=_classes(args, body))
366
+
367
+
368
+ def run_extract(client: NaceClient, args: argparse.Namespace) -> int:
369
+ body = _body(args)
370
+ if args.schema_version is not None and args.schema_id is None:
371
+ raise ValueError("--schema-version pins a version of a saved schema, so it needs --schema-id")
372
+ if args.schema is not None or args.schema_id is not None:
373
+ # A schema flag replaces whichever schema --options/--option named.
374
+ for key in ("schema", "schema_id", "schema_version"):
375
+ body.pop(key, None)
376
+ schema = read_json(args.schema, what="--schema") if args.schema is not None else None
377
+ if schema is not None and not isinstance(schema, dict):
378
+ raise ValueError("--schema must be a JSON object")
379
+ fields = {"schema": schema, "schema_id": args.schema_id, "schema_version": args.schema_version}
380
+ else:
381
+ fields = {key: body.pop(key, None) for key in ("schema", "schema_id", "schema_version")}
382
+ if fields["schema"] is None and fields["schema_id"] is None:
383
+ raise ValueError("give -s/--schema or --schema-id")
384
+ # As for the flags, refused here rather than by the SDK after a local SOURCE was uploaded.
385
+ if fields["schema"] is not None and fields["schema_id"] is not None:
386
+ raise ValueError("the options name both schema and schema_id; keep one")
387
+ if fields["schema_version"] is not None and fields["schema_id"] is None:
388
+ raise ValueError("schema_version pins a version of a saved schema, so it needs schema_id")
389
+ instructions = _take(body, "instructions", args.instructions, str)
390
+ return _submit(client, args, body, instructions=instructions, **fields)
391
+
392
+
393
+ def run_ground(client: NaceClient, args: argparse.Namespace) -> int:
394
+ body = _body(args)
395
+ targets: list[Any] = []
396
+ if args.targets_file:
397
+ loaded = read_json(args.targets_file, what="--targets")
398
+ if not isinstance(loaded, list):
399
+ raise ValueError("--targets must be a JSON array")
400
+ targets.extend(loaded)
401
+ for index, spec in enumerate(args.targets, start=1):
402
+ target_id, sep, text = spec.partition("=")
403
+ if not sep:
404
+ target_id, text = f"t{index}", spec
405
+ targets.append({"id": target_id.strip(), "text": text})
406
+ from_body = body.pop("targets", None)
407
+ if not targets:
408
+ if not from_body:
409
+ raise ValueError("give at least one --target or --targets")
410
+ targets = from_body
411
+ if args.semantic and isinstance(targets, list):
412
+ # NDI matches by meaning per target; there is no request-wide switch.
413
+ targets = [{"semantic": True, **target} if isinstance(target, dict) else target for target in targets]
414
+ return _submit(client, args, body, targets=targets)
@@ -0,0 +1,24 @@
1
+ """Bootstrap the ``nace`` executable: operating system trust store and UTF-8 stdio."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import sys
7
+
8
+ import truststore
9
+
10
+
11
+ def main(argv: list[str] | None = None) -> int:
12
+ """Enable native certificate verification and UTF-8 stdio before loading the CLI and SDK."""
13
+ truststore.inject_into_ssl()
14
+ # Piped stdio otherwise follows the Windows ANSI code page, which cannot encode every character a result may hold.
15
+ for stream in (sys.stdout, sys.stderr):
16
+ if isinstance(stream, io.TextIOWrapper):
17
+ stream.reconfigure(encoding="utf-8")
18
+ # The same for text piped in (`--state -`); `-sig` drops the BOM some Windows tools put first.
19
+ if isinstance(sys.stdin, io.TextIOWrapper):
20
+ sys.stdin.reconfigure(encoding="utf-8-sig")
21
+
22
+ from nace_cli._cli import main as cli_main # noqa: PLC0415
23
+
24
+ return cli_main(argv)
nace_cli/_inputs.py ADDED
@@ -0,0 +1,94 @@
1
+ """Reading command-line values: ``@file`` / ``-`` text, JSON, ``key=value`` options, page specs, ids."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ import uuid
9
+ from getpass import getpass
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+
14
+ def read_text(value: str) -> str:
15
+ """``-`` reads stdin, ``@PATH`` reads a file, anything else is the text itself."""
16
+ if value == "-":
17
+ return sys.stdin.read()
18
+ if value.startswith("@"):
19
+ return Path(value[1:]).read_text(encoding="utf-8")
20
+ return value
21
+
22
+
23
+ def read_secret(value: str, *, prompt: str) -> str:
24
+ """Like ``read_text``, without the one trailing newline a file or a pipe ends with.
25
+
26
+ ``-`` at a terminal asks with ``prompt`` and doesn't echo what is typed.
27
+ """
28
+ if value == "-" and sys.stdin.isatty():
29
+ return getpass(prompt)
30
+ text = read_text(value)
31
+ return text[:-1].removesuffix("\r") if text.endswith("\n") else text
32
+
33
+
34
+ def job_id(value: str) -> str:
35
+ """An argparse ``type``: a job id, refused as a usage error before any request when it is not a UUID."""
36
+ try:
37
+ return str(uuid.UUID(value.strip()))
38
+ except ValueError:
39
+ raise argparse.ArgumentTypeError(f"not a job id: {value!r}") from None
40
+
41
+
42
+ def read_json(value: str, *, what: str) -> Any:
43
+ """Inline JSON, ``@PATH``, ``-``, or a path to a JSON file."""
44
+ if not value.startswith(("@", "-", "{", "[")) and Path(value).is_file():
45
+ value = f"@{value}"
46
+ try:
47
+ return json.loads(read_text(value))
48
+ except json.JSONDecodeError as exc:
49
+ raise ValueError(f"{what} is not valid JSON: {exc.msg} (line {exc.lineno})") from None
50
+
51
+
52
+ def parse_options(pairs: list[str] | None, base: dict[str, Any] | None = None) -> dict[str, Any]:
53
+ """``key=value`` pairs into a request body. Dotted keys nest; a JSON value is parsed.
54
+
55
+ ``output.formats=["markdown"]`` becomes ``{"output": {"formats": ["markdown"]}}``;
56
+ ``parse_mode=high`` stays a string.
57
+ """
58
+ options: dict[str, Any] = dict(base or {})
59
+ for pair in pairs or []:
60
+ key, sep, raw = pair.partition("=")
61
+ if not sep or not key:
62
+ raise ValueError(f"--option needs KEY=VALUE, got {pair!r}")
63
+ try:
64
+ value: Any = json.loads(raw)
65
+ except json.JSONDecodeError:
66
+ value = raw
67
+ target = options
68
+ *parents, leaf = key.split(".")
69
+ for part in parents:
70
+ child = target.get(part)
71
+ if not isinstance(child, dict):
72
+ child = target[part] = {}
73
+ target = child
74
+ target[leaf] = value
75
+ return options
76
+
77
+
78
+ def page_ranges(spec: str | None) -> list[dict[str, int]] | None:
79
+ """``1-3,5`` into ``[{"start": 1, "end": 3}, {"start": 5, "end": 5}]`` (1-based, inclusive)."""
80
+ if not spec:
81
+ return None
82
+ ranges: list[dict[str, int]] = []
83
+ for part in spec.split(","):
84
+ part = part.strip()
85
+ start_text, sep, end_text = part.partition("-")
86
+ try:
87
+ start = int(start_text)
88
+ end = int(end_text) if sep else start
89
+ except ValueError:
90
+ raise ValueError(f"--pages must look like 3 or 1-5,8, got {spec!r}") from None
91
+ if start < 1 or end < start:
92
+ raise ValueError(f"--pages must look like 3 or 1-5,8, got {spec!r}")
93
+ ranges.append({"start": start, "end": end})
94
+ return ranges