unstract-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.
- unstract_cli/__init__.py +3 -0
- unstract_cli/__main__.py +100 -0
- unstract_cli/app.py +321 -0
- unstract_cli/commands/__init__.py +0 -0
- unstract_cli/commands/clone_cmd.py +297 -0
- unstract_cli/commands/common.py +114 -0
- unstract_cli/commands/config_cmd.py +551 -0
- unstract_cli/commands/docstudio_cmd.py +298 -0
- unstract_cli/commands/platform_cmd.py +692 -0
- unstract_cli/commands/whisper_cmd.py +419 -0
- unstract_cli/config.py +747 -0
- unstract_cli/core/__init__.py +0 -0
- unstract_cli/core/clients.py +417 -0
- unstract_cli/core/discover.py +189 -0
- unstract_cli/core/errors.py +443 -0
- unstract_cli/core/output.py +431 -0
- unstract_cli/core/overlay.py +50 -0
- unstract_cli/core/params.py +518 -0
- unstract_cli/core/platform.py +102 -0
- unstract_cli/core/poll.py +432 -0
- unstract_cli/overlay.toml +7 -0
- unstract_cli/specs/README.md +28 -0
- unstract_cli/specs/docstudio.json +1077 -0
- unstract_cli/specs/llmwhisperer.json +2709 -0
- unstract_cli/specs/provenance.json +16 -0
- unstract_cli-0.1.0.dist-info/METADATA +194 -0
- unstract_cli-0.1.0.dist-info/RECORD +30 -0
- unstract_cli-0.1.0.dist-info/WHEEL +4 -0
- unstract_cli-0.1.0.dist-info/entry_points.txt +3 -0
- unstract_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""`unstract docstudio deployment ...` -- running a deployed API.
|
|
2
|
+
|
|
3
|
+
The deployment client reports failure by returning a status code rather than
|
|
4
|
+
raising, and it has no polling loop of its own, so both are handled here.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from typing import Any
|
|
11
|
+
from urllib.parse import parse_qs, quote, urlparse
|
|
12
|
+
|
|
13
|
+
import click
|
|
14
|
+
from unstract.api_deployments.client import APIDeploymentsClient
|
|
15
|
+
|
|
16
|
+
from unstract_cli.app import Context, deployment_group, pass_context
|
|
17
|
+
from unstract_cli.commands.common import finish, raw_fields, wait_options
|
|
18
|
+
from unstract_cli.core.clients import (
|
|
19
|
+
deployment,
|
|
20
|
+
deployment_errors,
|
|
21
|
+
raise_for_result,
|
|
22
|
+
translated,
|
|
23
|
+
translating,
|
|
24
|
+
)
|
|
25
|
+
from unstract_cli.core.errors import CLIError, ExitCode
|
|
26
|
+
from unstract_cli.core.output import diagnostic
|
|
27
|
+
from unstract_cli.core.params import requested, spec_options
|
|
28
|
+
from unstract_cli.core.poll import (
|
|
29
|
+
PollSpec,
|
|
30
|
+
PollState,
|
|
31
|
+
classify,
|
|
32
|
+
persist,
|
|
33
|
+
preflight,
|
|
34
|
+
wait_for_completion,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
PRODUCT = "docstudio"
|
|
38
|
+
|
|
39
|
+
#: The run POST and the status GET spell the state under different names, and
|
|
40
|
+
#: the API answers HTTP 422 while still executing -- only the body decides.
|
|
41
|
+
RUN_POLL = PollSpec(
|
|
42
|
+
handle_field="status_check_api_endpoint",
|
|
43
|
+
terminal_success=("COMPLETED", "SUCCESS"),
|
|
44
|
+
terminal_failure=("ERROR", "ERROR_EXCEPTION", "FAILED", "STOPPED"),
|
|
45
|
+
status_field=("execution_status", "status"),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
#: What `--output raw` prints, best answer first: a queued run carries a handle
|
|
49
|
+
#: and no result, so a single field would be wrong for some of the shapes.
|
|
50
|
+
RUN_RAW = ("extraction_result", "execution_id")
|
|
51
|
+
STATUS_RAW = ("extraction_result", "execution_status")
|
|
52
|
+
|
|
53
|
+
#: Shared by the run POST and the status GET: what the run asked for has to be
|
|
54
|
+
#: asked for again when the result is read.
|
|
55
|
+
_SHARED_WITH_STATUS = ("include_metadata", "include_metrics", "include_extracted_text")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@raw_fields(*RUN_RAW)
|
|
59
|
+
@deployment_group.command("run")
|
|
60
|
+
@click.argument("target")
|
|
61
|
+
# Optional because a run can name its documents as `--presigned-urls` instead;
|
|
62
|
+
# the two are checked together below.
|
|
63
|
+
@click.argument("files", nargs=-1, type=click.Path(exists=True))
|
|
64
|
+
@wait_options()
|
|
65
|
+
@spec_options(
|
|
66
|
+
PRODUCT,
|
|
67
|
+
"execute",
|
|
68
|
+
client_method=APIDeploymentsClient.structure_file,
|
|
69
|
+
# `timeout` selects the server's own execution mode and would fight the
|
|
70
|
+
# CLI's polling for the same job.
|
|
71
|
+
exclude=("files", "timeout"),
|
|
72
|
+
)
|
|
73
|
+
@pass_context
|
|
74
|
+
def run(
|
|
75
|
+
ctx: Context,
|
|
76
|
+
target: str,
|
|
77
|
+
files: tuple[str, ...],
|
|
78
|
+
wait: bool,
|
|
79
|
+
interval: float,
|
|
80
|
+
wait_timeout: float,
|
|
81
|
+
save: str | None,
|
|
82
|
+
**params: Any,
|
|
83
|
+
) -> None:
|
|
84
|
+
"""Run a deployment against one or more documents.
|
|
85
|
+
|
|
86
|
+
TARGET is the deployment's API name, as `deployment ls` prints it. Name the
|
|
87
|
+
documents as local FILES, as --presigned-urls, or both. With --wait (the
|
|
88
|
+
default) this polls until the execution finishes and returns its result.
|
|
89
|
+
"""
|
|
90
|
+
sent = requested(params)
|
|
91
|
+
# Checked before the client is built: this is wrong whatever the config
|
|
92
|
+
# resolves to, and a credential error would name the wrong fault.
|
|
93
|
+
if not files and not sent.get("presigned_urls"):
|
|
94
|
+
raise CLIError(
|
|
95
|
+
"A run needs at least one document.",
|
|
96
|
+
ExitCode.USAGE,
|
|
97
|
+
hint=(
|
|
98
|
+
"Name local files as arguments, or pass --presigned-urls with "
|
|
99
|
+
"one or more HTTPS URLs."
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
client = deployment(ctx.config, target, ctx.transport_timeout)
|
|
103
|
+
if save and not wait:
|
|
104
|
+
raise CLIError(
|
|
105
|
+
"--save has nothing to write with --no-wait.",
|
|
106
|
+
ExitCode.USAGE,
|
|
107
|
+
hint=(
|
|
108
|
+
"Drop --no-wait, or start now and save later with "
|
|
109
|
+
"`deployment status --save`."
|
|
110
|
+
),
|
|
111
|
+
)
|
|
112
|
+
if save:
|
|
113
|
+
preflight(save)
|
|
114
|
+
key_source = ctx.config.deployment_key_source(target)
|
|
115
|
+
with deployment_errors(target, key_source), translated(endpoint=client.api_url):
|
|
116
|
+
started = client.structure_file(list(files), timeout=0, **sent)
|
|
117
|
+
raise_for_result(started, endpoint=client.api_url)
|
|
118
|
+
|
|
119
|
+
if not wait:
|
|
120
|
+
# The ack names no execution of its own, so the handle is derived
|
|
121
|
+
# from the endpoint it hands back and reported as meta.
|
|
122
|
+
finish(ctx, started, raw_fields=RUN_RAW, meta=_handle_meta(started))
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
result = wait_for_completion(
|
|
127
|
+
initial=started,
|
|
128
|
+
spec=RUN_POLL,
|
|
129
|
+
poll=_status_poller(
|
|
130
|
+
client, {k: v for k, v in sent.items() if k in _SHARED_WITH_STATUS}
|
|
131
|
+
),
|
|
132
|
+
save=save,
|
|
133
|
+
interval=interval,
|
|
134
|
+
timeout=wait_timeout,
|
|
135
|
+
on_status=lambda status: diagnostic(
|
|
136
|
+
f"status: {status}", quiet=ctx.quiet, verbosity=ctx.verbosity
|
|
137
|
+
),
|
|
138
|
+
on_retry=lambda exc: diagnostic(
|
|
139
|
+
f"retrying: {exc.message}", quiet=ctx.quiet, verbosity=ctx.verbosity
|
|
140
|
+
),
|
|
141
|
+
on_saved=lambda path: diagnostic(
|
|
142
|
+
f"saved: {path}", quiet=ctx.quiet, verbosity=ctx.verbosity
|
|
143
|
+
),
|
|
144
|
+
)
|
|
145
|
+
except CLIError as exc:
|
|
146
|
+
# Polling runs on a status URL, which is not what the status
|
|
147
|
+
# command takes: without the id a resume cannot be spelled.
|
|
148
|
+
handle = _handle_meta(started)
|
|
149
|
+
exc.extra = {**exc.extra, **handle}
|
|
150
|
+
if exc.exit_code is ExitCode.TIMEOUT and (
|
|
151
|
+
found := handle.get("execution_id")
|
|
152
|
+
):
|
|
153
|
+
# The status read is one-shot: a resume that drops --save
|
|
154
|
+
# spends it with nothing on disk.
|
|
155
|
+
saving = f" --save {save}" if save else ""
|
|
156
|
+
exc.hint = (
|
|
157
|
+
f"Resume with `unstract docstudio deployment status {target} "
|
|
158
|
+
f"{found}{saving}` rather than resubmitting the document."
|
|
159
|
+
)
|
|
160
|
+
raise
|
|
161
|
+
handle = _handle_meta(started)
|
|
162
|
+
_raise_for_failed_files(result, endpoint=client.api_url, extra=handle)
|
|
163
|
+
# A waited result names no execution, so the handle is reported as meta.
|
|
164
|
+
finish(ctx, result, raw_fields=RUN_RAW, meta=handle)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _failed_files(result: dict[str, Any]) -> list[dict[str, Any]]:
|
|
168
|
+
"""Per-file entries of a completed execution that produced no result."""
|
|
169
|
+
entries = result.get("extraction_result")
|
|
170
|
+
if not isinstance(entries, list):
|
|
171
|
+
return []
|
|
172
|
+
# The spec types a file's status as a bare string, and the service spells
|
|
173
|
+
# it in mixed case.
|
|
174
|
+
failed = []
|
|
175
|
+
for entry in entries:
|
|
176
|
+
if not isinstance(entry, dict):
|
|
177
|
+
continue
|
|
178
|
+
status = entry.get("status")
|
|
179
|
+
if status is None:
|
|
180
|
+
if entry.get("error"):
|
|
181
|
+
failed.append(entry)
|
|
182
|
+
elif str(status).casefold() != "success":
|
|
183
|
+
failed.append(entry)
|
|
184
|
+
return failed
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _raise_for_failed_files(
|
|
188
|
+
result: dict[str, Any], *, endpoint: str, extra: dict[str, Any]
|
|
189
|
+
) -> None:
|
|
190
|
+
# A completed execution says the batch ran, not that every document came
|
|
191
|
+
# out: a failed file is reported inside the success shape.
|
|
192
|
+
failed = _failed_files(result)
|
|
193
|
+
if not failed:
|
|
194
|
+
return
|
|
195
|
+
named = "; ".join(
|
|
196
|
+
f"{entry.get('file') or '?'}: {entry.get('error') or entry.get('status')}"
|
|
197
|
+
for entry in failed
|
|
198
|
+
)
|
|
199
|
+
raise CLIError(
|
|
200
|
+
f"{len(failed)} of {len(result['extraction_result'])} documents failed: {named}",
|
|
201
|
+
ExitCode.VALIDATION,
|
|
202
|
+
details=result,
|
|
203
|
+
endpoint=endpoint,
|
|
204
|
+
# The status read is one-shot, so the successful documents here
|
|
205
|
+
# survive nowhere else.
|
|
206
|
+
verbatim_details=True,
|
|
207
|
+
hint=(
|
|
208
|
+
"`error.details` carries the full result, successful documents "
|
|
209
|
+
"included. Resubmit only the files named in `failed_files`."
|
|
210
|
+
),
|
|
211
|
+
extra={**extra, "failed_files": [entry.get("file") for entry in failed]},
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _handle_meta(started: dict[str, Any]) -> dict[str, Any]:
|
|
216
|
+
"""The execution's identity, from wherever the run response carries it."""
|
|
217
|
+
if execution_id := started.get("execution_id"):
|
|
218
|
+
return {"execution_id": execution_id}
|
|
219
|
+
endpoint = str(started.get("status_check_api_endpoint") or "")
|
|
220
|
+
found = parse_qs(urlparse(endpoint).query).get("execution_id")
|
|
221
|
+
return {"execution_id": found[0]} if found else {}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _status_poller(
|
|
225
|
+
client: APIDeploymentsClient, params: dict[str, Any]
|
|
226
|
+
) -> Callable[[str], dict[str, Any]]:
|
|
227
|
+
"""Poll one execution, failing on a status code the poll loop cannot use."""
|
|
228
|
+
|
|
229
|
+
def poll(endpoint: str) -> dict[str, Any]:
|
|
230
|
+
result = client.check_execution_status(endpoint, **params)
|
|
231
|
+
# The client's own retry policy has already run and reports what it
|
|
232
|
+
# would retry as still pending.
|
|
233
|
+
if not result.get("pending"):
|
|
234
|
+
raise_for_result(result, endpoint=client.api_url, one_shot=True)
|
|
235
|
+
return result
|
|
236
|
+
|
|
237
|
+
return translating(poll, client.api_url, one_shot=True)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@raw_fields(*STATUS_RAW)
|
|
241
|
+
@deployment_group.command("status")
|
|
242
|
+
@click.argument("target")
|
|
243
|
+
@click.argument("execution_id")
|
|
244
|
+
@spec_options(
|
|
245
|
+
PRODUCT,
|
|
246
|
+
"status",
|
|
247
|
+
client_method=APIDeploymentsClient.check_execution_status,
|
|
248
|
+
exclude=("execution_id",),
|
|
249
|
+
)
|
|
250
|
+
@click.option(
|
|
251
|
+
"--save",
|
|
252
|
+
type=click.Path(dir_okay=False),
|
|
253
|
+
default=None,
|
|
254
|
+
help="Write the result here before printing it.",
|
|
255
|
+
)
|
|
256
|
+
@pass_context
|
|
257
|
+
def status(
|
|
258
|
+
ctx: Context, target: str, execution_id: str, save: str | None, **params: Any
|
|
259
|
+
) -> None:
|
|
260
|
+
"""Report the state of a running or finished execution."""
|
|
261
|
+
client = deployment(ctx.config, target, ctx.transport_timeout)
|
|
262
|
+
if save:
|
|
263
|
+
preflight(save)
|
|
264
|
+
# The id comes from the caller, so it could otherwise carry query syntax
|
|
265
|
+
# of its own.
|
|
266
|
+
endpoint = f"{client.api_url}?execution_id={quote(execution_id, safe='')}"
|
|
267
|
+
# The status read serves a result exactly once, which is what a 406 from
|
|
268
|
+
# it means.
|
|
269
|
+
key_source = ctx.config.deployment_key_source(target)
|
|
270
|
+
with (
|
|
271
|
+
deployment_errors(target, key_source),
|
|
272
|
+
translated(endpoint=client.api_url, one_shot=True),
|
|
273
|
+
):
|
|
274
|
+
result = client.check_execution_status(endpoint, **requested(params))
|
|
275
|
+
if not result.get("pending"):
|
|
276
|
+
raise_for_result(result, endpoint=client.api_url, one_shot=True)
|
|
277
|
+
# A finished-and-failed execution arrives inside an HTTP 200, so the status
|
|
278
|
+
# code alone would call it a success.
|
|
279
|
+
if classify(result, RUN_POLL) is PollState.FAILURE:
|
|
280
|
+
raise CLIError(
|
|
281
|
+
f"Execution {execution_id} finished with status "
|
|
282
|
+
f"{result.get('execution_status')!r}.",
|
|
283
|
+
ExitCode.VALIDATION,
|
|
284
|
+
details=result,
|
|
285
|
+
endpoint=client.api_url,
|
|
286
|
+
hint="Inspect `details` for the per-file error, or check the execution logs.",
|
|
287
|
+
extra={"execution_id": execution_id},
|
|
288
|
+
)
|
|
289
|
+
if save:
|
|
290
|
+
written = persist(save, result)
|
|
291
|
+
diagnostic(f"saved: {written}", quiet=ctx.quiet, verbosity=ctx.verbosity)
|
|
292
|
+
_raise_for_failed_files(
|
|
293
|
+
result, endpoint=client.api_url, extra={"execution_id": execution_id}
|
|
294
|
+
)
|
|
295
|
+
finish(ctx, result, raw_fields=STATUS_RAW)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
__all__ = ["run", "status"]
|