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,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"]
|