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.
@@ -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"]