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,551 @@
1
+ """The `config` command group -- local only, no network calls.
2
+
3
+ These commands map to no API operation: they operate purely on the local config
4
+ layer, and they are how a user or an agent bootstraps every other command.
5
+
6
+ Nothing here prompts: `init` refuses to clobber an existing file unless
7
+ `--force` is passed, rather than asking, so the CLI behaves the same whether or
8
+ not a human is watching.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import click
17
+
18
+ from unstract_cli.config import (
19
+ DOCSTUDIO,
20
+ KEY_SOURCES,
21
+ LLMWHISPERER,
22
+ PRODUCTS,
23
+ UNTRUSTED_PROJECT_KEYS,
24
+ ConfigError,
25
+ ConfigFile,
26
+ ResolvedConfig,
27
+ init_path,
28
+ load_config,
29
+ save_config,
30
+ settings_for,
31
+ starter_profiles,
32
+ )
33
+ from unstract_cli.core.clients import llmwhisperer, translated
34
+ from unstract_cli.core.errors import CLIError, ExitCode
35
+ from unstract_cli.core.output import (
36
+ OutputFormat,
37
+ diagnostic,
38
+ emit_result,
39
+ resolve_format,
40
+ )
41
+ from unstract_cli.core.platform import deployment_rows, organisation, platform_client
42
+
43
+ #: The probe entry for docstudio's platform key. Named for the credential,
44
+ #: not the product: the deployment key sits beside it under `docstudio`.
45
+ PLATFORM_KEY = "platform"
46
+
47
+ #: Keys never echoed back: this output lands in logs and transcripts as often
48
+ #: as on a screen.
49
+ _SECRET_KEY_HINTS = ("key", "token", "secret")
50
+
51
+
52
+ def _is_secret(key: str) -> bool:
53
+ return any(hint in key.lower() for hint in _SECRET_KEY_HINTS)
54
+
55
+
56
+ def _fmt(obj: Any) -> OutputFormat:
57
+ """Output format from the root context, defaulting when invoked standalone."""
58
+ return getattr(obj, "output", None) or resolve_format(None)
59
+
60
+
61
+ def _check_product(product: str) -> str:
62
+ if product not in PRODUCTS:
63
+ raise CLIError(
64
+ f"Unknown config target {product!r}.",
65
+ ExitCode.USAGE,
66
+ hint="Valid targets: " + ", ".join(PRODUCTS) + ".",
67
+ )
68
+ return product
69
+
70
+
71
+ def _check_key(product: str, key: str) -> str:
72
+ """A setting a product does not have would be written and never read again."""
73
+ if key not in (known := settings_for(product)):
74
+ raise CLIError(
75
+ f"{product} has no setting {key!r}.",
76
+ ExitCode.USAGE,
77
+ hint=f"Valid keys for {product}: " + ", ".join(known) + ".",
78
+ )
79
+ return key
80
+
81
+
82
+ def _saved(cfg: ConfigFile, path: Path | None = None) -> tuple[Path, dict[str, Any]]:
83
+ """Write the config, with the envelope's `meta` saying if the write is
84
+ not yet known to survive a crash: the warning reaches only stderr."""
85
+ meta: dict[str, Any] = {}
86
+ written = save_config(
87
+ cfg, path, on_unconfirmed=lambda: meta.update(durability="unconfirmed")
88
+ )
89
+ return written, meta
90
+
91
+
92
+ @click.group(name="config", help="Manage CLI configuration profiles (local only).")
93
+ def config_group() -> None:
94
+ """Local configuration management. These commands make no network calls."""
95
+
96
+
97
+ @config_group.command("init")
98
+ @click.option(
99
+ "--force", is_flag=True, default=False, help="Overwrite an existing config file."
100
+ )
101
+ @click.pass_obj
102
+ def config_init(obj: Any, force: bool) -> None:
103
+ """Create a starter config file with profile stubs.
104
+
105
+ The stubs reference environment variables and hold no keys. To store your
106
+ keys and be done, run `unstract auth login` instead.
107
+ """
108
+ path = init_path()
109
+ if path.exists() and not force:
110
+ # Never prompt: state the situation and the exact flag that resolves it.
111
+ raise CLIError(
112
+ f"Config already exists at {path}.",
113
+ ExitCode.USAGE,
114
+ hint="Pass --force to overwrite it, or edit the file directly.",
115
+ )
116
+
117
+ replaced = path.exists()
118
+ new = ConfigFile(
119
+ default_profile="cloud-us", profiles=starter_profiles(), path=path, exists=True
120
+ )
121
+ written, meta = _saved(new, path)
122
+ variables = sorted(
123
+ {
124
+ value.removeprefix("env:")
125
+ for profile in new.profiles.values()
126
+ for block in profile.values()
127
+ for value in block.values()
128
+ if isinstance(value, str) and value.startswith("env:")
129
+ }
130
+ )
131
+ diagnostic(
132
+ f"Wrote {written}.\n"
133
+ f"Next: export {' and '.join(variables)}, or run `unstract auth login` "
134
+ "to store keys in the file instead.\n"
135
+ "Then `unstract config doctor` shows what resolved.",
136
+ quiet=getattr(obj, "quiet", False),
137
+ verbosity=getattr(obj, "verbosity", 0),
138
+ )
139
+ emit_result(
140
+ {
141
+ "created": str(written),
142
+ "default_profile": "cloud-us",
143
+ "profiles": sorted(new.profiles),
144
+ "replaced_existing": replaced,
145
+ "note": (
146
+ "Credentials use env: indirection, so this file holds no secrets. "
147
+ "Set the referenced environment variables to authenticate, or run "
148
+ "`unstract auth login` to store keys in a profile. " + KEY_SOURCES
149
+ ),
150
+ },
151
+ _fmt(obj),
152
+ meta=meta,
153
+ )
154
+
155
+
156
+ @config_group.command("list", help="List profiles defined in the config file.")
157
+ @click.pass_obj
158
+ def config_list(obj: Any) -> None:
159
+ cfg = _loaded(obj)
160
+ emit_result(
161
+ {
162
+ "path": str(cfg.path),
163
+ "exists": cfg.exists,
164
+ "default_profile": cfg.default_profile,
165
+ "profiles": {
166
+ name: {
167
+ block: sorted(settings) if isinstance(settings, dict) else settings
168
+ for block, settings in blocks.items()
169
+ }
170
+ for name, blocks in cfg.profiles.items()
171
+ },
172
+ },
173
+ _fmt(obj),
174
+ )
175
+
176
+
177
+ @config_group.command("get")
178
+ @click.argument("product")
179
+ @click.argument("key")
180
+ @click.pass_obj
181
+ def config_get(obj: Any, product: str, key: str) -> None:
182
+ """Show a resolved setting, following flag > env > profile > default.
183
+
184
+ PRODUCT and KEY are positional -- not flags. Credentials are reported as
185
+ configured or not, never echoed.
186
+
187
+ \b
188
+ Examples:
189
+ unstract config get docstudio org_id
190
+ unstract --profile cloud-eu config get llmwhisperer base_url
191
+ """
192
+ _check_product(product)
193
+ try:
194
+ value = _resolved(obj).get(product, key)
195
+ except ConfigError as exc:
196
+ raise CLIError(str(exc), ExitCode.USAGE) from exc
197
+
198
+ emit_result(
199
+ {
200
+ "product": product,
201
+ "key": key,
202
+ "value": ("***SET***" if value else None) if _is_secret(key) else value,
203
+ "configured": value is not None,
204
+ },
205
+ _fmt(obj),
206
+ )
207
+
208
+
209
+ @config_group.command("set")
210
+ @click.argument("product")
211
+ @click.argument("key")
212
+ @click.argument("value")
213
+ @click.option("--profile", "-p", "profile", default=None, help="Profile to write to.")
214
+ @click.option(
215
+ "--deployment",
216
+ default=None,
217
+ metavar="API_NAME",
218
+ help="Store a docstudio api_key for this one deployment only.",
219
+ )
220
+ @click.pass_obj
221
+ def config_set(
222
+ obj: Any,
223
+ product: str,
224
+ key: str,
225
+ value: str,
226
+ profile: str | None,
227
+ deployment: str | None,
228
+ ) -> None:
229
+ """Set a value in the config file.
230
+
231
+ PRODUCT, KEY and VALUE are positional -- not flags. Writes to the active
232
+ profile unless --profile names another.
233
+
234
+ \b
235
+ Examples:
236
+ unstract config set docstudio org_id org_ABC123
237
+ unstract config set llmwhisperer api_key 'env:LLMWHISPERER_API_KEY'
238
+ unstract config set docstudio api_key dk_... --deployment invoice-parser
239
+
240
+ \b
241
+ A credential can be stored either way. `env:VAR_NAME` records where the
242
+ secret lives rather than the secret itself, which is what a shared machine
243
+ or a CI checkout wants; a literal value is what `auth login` writes, into a
244
+ file created `0600`. Passed here a literal also lands in your shell history.
245
+ """
246
+ _check_product(product)
247
+ _check_key(product, key)
248
+ if deployment is not None and (product, key) != (DOCSTUDIO, "api_key"):
249
+ raise CLIError(
250
+ "--deployment only applies to `docstudio api_key`.",
251
+ ExitCode.USAGE,
252
+ hint="A deployment entry holds nothing but the key that runs it.",
253
+ )
254
+ cfg = _loaded(obj)
255
+ name = profile or getattr(obj, "profile", None) or cfg.default_profile or "cloud-us"
256
+
257
+ block = cfg.profiles.setdefault(name, {})
258
+ if deployment is not None:
259
+ block = block.setdefault("deployments", {}).setdefault(deployment, {})
260
+ else:
261
+ block = block.setdefault(product, {})
262
+ block[key] = value
263
+ if not cfg.default_profile:
264
+ cfg.default_profile = name
265
+ written, meta = _saved(cfg)
266
+
267
+ warnings = []
268
+ if _is_secret(key) and not value.startswith("env:"):
269
+ warnings.append(
270
+ "Value stored literally, in a file created 0600. Write "
271
+ "`env:VAR_NAME` instead to hold a reference rather than the secret."
272
+ )
273
+ if cfg.is_project_local and key in UNTRUSTED_PROJECT_KEYS:
274
+ warnings.append(
275
+ f"{written} was found by searching upwards rather than named, so "
276
+ f"`{key}` written there is withheld when the config is loaded. Pass "
277
+ f"--config {written} to use it, or write it to the home config."
278
+ )
279
+ if cfg.is_project_local and value.startswith("env:"):
280
+ # Refused for every key here, so a silent write would report success
281
+ # for a setting that never resolves.
282
+ warnings.append(
283
+ f"{written} was found by searching upwards rather than named, so it "
284
+ f"may not choose which environment variable is read and `{value}` is "
285
+ f"ignored when the config is loaded. Pass --config {written} to use "
286
+ f"it, or write it to the home config."
287
+ )
288
+ warning = " ".join(warnings) or None
289
+
290
+ emit_result(
291
+ {
292
+ "profile": name,
293
+ "product": product,
294
+ "key": key,
295
+ "deployment": deployment,
296
+ "path": str(written),
297
+ "warning": warning,
298
+ },
299
+ _fmt(obj),
300
+ meta=meta,
301
+ )
302
+
303
+
304
+ def _probe(resolved: ResolvedConfig) -> dict[str, Any]:
305
+ """Check each credential against the service, where that is possible.
306
+
307
+ LLMWhisperer has a read-only usage endpoint, so its key can be verified for
308
+ real, and so does the platform API -- `whoami` reads nothing but the key
309
+ itself. A deployment has no side-effect-free endpoint -- the only thing to
310
+ call is an execution -- so its entry reports that the settings resolve and
311
+ says plainly that nothing was verified.
312
+
313
+ Keyed by credential rather than by product: docstudio holds two keys that
314
+ are checked differently.
315
+ """
316
+ out: dict[str, Any] = {}
317
+ try:
318
+ with translated(endpoint="get-usage-info"):
319
+ llmwhisperer(resolved).get_usage_info()
320
+ except CLIError as exc:
321
+ out[LLMWHISPERER] = {
322
+ "checked": True,
323
+ "ok": False,
324
+ "detail": exc.message,
325
+ "exit_code": int(exc.exit_code),
326
+ }
327
+ except ConfigError as exc:
328
+ out[LLMWHISPERER] = {"checked": False, "ok": False, "detail": str(exc)}
329
+ else:
330
+ out[LLMWHISPERER] = {
331
+ "checked": True,
332
+ "ok": True,
333
+ "detail": "The key was accepted by the usage endpoint.",
334
+ }
335
+
336
+ try:
337
+ with translated(endpoint="whoami"):
338
+ identity = platform_client(resolved).whoami()
339
+ except CLIError as exc:
340
+ out[PLATFORM_KEY] = {
341
+ "checked": True,
342
+ "ok": False,
343
+ "detail": exc.message,
344
+ "exit_code": int(exc.exit_code),
345
+ }
346
+ except ConfigError as exc:
347
+ # An absent platform key is optional; it must not decide the exit code.
348
+ out[PLATFORM_KEY] = {"checked": False, "ok": None, "detail": str(exc)}
349
+ else:
350
+ out[PLATFORM_KEY] = {
351
+ "checked": True,
352
+ "ok": True,
353
+ "organization_id": identity.get("organization_id"),
354
+ "detail": "The key was accepted, and resolved to an organisation.",
355
+ }
356
+
357
+ resolves = all(
358
+ resolved.get(DOCSTUDIO, key) for key in ("org_id", "api_key", "base_url")
359
+ )
360
+ out[DOCSTUDIO] = {
361
+ "checked": False,
362
+ # Null, not True: beside `checked: false` a true reads as a passed check.
363
+ "ok": None,
364
+ "resolved": resolves,
365
+ "detail": (
366
+ "Credentials resolve (org and key present) but were NOT verified -- "
367
+ "the deployment API has no side-effect-free endpoint to call, so a "
368
+ "wrong key is only discovered by running a deployment."
369
+ if resolves
370
+ else "Organisation or key is missing; nothing was called."
371
+ ),
372
+ }
373
+ return out
374
+
375
+
376
+ def _stale_deployments(resolved: ResolvedConfig, names: list[str]) -> list[str]:
377
+ """Which deployment entries name a deployment the server no longer has.
378
+
379
+ An API name is editable server-side, so an entry written under one can be
380
+ orphaned without anything local changing. Raises when the listing cannot
381
+ be asked: a check that was requested and did not run must not read as a
382
+ check that passed.
383
+ """
384
+ org_id = organisation(resolved)
385
+ client = platform_client(resolved, org_id)
386
+ with translated(endpoint="api/deployment/"):
387
+ return [
388
+ name
389
+ for name in names
390
+ if not deployment_rows(client.list_deployments(org_id, api_name=name))
391
+ ]
392
+
393
+
394
+ @config_group.command("doctor", help="Diagnose how each setting resolves.")
395
+ @click.option(
396
+ "--probe/--no-probe",
397
+ default=False,
398
+ help="Also check the resolved credentials against the service.",
399
+ )
400
+ @click.pass_obj
401
+ def config_doctor(obj: Any, probe: bool) -> None:
402
+ """Report where each setting resolves from, without echoing any secret.
403
+
404
+ Answers the question that costs the most time: the CLI reports a key as "not
405
+ configured", but you set it -- where is it looking? For `env:` references it
406
+ says whether the variable is present in THIS process, a shell `export` in a
407
+ login profile the CLI never inherited being the classic trap.
408
+
409
+ Resolution is answered offline. --probe adds the second question -- does the
410
+ resolved key work -- which needs the network, so it is opt-in. With a
411
+ platform key it also checks that every deployment entry still names a
412
+ deployment the organisation has.
413
+
414
+ Exits 0 only when nothing it checked failed. A setting that is simply not
415
+ configured is a report, not a failure; a setting that points somewhere and
416
+ does not arrive -- an unset `env:` variable, an unknown profile, a probe the
417
+ service rejected -- exits non-zero, because a setup script branches on that.
418
+ """
419
+ resolved = _resolved(obj)
420
+ problems: list[str] = []
421
+ products: dict[str, Any] = {}
422
+ for product in PRODUCTS:
423
+ entry: dict[str, Any] = {}
424
+ for key in settings_for(product):
425
+ try:
426
+ entry[key] = resolved.resolution_source(product, key)
427
+ except ConfigError as exc:
428
+ entry[key] = {"resolved": False, "source": "unset", "detail": str(exc)}
429
+ if detail := entry[key].get("detail"):
430
+ problems.append(f"{product}.{key}: {detail}")
431
+ for stray in resolved.unknown_settings(product):
432
+ problems.append(f"{product}.{stray}: not a setting {product} has.")
433
+ products[product] = entry
434
+
435
+ try:
436
+ deployments = list(resolved.deployment_names())
437
+ except ConfigError as exc:
438
+ deployments = []
439
+ problems.append(str(exc))
440
+ for api_name in deployments:
441
+ # An entry may carry a key of its own, and falls back to the profile's
442
+ # key silently.
443
+ if detail := resolved.withheld_detail("deployments", api_name, "api_key"):
444
+ problems.append(f"deployment {api_name}: {detail}")
445
+ try:
446
+ # Resolved the way a run would: being listed says nothing about
447
+ # whether the key behind it arrives.
448
+ resolved.deployment_key(api_name)
449
+ except ConfigError as exc:
450
+ problems.append(f"deployment {api_name}: {exc}")
451
+
452
+ report: dict[str, Any] = {
453
+ "active_profile": resolved.active_profile,
454
+ "config_path": str(resolved.file.path),
455
+ "config_exists": resolved.file.exists,
456
+ "products": products,
457
+ "deployments": deployments,
458
+ }
459
+ if any(
460
+ not entry["api_key"]["resolved"]
461
+ for entry in products.values()
462
+ if "api_key" in entry
463
+ ):
464
+ # The field name avoids the word the payload scrubber redacts on.
465
+ report["getting_started"] = KEY_SOURCES
466
+ if probe:
467
+ report["probe"] = _probe(resolved)
468
+ problems += [
469
+ f"probe {name}: {result.get('detail')}"
470
+ for name, result in report["probe"].items()
471
+ if result["ok"] is False
472
+ ]
473
+ notes = []
474
+ platform_probe = report["probe"][PLATFORM_KEY]
475
+ if deployments and not platform_probe["checked"]:
476
+ # The flag was explicit, so the skip is said rather than silent.
477
+ notes.append(
478
+ "probe: deployment entries were not checked against the "
479
+ f"organisation -- no platform key resolves for profile "
480
+ f"{resolved.active_profile!r}."
481
+ )
482
+ elif deployments and platform_probe["ok"] is not True:
483
+ # The listing would send the key that was just rejected, so it can
484
+ # only fail the same way and report the same failure twice.
485
+ notes.append(
486
+ "probe: deployment entries were not checked against the "
487
+ "organisation -- the platform key itself did not pass."
488
+ )
489
+ elif deployments:
490
+ try:
491
+ stale = _stale_deployments(resolved, deployments)
492
+ except (CLIError, ConfigError) as exc:
493
+ problems.append(f"probe deployments: {exc}")
494
+ else:
495
+ # A warning, not a problem: the entry is harmless until it is
496
+ # run, and the server is the only authority on what it is
497
+ # called now.
498
+ report["stale_deployments"] = stale
499
+ notes += [
500
+ f"warning: no deployment is called {api_name!r} any more; "
501
+ "run `unstract docstudio deployment ls` for the current names."
502
+ for api_name in stale
503
+ ]
504
+ for note in notes:
505
+ diagnostic(
506
+ note,
507
+ quiet=getattr(obj, "quiet", False),
508
+ verbosity=getattr(obj, "verbosity", 0),
509
+ )
510
+
511
+ if problems:
512
+ report["problems"] = problems
513
+ more = "" if len(problems) == 1 else f" (+{len(problems) - 1} more)"
514
+ raise CLIError(
515
+ f"{len(problems)} configuration check(s) failed: {problems[0]}{more}",
516
+ ExitCode.GENERIC,
517
+ details=report,
518
+ hint=(
519
+ "`details` carries the whole report, including where each setting "
520
+ "resolved from."
521
+ ),
522
+ )
523
+ emit_result(report, _fmt(obj))
524
+
525
+
526
+ def _loaded(obj: Any) -> ConfigFile:
527
+ """The config file, with its warnings reported.
528
+
529
+ A `config` subcommand may run with no root context to have loaded the file,
530
+ so it reports here what the file warned about -- reading it silently would
531
+ make these the quietest commands in the CLI about their own subject.
532
+ """
533
+ cfg = load_config()
534
+ for warning in cfg.warnings:
535
+ diagnostic(
536
+ warning,
537
+ quiet=getattr(obj, "quiet", False),
538
+ verbosity=getattr(obj, "verbosity", 0),
539
+ )
540
+ return cfg
541
+
542
+
543
+ def _resolved(obj: Any) -> ResolvedConfig:
544
+ """The root context's config, or a freshly loaded one when invoked standalone."""
545
+ # Already loaded means the context already reported its warnings.
546
+ if (existing := getattr(obj, "_config", None)) is not None:
547
+ return existing
548
+ return ResolvedConfig(file=_loaded(obj), profile_name=getattr(obj, "profile", None))
549
+
550
+
551
+ __all__ = ["config_group"]