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.
@@ -0,0 +1,3 @@
1
+ """Unstract CLI."""
2
+
3
+ __version__ = "0.1.0rc1"
@@ -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