cu-cli 0.1.0b1__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.
- cu_cli/__init__.py +17 -0
- cu_cli/__main__.py +11 -0
- cu_cli/apiversion.py +124 -0
- cu_cli/cli.py +138 -0
- cu_cli/client.py +138 -0
- cu_cli/commands/__init__.py +4 -0
- cu_cli/commands/_command_spec.py +94 -0
- cu_cli/commands/_help.py +33 -0
- cu_cli/commands/_infra_models.py +184 -0
- cu_cli/commands/_infra_wizard.py +630 -0
- cu_cli/commands/_model_setup.py +46 -0
- cu_cli/commands/_options.py +112 -0
- cu_cli/commands/analyze.py +631 -0
- cu_cli/commands/analyzer.py +1462 -0
- cu_cli/commands/defaults.py +172 -0
- cu_cli/commands/doctor.py +166 -0
- cu_cli/commands/env_var.py +67 -0
- cu_cli/commands/infra.py +302 -0
- cu_cli/commands/profile_cmd.py +525 -0
- cu_cli/commands/upgrade.py +120 -0
- cu_cli/core/__init__.py +17 -0
- cu_cli/core/analyze.py +44 -0
- cu_cli/core/analyzers.py +30 -0
- cu_cli/core/azure_resources.py +486 -0
- cu_cli/core/defaults.py +18 -0
- cu_cli/core/doctor.py +42 -0
- cu_cli/core/foundry.py +68 -0
- cu_cli/core/infra_models.py +367 -0
- cu_cli/core/inputs.py +209 -0
- cu_cli/core/schema.py +24 -0
- cu_cli/errors.py +174 -0
- cu_cli/exit_codes.py +20 -0
- cu_cli/modality.py +24 -0
- cu_cli/output.py +179 -0
- cu_cli/profile.py +30 -0
- cu_cli/py.typed +0 -0
- cu_cli/resources/__init__.py +4 -0
- cu_cli/resources/azd_template/README.md +187 -0
- cu_cli/resources/azd_template/azure.yaml +27 -0
- cu_cli/resources/azd_template/hooks/postprovision.ps1 +320 -0
- cu_cli/resources/azd_template/hooks/postprovision.sh +299 -0
- cu_cli/resources/azd_template/infra/main.bicep +115 -0
- cu_cli/resources/azd_template/infra/main.parameters.json +30 -0
- cu_cli/resources/azd_template/infra/models.json +1 -0
- cu_cli/resources/azd_template/infra/modules/foundry.bicep +122 -0
- cu_cli/schema_validate.py +28 -0
- cu_cli/spec_validate.py +18 -0
- cu_cli/telemetry.py +42 -0
- cu_cli/update_check.py +154 -0
- cu_cli/update_provider.py +92 -0
- cu_cli/windows_self_upgrade.py +245 -0
- cu_cli-0.1.0b1.dist-info/METADATA +345 -0
- cu_cli-0.1.0b1.dist-info/RECORD +56 -0
- cu_cli-0.1.0b1.dist-info/WHEEL +5 -0
- cu_cli-0.1.0b1.dist-info/entry_points.txt +3 -0
- cu_cli-0.1.0b1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation.
|
|
2
|
+
# Licensed under the MIT license.
|
|
3
|
+
|
|
4
|
+
"""``cu analyze`` standalone adapter."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import tempfile
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from azure.core.exceptions import HttpResponseError
|
|
15
|
+
from cu_cli_core.command_spec import (
|
|
16
|
+
ANALYZE,
|
|
17
|
+
CommandBindingError,
|
|
18
|
+
build_request,
|
|
19
|
+
resolve_identifier,
|
|
20
|
+
)
|
|
21
|
+
import rich_click as click
|
|
22
|
+
from rich.markup import escape as _esc
|
|
23
|
+
|
|
24
|
+
from ..apiversion import API_VERSION_HELP, ensure_supported, supports_api_feature
|
|
25
|
+
from ..client import build_client
|
|
26
|
+
from ..profile import Profile
|
|
27
|
+
from cu_cli_core.analysis import (
|
|
28
|
+
AnalyzeJob,
|
|
29
|
+
AnalyzeOutcome,
|
|
30
|
+
AnalyzeResponse,
|
|
31
|
+
analyze_one,
|
|
32
|
+
analyze_one_inline,
|
|
33
|
+
analyze_one_inline_with_usage,
|
|
34
|
+
analyze_one_with_usage,
|
|
35
|
+
)
|
|
36
|
+
from ..errors import CuCliError, _format_service_error, friendly_errors
|
|
37
|
+
from ..exit_codes import GENERIC_ERROR, VALIDATION_FAILURE
|
|
38
|
+
from ..output import (
|
|
39
|
+
EmptyMarkdownOutputError,
|
|
40
|
+
console,
|
|
41
|
+
dump_json,
|
|
42
|
+
dump_markdown,
|
|
43
|
+
dumps_json,
|
|
44
|
+
render_markdown,
|
|
45
|
+
to_jsonable,
|
|
46
|
+
)
|
|
47
|
+
from ._options import CALLING_TIME_OPTION, calling_time
|
|
48
|
+
from ._command_spec import with_command_arguments
|
|
49
|
+
|
|
50
|
+
if TYPE_CHECKING:
|
|
51
|
+
from cu_cli_core.contracts import ExecutionPlan, ExistingResultPolicy, InputPlan
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _run_one(client, job: AnalyzeJob):
|
|
55
|
+
"""Thin, patchable seam around :func:`cu_cli.core.analyze.analyze_one`.
|
|
56
|
+
|
|
57
|
+
Kept as a module-level indirection so tests can inject a fake analyzer and
|
|
58
|
+
so callers that want the originating job alongside the result get the
|
|
59
|
+
familiar ``(job, result)`` tuple. All real work lives in ``core``.
|
|
60
|
+
"""
|
|
61
|
+
return job, analyze_one(client, job)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _run_one_inline(client, job: AnalyzeJob):
|
|
65
|
+
"""Run one job through the synchronous inline analyze API."""
|
|
66
|
+
return job, analyze_one_inline(client, job)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _run_one_with_usage(client, job: AnalyzeJob):
|
|
70
|
+
"""Run one long-running analysis while retaining usage metadata."""
|
|
71
|
+
return job, analyze_one_with_usage(client, job)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _run_one_inline_with_usage(client, job: AnalyzeJob):
|
|
75
|
+
"""Run one inline analysis while retaining usage metadata."""
|
|
76
|
+
return job, analyze_one_inline_with_usage(client, job)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _print_usage(usage, *, input_ref: str) -> None:
|
|
80
|
+
"""Render request usage to stderr without changing data written to stdout."""
|
|
81
|
+
console.print("\n")
|
|
82
|
+
console.print(f"[bold cyan]Usage:[/bold cyan] {_esc(input_ref)}")
|
|
83
|
+
if usage is None:
|
|
84
|
+
console.print("[dim]usage details were not returned by the service.[/dim]")
|
|
85
|
+
return
|
|
86
|
+
console.print_json(data=to_jsonable(usage))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
_ON_EXISTS_ENV = "CU_ON_EXISTS"
|
|
90
|
+
_ON_EXISTS_CHOICES = ("error", "skip", "reanalyze")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _friendly_analyze_error(exc: BaseException) -> str:
|
|
94
|
+
"""Return a user-facing error string for per-input analyze failures."""
|
|
95
|
+
msg = str(exc)
|
|
96
|
+
if isinstance(exc, EmptyMarkdownOutputError) or (
|
|
97
|
+
"to_llm_input() returned empty markdown output" in msg
|
|
98
|
+
):
|
|
99
|
+
return (
|
|
100
|
+
"Analysis succeeded, but the Markdown view was empty. "
|
|
101
|
+
"Retry with --json to inspect the complete result."
|
|
102
|
+
)
|
|
103
|
+
if isinstance(exc, HttpResponseError):
|
|
104
|
+
return _format_service_error(exc)
|
|
105
|
+
return msg
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _resolve_on_exists(explicit: str | None) -> ExistingResultPolicy:
|
|
109
|
+
"""Resolve the explicit or environment-selected existing-result policy."""
|
|
110
|
+
from cu_cli_core.contracts import ExistingResultPolicy
|
|
111
|
+
|
|
112
|
+
raw = explicit or os.environ.get(_ON_EXISTS_ENV, "").strip().lower() or "error"
|
|
113
|
+
if not raw:
|
|
114
|
+
raw = "error"
|
|
115
|
+
if raw not in _ON_EXISTS_CHOICES:
|
|
116
|
+
raise CuCliError(
|
|
117
|
+
f"{_ON_EXISTS_ENV} must be one of {'|'.join(_ON_EXISTS_CHOICES)} "
|
|
118
|
+
f"(got {os.environ.get(_ON_EXISTS_ENV)!r}).",
|
|
119
|
+
exit_code=VALIDATION_FAILURE,
|
|
120
|
+
)
|
|
121
|
+
return ExistingResultPolicy(raw)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _validate_report_path(report_path: Path | None, jobs: list[AnalyzeJob]) -> None:
|
|
125
|
+
"""Reject a report path that would overwrite a finalized analysis result."""
|
|
126
|
+
if report_path is None:
|
|
127
|
+
return
|
|
128
|
+
resolved_report_path = report_path.resolve(strict=False)
|
|
129
|
+
for job in jobs:
|
|
130
|
+
if (
|
|
131
|
+
job.out_path is not None
|
|
132
|
+
and job.out_path.resolve(strict=False) == resolved_report_path
|
|
133
|
+
):
|
|
134
|
+
raise CuCliError(
|
|
135
|
+
f"--report-file conflicts with an analysis result file: {report_path}",
|
|
136
|
+
hint=(
|
|
137
|
+
"choose a different --report-file path; planned result path: "
|
|
138
|
+
f"{job.out_path}."
|
|
139
|
+
),
|
|
140
|
+
exit_code=VALIDATION_FAILURE,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _preflight_output_writes(
|
|
145
|
+
jobs: list[AnalyzeJob],
|
|
146
|
+
*,
|
|
147
|
+
report_path: Path | None,
|
|
148
|
+
) -> None:
|
|
149
|
+
output_paths = [job.out_path for job in jobs if job.out_path is not None]
|
|
150
|
+
if report_path is not None:
|
|
151
|
+
output_paths.append(report_path)
|
|
152
|
+
|
|
153
|
+
directories = {path.parent.resolve(strict=False) for path in output_paths}
|
|
154
|
+
for directory in sorted(directories, key=str):
|
|
155
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
156
|
+
with tempfile.NamedTemporaryFile(
|
|
157
|
+
dir=directory,
|
|
158
|
+
prefix=".cu-write-check-",
|
|
159
|
+
) as handle:
|
|
160
|
+
handle.write(b"\0")
|
|
161
|
+
handle.flush()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _write_analyze_report(path: Path, *, analyzer_id, fmt: str, results: list[dict]) -> None:
|
|
165
|
+
"""Write a machine-readable per-input status report (regression).
|
|
166
|
+
|
|
167
|
+
``results`` is a flat list of ``{"input", "status", ...}`` records where
|
|
168
|
+
``status`` is ``succeeded`` / ``failed`` / ``skipped``. The stable ``schema`` key
|
|
169
|
+
lets agents parse the summary without scraping human-formatted stderr.
|
|
170
|
+
"""
|
|
171
|
+
counts = {"succeeded": 0, "failed": 0, "skipped": 0}
|
|
172
|
+
for r in results:
|
|
173
|
+
status = r.get("status")
|
|
174
|
+
if status in counts:
|
|
175
|
+
counts[status] += 1
|
|
176
|
+
counts["total"] = len(results)
|
|
177
|
+
payload = dumps_json(
|
|
178
|
+
{
|
|
179
|
+
"schema": "cu-cli/analyze-report/v1",
|
|
180
|
+
"analyzer": analyzer_id,
|
|
181
|
+
"result_view": "full" if fmt == "json" else "llm-input",
|
|
182
|
+
"counts": counts,
|
|
183
|
+
"results": results,
|
|
184
|
+
}
|
|
185
|
+
)
|
|
186
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
187
|
+
temporary: Path | None = None
|
|
188
|
+
try:
|
|
189
|
+
with tempfile.NamedTemporaryFile(
|
|
190
|
+
mode="w",
|
|
191
|
+
encoding="utf-8",
|
|
192
|
+
dir=path.parent,
|
|
193
|
+
prefix=f".{path.name}.",
|
|
194
|
+
suffix=".tmp",
|
|
195
|
+
delete=False,
|
|
196
|
+
) as handle:
|
|
197
|
+
handle.write(payload)
|
|
198
|
+
temporary = Path(handle.name)
|
|
199
|
+
os.replace(temporary, path)
|
|
200
|
+
except OSError:
|
|
201
|
+
if temporary is not None:
|
|
202
|
+
temporary.unlink(missing_ok=True)
|
|
203
|
+
raise
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _print_extension_counts(input_plan: InputPlan) -> None:
|
|
207
|
+
for extension, count in input_plan.extension_counts.items():
|
|
208
|
+
console.print(f" {extension:<10} {count}")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _print_discovery_skips(input_plan: InputPlan) -> None:
|
|
212
|
+
if not input_plan.skipped:
|
|
213
|
+
return
|
|
214
|
+
console.print(f"Skipped during discovery: {len(input_plan.skipped)}")
|
|
215
|
+
for item in input_plan.skipped:
|
|
216
|
+
console.print(f" [dim]- {_esc(str(item.path))}: {_esc(item.reason)}[/dim]")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _print_discovery(input_plan: InputPlan, *, analyzer_id: str) -> None:
|
|
220
|
+
console.print(f"[yellow]Found {len(input_plan.inputs)} files:[/yellow]")
|
|
221
|
+
_print_extension_counts(input_plan)
|
|
222
|
+
_print_discovery_skips(input_plan)
|
|
223
|
+
console.print(f"\nAnalyzer: {analyzer_id}")
|
|
224
|
+
console.print(f"Recursive: {'yes' if input_plan.recursive else 'no'}")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _print_dry_run(plan: ExecutionPlan, *, analyzer_id: str) -> None:
|
|
228
|
+
input_plan = plan.input_plan
|
|
229
|
+
console.print("[bold cyan]Dry run[/bold cyan]")
|
|
230
|
+
console.print(
|
|
231
|
+
f"Selected: {len(input_plan.inputs)} file(s), {input_plan.total_bytes} byte(s)"
|
|
232
|
+
)
|
|
233
|
+
_print_extension_counts(input_plan)
|
|
234
|
+
_print_discovery_skips(input_plan)
|
|
235
|
+
console.print(f"Analyzer: {analyzer_id}")
|
|
236
|
+
console.print(f"Recursive: {'yes' if input_plan.recursive else 'no'}")
|
|
237
|
+
console.print(f"On existing: {plan.on_existing.value}")
|
|
238
|
+
for output in plan.outputs:
|
|
239
|
+
if output.path is None:
|
|
240
|
+
destination = "stdout"
|
|
241
|
+
else:
|
|
242
|
+
destination = str(output.path)
|
|
243
|
+
if output.exists:
|
|
244
|
+
action = (
|
|
245
|
+
"skip"
|
|
246
|
+
if plan.on_existing.value == "skip"
|
|
247
|
+
else plan.on_existing.value
|
|
248
|
+
)
|
|
249
|
+
console.print(
|
|
250
|
+
f" {_esc(str(output.source.path))} -> {_esc(destination)} "
|
|
251
|
+
f"[dim](exists: {action})[/dim]"
|
|
252
|
+
)
|
|
253
|
+
else:
|
|
254
|
+
console.print(f" {_esc(str(output.source.path))} -> {_esc(destination)}")
|
|
255
|
+
console.print(
|
|
256
|
+
"[dim]No service calls or files were written. Analyzer existence, "
|
|
257
|
+
"service-side format acceptance, usage, and cost were not validated.[/dim]"
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@click.command("analyze",
|
|
262
|
+
help=ANALYZE.help,
|
|
263
|
+
epilog="When a result file already exists, analyze stops unless "
|
|
264
|
+
"--on-existing skip or --on-existing reanalyze is selected. "
|
|
265
|
+
"Set CU_ON_EXISTS=error|skip|reanalyze to change the default.\n\n"
|
|
266
|
+
"[white] [/white]\n\n"
|
|
267
|
+
"[bold cyan]Common commands:[/bold cyan]\n\n"
|
|
268
|
+
"[bold green]cu analyze[/bold green] [bold yellow]FILE[/bold yellow]\n\n"
|
|
269
|
+
"[white]\u00a0\u00a0Analyze one file with the configured default "
|
|
270
|
+
"analyzer.[/white]\n\n"
|
|
271
|
+
"[bold green]cu analyze[/bold green] [bold yellow]FILE[/bold yellow] "
|
|
272
|
+
"[bold cyan]-a[/bold cyan] [bold magenta]prebuilt-invoice[/bold magenta] "
|
|
273
|
+
"[bold cyan]--json[/bold cyan]\n\n"
|
|
274
|
+
"[white]\u00a0\u00a0Extract invoice fields as JSON.[/white]\n\n"
|
|
275
|
+
"[bold green]cu analyze[/bold green] "
|
|
276
|
+
"[bold cyan]--source[/bold cyan] [bold yellow]DIRECTORY[/bold yellow] "
|
|
277
|
+
"[bold cyan]--output-dir[/bold cyan] [bold yellow]TARGET_DIR[/bold yellow]\n\n"
|
|
278
|
+
"[white]\u00a0\u00a0Analyze immediate files in DIRECTORY and write all "
|
|
279
|
+
"result files to TARGET_DIR instead of beside each input.[/white]")
|
|
280
|
+
@with_command_arguments(ANALYZE)
|
|
281
|
+
@CALLING_TIME_OPTION
|
|
282
|
+
@click.option("-p", "--profile", "profile_name", default=None,
|
|
283
|
+
help="Named CU CLI profile to use (from cu profile).")
|
|
284
|
+
@click.option("--endpoint", default=None, help="Override configured endpoint.")
|
|
285
|
+
@click.option("--auth-mode", type=click.Choice(["login", "key"]), default=None,
|
|
286
|
+
help="Authentication mode; defaults to the selected CU CLI profile.")
|
|
287
|
+
@click.option("--api-key", default=None, help="Override configured API key.")
|
|
288
|
+
@click.option("--api-version", "api_version", default=None,
|
|
289
|
+
help=API_VERSION_HELP)
|
|
290
|
+
@friendly_errors
|
|
291
|
+
def cmd_analyze(
|
|
292
|
+
inputs,
|
|
293
|
+
files,
|
|
294
|
+
sources,
|
|
295
|
+
pattern,
|
|
296
|
+
recursive,
|
|
297
|
+
analyzer_id,
|
|
298
|
+
out_dir,
|
|
299
|
+
output_file,
|
|
300
|
+
llm_input,
|
|
301
|
+
json_output,
|
|
302
|
+
report_path,
|
|
303
|
+
concurrency,
|
|
304
|
+
on_existing,
|
|
305
|
+
dry_run,
|
|
306
|
+
assume_yes,
|
|
307
|
+
endpoint,
|
|
308
|
+
api_key,
|
|
309
|
+
api_version,
|
|
310
|
+
auth_mode,
|
|
311
|
+
profile_name,
|
|
312
|
+
inline,
|
|
313
|
+
show_usage,
|
|
314
|
+
show_calling_time,
|
|
315
|
+
) -> None:
|
|
316
|
+
from cu_cli_core.contracts import ExistingResultPolicy, InputOrigin, ResultView
|
|
317
|
+
from cu_cli_core.input_planning import plan_inputs, plan_outputs
|
|
318
|
+
|
|
319
|
+
try:
|
|
320
|
+
request = build_request(
|
|
321
|
+
ANALYZE,
|
|
322
|
+
{
|
|
323
|
+
"inputs": inputs,
|
|
324
|
+
"files": files,
|
|
325
|
+
"sources": sources,
|
|
326
|
+
"pattern": pattern,
|
|
327
|
+
"recursive": recursive,
|
|
328
|
+
"analyzer_id": analyzer_id,
|
|
329
|
+
"inline": inline,
|
|
330
|
+
"show_usage": show_usage,
|
|
331
|
+
"llm_input": llm_input,
|
|
332
|
+
"json_output": json_output,
|
|
333
|
+
"output_file": output_file,
|
|
334
|
+
"out_dir": out_dir,
|
|
335
|
+
"on_existing": on_existing,
|
|
336
|
+
"dry_run": dry_run,
|
|
337
|
+
"assume_yes": assume_yes,
|
|
338
|
+
"report_path": report_path,
|
|
339
|
+
"concurrency": concurrency,
|
|
340
|
+
},
|
|
341
|
+
)
|
|
342
|
+
except CommandBindingError as exc:
|
|
343
|
+
raise CuCliError(str(exc), exit_code=VALIDATION_FAILURE) from exc
|
|
344
|
+
|
|
345
|
+
# Fail fast, before any input discovery, CU service call, or result-file
|
|
346
|
+
# write: an existing report is never overwritten.
|
|
347
|
+
if dry_run and assume_yes:
|
|
348
|
+
raise CuCliError(
|
|
349
|
+
"--dry-run and --yes cannot be combined.",
|
|
350
|
+
exit_code=VALIDATION_FAILURE,
|
|
351
|
+
)
|
|
352
|
+
if llm_input and json_output:
|
|
353
|
+
raise CuCliError(
|
|
354
|
+
"--llm-input and --json cannot be combined.",
|
|
355
|
+
exit_code=VALIDATION_FAILURE,
|
|
356
|
+
)
|
|
357
|
+
if not dry_run and report_path is not None and report_path.exists():
|
|
358
|
+
raise CuCliError(
|
|
359
|
+
f"--report-file already exists: {report_path}",
|
|
360
|
+
hint="choose a new --report-file path; existing reports are never overwritten.",
|
|
361
|
+
exit_code=VALIDATION_FAILURE,
|
|
362
|
+
)
|
|
363
|
+
input_plan = plan_inputs(
|
|
364
|
+
positional=request.positional_inputs,
|
|
365
|
+
files=request.files,
|
|
366
|
+
sources=request.sources,
|
|
367
|
+
pattern=request.pattern,
|
|
368
|
+
recursive=request.recursive,
|
|
369
|
+
)
|
|
370
|
+
policy = _resolve_on_exists(request.on_existing)
|
|
371
|
+
view = ResultView.FULL if json_output else ResultView.LLM_INPUT
|
|
372
|
+
fmt = "json" if view is ResultView.FULL else "markdown"
|
|
373
|
+
execution_plan = plan_outputs(
|
|
374
|
+
input_plan,
|
|
375
|
+
view=view,
|
|
376
|
+
output_file=request.output_file,
|
|
377
|
+
output_dir=request.output_dir,
|
|
378
|
+
on_existing=policy,
|
|
379
|
+
dry_run=dry_run,
|
|
380
|
+
)
|
|
381
|
+
profile = Profile.load(profile_name=profile_name)
|
|
382
|
+
effective_analyzer = request.analyzer or profile.default_analyzer
|
|
383
|
+
if not effective_analyzer:
|
|
384
|
+
raise CuCliError(
|
|
385
|
+
"no analyzer was specified and no default_analyzer is configured.",
|
|
386
|
+
hint="pass --analyzer ANALYZER_ID or run "
|
|
387
|
+
"`cu profile set default_analyzer ANALYZER_ID`.",
|
|
388
|
+
exit_code=VALIDATION_FAILURE,
|
|
389
|
+
)
|
|
390
|
+
skipped_report = [
|
|
391
|
+
{
|
|
392
|
+
"input": str(item.path),
|
|
393
|
+
"status": "skipped",
|
|
394
|
+
"reason": item.reason,
|
|
395
|
+
"output": None,
|
|
396
|
+
}
|
|
397
|
+
for item in input_plan.skipped
|
|
398
|
+
]
|
|
399
|
+
|
|
400
|
+
jobs = [
|
|
401
|
+
AnalyzeJob(
|
|
402
|
+
input_ref=str(output.source.path),
|
|
403
|
+
analyzer_id=effective_analyzer,
|
|
404
|
+
out_path=output.path,
|
|
405
|
+
output_format=fmt,
|
|
406
|
+
)
|
|
407
|
+
for output in execution_plan.outputs
|
|
408
|
+
]
|
|
409
|
+
to_stdout = len(jobs) == 1 and jobs[0].out_path is None
|
|
410
|
+
|
|
411
|
+
effective_api_version = ensure_supported(api_version or profile.api_version)
|
|
412
|
+
if inline:
|
|
413
|
+
if not supports_api_feature(effective_api_version, "inline-analysis"):
|
|
414
|
+
raise CuCliError(
|
|
415
|
+
"--inline requires API version 2026-06-01-preview.",
|
|
416
|
+
hint="pass `--api-version 2026-06-01-preview` or save it with "
|
|
417
|
+
"`cu profile set api_version 2026-06-01-preview`.",
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
skipped: list[Path] = []
|
|
421
|
+
if not to_stdout:
|
|
422
|
+
if not dry_run:
|
|
423
|
+
_validate_report_path(report_path, jobs)
|
|
424
|
+
existing = [j for j in jobs if j.out_path is not None and j.out_path.exists()]
|
|
425
|
+
if dry_run:
|
|
426
|
+
_print_dry_run(execution_plan, analyzer_id=effective_analyzer)
|
|
427
|
+
return
|
|
428
|
+
if existing:
|
|
429
|
+
where = f"under {out_dir}" if out_dir is not None else "next to the inputs"
|
|
430
|
+
if policy is ExistingResultPolicy.ERROR:
|
|
431
|
+
raise CuCliError(
|
|
432
|
+
f"{len(existing)} of {len(jobs)} result file(s) already exist {where}.",
|
|
433
|
+
hint="choose --on-existing skip to keep them or "
|
|
434
|
+
"--on-existing reanalyze to replace them (re-bills).",
|
|
435
|
+
exit_code=VALIDATION_FAILURE,
|
|
436
|
+
)
|
|
437
|
+
if policy is ExistingResultPolicy.SKIP:
|
|
438
|
+
skipped = [j.out_path for j in existing if j.out_path is not None]
|
|
439
|
+
for j in existing:
|
|
440
|
+
skipped_report.append({
|
|
441
|
+
"input": j.input_ref,
|
|
442
|
+
"status": "skipped",
|
|
443
|
+
"reason": "result file already exists",
|
|
444
|
+
"output": str(j.out_path) if j.out_path is not None else None,
|
|
445
|
+
})
|
|
446
|
+
existing_ids = {id(j) for j in existing}
|
|
447
|
+
jobs = [j for j in jobs if id(j) not in existing_ids]
|
|
448
|
+
elif policy is ExistingResultPolicy.REANALYZE:
|
|
449
|
+
console.print(
|
|
450
|
+
"[yellow]Warning:[/yellow] Reanalysis sends the input to "
|
|
451
|
+
"Content Understanding again and may incur additional charges. "
|
|
452
|
+
"Existing result files will be replaced."
|
|
453
|
+
)
|
|
454
|
+
if not jobs:
|
|
455
|
+
message = (
|
|
456
|
+
f"[green]nothing to do:[/green] {len(skipped)} result file(s) already exist "
|
|
457
|
+
"(skipped)."
|
|
458
|
+
)
|
|
459
|
+
if input_plan.skipped:
|
|
460
|
+
message += f" {len(input_plan.skipped)} source entry(s) skipped during discovery."
|
|
461
|
+
console.print(message)
|
|
462
|
+
if report_path is not None:
|
|
463
|
+
_write_analyze_report(report_path, analyzer_id=effective_analyzer, fmt=fmt,
|
|
464
|
+
results=skipped_report)
|
|
465
|
+
console.print(f"[dim]report:[/dim] wrote {report_path}")
|
|
466
|
+
return
|
|
467
|
+
discovered = any(
|
|
468
|
+
item.origin in {InputOrigin.POSITIONAL_SOURCE, InputOrigin.NAMED_SOURCE}
|
|
469
|
+
for item in input_plan.inputs
|
|
470
|
+
)
|
|
471
|
+
if not assume_yes and discovered and len(jobs) > 1 and sys.stdin.isatty():
|
|
472
|
+
_print_discovery(input_plan, analyzer_id=effective_analyzer)
|
|
473
|
+
if not click.confirm("proceed?", default=False):
|
|
474
|
+
raise CuCliError("aborted by user.", hint="narrow the inputs or pass --yes.")
|
|
475
|
+
elif dry_run:
|
|
476
|
+
_print_dry_run(execution_plan, analyzer_id=effective_analyzer)
|
|
477
|
+
return
|
|
478
|
+
|
|
479
|
+
_preflight_output_writes(jobs, report_path=report_path)
|
|
480
|
+
client = build_client(profile, endpoint_override=endpoint, api_key_override=api_key,
|
|
481
|
+
api_version_override=api_version,
|
|
482
|
+
auth_mode_override=auth_mode)
|
|
483
|
+
if show_usage:
|
|
484
|
+
run_one = _run_one_inline_with_usage if inline else _run_one_with_usage
|
|
485
|
+
else:
|
|
486
|
+
run_one = _run_one_inline if inline else _run_one
|
|
487
|
+
|
|
488
|
+
if to_stdout:
|
|
489
|
+
job = jobs[0]
|
|
490
|
+
with calling_time(show_calling_time) as calling_timer:
|
|
491
|
+
batch_result = resolve_identifier(ANALYZE.operation)(
|
|
492
|
+
client,
|
|
493
|
+
request,
|
|
494
|
+
input_plan=input_plan,
|
|
495
|
+
jobs=[job],
|
|
496
|
+
run=lambda c, j: run_one(c, j)[1],
|
|
497
|
+
)
|
|
498
|
+
if batch_result.failures:
|
|
499
|
+
failure = batch_result.failures[0].error
|
|
500
|
+
assert failure is not None
|
|
501
|
+
# Match the batch path: a failed single-input run still emits the
|
|
502
|
+
# --report file (one "failed" entry) before the friendly error exits
|
|
503
|
+
# 1, so a scripted single-file caller never gets a missing report
|
|
504
|
+
# (regression). Re-raise the captured core outcome so @friendly_errors
|
|
505
|
+
# renders the same message and exit code as before.
|
|
506
|
+
if report_path is not None:
|
|
507
|
+
_write_analyze_report(
|
|
508
|
+
report_path, analyzer_id=effective_analyzer, fmt=fmt,
|
|
509
|
+
results=[{"input": job.input_ref, "status": "failed",
|
|
510
|
+
"analyzer": job.analyzer_id,
|
|
511
|
+
"error": _friendly_analyze_error(failure)}] + skipped_report,
|
|
512
|
+
)
|
|
513
|
+
console.print(f"[dim]report:[/dim] wrote {report_path}")
|
|
514
|
+
raise failure
|
|
515
|
+
response = batch_result.successes[0].result
|
|
516
|
+
if show_usage:
|
|
517
|
+
assert isinstance(response, AnalyzeResponse)
|
|
518
|
+
result = response.result
|
|
519
|
+
else:
|
|
520
|
+
result = response
|
|
521
|
+
if fmt == "json":
|
|
522
|
+
dump_json(result)
|
|
523
|
+
else:
|
|
524
|
+
try:
|
|
525
|
+
dump_markdown(result)
|
|
526
|
+
except EmptyMarkdownOutputError as exc:
|
|
527
|
+
error = _friendly_analyze_error(exc)
|
|
528
|
+
if report_path is not None:
|
|
529
|
+
_write_analyze_report(
|
|
530
|
+
report_path,
|
|
531
|
+
analyzer_id=effective_analyzer,
|
|
532
|
+
fmt=fmt,
|
|
533
|
+
results=[
|
|
534
|
+
{
|
|
535
|
+
"input": job.input_ref,
|
|
536
|
+
"status": "failed",
|
|
537
|
+
"analyzer": job.analyzer_id,
|
|
538
|
+
"error": error,
|
|
539
|
+
}
|
|
540
|
+
] + skipped_report,
|
|
541
|
+
)
|
|
542
|
+
console.print(f"[dim]report:[/dim] wrote {report_path}")
|
|
543
|
+
raise CuCliError(error) from exc
|
|
544
|
+
if report_path is not None:
|
|
545
|
+
_write_analyze_report(
|
|
546
|
+
report_path, analyzer_id=effective_analyzer, fmt=fmt,
|
|
547
|
+
results=[{"input": job.input_ref, "status": "succeeded",
|
|
548
|
+
"analyzer": job.analyzer_id, "output": None}] + skipped_report,
|
|
549
|
+
)
|
|
550
|
+
console.print(f"[dim]report:[/dim] wrote {report_path}")
|
|
551
|
+
if show_usage:
|
|
552
|
+
assert isinstance(response, AnalyzeResponse)
|
|
553
|
+
_print_usage(response.usage, input_ref=job.input_ref)
|
|
554
|
+
calling_timer.print()
|
|
555
|
+
return
|
|
556
|
+
|
|
557
|
+
failures: list[tuple[str, str]] = []
|
|
558
|
+
written: list[Path] = []
|
|
559
|
+
results_report: list[dict] = []
|
|
560
|
+
usage_results: list[tuple[str, object]] = []
|
|
561
|
+
console.print(f"[bold]analyze[/bold] {len(jobs)} file(s) -> "
|
|
562
|
+
f"{out_dir or 'alongside inputs'}")
|
|
563
|
+
|
|
564
|
+
def _persist(outcome: AnalyzeOutcome) -> None:
|
|
565
|
+
job = outcome.job
|
|
566
|
+
if not outcome.ok:
|
|
567
|
+
assert outcome.error is not None
|
|
568
|
+
err = _friendly_analyze_error(outcome.error)
|
|
569
|
+
failures.append((job.input_ref, err))
|
|
570
|
+
results_report.append({"input": job.input_ref, "status": "failed",
|
|
571
|
+
"analyzer": job.analyzer_id, "error": err})
|
|
572
|
+
return
|
|
573
|
+
try:
|
|
574
|
+
assert job.out_path is not None
|
|
575
|
+
if show_usage:
|
|
576
|
+
assert isinstance(outcome.result, AnalyzeResponse)
|
|
577
|
+
result = outcome.result.result
|
|
578
|
+
usage_results.append((job.input_ref, outcome.result.usage))
|
|
579
|
+
else:
|
|
580
|
+
result = outcome.result
|
|
581
|
+
if fmt == "json":
|
|
582
|
+
dump_json(result, out=job.out_path)
|
|
583
|
+
else:
|
|
584
|
+
job.out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
585
|
+
job.out_path.write_text(render_markdown(result), encoding="utf-8")
|
|
586
|
+
written.append(job.out_path)
|
|
587
|
+
results_report.append({"input": job.input_ref, "status": "succeeded",
|
|
588
|
+
"analyzer": job.analyzer_id, "output": str(job.out_path)})
|
|
589
|
+
except Exception as exc: # noqa: BLE001 — per-file isolation on write
|
|
590
|
+
err = _friendly_analyze_error(exc)
|
|
591
|
+
failures.append((job.input_ref, err))
|
|
592
|
+
results_report.append({"input": job.input_ref, "status": "failed",
|
|
593
|
+
"analyzer": job.analyzer_id, "error": err})
|
|
594
|
+
|
|
595
|
+
with calling_time(show_calling_time) as calling_timer:
|
|
596
|
+
resolve_identifier(ANALYZE.operation)(
|
|
597
|
+
client,
|
|
598
|
+
request,
|
|
599
|
+
input_plan=input_plan,
|
|
600
|
+
jobs=jobs,
|
|
601
|
+
on_result=_persist,
|
|
602
|
+
run=lambda c, j: run_one(c, j)[1],
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
# Write the report before the failure exit so agents always get it (regression).
|
|
606
|
+
if report_path is not None:
|
|
607
|
+
_write_analyze_report(report_path, analyzer_id=effective_analyzer, fmt=fmt,
|
|
608
|
+
results=results_report + skipped_report)
|
|
609
|
+
console.print(f"[dim]report:[/dim] wrote {report_path}")
|
|
610
|
+
summary = [f"[green]{len(written)} ok[/green]", f"[red]{len(failures)} failed[/red]"]
|
|
611
|
+
if skipped:
|
|
612
|
+
summary.append(f"[dim]{len(skipped)} skipped (existing)[/dim]")
|
|
613
|
+
if input_plan.skipped:
|
|
614
|
+
summary.append(f"[dim]{len(input_plan.skipped)} skipped (discovery)[/dim]")
|
|
615
|
+
console.print(", ".join(summary))
|
|
616
|
+
for path in written:
|
|
617
|
+
console.print(f" [green]->[/green] {path}")
|
|
618
|
+
if failures:
|
|
619
|
+
# List every failed input and its full reason — never truncate the file
|
|
620
|
+
# list or the per-file service message, so an agent can act on each one
|
|
621
|
+
# (regression).
|
|
622
|
+
console.print(f"[red]{len(failures)} failed input(s):[/red]")
|
|
623
|
+
for ref, err in failures:
|
|
624
|
+
console.print(f" [red]x[/red] {_esc(ref)}")
|
|
625
|
+
for line in (err.splitlines() or [""]):
|
|
626
|
+
console.print(f" [dim]{_esc(line)}[/dim]")
|
|
627
|
+
for input_ref, usage in usage_results:
|
|
628
|
+
_print_usage(usage, input_ref=input_ref)
|
|
629
|
+
calling_timer.print()
|
|
630
|
+
if failures:
|
|
631
|
+
sys.exit(GENERIC_ERROR)
|