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/_jobs.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""``nace job``, ``jobs``, ``cancel``, ``file``, ``job-request``, ``rows``, ``events``, and the wait every create uses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import hashlib
|
|
7
|
+
import itertools
|
|
8
|
+
import sys
|
|
9
|
+
from collections.abc import Iterator
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from nace_sdk import (
|
|
13
|
+
DOCUMENT_OPERATIONS,
|
|
14
|
+
JOB_STATUSES,
|
|
15
|
+
MAX_ROWS_PAGE_SIZE,
|
|
16
|
+
DocumentJob,
|
|
17
|
+
NaceAPIConnectionError,
|
|
18
|
+
NaceAPIError,
|
|
19
|
+
NaceClient,
|
|
20
|
+
NaceError,
|
|
21
|
+
NaceJobFailedError,
|
|
22
|
+
NaceJobTimeoutError,
|
|
23
|
+
RowsPage,
|
|
24
|
+
)
|
|
25
|
+
from nace_sdk._core.endpoints import MAX_PAGE_SIZE, job_path, resource_path
|
|
26
|
+
from nace_sdk._core.jobs import DEFAULT_WAIT_SECONDS
|
|
27
|
+
|
|
28
|
+
from nace_cli._inputs import job_id
|
|
29
|
+
from nace_cli._output import add_job_output_args, emit, fetch_file, job_line, output_mode, render_job, to_json
|
|
30
|
+
|
|
31
|
+
#: Extensions of the job files the document service always sends as one type (it writes figure
|
|
32
|
+
#: and ground crops as PNG). Events, a stream, get none.
|
|
33
|
+
_EXTENSIONS = {
|
|
34
|
+
"document-content": ".md",
|
|
35
|
+
"spreadsheet-content": ".md",
|
|
36
|
+
"parse-images": ".png",
|
|
37
|
+
"ground-crops": ".png",
|
|
38
|
+
"converted-pdf": ".pdf",
|
|
39
|
+
"parse-transcript": ".json",
|
|
40
|
+
"ground-transcript": ".json",
|
|
41
|
+
"spreadsheet-cell-maps": ".json",
|
|
42
|
+
"spreadsheet-rows": ".json",
|
|
43
|
+
"request": ".json",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def add_wait_args(parser: argparse.ArgumentParser) -> None:
|
|
48
|
+
parser.add_argument("--async", dest="async_submit", action="store_true", help="Print the job id and return at once.")
|
|
49
|
+
_add_wait_timeout(parser, "How long to wait for the job")
|
|
50
|
+
parser.add_argument("--watch", action="store_true", help="Print progress events to stderr while waiting.")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _add_wait_timeout(parser: argparse.ArgumentParser, what: str) -> None:
|
|
54
|
+
parser.add_argument(
|
|
55
|
+
"--wait-timeout",
|
|
56
|
+
type=float,
|
|
57
|
+
default=DEFAULT_WAIT_SECONDS,
|
|
58
|
+
metavar="SECONDS",
|
|
59
|
+
help=f"{what} (default {DEFAULT_WAIT_SECONDS:.0f}s). It keeps going server-side after.",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def add_parsers(sub: argparse._SubParsersAction, common: argparse.ArgumentParser) -> None:
|
|
64
|
+
job = sub.add_parser("job", parents=[common], help="Read one job; --wait for it to finish.")
|
|
65
|
+
job.add_argument("job_id", type=job_id, help="The job id.")
|
|
66
|
+
job.add_argument("--wait", action="store_true", help="Wait until the job finishes.")
|
|
67
|
+
_add_wait_timeout(job, "With --wait, how long to wait for the job")
|
|
68
|
+
job.add_argument("--watch", action="store_true", help="Print progress events to stderr while waiting; implies --wait.")
|
|
69
|
+
add_job_output_args(job)
|
|
70
|
+
job.set_defaults(run=run_get, family="client", async_submit=False)
|
|
71
|
+
|
|
72
|
+
jobs = sub.add_parser("jobs", parents=[common], help="List your jobs, newest first.")
|
|
73
|
+
jobs.add_argument("--operation", choices=DOCUMENT_OPERATIONS, default=None, help="Only jobs of this operation.")
|
|
74
|
+
jobs.add_argument("--status", choices=JOB_STATUSES, default=None, help="Only jobs in this status.")
|
|
75
|
+
jobs.add_argument("--limit", type=int, default=20, help=f"Jobs per page, 1 to {MAX_PAGE_SIZE} (default 20).")
|
|
76
|
+
pages = jobs.add_mutually_exclusive_group()
|
|
77
|
+
pages.add_argument("--cursor", default=None, help="Start at this page: the next_cursor of the one before.")
|
|
78
|
+
pages.add_argument("--all", action="store_true", help="Every job, across every page.")
|
|
79
|
+
jobs.set_defaults(run=run_list, family="client")
|
|
80
|
+
|
|
81
|
+
cancel = sub.add_parser("cancel", parents=[common], help="Cancel a running job, or drop a finished one from `nace jobs`.")
|
|
82
|
+
cancel.add_argument("job_id", type=job_id, help="The job id.")
|
|
83
|
+
cancel.set_defaults(run=run_cancel, family="client")
|
|
84
|
+
|
|
85
|
+
file = sub.add_parser("file", parents=[common], help="Save a file a job produced: full Markdown, a figure crop, a transcript, ...")
|
|
86
|
+
file.add_argument("job_id", type=job_id, help="The job id.")
|
|
87
|
+
file.add_argument("path", help="The link from the job's result, or the part after the job id (parse-images/<ref>).")
|
|
88
|
+
file.add_argument(
|
|
89
|
+
"--save",
|
|
90
|
+
default=None,
|
|
91
|
+
metavar="PATH",
|
|
92
|
+
help="Where to write it; - for stdout (default: <job id>.md for a document's Markdown, else <job id>-<kind>[-<digest>] with its extension).",
|
|
93
|
+
)
|
|
94
|
+
file.add_argument("--link", action="store_true", help="Print a stored file's five-minute signed link instead of downloading.")
|
|
95
|
+
_add_wait_timeout(file, "How long to wait for content still being prepared")
|
|
96
|
+
file.set_defaults(run=run_file, family="client")
|
|
97
|
+
|
|
98
|
+
request = sub.add_parser("job-request", parents=[common], help="Print the request a job ran under, as JSON.")
|
|
99
|
+
request.add_argument("job_id", type=job_id, help="The job id.")
|
|
100
|
+
request.set_defaults(run=run_request, family="client")
|
|
101
|
+
|
|
102
|
+
rows = sub.add_parser("rows", parents=[common], help="Print a parsed sheet's rows as TSV (--json for the page object).")
|
|
103
|
+
rows.add_argument("job_id", type=job_id, help="The parse job id.")
|
|
104
|
+
rows.add_argument("path", help="The sheet's rows_url from the parse result, or the part after the job id.")
|
|
105
|
+
rows.add_argument("--start-row", type=int, default=None, metavar="N", help="First row, numbered from 0 (default 0).")
|
|
106
|
+
rows.add_argument("--limit", type=int, default=None, metavar="N", help=f"Rows per page, 1 to {MAX_ROWS_PAGE_SIZE} (default 50).")
|
|
107
|
+
rows.add_argument("--all", action="store_true", help=f"Every row from --start-row on, in pages of --limit (default {MAX_ROWS_PAGE_SIZE}).")
|
|
108
|
+
rows.set_defaults(run=run_rows, family="client")
|
|
109
|
+
|
|
110
|
+
events = sub.add_parser("events", parents=[common], help="Stream a job's progress events, one JSON object per line.")
|
|
111
|
+
events.add_argument("job_id", type=job_id, help="The job id.")
|
|
112
|
+
events.add_argument("--last-event-id", default=None, metavar="ID", help="Resume numbering after this event's sequence.")
|
|
113
|
+
events.set_defaults(run=run_events, family="client")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def wait_for(client: NaceClient, job: DocumentJob, args: argparse.Namespace) -> tuple[DocumentJob, int]:
|
|
117
|
+
"""Wait for ``job`` unless ``--async``; status lines go to stderr."""
|
|
118
|
+
print(f"job {job.job_id} {job.status}", file=sys.stderr)
|
|
119
|
+
if getattr(args, "async_submit", False):
|
|
120
|
+
return job, 0
|
|
121
|
+
try:
|
|
122
|
+
if job.is_terminal:
|
|
123
|
+
if job.status != "succeeded":
|
|
124
|
+
raise NaceJobFailedError(job)
|
|
125
|
+
return job, 0
|
|
126
|
+
if getattr(args, "watch", False):
|
|
127
|
+
_watch(client, job.job_id)
|
|
128
|
+
job = client.jobs.wait(job.job_id, timeout=args.wait_timeout)
|
|
129
|
+
except NaceJobFailedError as exc:
|
|
130
|
+
code = f" [{exc.job.error_code}]" if exc.job.error_code else ""
|
|
131
|
+
print(f"error:{code} {exc}", file=sys.stderr)
|
|
132
|
+
return exc.job, 1
|
|
133
|
+
except NaceJobTimeoutError as exc:
|
|
134
|
+
print(f"error: {exc}; read it later with `nace job {exc.job.job_id}`", file=sys.stderr)
|
|
135
|
+
return exc.job, 1
|
|
136
|
+
print(f"job {job.job_id} {job.status}", file=sys.stderr)
|
|
137
|
+
return job, 0
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _watch(client: NaceClient, job_id: str) -> None:
|
|
141
|
+
"""Progress frames to stderr until the stream ends. Best effort: the wait that follows decides."""
|
|
142
|
+
try:
|
|
143
|
+
for event in client.jobs.events(job_id):
|
|
144
|
+
parts = [str(part) for part in (event.status, event.message) if part]
|
|
145
|
+
if event.progress:
|
|
146
|
+
parts.append(" ".join(f"{key}={value}" for key, value in event.progress.items()))
|
|
147
|
+
if parts:
|
|
148
|
+
print(f" {' · '.join(parts)}", file=sys.stderr)
|
|
149
|
+
except Exception as exc: # noqa: BLE001 - the poll below still reads the outcome
|
|
150
|
+
print(f" (event stream ended: {exc})", file=sys.stderr)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def submit_and_report(client: NaceClient, job: DocumentJob, args: argparse.Namespace) -> int:
|
|
154
|
+
job, code = wait_for(client, job, args)
|
|
155
|
+
if getattr(args, "async_submit", False):
|
|
156
|
+
print(job.job_id)
|
|
157
|
+
return 0
|
|
158
|
+
if code != 0:
|
|
159
|
+
if output_mode(args) == "json":
|
|
160
|
+
emit(job.model_dump_json(indent=2, exclude_none=True), args)
|
|
161
|
+
return code
|
|
162
|
+
return render_job(client, job, args)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def run_get(client: NaceClient, args: argparse.Namespace) -> int:
|
|
166
|
+
job = client.jobs.get(args.job_id)
|
|
167
|
+
if args.wait or args.watch:
|
|
168
|
+
return submit_and_report(client, job, args)
|
|
169
|
+
return render_job(client, job, args)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def run_list(client: NaceClient, args: argparse.Namespace) -> int:
|
|
173
|
+
if args.all:
|
|
174
|
+
items = list(client.jobs.iter(operation=args.operation, status=args.status))
|
|
175
|
+
next_cursor = None
|
|
176
|
+
else:
|
|
177
|
+
page = client.jobs.list(limit=args.limit, operation=args.operation, status=args.status, cursor=args.cursor)
|
|
178
|
+
items, next_cursor = list(page.items), page.next_cursor
|
|
179
|
+
if args.json:
|
|
180
|
+
print(to_json({"items": [item.model_dump(mode="json", exclude_none=True) for item in items], "next_cursor": next_cursor}))
|
|
181
|
+
return 0
|
|
182
|
+
for item in items:
|
|
183
|
+
print(job_line(item))
|
|
184
|
+
more = f"--cursor {next_cursor} (or --all)"
|
|
185
|
+
if not items and next_cursor:
|
|
186
|
+
# A filtered page can come back empty while later pages still match.
|
|
187
|
+
print(f"no jobs on this page; more may match: {more}", file=sys.stderr)
|
|
188
|
+
elif not items:
|
|
189
|
+
print("no jobs", file=sys.stderr)
|
|
190
|
+
elif next_cursor:
|
|
191
|
+
print(f"more jobs: {more}", file=sys.stderr)
|
|
192
|
+
return 0
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def run_cancel(client: NaceClient, args: argparse.Namespace) -> int:
|
|
196
|
+
client.jobs.delete(args.job_id)
|
|
197
|
+
print(f"cancelled {args.job_id}", file=sys.stderr)
|
|
198
|
+
return 0
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _job_file(job: str, path: str) -> list[str]:
|
|
202
|
+
"""``path``'s segments after the job id: ``[kind, reference...]``. Refuses what the SDK would, before any request."""
|
|
203
|
+
return resource_path(job, path).removeprefix(f"{job_path(job)}/").split("/")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _default_file_name(job: str, segments: list[str]) -> str:
|
|
207
|
+
"""Where ``nace file`` saves a job file when ``--save`` is not given.
|
|
208
|
+
|
|
209
|
+
A stored file's reference is long and opaque (its storage URL, encoded), so it is shortened to a
|
|
210
|
+
digest that keeps the files of one kind apart.
|
|
211
|
+
"""
|
|
212
|
+
kind, reference = segments[0], "/".join(segments[1:])
|
|
213
|
+
extension = _EXTENSIONS.get(kind, "")
|
|
214
|
+
if kind == "document-content":
|
|
215
|
+
return f"{job}{extension}"
|
|
216
|
+
digest = f"-{hashlib.sha256(reference.encode()).hexdigest()[:8]}" if reference else ""
|
|
217
|
+
return f"{job}-{kind}{digest}{extension}"
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def run_file(client: NaceClient, args: argparse.Namespace) -> int:
|
|
221
|
+
# A bad PATH is a usage error here, as it is for `nace rows`.
|
|
222
|
+
segments = _job_file(args.job_id, args.path)
|
|
223
|
+
if args.link:
|
|
224
|
+
try:
|
|
225
|
+
link = client.jobs.file_link(args.job_id, args.path)
|
|
226
|
+
except (NaceAPIError, NaceAPIConnectionError):
|
|
227
|
+
raise
|
|
228
|
+
except NaceError as exc:
|
|
229
|
+
# What is left is the answer itself: the file streams, so it has no signed link.
|
|
230
|
+
raise NaceError(f"{exc} With nace, drop --link to save it.") from None
|
|
231
|
+
if args.json:
|
|
232
|
+
print(link.model_dump_json(indent=2))
|
|
233
|
+
else:
|
|
234
|
+
print(link.url)
|
|
235
|
+
return 0
|
|
236
|
+
save = args.save or _default_file_name(args.job_id, segments)
|
|
237
|
+
fetch_file(client, args.job_id, args.path, save, wait_timeout=args.wait_timeout)
|
|
238
|
+
return 0
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def run_request(client: NaceClient, args: argparse.Namespace) -> int:
|
|
242
|
+
print(to_json(client.jobs.get_request(args.job_id).model_dump(mode="json")))
|
|
243
|
+
return 0
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _cell(value: Any) -> str:
|
|
247
|
+
text = "" if value is None else str(value)
|
|
248
|
+
return text.replace("\\", "\\\\").replace("\t", "\\t").replace("\n", "\\n").replace("\r", "\\r")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _row_pages(client: NaceClient, args: argparse.Namespace) -> Iterator[RowsPage]:
|
|
252
|
+
"""The page ``--start-row``/``--limit`` name, then with ``--all`` every page after it."""
|
|
253
|
+
# Only an omitted --limit takes the largest page: an explicit 0 still reaches the SDK's range check.
|
|
254
|
+
limit = MAX_ROWS_PAGE_SIZE if args.all and args.limit is None else args.limit
|
|
255
|
+
page = client.jobs.rows(args.job_id, args.path, start_row=args.start_row, limit=limit)
|
|
256
|
+
yield page
|
|
257
|
+
while args.all and page.rows and (end := page.start_row + len(page.rows)) < page.total_rows:
|
|
258
|
+
page = client.jobs.rows(args.job_id, args.path, start_row=end, limit=limit)
|
|
259
|
+
yield page
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def run_rows(client: NaceClient, args: argparse.Namespace) -> int:
|
|
263
|
+
pages = _row_pages(client, args)
|
|
264
|
+
first = last = next(pages)
|
|
265
|
+
if args.json and not args.all:
|
|
266
|
+
print(to_json(first.model_dump(mode="json")))
|
|
267
|
+
return 0
|
|
268
|
+
rows: list[dict[str, str]] = []
|
|
269
|
+
if not args.json:
|
|
270
|
+
print("\t".join(_cell(column.name) for column in first.columns))
|
|
271
|
+
for page in itertools.chain([first], pages):
|
|
272
|
+
last = page
|
|
273
|
+
if args.json:
|
|
274
|
+
rows.extend(page.rows)
|
|
275
|
+
continue
|
|
276
|
+
for row in page.rows:
|
|
277
|
+
print("\t".join(_cell(row.get(column.name)) for column in first.columns))
|
|
278
|
+
if args.json:
|
|
279
|
+
columns = [column.model_dump(mode="json") for column in first.columns]
|
|
280
|
+
print(to_json({"columns": columns, "rows": rows, "total_rows": last.total_rows}))
|
|
281
|
+
return 0
|
|
282
|
+
end = last.start_row + len(last.rows)
|
|
283
|
+
span = f"rows {first.start_row}-{end - 1}" if end > first.start_row else f"no rows from {first.start_row}"
|
|
284
|
+
more = f"; more: --start-row {end} (or --all)" if last.rows and end < last.total_rows else ""
|
|
285
|
+
print(f"{span} of {last.total_rows}{more}", file=sys.stderr)
|
|
286
|
+
return 0
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def run_events(client: NaceClient, args: argparse.Namespace) -> int:
|
|
290
|
+
for event in client.jobs.events(args.job_id, last_event_id=args.last_event_id):
|
|
291
|
+
print(event.model_dump_json(exclude_none=True), flush=True)
|
|
292
|
+
return 0
|
nace_cli/_login.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""``nace login``, ``nace logout`` and ``nace version``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from getpass import getpass
|
|
8
|
+
|
|
9
|
+
from nace_sdk.constants import BASE_URL_ENV, DEFAULT_BASE_URL, read_env
|
|
10
|
+
from nace_sdk.credentials import CredentialsError, config_path, login, logout, read_config
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _env_base_url() -> str:
|
|
14
|
+
return read_env(BASE_URL_ENV) or ""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def dashboard_url(path: str) -> str:
|
|
18
|
+
"""A dashboard page on the Drex the CLI talks to: ``$NACE_BASE_URL``, else the saved one, else production.
|
|
19
|
+
|
|
20
|
+
A config file that can't be read counts as none: this only words a hint.
|
|
21
|
+
"""
|
|
22
|
+
base = _env_base_url()
|
|
23
|
+
if not base:
|
|
24
|
+
try:
|
|
25
|
+
base = read_config(config_path(), recover=True).get("base_url", "")
|
|
26
|
+
except OSError:
|
|
27
|
+
base = ""
|
|
28
|
+
return f"{(base or DEFAULT_BASE_URL).rstrip('/')}{path}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def add_parsers(sub: argparse._SubParsersAction) -> None:
|
|
32
|
+
# Building the parser reads no file, so --help and version work whatever state the config is in.
|
|
33
|
+
host = (_env_base_url() or DEFAULT_BASE_URL).rstrip("/")
|
|
34
|
+
login_p = sub.add_parser("login", help=f"Save an API key (create one at {host}/dashboard/api-keys).")
|
|
35
|
+
login_p.add_argument(
|
|
36
|
+
"--api-key",
|
|
37
|
+
default=None,
|
|
38
|
+
metavar="KEY",
|
|
39
|
+
help="The nace_sk_ key; --api-key - reads it from stdin (hidden at a terminal). Omit to type it hidden.",
|
|
40
|
+
)
|
|
41
|
+
login_p.add_argument("--base-url", default=None, metavar="URL", help="Save a different API host with the key.")
|
|
42
|
+
login_p.set_defaults(run=run_login, family="standalone")
|
|
43
|
+
|
|
44
|
+
logout_p = sub.add_parser("logout", help="Delete the saved API key.")
|
|
45
|
+
logout_p.set_defaults(run=run_logout, family="standalone")
|
|
46
|
+
|
|
47
|
+
version = sub.add_parser("version", help="Print CLI and SDK versions.")
|
|
48
|
+
version.set_defaults(run=run_version, family="standalone")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def run_login(args: argparse.Namespace) -> int:
|
|
52
|
+
key = args.api_key
|
|
53
|
+
if key == "-":
|
|
54
|
+
key = getpass("Drex API key: ") if sys.stdin.isatty() else sys.stdin.readline()
|
|
55
|
+
elif key is None:
|
|
56
|
+
if not sys.stdin.isatty():
|
|
57
|
+
raise CredentialsError("pass --api-key KEY (or --api-key - to read stdin) when not in a terminal")
|
|
58
|
+
key = getpass("Drex API key: ")
|
|
59
|
+
path = login(key, base_url=args.base_url)
|
|
60
|
+
print(f"saved API key to {path}", file=sys.stderr)
|
|
61
|
+
return 0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def run_logout(_args: argparse.Namespace) -> int:
|
|
65
|
+
path = logout()
|
|
66
|
+
print(f"removed {path}" if path else "no saved API key", file=sys.stderr)
|
|
67
|
+
return 0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def run_version(_args: argparse.Namespace) -> int:
|
|
71
|
+
from nace_sdk import __version__ as sdk_version # noqa: PLC0415
|
|
72
|
+
|
|
73
|
+
from nace_cli import __version__ # noqa: PLC0415
|
|
74
|
+
|
|
75
|
+
print(f"nace {__version__}")
|
|
76
|
+
print(f"nace-sdk {sdk_version}")
|
|
77
|
+
return 0
|
nace_cli/_output.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Stdout is the payload; stderr is status. ``-o`` picks the shape of a job's output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import shutil
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from nace_sdk import DocumentJob, NaceAPIError, NaceClient, NaceError
|
|
14
|
+
from nace_sdk._core.jobs import DEFAULT_WAIT_SECONDS
|
|
15
|
+
|
|
16
|
+
OUTPUT_CHOICES = ("auto", "md", "json", "result", "id")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CommandFailed(Exception):
|
|
20
|
+
"""A failure after a request went out (exit 1), as opposed to a usage error (exit 2)."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def add_job_output_args(parser: argparse.ArgumentParser) -> None:
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
"-o",
|
|
26
|
+
"--output",
|
|
27
|
+
choices=OUTPUT_CHOICES,
|
|
28
|
+
default="auto",
|
|
29
|
+
help="auto: Markdown for a parse, the result JSON otherwise; md; json (the whole job); result; id.",
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument("--save", default=None, metavar="PATH", help="Write the output to this file instead of stdout.")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def output_mode(args: argparse.Namespace) -> str:
|
|
35
|
+
if getattr(args, "json", False):
|
|
36
|
+
return "json"
|
|
37
|
+
return getattr(args, "output", None) or "auto"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def to_json(value: Any) -> str:
|
|
41
|
+
return json.dumps(value, indent=2, ensure_ascii=False)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def job_json(job: DocumentJob) -> str:
|
|
45
|
+
return job.model_dump_json(indent=2, exclude_none=True)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _document(job: DocumentJob) -> dict[str, Any] | None:
|
|
49
|
+
result = job.result
|
|
50
|
+
document = result.get("document") if isinstance(result, dict) else None
|
|
51
|
+
return document if isinstance(document, dict) else None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def parse_text(job: DocumentJob) -> str | None:
|
|
55
|
+
"""A parse job's Markdown, else its plain text, else ``None``."""
|
|
56
|
+
document = _document(job)
|
|
57
|
+
if document is None:
|
|
58
|
+
return None
|
|
59
|
+
for field in ("markdown", "text"):
|
|
60
|
+
value = document.get(field)
|
|
61
|
+
if isinstance(value, str):
|
|
62
|
+
return value
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def emit(text: str, args: argparse.Namespace) -> int:
|
|
67
|
+
save = getattr(args, "save", None)
|
|
68
|
+
if not save or save == "-":
|
|
69
|
+
print(text)
|
|
70
|
+
return 0
|
|
71
|
+
target = Path(save)
|
|
72
|
+
# encoding is explicit: the default follows the locale, so a C/POSIX
|
|
73
|
+
# environment would fail on any non-ASCII document the job just produced.
|
|
74
|
+
try:
|
|
75
|
+
target.write_text(text if text.endswith("\n") else f"{text}\n", encoding="utf-8")
|
|
76
|
+
except OSError as exc:
|
|
77
|
+
# The job already ran and was billed: print the result rather than lose it.
|
|
78
|
+
print(f"error: could not write {target}: {exc.strerror or exc}", file=sys.stderr)
|
|
79
|
+
print(text)
|
|
80
|
+
return 1
|
|
81
|
+
print(f"saved {target}", file=sys.stderr)
|
|
82
|
+
return 0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def fetch_file(client: NaceClient, job_id: str, path: str, save: str | None, *, wait_timeout: float) -> None:
|
|
86
|
+
"""Save one of a job's files to ``save``, or copy it to stdout when ``save`` is unset or ``-``.
|
|
87
|
+
|
|
88
|
+
``path`` comes from a result, or was checked before the call (``nace file``).
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
CommandFailed: The file was still being prepared after ``wait_timeout`` seconds.
|
|
92
|
+
"""
|
|
93
|
+
try:
|
|
94
|
+
if save and save != "-":
|
|
95
|
+
saved = client.jobs.download(job_id, path, save, wait_timeout=wait_timeout)
|
|
96
|
+
print(f"saved {saved}", file=sys.stderr)
|
|
97
|
+
return
|
|
98
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
99
|
+
saved = client.jobs.download(job_id, path, Path(tmp) / "file", wait_timeout=wait_timeout)
|
|
100
|
+
sys.stdout.flush()
|
|
101
|
+
with saved.open("rb") as handle:
|
|
102
|
+
shutil.copyfileobj(handle, sys.stdout.buffer)
|
|
103
|
+
sys.stdout.buffer.flush()
|
|
104
|
+
except NaceAPIError:
|
|
105
|
+
raise
|
|
106
|
+
except NaceError as exc:
|
|
107
|
+
# The job id and path are checked before the call, so what is left followed a request.
|
|
108
|
+
raise CommandFailed(str(exc)) from None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def emit_markdown(client: NaceClient, job: DocumentJob, markdown: str, args: argparse.Namespace) -> int:
|
|
112
|
+
"""A parse's Markdown, else its text. A large document's result holds only a preview, so its full content is fetched instead.
|
|
113
|
+
|
|
114
|
+
That content can be Markdown when only text was asked for: the document service may hold it only that way.
|
|
115
|
+
A workbook's sheets are pointed at, not fetched.
|
|
116
|
+
"""
|
|
117
|
+
document = _document(job) or {}
|
|
118
|
+
content_url = document.get("content_url")
|
|
119
|
+
if isinstance(content_url, str) and content_url:
|
|
120
|
+
has_markdown = isinstance(document.get("markdown"), str)
|
|
121
|
+
note = "" if has_markdown else ", which can be Markdown though you asked only for text"
|
|
122
|
+
print(f"the result holds a preview of this large document; fetching its full content{note}", file=sys.stderr)
|
|
123
|
+
try:
|
|
124
|
+
fetch_file(client, job.job_id, content_url, getattr(args, "save", None), wait_timeout=getattr(args, "wait_timeout", DEFAULT_WAIT_SECONDS))
|
|
125
|
+
except (NaceError, CommandFailed, OSError) as exc:
|
|
126
|
+
# The job already ran and was billed: print the preview rather than nothing.
|
|
127
|
+
print(f"error: could not fetch the full content: {exc}", file=sys.stderr)
|
|
128
|
+
shown = "Markdown" if has_markdown else "text"
|
|
129
|
+
print(f"the {shown} below is a preview; fetch the full content with: nace file {job.job_id} {content_url}", file=sys.stderr)
|
|
130
|
+
print(markdown)
|
|
131
|
+
return 1
|
|
132
|
+
return 0
|
|
133
|
+
spreadsheet = document.get("spreadsheet")
|
|
134
|
+
sheets = spreadsheet.get("sheets") if isinstance(spreadsheet, dict) else None
|
|
135
|
+
previews = [sheet for sheet in sheets or [] if isinstance(sheet, dict) and isinstance(sheet.get("content_url"), str)]
|
|
136
|
+
if previews:
|
|
137
|
+
print("the Markdown below previews these sheets; save each one in full with:", file=sys.stderr)
|
|
138
|
+
for sheet in previews:
|
|
139
|
+
print(f" {sheet.get('sheet_name')}: nace file {job.job_id} {sheet['content_url']}", file=sys.stderr)
|
|
140
|
+
return emit(markdown, args)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def render_job(client: NaceClient, job: DocumentJob, args: argparse.Namespace) -> int:
|
|
144
|
+
mode = output_mode(args)
|
|
145
|
+
if mode == "id":
|
|
146
|
+
print(job.job_id)
|
|
147
|
+
return 0
|
|
148
|
+
if mode == "json":
|
|
149
|
+
return emit(job_json(job), args)
|
|
150
|
+
if mode == "md":
|
|
151
|
+
markdown = parse_text(job)
|
|
152
|
+
if markdown is None:
|
|
153
|
+
print("error: this job has no Markdown; use -o auto, json, result or id", file=sys.stderr)
|
|
154
|
+
return 1
|
|
155
|
+
return emit_markdown(client, job, markdown, args)
|
|
156
|
+
if job.result is None:
|
|
157
|
+
# Not finished, or failed: the job itself is the useful answer.
|
|
158
|
+
return emit(job_json(job), args)
|
|
159
|
+
if mode == "auto":
|
|
160
|
+
markdown = parse_text(job)
|
|
161
|
+
if markdown is not None:
|
|
162
|
+
return emit_markdown(client, job, markdown, args)
|
|
163
|
+
return emit(to_json(job.result), args)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def job_line(job: DocumentJob) -> str:
|
|
167
|
+
credits = f" {job.credits} credits" if job.credits is not None else ""
|
|
168
|
+
created = f" {job.created_at}" if job.created_at else ""
|
|
169
|
+
return f"{job.job_id} {job.kind:<8} {job.status:<9}{credits}{created}"
|
nace_cli/_schemas.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""``nace schemas``, ``nace schema``, and managing a saved extraction schema's versions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Callable, Iterable
|
|
8
|
+
|
|
9
|
+
from nace_sdk import ExtractionSchema, ExtractionSchemaList, NaceClient
|
|
10
|
+
from nace_sdk._core.endpoints import EXTRACTION_SCHEMA_MAX_PAGE_SIZE
|
|
11
|
+
|
|
12
|
+
from nace_cli._inputs import read_json
|
|
13
|
+
from nace_cli._output import to_json
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def add_parsers(sub: argparse._SubParsersAction, common: argparse.ArgumentParser) -> None:
|
|
17
|
+
schemas = sub.add_parser("schemas", parents=[common], help="List saved extraction schemas at their latest version, yours and Nace's, by id.")
|
|
18
|
+
_add_page_args(schemas, "Schemas")
|
|
19
|
+
schemas.set_defaults(run=run_list, family="client")
|
|
20
|
+
|
|
21
|
+
schema = sub.add_parser("schema", parents=[common], help="Print a saved schema's JSON Schema, at its latest version.")
|
|
22
|
+
schema.add_argument("schema_id", help="The schema's id (sch_...).")
|
|
23
|
+
schema.set_defaults(run=run_get, family="client")
|
|
24
|
+
|
|
25
|
+
create = sub.add_parser("schema-create", parents=[common], help="Save a new extraction schema.")
|
|
26
|
+
create.add_argument("name", help="A name for the schema.")
|
|
27
|
+
create.add_argument("-s", "--schema", required=True, help="JSON Schema: a file path, @FILE, or inline JSON.")
|
|
28
|
+
create.add_argument("--description", default=None, help="What the schema is for.")
|
|
29
|
+
create.set_defaults(run=run_create, family="client")
|
|
30
|
+
|
|
31
|
+
versions = sub.add_parser("schema-versions", parents=[common], help="List a saved schema's versions, oldest first.")
|
|
32
|
+
versions.add_argument("schema_id", help="The schema's id (sch_...).")
|
|
33
|
+
_add_page_args(versions, "Versions")
|
|
34
|
+
versions.set_defaults(run=run_list_versions, family="client")
|
|
35
|
+
|
|
36
|
+
version = sub.add_parser("schema-version", parents=[common], help="Print one version of a saved schema's JSON Schema.")
|
|
37
|
+
version.add_argument("schema_id", help="The schema's id (sch_...).")
|
|
38
|
+
version.add_argument("version", type=int, help="The version number.")
|
|
39
|
+
version.set_defaults(run=run_get_version, family="client")
|
|
40
|
+
|
|
41
|
+
add_version = sub.add_parser("schema-version-add", parents=[common], help="Add a new version to an existing schema.")
|
|
42
|
+
add_version.add_argument("schema_id", help="The schema's id (sch_...).")
|
|
43
|
+
add_version.add_argument("-s", "--schema", required=True, help="JSON Schema: a file path, @FILE, or inline JSON.")
|
|
44
|
+
add_version.add_argument("--name", default=None, help="Rename the schema; omit to keep its current name.")
|
|
45
|
+
add_version.add_argument("--description", default=None, help="Omit to keep the current description.")
|
|
46
|
+
add_version.set_defaults(run=run_add_version, family="client")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _add_page_args(parser: argparse.ArgumentParser, what: str) -> None:
|
|
50
|
+
parser.add_argument("--limit", type=int, default=20, help=f"{what} per page, 1 to {EXTRACTION_SCHEMA_MAX_PAGE_SIZE} (default 20).")
|
|
51
|
+
pages = parser.add_mutually_exclusive_group()
|
|
52
|
+
pages.add_argument("--cursor", default=None, help="Start at this page: the next_cursor of the one before.")
|
|
53
|
+
pages.add_argument("--all", action="store_true", help=f"Every {what.lower()[:-1]}, across every page.")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _summary(item: ExtractionSchema) -> str:
|
|
57
|
+
return f"{item.schema_id} v{item.version} {item.name}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _emit(item: ExtractionSchema, args: argparse.Namespace) -> int:
|
|
61
|
+
"""A schema just saved: its summary line, or the whole record with ``--json``."""
|
|
62
|
+
if args.json:
|
|
63
|
+
print(item.model_dump_json(indent=2, by_alias=True, exclude_none=True))
|
|
64
|
+
else:
|
|
65
|
+
print(_summary(item))
|
|
66
|
+
return 0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _emit_schema(item: ExtractionSchema, args: argparse.Namespace) -> int:
|
|
70
|
+
"""A schema read back: the JSON Schema itself on stdout, ready for ``--schema``, and its summary on stderr."""
|
|
71
|
+
if args.json:
|
|
72
|
+
print(item.model_dump_json(indent=2, by_alias=True, exclude_none=True))
|
|
73
|
+
return 0
|
|
74
|
+
print(_summary(item), file=sys.stderr)
|
|
75
|
+
if item.schema_ is not None:
|
|
76
|
+
print(to_json(item.schema_))
|
|
77
|
+
return 0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _emit_page(args: argparse.Namespace, what: str, page: Callable[[], ExtractionSchemaList], every: Callable[[], Iterable[ExtractionSchema]]) -> int:
|
|
81
|
+
if args.all:
|
|
82
|
+
items, next_cursor = list(every()), None
|
|
83
|
+
else:
|
|
84
|
+
listed = page()
|
|
85
|
+
items, next_cursor = list(listed.items), listed.next_cursor
|
|
86
|
+
if args.json:
|
|
87
|
+
payload = [item.model_dump(mode="json", by_alias=True, exclude_none=True) for item in items]
|
|
88
|
+
print(to_json({"items": payload, "next_cursor": next_cursor}))
|
|
89
|
+
return 0
|
|
90
|
+
for item in items:
|
|
91
|
+
print(_summary(item))
|
|
92
|
+
if not items:
|
|
93
|
+
print(f"no {what}", file=sys.stderr)
|
|
94
|
+
if next_cursor:
|
|
95
|
+
print(f"more {what}: --cursor {next_cursor} (or --all)", file=sys.stderr)
|
|
96
|
+
return 0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def run_list(client: NaceClient, args: argparse.Namespace) -> int:
|
|
100
|
+
schemas = client.extraction_schemas
|
|
101
|
+
return _emit_page(args, "schemas", lambda: schemas.list(limit=args.limit, cursor=args.cursor), schemas.iter)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def run_get(client: NaceClient, args: argparse.Namespace) -> int:
|
|
105
|
+
return _emit_schema(client.extraction_schemas.get(args.schema_id), args)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def run_create(client: NaceClient, args: argparse.Namespace) -> int:
|
|
109
|
+
schema = read_json(args.schema, what="--schema")
|
|
110
|
+
if not isinstance(schema, dict):
|
|
111
|
+
raise ValueError("--schema must be a JSON object")
|
|
112
|
+
created = client.extraction_schemas.create(args.name, schema, description=args.description)
|
|
113
|
+
return _emit(created, args)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def run_list_versions(client: NaceClient, args: argparse.Namespace) -> int:
|
|
117
|
+
schemas = client.extraction_schemas
|
|
118
|
+
return _emit_page(
|
|
119
|
+
args,
|
|
120
|
+
"versions",
|
|
121
|
+
lambda: schemas.list_versions(args.schema_id, limit=args.limit, cursor=args.cursor),
|
|
122
|
+
lambda: schemas.iter_versions(args.schema_id),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def run_get_version(client: NaceClient, args: argparse.Namespace) -> int:
|
|
127
|
+
return _emit_schema(client.extraction_schemas.get_version(args.schema_id, args.version), args)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def run_add_version(client: NaceClient, args: argparse.Namespace) -> int:
|
|
131
|
+
schema = read_json(args.schema, what="--schema")
|
|
132
|
+
if not isinstance(schema, dict):
|
|
133
|
+
raise ValueError("--schema must be a JSON object")
|
|
134
|
+
created = client.extraction_schemas.create_version(args.schema_id, schema, name=args.name, description=args.description)
|
|
135
|
+
return _emit(created, args)
|
nace_cli/py.typed
ADDED
|
File without changes
|