unstract-cli 0.1.0rc1__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 +534 -0
- unstract_cli/commands/docstudio_cmd.py +298 -0
- unstract_cli/commands/platform_cmd.py +659 -0
- unstract_cli/commands/whisper_cmd.py +419 -0
- unstract_cli/config.py +745 -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.0rc1.dist-info/METADATA +197 -0
- unstract_cli-0.1.0rc1.dist-info/RECORD +29 -0
- unstract_cli-0.1.0rc1.dist-info/WHEEL +4 -0
- unstract_cli-0.1.0rc1.dist-info/entry_points.txt +3 -0
unstract_cli/__init__.py
ADDED
unstract_cli/__main__.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Entry point: turns every failure into an envelope plus a stable exit code.
|
|
2
|
+
|
|
3
|
+
Click's own error handling is bypassed on purpose. By default it prints prose to
|
|
4
|
+
stderr and exits 1 or 2 with nothing on stdout, which leaves a caller parsing
|
|
5
|
+
stdout with an empty stream and no way to tell a usage error from a server
|
|
6
|
+
failure.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import contextlib
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
import click
|
|
16
|
+
|
|
17
|
+
from unstract_cli.app import Context, cli
|
|
18
|
+
from unstract_cli.config import ConfigError
|
|
19
|
+
from unstract_cli.core.errors import CLIError, ExitCode, set_warning_sink
|
|
20
|
+
from unstract_cli.core.output import AgentMode, OutputFormat, emit_error, resolve_format
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _option_from_argv(argv: list[str], *spellings: str) -> str | None:
|
|
24
|
+
"""Best-effort read of one option before Click has parsed anything.
|
|
25
|
+
|
|
26
|
+
A failure during parsing still has to be rendered, and the parsed context
|
|
27
|
+
does not exist yet at that point.
|
|
28
|
+
"""
|
|
29
|
+
for i, arg in enumerate(argv):
|
|
30
|
+
for spelling in spellings:
|
|
31
|
+
if arg.startswith(f"{spelling}="):
|
|
32
|
+
return arg.split("=", 1)[1]
|
|
33
|
+
if arg == spelling and i + 1 < len(argv):
|
|
34
|
+
return argv[i + 1]
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _format_from_argv(argv: list[str]) -> OutputFormat:
|
|
39
|
+
"""Resolve the format the same way the parsed run would."""
|
|
40
|
+
try:
|
|
41
|
+
return resolve_format(
|
|
42
|
+
_option_from_argv(argv, "--output", "-o"),
|
|
43
|
+
_option_from_argv(argv, "--agent") or AgentMode.AUTO,
|
|
44
|
+
)
|
|
45
|
+
except CLIError:
|
|
46
|
+
# Nothing renders an envelope this early, so a bad value is left for
|
|
47
|
+
# Click's own Choice to reject once parsing reaches it.
|
|
48
|
+
return resolve_format(None)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def main(argv: list[str] | None = None) -> int:
|
|
52
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
53
|
+
# Guessed from argv, then corrected by the root callback: a failure after
|
|
54
|
+
# parsing has to render in the format the run actually resolved.
|
|
55
|
+
ctx = Context(output=_format_from_argv(args))
|
|
56
|
+
try:
|
|
57
|
+
cli.main(args=args, standalone_mode=False, obj=ctx)
|
|
58
|
+
except CLIError as exc:
|
|
59
|
+
return int(emit_error(exc, ctx.output))
|
|
60
|
+
except ConfigError as exc:
|
|
61
|
+
return int(emit_error(CLIError(str(exc), ExitCode.USAGE), ctx.output))
|
|
62
|
+
except click.UsageError as exc:
|
|
63
|
+
return int(
|
|
64
|
+
emit_error(
|
|
65
|
+
CLIError(exc.format_message(), ExitCode.USAGE, hint="Run with --help."),
|
|
66
|
+
ctx.output,
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
except BrokenPipeError:
|
|
70
|
+
# Nowhere left to render the envelope. Python flushes stdout at exit,
|
|
71
|
+
# so point it at devnull or this is raised again on the way out.
|
|
72
|
+
with contextlib.suppress(OSError):
|
|
73
|
+
os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
|
|
74
|
+
return int(ExitCode.GENERIC)
|
|
75
|
+
except OSError as exc:
|
|
76
|
+
# A full disk or an unwritable path is the caller's to fix, and they
|
|
77
|
+
# still need a parseable envelope rather than a traceback.
|
|
78
|
+
return int(
|
|
79
|
+
emit_error(
|
|
80
|
+
CLIError(str(exc), ExitCode.GENERIC, hint="Check the path and disk."),
|
|
81
|
+
ctx.output,
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
except (click.Abort, KeyboardInterrupt):
|
|
85
|
+
# Nothing here prompts, so Click's Abort can only mean an interrupt.
|
|
86
|
+
return int(
|
|
87
|
+
emit_error(
|
|
88
|
+
CLIError("Interrupted.", ExitCode.INTERRUPTED, retryable=True), ctx.output
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
except click.exceptions.Exit as exc: # --help and --version exit through here
|
|
92
|
+
return int(exc.exit_code)
|
|
93
|
+
finally:
|
|
94
|
+
# Notes held before a sink was bound still have to reach the user.
|
|
95
|
+
set_warning_sink(None)
|
|
96
|
+
return int(ExitCode.SUCCESS)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
if __name__ == "__main__": # pragma: no cover
|
|
100
|
+
sys.exit(main())
|
unstract_cli/app.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""The root Click application: global options and the command groups.
|
|
2
|
+
|
|
3
|
+
Global options are declared once here and reach every command through the Click
|
|
4
|
+
context, so no command re-implements profile selection or output formatting.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import click
|
|
14
|
+
|
|
15
|
+
from unstract_cli.commands.config_cmd import config_group
|
|
16
|
+
from unstract_cli.config import (
|
|
17
|
+
DOCSTUDIO,
|
|
18
|
+
LLMWHISPERER,
|
|
19
|
+
SECRET_SETTINGS,
|
|
20
|
+
ConfigError,
|
|
21
|
+
ResolvedConfig,
|
|
22
|
+
load_config,
|
|
23
|
+
set_config_path,
|
|
24
|
+
)
|
|
25
|
+
from unstract_cli.core.clients import DEFAULT_TRANSPORT_TIMEOUT
|
|
26
|
+
from unstract_cli.core.discover import TIERS, discover
|
|
27
|
+
from unstract_cli.core.errors import CLIError, ExitCode, set_warning_sink
|
|
28
|
+
from unstract_cli.core.output import (
|
|
29
|
+
AgentMode,
|
|
30
|
+
OutputFormat,
|
|
31
|
+
diagnostic,
|
|
32
|
+
emit_result,
|
|
33
|
+
resolve_format,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Context:
|
|
39
|
+
"""Everything a command needs from the global options."""
|
|
40
|
+
|
|
41
|
+
output: OutputFormat = OutputFormat.TABLE
|
|
42
|
+
quiet: bool = False
|
|
43
|
+
verbosity: int = 0
|
|
44
|
+
profile: str | None = None
|
|
45
|
+
#: Transport timeout for the clients that have none of their own.
|
|
46
|
+
transport_timeout: float | None = DEFAULT_TRANSPORT_TIMEOUT
|
|
47
|
+
#: Command-line overrides, keyed `product.setting` -- the top tier of
|
|
48
|
+
#: flag > env > profile > default.
|
|
49
|
+
overrides: dict[str, Any] = field(default_factory=dict)
|
|
50
|
+
_config: ResolvedConfig | None = field(default=None, repr=False)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def config(self) -> ResolvedConfig:
|
|
54
|
+
"""Load the config lazily, so commands that need none never read a file."""
|
|
55
|
+
if self._config is None:
|
|
56
|
+
try:
|
|
57
|
+
cfg = load_config()
|
|
58
|
+
except ConfigError as exc:
|
|
59
|
+
raise CLIError(str(exc), ExitCode.USAGE) from exc
|
|
60
|
+
for warning in cfg.warnings:
|
|
61
|
+
diagnostic(warning, quiet=self.quiet, verbosity=self.verbosity)
|
|
62
|
+
self._config = ResolvedConfig(
|
|
63
|
+
file=cfg, profile_name=self.profile, overrides=self.overrides
|
|
64
|
+
)
|
|
65
|
+
return self._config
|
|
66
|
+
|
|
67
|
+
def override(self, product: str, values: dict[str, Any]) -> None:
|
|
68
|
+
"""Record the connection flags given for one product.
|
|
69
|
+
|
|
70
|
+
Called from the product group, before any command runs, so the flag tier
|
|
71
|
+
is populated by the time a command resolves anything.
|
|
72
|
+
"""
|
|
73
|
+
for key, value in values.items():
|
|
74
|
+
if value is None:
|
|
75
|
+
continue
|
|
76
|
+
if key in SECRET_SETTINGS:
|
|
77
|
+
diagnostic(
|
|
78
|
+
"warning: a key passed on the command line lands in shell "
|
|
79
|
+
"history and in the process list. Prefer the environment "
|
|
80
|
+
"variable or `env:` indirection in a profile.",
|
|
81
|
+
quiet=self.quiet,
|
|
82
|
+
verbosity=self.verbosity,
|
|
83
|
+
)
|
|
84
|
+
self.overrides[f"{product}.{key}"] = value
|
|
85
|
+
|
|
86
|
+
def secrets(self) -> list[str]:
|
|
87
|
+
"""Resolved credentials, for scrubbing anything on its way to a stream."""
|
|
88
|
+
out: list[str] = []
|
|
89
|
+
for product, key in (
|
|
90
|
+
(LLMWHISPERER, "api_key"),
|
|
91
|
+
(DOCSTUDIO, "api_key"),
|
|
92
|
+
(DOCSTUDIO, "platform_key"),
|
|
93
|
+
):
|
|
94
|
+
try:
|
|
95
|
+
if value := self.config.get(product, key):
|
|
96
|
+
out.append(str(value))
|
|
97
|
+
except (ConfigError, CLIError):
|
|
98
|
+
# Unresolvable means unprintable too, and raising would
|
|
99
|
+
# replace a finished report with a config error.
|
|
100
|
+
continue
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
pass_context = click.make_pass_decorator(Context, ensure=True)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# `invoke_without_command` so `--discover` answers on its own: it is how a
|
|
108
|
+
# caller learns which commands exist.
|
|
109
|
+
@click.group(
|
|
110
|
+
invoke_without_command=True,
|
|
111
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
112
|
+
)
|
|
113
|
+
@click.option(
|
|
114
|
+
"--config",
|
|
115
|
+
"config_file",
|
|
116
|
+
default=None,
|
|
117
|
+
type=click.Path(dir_okay=False),
|
|
118
|
+
help="Config file to use, overriding discovery.",
|
|
119
|
+
)
|
|
120
|
+
@click.option("--profile", "-p", default=None, help="Configuration profile to use.")
|
|
121
|
+
@click.option(
|
|
122
|
+
"--output",
|
|
123
|
+
"-o",
|
|
124
|
+
default=None,
|
|
125
|
+
type=click.Choice([f.value for f in OutputFormat]),
|
|
126
|
+
help="Output format. Defaults to table, or to json when --agent resolves "
|
|
127
|
+
"to yes; pass it explicitly to parse the output.",
|
|
128
|
+
)
|
|
129
|
+
@click.option(
|
|
130
|
+
"--agent",
|
|
131
|
+
type=click.Choice([m.value for m in AgentMode]),
|
|
132
|
+
default=AgentMode.AUTO.value,
|
|
133
|
+
help="Whether a coding agent is driving this: sets the default format to "
|
|
134
|
+
"json. Only the default -- an explicit --output always wins.",
|
|
135
|
+
)
|
|
136
|
+
@click.option(
|
|
137
|
+
"--quiet",
|
|
138
|
+
"-q",
|
|
139
|
+
is_flag=True,
|
|
140
|
+
default=False,
|
|
141
|
+
help="Suppress diagnostics on stderr. stdout is unaffected.",
|
|
142
|
+
)
|
|
143
|
+
@click.option("--verbose", "-v", count=True, help="Increase diagnostic detail.")
|
|
144
|
+
@click.option(
|
|
145
|
+
"--discover",
|
|
146
|
+
"discover_tier",
|
|
147
|
+
type=click.Choice(TIERS),
|
|
148
|
+
default=None,
|
|
149
|
+
help="Describe this CLI as JSON instead of running a command, useful for agents.",
|
|
150
|
+
)
|
|
151
|
+
@click.version_option(package_name="unstract-cli")
|
|
152
|
+
@click.pass_context
|
|
153
|
+
def cli(
|
|
154
|
+
ctx: click.Context,
|
|
155
|
+
config_file: str | None,
|
|
156
|
+
profile: str | None,
|
|
157
|
+
output: str | None,
|
|
158
|
+
agent: str,
|
|
159
|
+
quiet: bool,
|
|
160
|
+
verbose: int,
|
|
161
|
+
discover_tier: str | None,
|
|
162
|
+
) -> None:
|
|
163
|
+
"""The official CLI for Unstract.
|
|
164
|
+
|
|
165
|
+
LLMWhisperer extracts text and layout from documents; Document Studio runs
|
|
166
|
+
them through API deployments that return structured JSON.
|
|
167
|
+
|
|
168
|
+
Scripting or driving this from an agent: `-o json` prints one
|
|
169
|
+
`{ok, data, error, meta}` envelope on stdout and nothing else, failures
|
|
170
|
+
exit non-zero with a stable code, and `--discover groups|summary|full`
|
|
171
|
+
describes the commands, their flags and the output contract as JSON without
|
|
172
|
+
running anything.
|
|
173
|
+
"""
|
|
174
|
+
set_config_path(config_file)
|
|
175
|
+
# Filled in rather than replaced: the entry point holds this object, so a
|
|
176
|
+
# failure anywhere below renders in the format resolved here.
|
|
177
|
+
obj = ctx.ensure_object(Context)
|
|
178
|
+
obj.output = resolve_format(output, agent)
|
|
179
|
+
obj.quiet = quiet
|
|
180
|
+
obj.verbosity = verbose
|
|
181
|
+
obj.profile = profile
|
|
182
|
+
# Modules the output layer imports cannot import it back, so their notes
|
|
183
|
+
# reach it through this sink rather than stderr unfiltered.
|
|
184
|
+
set_warning_sink(
|
|
185
|
+
lambda message: diagnostic(message, quiet=obj.quiet, verbosity=obj.verbosity)
|
|
186
|
+
)
|
|
187
|
+
if discover_tier:
|
|
188
|
+
# Discovery has to answer before any configuration exists.
|
|
189
|
+
emit_result(discover(cli, discover_tier), OutputFormat.JSON)
|
|
190
|
+
ctx.exit(int(ExitCode.SUCCESS))
|
|
191
|
+
if ctx.invoked_subcommand is None:
|
|
192
|
+
if obj.output is not OutputFormat.TABLE:
|
|
193
|
+
# stdout carries one envelope and nothing else: help printed there
|
|
194
|
+
# with exit 0 tells a parser that work it never did succeeded.
|
|
195
|
+
raise CLIError(
|
|
196
|
+
"No command given.",
|
|
197
|
+
ExitCode.USAGE,
|
|
198
|
+
hint="`--discover groups` lists what can be run, as JSON.",
|
|
199
|
+
)
|
|
200
|
+
click.echo(ctx.get_help())
|
|
201
|
+
ctx.exit(int(ExitCode.SUCCESS))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
#: The connection flags a product group can carry, named after the setting
|
|
205
|
+
#: each one overrides.
|
|
206
|
+
_CONNECTION_FLAGS: dict[str, str] = {
|
|
207
|
+
"base_url": "Service URL to use.",
|
|
208
|
+
"api_key": "API key to use.",
|
|
209
|
+
"org_id": "Organisation to run against.",
|
|
210
|
+
"platform_key": "Platform key to use, for the commands that take one.",
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _connection_options(*settings: str) -> Callable[[Any], Any]:
|
|
215
|
+
"""The per-product connection settings, as flags.
|
|
216
|
+
|
|
217
|
+
They sit on the product group rather than on each command: they say where to
|
|
218
|
+
connect, which is the same question for every command underneath.
|
|
219
|
+
"""
|
|
220
|
+
options = [
|
|
221
|
+
click.option(
|
|
222
|
+
f"--{name.replace('_', '-')}", default=None, help=_CONNECTION_FLAGS[name]
|
|
223
|
+
)
|
|
224
|
+
for name in ("base_url", *settings)
|
|
225
|
+
]
|
|
226
|
+
|
|
227
|
+
def decorate(func: Any) -> Any:
|
|
228
|
+
for option in reversed(options):
|
|
229
|
+
func = option(func)
|
|
230
|
+
return func
|
|
231
|
+
|
|
232
|
+
return decorate
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@cli.group("whisper")
|
|
236
|
+
@_connection_options("api_key")
|
|
237
|
+
@pass_context
|
|
238
|
+
def whisper_group(ctx: Context, **overrides: str | None) -> None:
|
|
239
|
+
"""Extract text and layout from documents with LLMWhisperer."""
|
|
240
|
+
ctx.override(LLMWHISPERER, overrides)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@cli.group("docstudio")
|
|
244
|
+
@_connection_options("api_key", "org_id", "platform_key")
|
|
245
|
+
@click.option(
|
|
246
|
+
"--transport-timeout",
|
|
247
|
+
type=click.FloatRange(min=0),
|
|
248
|
+
default=DEFAULT_TRANSPORT_TIMEOUT,
|
|
249
|
+
show_default=True,
|
|
250
|
+
help="Seconds before a stalled connection is given up on. 0 waits forever.",
|
|
251
|
+
)
|
|
252
|
+
@pass_context
|
|
253
|
+
def docstudio_group(
|
|
254
|
+
ctx: Context, transport_timeout: float, **overrides: str | None
|
|
255
|
+
) -> None:
|
|
256
|
+
"""Run Document Studio API deployments."""
|
|
257
|
+
ctx.transport_timeout = transport_timeout or None
|
|
258
|
+
ctx.override(DOCSTUDIO, overrides)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@docstudio_group.group("deployment")
|
|
262
|
+
def deployment_group() -> None:
|
|
263
|
+
"""Work with a deployed API."""
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@cli.group("auth")
|
|
267
|
+
@_connection_options("platform_key")
|
|
268
|
+
@click.option(
|
|
269
|
+
"--transport-timeout",
|
|
270
|
+
type=click.FloatRange(min=0),
|
|
271
|
+
default=DEFAULT_TRANSPORT_TIMEOUT,
|
|
272
|
+
show_default=True,
|
|
273
|
+
help="Seconds before a stalled connection is given up on. 0 waits forever.",
|
|
274
|
+
)
|
|
275
|
+
@pass_context
|
|
276
|
+
def auth_group(
|
|
277
|
+
ctx: Context, transport_timeout: float | None, **overrides: str | None
|
|
278
|
+
) -> None:
|
|
279
|
+
"""Sign in, and identify the credential you are using.
|
|
280
|
+
|
|
281
|
+
Its flags configure the platform key, which is the credential that knows
|
|
282
|
+
which organisation it belongs to. A deployment key does not: it authenticates
|
|
283
|
+
against the deployment it was minted for and never reaches this endpoint.
|
|
284
|
+
"""
|
|
285
|
+
# The same spelling as every other group: the clients are given no bound
|
|
286
|
+
# of their own, so an unset flag would wait on a black-holed host forever.
|
|
287
|
+
ctx.transport_timeout = transport_timeout or None
|
|
288
|
+
ctx.override(DOCSTUDIO, overrides)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
cli.add_command(config_group)
|
|
292
|
+
|
|
293
|
+
# Imported for the side effect of registering commands, and last because they
|
|
294
|
+
# hang those commands off the groups declared above.
|
|
295
|
+
from unstract_cli.commands import ( # noqa: E402,F401
|
|
296
|
+
clone_cmd,
|
|
297
|
+
docstudio_cmd,
|
|
298
|
+
platform_cmd,
|
|
299
|
+
whisper_cmd,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def command_tree() -> dict[str, Any]:
|
|
304
|
+
"""The registered command tree, read back from Click itself.
|
|
305
|
+
|
|
306
|
+
Describing commands anywhere but from the parser lets the description drift
|
|
307
|
+
from what the parser accepts, so discovery and help always read this.
|
|
308
|
+
"""
|
|
309
|
+
|
|
310
|
+
def walk(command: click.Command) -> dict[str, Any]:
|
|
311
|
+
entry: dict[str, Any] = {"help": (command.help or "").strip().split("\n")[0]}
|
|
312
|
+
if isinstance(command, click.Group):
|
|
313
|
+
entry["commands"] = {
|
|
314
|
+
name: walk(sub) for name, sub in sorted(command.commands.items())
|
|
315
|
+
}
|
|
316
|
+
return entry
|
|
317
|
+
|
|
318
|
+
return walk(cli)["commands"]
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
__all__ = ["Context", "cli", "command_tree", "pass_context"]
|
|
File without changes
|