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/__init__.py +9 -0
- nace_cli/_cli.py +151 -0
- nace_cli/_decide.py +147 -0
- nace_cli/_docops.py +414 -0
- nace_cli/_entrypoint.py +24 -0
- nace_cli/_inputs.py +94 -0
- nace_cli/_jobs.py +292 -0
- nace_cli/_login.py +77 -0
- nace_cli/_output.py +169 -0
- nace_cli/_schemas.py +135 -0
- nace_cli/py.typed +0 -0
- nace_cli-0.1.0.dist-info/METADATA +124 -0
- nace_cli-0.1.0.dist-info/RECORD +16 -0
- nace_cli-0.1.0.dist-info/WHEEL +4 -0
- nace_cli-0.1.0.dist-info/entry_points.txt +3 -0
- nace_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
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)
|
nace_cli/_entrypoint.py
ADDED
|
@@ -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
|