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,1462 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation.
|
|
2
|
+
# Licensed under the MIT license.
|
|
3
|
+
|
|
4
|
+
"""``cu analyzer`` — manage and author custom analyzers.
|
|
5
|
+
|
|
6
|
+
MVP command surface: ``list``, ``show``, ``create``, ``delete``,
|
|
7
|
+
``test``, ``validate``, and ``schema create``. The ``validate`` and default
|
|
8
|
+
``schema create`` paths are **LLM-free and offline** — they give coding
|
|
9
|
+
agents a deterministic author->validate loop with no service round-trips.
|
|
10
|
+
|
|
11
|
+
Exit-code convention: ``validate`` exits ``2`` on a schema error so agents can
|
|
12
|
+
branch on structural validity without parsing prose.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
import shlex
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import rich_click as click
|
|
25
|
+
from rich.markup import escape as _esc
|
|
26
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
|
|
27
|
+
from rich.table import Table
|
|
28
|
+
|
|
29
|
+
from ..apiversion import API_VERSION_HELP, resolve_api_version
|
|
30
|
+
from ..client import build_client, resolve
|
|
31
|
+
from cu_cli_core.command_spec import (
|
|
32
|
+
ANALYZER_COPY,
|
|
33
|
+
ANALYZER_CREATE,
|
|
34
|
+
ANALYZER_DELETE,
|
|
35
|
+
ANALYZER_LIST,
|
|
36
|
+
ANALYZER_SCHEMA_CREATE,
|
|
37
|
+
ANALYZER_SHOW,
|
|
38
|
+
ANALYZER_TEST,
|
|
39
|
+
ANALYZER_VALIDATE,
|
|
40
|
+
CommandBindingError,
|
|
41
|
+
build_request,
|
|
42
|
+
resolve_identifier,
|
|
43
|
+
)
|
|
44
|
+
from ..profile import Profile
|
|
45
|
+
from ..core import analyzers as _analyzers
|
|
46
|
+
from cu_cli_core.schema import (
|
|
47
|
+
FIELD_SCHEMA_SUGGEST_ANALYZER_ID,
|
|
48
|
+
MODALITY_BASE,
|
|
49
|
+
starter_schema,
|
|
50
|
+
template_completion_model,
|
|
51
|
+
validate_document_sample,
|
|
52
|
+
)
|
|
53
|
+
from ..errors import CuCliError, friendly_errors
|
|
54
|
+
from ..exit_codes import GENERIC_ERROR, SUCCESS, VALIDATION_FAILURE
|
|
55
|
+
from ..output import analyzer_table, console, dump_json
|
|
56
|
+
from cu_cli_core.schema_validation import (
|
|
57
|
+
custom_analyzer_id_error,
|
|
58
|
+
parse_and_validate,
|
|
59
|
+
schema_pinned_version,
|
|
60
|
+
)
|
|
61
|
+
from ._options import calling_time, print_runtime_context, with_auth_options
|
|
62
|
+
from ._command_spec import with_command_arguments
|
|
63
|
+
from ._help import common_commands
|
|
64
|
+
|
|
65
|
+
# Backward-compatible aliases: schema authoring logic now lives in
|
|
66
|
+
# ``cu_cli_core.schema``; these names are kept for existing integrations and tests.
|
|
67
|
+
_MODALITY_BASE = MODALITY_BASE
|
|
68
|
+
_FIELD_SCHEMA_SUGGEST_ANALYZER_ID = FIELD_SCHEMA_SUGGEST_ANALYZER_ID
|
|
69
|
+
_starter_schema = starter_schema
|
|
70
|
+
_template_completion_model = template_completion_model
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _non_directory_parent(path: Path) -> Path | None:
|
|
74
|
+
parent = path.parent
|
|
75
|
+
while not parent.exists() and not parent.is_symlink():
|
|
76
|
+
if parent == parent.parent:
|
|
77
|
+
return None
|
|
78
|
+
parent = parent.parent
|
|
79
|
+
return None if parent.is_dir() else parent
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _require_output_available(
|
|
83
|
+
path: Path | None,
|
|
84
|
+
*,
|
|
85
|
+
force: bool,
|
|
86
|
+
description: str,
|
|
87
|
+
) -> None:
|
|
88
|
+
if path is None:
|
|
89
|
+
return
|
|
90
|
+
blocking_parent = _non_directory_parent(path)
|
|
91
|
+
if blocking_parent is not None:
|
|
92
|
+
raise CuCliError(
|
|
93
|
+
f"{description} parent path is not a directory: {blocking_parent}",
|
|
94
|
+
hint="choose another output path or replace the parent file with a directory.",
|
|
95
|
+
)
|
|
96
|
+
if force:
|
|
97
|
+
return
|
|
98
|
+
if path.exists() or path.is_symlink():
|
|
99
|
+
raise CuCliError(
|
|
100
|
+
f"{description} already exists: {path}",
|
|
101
|
+
hint="choose another path or pass --force to overwrite it.",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _write_json_output(
|
|
106
|
+
payload: Any,
|
|
107
|
+
path: Path | None,
|
|
108
|
+
*,
|
|
109
|
+
force: bool,
|
|
110
|
+
description: str,
|
|
111
|
+
) -> None:
|
|
112
|
+
try:
|
|
113
|
+
dump_json(payload, out=path, overwrite=force)
|
|
114
|
+
except FileExistsError as exc:
|
|
115
|
+
blocking_parent = _non_directory_parent(path) if path is not None else None
|
|
116
|
+
if blocking_parent is not None:
|
|
117
|
+
raise CuCliError(
|
|
118
|
+
f"{description} parent path is not a directory: {blocking_parent}",
|
|
119
|
+
hint="choose another output path or replace the parent file with a directory.",
|
|
120
|
+
) from exc
|
|
121
|
+
raise CuCliError(
|
|
122
|
+
f"{description} already exists: {path}",
|
|
123
|
+
hint="choose another path or pass --force to overwrite it.",
|
|
124
|
+
) from exc
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _extract_fields_from_result(result: Any) -> dict[str, dict[str, Any]]:
|
|
128
|
+
from cu_cli_core.operations.analysis import extract_fields_from_result
|
|
129
|
+
|
|
130
|
+
return extract_fields_from_result(result)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _test_summary(samples: list[dict[str, Any]]) -> dict[str, Any]:
|
|
134
|
+
from cu_cli_core.operations.analysis import analyzer_test_summary
|
|
135
|
+
|
|
136
|
+
return analyzer_test_summary(samples)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _client(endpoint, api_key, api_version, entra, profile_name, show_runtime_context):
|
|
140
|
+
profile = Profile.load(profile_name=profile_name)
|
|
141
|
+
auth = resolve(
|
|
142
|
+
profile,
|
|
143
|
+
endpoint_override=endpoint,
|
|
144
|
+
api_key_override=api_key,
|
|
145
|
+
api_version_override=api_version,
|
|
146
|
+
force_entra=entra,
|
|
147
|
+
)
|
|
148
|
+
if show_runtime_context:
|
|
149
|
+
print_runtime_context(auth, profile)
|
|
150
|
+
return build_client(
|
|
151
|
+
profile,
|
|
152
|
+
endpoint_override=endpoint,
|
|
153
|
+
api_key_override=api_key,
|
|
154
|
+
api_version_override=api_version,
|
|
155
|
+
force_entra=entra,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _require_custom_analyzer_id(analyzer_id: str) -> None:
|
|
160
|
+
error = custom_analyzer_id_error(analyzer_id)
|
|
161
|
+
if error:
|
|
162
|
+
raise CuCliError(
|
|
163
|
+
f"invalid custom analyzer ID '{analyzer_id}'.",
|
|
164
|
+
hint=error,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@click.group("analyzer",
|
|
169
|
+
help="Manage analyzers, which define how Content Understanding processes files. "
|
|
170
|
+
"List, show, create, copy, delete, and test analyzers, or create and validate "
|
|
171
|
+
"local analyzer schemas.",
|
|
172
|
+
epilog="[bold cyan]Common commands:[/bold cyan]\n\n"
|
|
173
|
+
"[bold green]cu analyzer list[/bold green]\n\n"
|
|
174
|
+
"[white]\u00a0\u00a0List available analyzers.[/white]\n\n"
|
|
175
|
+
"[bold green]cu analyzer schema create[/bold green] "
|
|
176
|
+
"[bold cyan]--from-sample[/bold cyan] [bold yellow]SAMPLE_FILE[/bold yellow]\n\n"
|
|
177
|
+
"[white]\u00a0\u00a0Create a custom schema from one document sample.[/white]\n\n"
|
|
178
|
+
"[bold green]cu analyzer create[/bold green] [bold yellow]NAME[/bold yellow] "
|
|
179
|
+
"[bold cyan]--schema[/bold cyan] [bold yellow]SCHEMA.json[/bold yellow]\n\n"
|
|
180
|
+
"[white]\u00a0\u00a0Create a custom analyzer from a schema.[/white]")
|
|
181
|
+
def analyzer_group() -> None:
|
|
182
|
+
pass
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# --- CRUD ------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@analyzer_group.command(
|
|
189
|
+
"list",
|
|
190
|
+
help="List analyzers in the Microsoft Foundry resource.",
|
|
191
|
+
epilog=common_commands(
|
|
192
|
+
("cu analyzer list", "List all analyzers as a table."),
|
|
193
|
+
("cu analyzer list --kind custom", "List only custom analyzers."),
|
|
194
|
+
("cu analyzer list --json", "List analyzers as machine-readable JSON."),
|
|
195
|
+
),
|
|
196
|
+
)
|
|
197
|
+
@with_command_arguments(ANALYZER_LIST)
|
|
198
|
+
@with_auth_options
|
|
199
|
+
@friendly_errors
|
|
200
|
+
def cmd_list(
|
|
201
|
+
kind, sort_by, json_output, endpoint, api_key, api_version, entra, profile_name,
|
|
202
|
+
show_runtime_context, show_calling_time
|
|
203
|
+
) -> None:
|
|
204
|
+
request = build_request(
|
|
205
|
+
ANALYZER_LIST,
|
|
206
|
+
{"kind": kind, "sort_by": sort_by, "json_output": json_output},
|
|
207
|
+
)
|
|
208
|
+
client = _client(endpoint, api_key, api_version, entra, profile_name, show_runtime_context)
|
|
209
|
+
with calling_time(show_calling_time) as calling_timer:
|
|
210
|
+
items = resolve_identifier(ANALYZER_LIST.operation)(
|
|
211
|
+
client,
|
|
212
|
+
kind=request.kind,
|
|
213
|
+
sort_by=request.sort_by,
|
|
214
|
+
)
|
|
215
|
+
if json_output:
|
|
216
|
+
dump_json([a.as_dict() for a in items])
|
|
217
|
+
else:
|
|
218
|
+
console.print(analyzer_table(items))
|
|
219
|
+
console.print(f"\n[dim]{len(items)} analyzer(s)[/dim]")
|
|
220
|
+
calling_timer.print()
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@analyzer_group.command(
|
|
224
|
+
"show",
|
|
225
|
+
help=ANALYZER_SHOW.help,
|
|
226
|
+
epilog=common_commands(
|
|
227
|
+
("cu analyzer show ANALYZER_NAME", "Print one analyzer definition as JSON."),
|
|
228
|
+
),
|
|
229
|
+
)
|
|
230
|
+
@with_command_arguments(ANALYZER_SHOW)
|
|
231
|
+
@with_auth_options
|
|
232
|
+
@friendly_errors
|
|
233
|
+
def cmd_show(
|
|
234
|
+
positional_analyzer_name, analyzer_name, endpoint, api_key, api_version, entra,
|
|
235
|
+
profile_name, show_runtime_context, show_calling_time
|
|
236
|
+
) -> None:
|
|
237
|
+
try:
|
|
238
|
+
request = build_request(
|
|
239
|
+
ANALYZER_SHOW,
|
|
240
|
+
{
|
|
241
|
+
"positional_analyzer_name": positional_analyzer_name,
|
|
242
|
+
"analyzer_name": analyzer_name,
|
|
243
|
+
},
|
|
244
|
+
)
|
|
245
|
+
except CommandBindingError as exc:
|
|
246
|
+
raise CuCliError(str(exc), exit_code=VALIDATION_FAILURE) from exc
|
|
247
|
+
client = _client(endpoint, api_key, api_version, entra, profile_name, show_runtime_context)
|
|
248
|
+
with calling_time(show_calling_time) as calling_timer:
|
|
249
|
+
analyzer = resolve_identifier(ANALYZER_SHOW.operation)(client, request.name)
|
|
250
|
+
dump_json(analyzer.as_dict())
|
|
251
|
+
calling_timer.print()
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@analyzer_group.command(
|
|
255
|
+
"create",
|
|
256
|
+
help=ANALYZER_CREATE.help,
|
|
257
|
+
epilog=common_commands(
|
|
258
|
+
(
|
|
259
|
+
"cu analyzer create ANALYZER_NAME --schema SCHEMA.json",
|
|
260
|
+
"Create an analyzer using the standalone positional shortcut.",
|
|
261
|
+
),
|
|
262
|
+
(
|
|
263
|
+
"cu analyzer create --name ANALYZER_NAME --schema SCHEMA.json",
|
|
264
|
+
"Create an analyzer using the canonical named selector.",
|
|
265
|
+
),
|
|
266
|
+
),
|
|
267
|
+
)
|
|
268
|
+
@with_command_arguments(ANALYZER_CREATE)
|
|
269
|
+
@with_auth_options
|
|
270
|
+
@friendly_errors
|
|
271
|
+
def cmd_create(
|
|
272
|
+
positional_analyzer_name, analyzer_name, schema_path,
|
|
273
|
+
endpoint, api_key, api_version, entra, profile_name,
|
|
274
|
+
show_runtime_context, show_calling_time
|
|
275
|
+
) -> None:
|
|
276
|
+
try:
|
|
277
|
+
request = build_request(
|
|
278
|
+
ANALYZER_CREATE,
|
|
279
|
+
{
|
|
280
|
+
"positional_analyzer_name": positional_analyzer_name,
|
|
281
|
+
"analyzer_name": analyzer_name,
|
|
282
|
+
"schema_path": schema_path,
|
|
283
|
+
},
|
|
284
|
+
)
|
|
285
|
+
except CommandBindingError as exc:
|
|
286
|
+
raise CuCliError(str(exc), exit_code=VALIDATION_FAILURE) from exc
|
|
287
|
+
parse_result, body = parse_and_validate(request.schema.read_text(encoding="utf-8"))
|
|
288
|
+
if body is None:
|
|
289
|
+
message = parse_result.errors[0].msg
|
|
290
|
+
if message.startswith("file "):
|
|
291
|
+
message = f"schema {message}"
|
|
292
|
+
raise CuCliError(message)
|
|
293
|
+
if parse_result.errors:
|
|
294
|
+
finding = parse_result.errors[0]
|
|
295
|
+
raise CuCliError(
|
|
296
|
+
f"invalid schema at {finding.path}: {finding.msg}",
|
|
297
|
+
hint=f"run `cu analyzer validate {request.schema}` for all validation findings.",
|
|
298
|
+
)
|
|
299
|
+
aid = request.name
|
|
300
|
+
_require_custom_analyzer_id(aid)
|
|
301
|
+
profile = Profile.load(profile_name=profile_name)
|
|
302
|
+
resolved_api_version = resolve_api_version(
|
|
303
|
+
flag=api_version,
|
|
304
|
+
schema_pinned=schema_pinned_version(body),
|
|
305
|
+
profile=profile.api_version,
|
|
306
|
+
)
|
|
307
|
+
client = _client(
|
|
308
|
+
endpoint,
|
|
309
|
+
api_key,
|
|
310
|
+
resolved_api_version,
|
|
311
|
+
entra,
|
|
312
|
+
profile_name,
|
|
313
|
+
show_runtime_context,
|
|
314
|
+
)
|
|
315
|
+
with calling_time(show_calling_time) as calling_timer:
|
|
316
|
+
result = resolve_identifier(ANALYZER_CREATE.operation)(client, aid, body)
|
|
317
|
+
final_id = getattr(result, "analyzer_id", aid)
|
|
318
|
+
console.print(f"[green]ok[/green] created analyzer: {final_id}")
|
|
319
|
+
calling_timer.print()
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
@analyzer_group.command(
|
|
323
|
+
"delete",
|
|
324
|
+
help="Delete an analyzer.",
|
|
325
|
+
epilog=common_commands(
|
|
326
|
+
("cu analyzer delete ANALYZER_NAME", "Confirm and delete a custom analyzer."),
|
|
327
|
+
("cu analyzer delete -n ANALYZER_NAME --yes", "Delete without confirmation."),
|
|
328
|
+
),
|
|
329
|
+
)
|
|
330
|
+
@with_command_arguments(ANALYZER_DELETE)
|
|
331
|
+
@with_auth_options
|
|
332
|
+
@friendly_errors
|
|
333
|
+
def cmd_delete(
|
|
334
|
+
positional_analyzer_name, analyzer_name, yes,
|
|
335
|
+
endpoint, api_key, api_version, entra, profile_name,
|
|
336
|
+
show_runtime_context, show_calling_time
|
|
337
|
+
) -> None:
|
|
338
|
+
try:
|
|
339
|
+
request = build_request(
|
|
340
|
+
ANALYZER_DELETE,
|
|
341
|
+
{
|
|
342
|
+
"positional_analyzer_name": positional_analyzer_name,
|
|
343
|
+
"analyzer_name": analyzer_name,
|
|
344
|
+
"yes": yes,
|
|
345
|
+
},
|
|
346
|
+
)
|
|
347
|
+
except CommandBindingError as exc:
|
|
348
|
+
raise CuCliError(str(exc), exit_code=VALIDATION_FAILURE) from exc
|
|
349
|
+
if not request.yes:
|
|
350
|
+
click.confirm(f"Delete analyzer '{request.name}'?", abort=True)
|
|
351
|
+
client = _client(endpoint, api_key, api_version, entra, profile_name, show_runtime_context)
|
|
352
|
+
with calling_time(show_calling_time) as calling_timer:
|
|
353
|
+
resolve_identifier(ANALYZER_DELETE.operation)(client, request.name)
|
|
354
|
+
console.print(f"[green]ok[/green] deleted {request.name}")
|
|
355
|
+
calling_timer.print()
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
class _CopyProgress:
|
|
359
|
+
"""Render live copy phases in a terminal and durable lines elsewhere."""
|
|
360
|
+
|
|
361
|
+
def __init__(self, initial_message: str) -> None:
|
|
362
|
+
self._message = initial_message
|
|
363
|
+
self._progress: Progress | None = None
|
|
364
|
+
self._task_id: Any = None
|
|
365
|
+
|
|
366
|
+
def start(self) -> None:
|
|
367
|
+
if console.is_terminal:
|
|
368
|
+
self._progress = Progress(
|
|
369
|
+
SpinnerColumn(style="cyan"),
|
|
370
|
+
TextColumn("{task.description}", markup=False),
|
|
371
|
+
TimeElapsedColumn(),
|
|
372
|
+
console=console,
|
|
373
|
+
transient=True,
|
|
374
|
+
)
|
|
375
|
+
self._task_id = self._progress.add_task(self._message, total=None)
|
|
376
|
+
self._progress.start()
|
|
377
|
+
else:
|
|
378
|
+
self._print_line(self._message)
|
|
379
|
+
|
|
380
|
+
def update(self, message: str) -> None:
|
|
381
|
+
self._message = message
|
|
382
|
+
if self._progress is not None:
|
|
383
|
+
self._progress.update(self._task_id, description=message)
|
|
384
|
+
else:
|
|
385
|
+
self._print_line(message)
|
|
386
|
+
|
|
387
|
+
def stop(self) -> None:
|
|
388
|
+
if self._progress is not None:
|
|
389
|
+
self._progress.stop()
|
|
390
|
+
self._progress = None
|
|
391
|
+
self._task_id = None
|
|
392
|
+
|
|
393
|
+
@staticmethod
|
|
394
|
+
def _print_line(message: str) -> None:
|
|
395
|
+
console.print(f"[dim]…[/dim] {_esc(message)}")
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
@analyzer_group.command(
|
|
399
|
+
"copy",
|
|
400
|
+
help=ANALYZER_COPY.help,
|
|
401
|
+
epilog=common_commands(
|
|
402
|
+
(
|
|
403
|
+
"cu analyzer copy SOURCE DESTINATION",
|
|
404
|
+
"Copy within the active resource.",
|
|
405
|
+
),
|
|
406
|
+
(
|
|
407
|
+
"cu analyzer copy --source SOURCE --destination DESTINATION "
|
|
408
|
+
"--source-profile dev --destination-profile prod",
|
|
409
|
+
"Copy between resources represented by named profiles.",
|
|
410
|
+
),
|
|
411
|
+
(
|
|
412
|
+
"cu analyzer copy SOURCE DESTINATION "
|
|
413
|
+
"--source-resource RESOURCE --destination-resource RESOURCE",
|
|
414
|
+
"Resolve source and destination resources directly from Azure.",
|
|
415
|
+
),
|
|
416
|
+
),
|
|
417
|
+
)
|
|
418
|
+
@with_command_arguments(ANALYZER_COPY)
|
|
419
|
+
@with_auth_options
|
|
420
|
+
@friendly_errors
|
|
421
|
+
def cmd_copy(
|
|
422
|
+
positional_source, positional_destination, named_source, named_destination,
|
|
423
|
+
source_resource, source_subscription, source_resource_group, source_profile,
|
|
424
|
+
destination_resource, destination_subscription, destination_resource_group,
|
|
425
|
+
destination_profile,
|
|
426
|
+
endpoint, api_key, api_version, entra, profile_name,
|
|
427
|
+
show_runtime_context, show_calling_time,
|
|
428
|
+
) -> None:
|
|
429
|
+
"""Copy an analyzer within one resource or across two.
|
|
430
|
+
|
|
431
|
+
**Same-resource** (normal path):
|
|
432
|
+
``cu analyzer copy SOURCE DESTINATION`` — uses the active or
|
|
433
|
+
``--profile``-selected CU profile for both sides and performs one
|
|
434
|
+
``begin_copy_analyzer`` call.
|
|
435
|
+
|
|
436
|
+
Before copying, the CLI resolves each selected endpoint to a canonical
|
|
437
|
+
Azure resource (ARM ID, region, endpoint) in the explicitly selected or
|
|
438
|
+
active Azure CLI subscription. For a **cross-resource** copy, it then
|
|
439
|
+
automatically calls
|
|
440
|
+
``grant_copy_authorization`` on the source and ``begin_copy_analyzer`` on
|
|
441
|
+
the destination with source ARM ID + region. The authorization record is
|
|
442
|
+
destination-scoped, time-limited, and never printed or persisted.
|
|
443
|
+
|
|
444
|
+
The signed-in Azure identity needs **Reader** on each selected
|
|
445
|
+
subscription/resource group for management-plane discovery. Login-authenticated
|
|
446
|
+
copies also need **Cognitive Services User** on both CU accounts.
|
|
447
|
+
|
|
448
|
+
Content Understanding has no in-place replace — an existing destination ID
|
|
449
|
+
stops the copy with a hint to delete-and-re-copy or pick a versioned ID.
|
|
450
|
+
Recursive copy of classification/segmentation dependencies is out of
|
|
451
|
+
scope; if the source references custom analyzers missing on the destination,
|
|
452
|
+
the CLI fails **before** the parent copy and prints the required IDs plus
|
|
453
|
+
``cu analyzer copy`` commands to run first.
|
|
454
|
+
"""
|
|
455
|
+
try:
|
|
456
|
+
request = build_request(
|
|
457
|
+
ANALYZER_COPY,
|
|
458
|
+
{
|
|
459
|
+
"positional_source": positional_source,
|
|
460
|
+
"positional_destination": positional_destination,
|
|
461
|
+
"named_source": named_source,
|
|
462
|
+
"named_destination": named_destination,
|
|
463
|
+
"source_resource": source_resource,
|
|
464
|
+
"source_subscription": source_subscription,
|
|
465
|
+
"source_resource_group": source_resource_group,
|
|
466
|
+
"source_profile": source_profile,
|
|
467
|
+
"destination_resource": destination_resource,
|
|
468
|
+
"destination_subscription": destination_subscription,
|
|
469
|
+
"destination_resource_group": destination_resource_group,
|
|
470
|
+
"destination_profile": destination_profile,
|
|
471
|
+
},
|
|
472
|
+
)
|
|
473
|
+
except CommandBindingError as exc:
|
|
474
|
+
raise CuCliError(str(exc), exit_code=VALIDATION_FAILURE) from exc
|
|
475
|
+
source_analyzer_id = request.source
|
|
476
|
+
destination_analyzer_id = request.destination
|
|
477
|
+
|
|
478
|
+
# Validate analyzer IDs before any auth or service call. This catches
|
|
479
|
+
# the common typo of passing a schema path or URL where an ID was
|
|
480
|
+
# expected, without burning a management-plane discovery + Entra token
|
|
481
|
+
# exchange first.
|
|
482
|
+
_validate_analyzer_id(source_analyzer_id, role="source")
|
|
483
|
+
_validate_analyzer_id(destination_analyzer_id, role="destination")
|
|
484
|
+
|
|
485
|
+
# --endpoint composes with the active/named-profile path only. Combining it
|
|
486
|
+
# with direct resource selectors is ambiguous because discovery resolves the
|
|
487
|
+
# endpoint from the Azure management plane.
|
|
488
|
+
if endpoint and (source_resource or destination_resource or destination_profile):
|
|
489
|
+
raise CuCliError(
|
|
490
|
+
"--endpoint cannot be combined with --source-resource, "
|
|
491
|
+
"--destination-resource, or --destination-profile.",
|
|
492
|
+
hint="resource selectors resolve their own endpoints, and one endpoint "
|
|
493
|
+
"override cannot safely represent two named resources. Drop "
|
|
494
|
+
"--endpoint, or use it only when the destination defaults to the source.",
|
|
495
|
+
)
|
|
496
|
+
if api_key and (source_resource or destination_resource or destination_profile):
|
|
497
|
+
raise CuCliError(
|
|
498
|
+
"--api-key cannot be combined with --source-resource, "
|
|
499
|
+
"--destination-resource, or --destination-profile.",
|
|
500
|
+
hint="direct Azure resource selectors always use the signed-in Entra "
|
|
501
|
+
"identity, and one account-scoped key cannot safely authenticate "
|
|
502
|
+
"two named resources. Store side-specific keys in the source and "
|
|
503
|
+
"destination profiles instead.",
|
|
504
|
+
)
|
|
505
|
+
# Resource-group narrowing applies only to direct selectors. Subscription
|
|
506
|
+
# selection also composes with profile-backed sides and is authoritative
|
|
507
|
+
# for endpoint discovery.
|
|
508
|
+
if not source_resource and source_resource_group:
|
|
509
|
+
raise CuCliError(
|
|
510
|
+
"--source-resource-group requires --source-resource.",
|
|
511
|
+
hint="use --source-subscription to scope an active or named source profile.",
|
|
512
|
+
)
|
|
513
|
+
if not destination_resource and destination_resource_group:
|
|
514
|
+
raise CuCliError(
|
|
515
|
+
"--destination-resource-group requires --destination-resource.",
|
|
516
|
+
hint="use --destination-subscription to scope a named destination profile.",
|
|
517
|
+
)
|
|
518
|
+
if destination_subscription and not (destination_resource or destination_profile):
|
|
519
|
+
raise CuCliError(
|
|
520
|
+
"--destination-subscription requires --destination-resource "
|
|
521
|
+
"or --destination-profile.",
|
|
522
|
+
hint="select the destination resource or named profile to scope.",
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
# Fast-path same-ID guard before anything expensive. Selector/profile
|
|
526
|
+
# paths need final resolved-resource comparison, so they are checked
|
|
527
|
+
# again below after ``cross_resource`` is known.
|
|
528
|
+
if source_analyzer_id == destination_analyzer_id and not (
|
|
529
|
+
source_resource or source_profile or destination_resource or destination_profile
|
|
530
|
+
):
|
|
531
|
+
_reject_identical_ids_same_resource(source_analyzer_id, destination_analyzer_id)
|
|
532
|
+
|
|
533
|
+
# Resolve source and destination contexts.
|
|
534
|
+
copy_progress = _CopyProgress(
|
|
535
|
+
f"Resolving source and destination resources for analyzer copy "
|
|
536
|
+
f"'{source_analyzer_id}' -> '{destination_analyzer_id}'..."
|
|
537
|
+
)
|
|
538
|
+
copy_progress.start()
|
|
539
|
+
ctx = click.get_current_context()
|
|
540
|
+
if ctx is not None:
|
|
541
|
+
ctx.call_on_close(copy_progress.stop)
|
|
542
|
+
src_ctx = _resolve_side(
|
|
543
|
+
selector=source_resource,
|
|
544
|
+
sub=source_subscription,
|
|
545
|
+
rg=source_resource_group,
|
|
546
|
+
profile_name=source_profile,
|
|
547
|
+
# Fall through to the standard top-level auth options only for the source.
|
|
548
|
+
fallback_profile=profile_name,
|
|
549
|
+
endpoint=endpoint,
|
|
550
|
+
api_key=api_key,
|
|
551
|
+
api_version=api_version,
|
|
552
|
+
entra=entra,
|
|
553
|
+
side="source",
|
|
554
|
+
)
|
|
555
|
+
src_ctx = _ensure_arm_metadata(
|
|
556
|
+
src_ctx,
|
|
557
|
+
side_flag="--source-resource",
|
|
558
|
+
subscription_id=source_subscription,
|
|
559
|
+
)
|
|
560
|
+
destination_ctx = _resolve_side(
|
|
561
|
+
selector=destination_resource,
|
|
562
|
+
sub=destination_subscription,
|
|
563
|
+
rg=destination_resource_group,
|
|
564
|
+
profile_name=destination_profile,
|
|
565
|
+
# If the user set no destination selector, it defaults to the resolved source
|
|
566
|
+
# context (matches the "same resource, distinct IDs" mainline).
|
|
567
|
+
fallback_profile=profile_name if (destination_resource or destination_profile) else None,
|
|
568
|
+
endpoint=endpoint if (destination_resource or destination_profile) else None,
|
|
569
|
+
api_key=api_key if (destination_resource or destination_profile) else None,
|
|
570
|
+
api_version=api_version if (destination_resource or destination_profile) else None,
|
|
571
|
+
entra=entra if (destination_resource or destination_profile) else False,
|
|
572
|
+
side="destination",
|
|
573
|
+
default_from=src_ctx,
|
|
574
|
+
)
|
|
575
|
+
destination_ctx = _ensure_arm_metadata(
|
|
576
|
+
destination_ctx,
|
|
577
|
+
side_flag="--destination-resource",
|
|
578
|
+
subscription_id=destination_subscription,
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
# Both sides now carry canonical ARM metadata, so the copy mode is based on
|
|
582
|
+
# resource identity rather than endpoint spelling or profile provenance.
|
|
583
|
+
cross_resource = _is_cross_resource(src_ctx, destination_ctx)
|
|
584
|
+
|
|
585
|
+
# Selector presence alone does not prove a cross-resource copy: a source-only
|
|
586
|
+
# resource selector defaults the destination to that source, and two named profiles may
|
|
587
|
+
# resolve to the same account. Reject identical IDs after final resolution
|
|
588
|
+
# whenever the effective mode is same-resource.
|
|
589
|
+
if not cross_resource:
|
|
590
|
+
_reject_identical_ids_same_resource(source_analyzer_id, destination_analyzer_id)
|
|
591
|
+
elif src_ctx.api_version != destination_ctx.api_version:
|
|
592
|
+
raise CuCliError(
|
|
593
|
+
"source and destination API versions must match for analyzer copy: "
|
|
594
|
+
f"source={src_ctx.api_version}, destination={destination_ctx.api_version}.",
|
|
595
|
+
hint="select profiles with the same API version or pass --api-version "
|
|
596
|
+
"to use one version for both sides, then retry.",
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
# --info: show resolved source/destination before any data-plane call.
|
|
600
|
+
if show_runtime_context:
|
|
601
|
+
_print_copy_runtime_context(src_ctx, destination_ctx, cross_resource=cross_resource)
|
|
602
|
+
|
|
603
|
+
# Dependency preflight for cross-resource copies. Same-resource copies
|
|
604
|
+
# inherit the source resource's own catalog, so nothing to check.
|
|
605
|
+
source_analyzer = None
|
|
606
|
+
if cross_resource:
|
|
607
|
+
copy_progress.update("Checking source analyzer and destination dependencies...")
|
|
608
|
+
source_analyzer = _analyzers.get_copy_source_analyzer(
|
|
609
|
+
src_ctx.client,
|
|
610
|
+
source_analyzer_id,
|
|
611
|
+
)
|
|
612
|
+
deps = _analyzers.collect_custom_dependencies(source_analyzer)
|
|
613
|
+
if deps:
|
|
614
|
+
missing = _analyzers.preflight_dependencies_on_target(destination_ctx.client, deps)
|
|
615
|
+
if missing:
|
|
616
|
+
cmds = "\n".join(
|
|
617
|
+
" " + _dependency_copy_cli_command(
|
|
618
|
+
d,
|
|
619
|
+
src_ctx,
|
|
620
|
+
destination_ctx,
|
|
621
|
+
source_subscription=source_subscription,
|
|
622
|
+
destination_subscription=destination_subscription,
|
|
623
|
+
api_version=api_version,
|
|
624
|
+
)
|
|
625
|
+
for d in missing
|
|
626
|
+
)
|
|
627
|
+
raise CuCliError(
|
|
628
|
+
f"source analyzer '{source_analyzer_id}' references custom analyzers "
|
|
629
|
+
f"that are missing on the destination resource: {', '.join(missing)}.",
|
|
630
|
+
hint="Content Understanding does not recursively copy classifier "
|
|
631
|
+
"sub-analyzers. Copy each dependency first, then re-run:\n" + cmds,
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
# Perform the copy.
|
|
635
|
+
copy_progress.update("Checking source and destination analyzers...")
|
|
636
|
+
with calling_time(show_calling_time) as calling_timer:
|
|
637
|
+
resolve_identifier(ANALYZER_COPY.operation)(
|
|
638
|
+
src_ctx.client,
|
|
639
|
+
source_analyzer_id,
|
|
640
|
+
destination_analyzer_id,
|
|
641
|
+
target_client=(destination_ctx.client if cross_resource else None),
|
|
642
|
+
source_azure_resource_id=(src_ctx.resource.arm_id if cross_resource and src_ctx.resource else None),
|
|
643
|
+
source_region=(src_ctx.resource.region if cross_resource and src_ctx.resource else None),
|
|
644
|
+
target_azure_resource_id=(
|
|
645
|
+
destination_ctx.resource.arm_id
|
|
646
|
+
if cross_resource and destination_ctx.resource
|
|
647
|
+
else None
|
|
648
|
+
),
|
|
649
|
+
target_region=(
|
|
650
|
+
destination_ctx.resource.region
|
|
651
|
+
if cross_resource and destination_ctx.resource
|
|
652
|
+
else None
|
|
653
|
+
),
|
|
654
|
+
progress=copy_progress.update,
|
|
655
|
+
source_analyzer=source_analyzer,
|
|
656
|
+
target_cli_options=_target_cli_options(destination_ctx),
|
|
657
|
+
)
|
|
658
|
+
copy_progress.stop()
|
|
659
|
+
if cross_resource:
|
|
660
|
+
console.print(
|
|
661
|
+
f"[green]ok[/green] copied '{source_analyzer_id}' -> '{destination_analyzer_id}' "
|
|
662
|
+
f"on destination [cyan]"
|
|
663
|
+
f"{destination_ctx.resource.account_name if destination_ctx.resource else destination_ctx.endpoint}"
|
|
664
|
+
f"[/cyan]"
|
|
665
|
+
)
|
|
666
|
+
else:
|
|
667
|
+
console.print(
|
|
668
|
+
f"[green]ok[/green] copied '{source_analyzer_id}' -> '{destination_analyzer_id}'"
|
|
669
|
+
)
|
|
670
|
+
show_command = _target_cli_command("show", destination_analyzer_id, destination_ctx)
|
|
671
|
+
console.print(f"[dim]hint:[/dim] inspect the copy with [cyan]{show_command}[/cyan]")
|
|
672
|
+
calling_timer.print()
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
# --- cmd_copy internals ------------------------------------------------------
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
class _SideCtx:
|
|
679
|
+
"""Resolved copy side: an SDK client plus optional Azure resource metadata.
|
|
680
|
+
|
|
681
|
+
- ``client``: the CU SDK client bound to this side's endpoint + auth.
|
|
682
|
+
- ``endpoint``: the resolved endpoint URL (for display when no ARM resource
|
|
683
|
+
was involved).
|
|
684
|
+
- ``resource``: the :class:`ResolvedResource` when the side came from a
|
|
685
|
+
direct resource selector; ``None`` when it came from a CU profile
|
|
686
|
+
(no ARM-ID / region available without discovery, and none is needed for
|
|
687
|
+
a same-resource copy).
|
|
688
|
+
- ``source_label``: short human string for progress lines and --info.
|
|
689
|
+
- ``api_version``: the effective request API version for this side.
|
|
690
|
+
"""
|
|
691
|
+
|
|
692
|
+
__slots__ = (
|
|
693
|
+
"client",
|
|
694
|
+
"endpoint",
|
|
695
|
+
"resource",
|
|
696
|
+
"source_label",
|
|
697
|
+
"profile_name",
|
|
698
|
+
"force_entra",
|
|
699
|
+
"api_version",
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
def __init__(
|
|
703
|
+
self,
|
|
704
|
+
client: Any,
|
|
705
|
+
endpoint: str,
|
|
706
|
+
resource: Any,
|
|
707
|
+
source_label: str,
|
|
708
|
+
*,
|
|
709
|
+
profile_name: str | None = None,
|
|
710
|
+
force_entra: bool | str = False,
|
|
711
|
+
api_version: str,
|
|
712
|
+
) -> None:
|
|
713
|
+
self.client = client
|
|
714
|
+
self.endpoint = endpoint
|
|
715
|
+
self.resource = resource
|
|
716
|
+
self.source_label = source_label
|
|
717
|
+
self.profile_name = profile_name
|
|
718
|
+
self.force_entra = force_entra
|
|
719
|
+
self.api_version = api_version
|
|
720
|
+
|
|
721
|
+
# Content Understanding 2025-11-01 REST contract:
|
|
722
|
+
# ``^[a-zA-Z0-9._-]{1,64}$``. Every allowed character is legal in the first
|
|
723
|
+
# position too; Click users can pass a leading-hyphen positional after ``--``.
|
|
724
|
+
_ANALYZER_ID_RE = re.compile(r"^[A-Za-z0-9._-]{1,64}$")
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _validate_analyzer_id(aid: str | None, *, role: str) -> None:
|
|
728
|
+
"""Fail fast on obviously-invalid analyzer IDs before any auth or service call.
|
|
729
|
+
|
|
730
|
+
Catches the common typo case of passing a schema JSON path (contains ``/``)
|
|
731
|
+
or a URL where an ID was expected — the service would reject with 400, but
|
|
732
|
+
not before the CLI has already run management-plane discovery and Entra
|
|
733
|
+
token exchange. Empty strings are rejected too; Click's positional argument
|
|
734
|
+
parsing shouldn't yield them, but a shell script passing ``""`` explicitly
|
|
735
|
+
would otherwise reach the service.
|
|
736
|
+
"""
|
|
737
|
+
if not aid:
|
|
738
|
+
raise CuCliError(
|
|
739
|
+
f"{role} analyzer ID is empty.",
|
|
740
|
+
hint="pass a valid analyzer ID (1-64 chars, alphanumeric + underscore/dot/hyphen).",
|
|
741
|
+
)
|
|
742
|
+
if not _ANALYZER_ID_RE.fullmatch(aid):
|
|
743
|
+
raise CuCliError(
|
|
744
|
+
f"{role} analyzer ID {aid!r} is not a valid Content Understanding analyzer ID.",
|
|
745
|
+
hint="use 1-64 alphanumeric, underscore, dot, or hyphen characters "
|
|
746
|
+
"(regex: [A-Za-z0-9._-]{1,64}). "
|
|
747
|
+
"If you meant a schema file, pass it to `cu analyzer create --schema` instead.",
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def _reject_identical_ids_same_resource(
|
|
752
|
+
source_analyzer_id: str,
|
|
753
|
+
destination_analyzer_id: str,
|
|
754
|
+
) -> None:
|
|
755
|
+
"""Reject an effective same-resource copy whose object names match."""
|
|
756
|
+
if source_analyzer_id != destination_analyzer_id:
|
|
757
|
+
return
|
|
758
|
+
raise CuCliError(
|
|
759
|
+
f"source and destination analyzers are identical ('{source_analyzer_id}'); "
|
|
760
|
+
"copy is a no-op on the same resource.",
|
|
761
|
+
hint="pick a distinct DESTINATION (for example append '_v2' "
|
|
762
|
+
"for a versioned copy), or select a destination that resolves to a "
|
|
763
|
+
"different Azure resource.",
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def _resolve_side(
|
|
768
|
+
*,
|
|
769
|
+
selector: str | None,
|
|
770
|
+
sub: str | None,
|
|
771
|
+
rg: str | None,
|
|
772
|
+
profile_name: str | None,
|
|
773
|
+
fallback_profile: str | None,
|
|
774
|
+
endpoint: str | None,
|
|
775
|
+
api_key: str | None,
|
|
776
|
+
api_version: str | None,
|
|
777
|
+
entra: bool,
|
|
778
|
+
side: str,
|
|
779
|
+
default_from: _SideCtx | None = None,
|
|
780
|
+
) -> _SideCtx:
|
|
781
|
+
"""Resolve one side (source or destination) to a :class:`_SideCtx`.
|
|
782
|
+
|
|
783
|
+
Precedence (highest to lowest):
|
|
784
|
+
1. ``selector`` (a side-specific resource option) → Azure management discovery.
|
|
785
|
+
2. ``profile_name`` (a side-specific profile option) → named CU profile.
|
|
786
|
+
3. ``fallback_profile`` (top-level ``--profile``) → named CU profile.
|
|
787
|
+
4. active CU profile.
|
|
788
|
+
For the destination side only, when no destination selector or profile is provided,
|
|
789
|
+
all, we return ``default_from`` so the same client is reused (guarantees a
|
|
790
|
+
same-resource copy with one SDK call).
|
|
791
|
+
"""
|
|
792
|
+
if selector:
|
|
793
|
+
from ..core.azure_resources import resolve_resource
|
|
794
|
+
resolved = resolve_resource(selector, subscription_id=sub, resource_group=rg)
|
|
795
|
+
resolved_api_version = _resolve_copy_api_version(
|
|
796
|
+
profile_name=None,
|
|
797
|
+
api_version=api_version,
|
|
798
|
+
)
|
|
799
|
+
client = _client_from_resource(resolved, api_version=resolved_api_version)
|
|
800
|
+
return _SideCtx(client=client, endpoint=resolved.endpoint,
|
|
801
|
+
resource=resolved, source_label=selector, force_entra=True,
|
|
802
|
+
api_version=resolved_api_version)
|
|
803
|
+
if profile_name:
|
|
804
|
+
client, resolved_ep, resolved_api_version = _client_from_named_profile(
|
|
805
|
+
profile_name,
|
|
806
|
+
endpoint=endpoint,
|
|
807
|
+
api_key=api_key,
|
|
808
|
+
api_version=api_version,
|
|
809
|
+
entra=entra,
|
|
810
|
+
)
|
|
811
|
+
return _SideCtx(client=client, endpoint=resolved_ep,
|
|
812
|
+
resource=None, source_label=f"CU CLI profile '{profile_name}'",
|
|
813
|
+
profile_name=profile_name, force_entra=entra,
|
|
814
|
+
api_version=resolved_api_version)
|
|
815
|
+
if side == "destination" and default_from is not None:
|
|
816
|
+
# No destination context provided: reuse the source client and resource.
|
|
817
|
+
return default_from
|
|
818
|
+
# Fall through: active or --profile-selected profile with top-level overrides.
|
|
819
|
+
selected_profile = fallback_profile or profile_name
|
|
820
|
+
resolved_api_version = _resolve_copy_api_version(
|
|
821
|
+
profile_name=selected_profile,
|
|
822
|
+
api_version=api_version,
|
|
823
|
+
)
|
|
824
|
+
client = _client(endpoint, api_key, resolved_api_version, entra, selected_profile,
|
|
825
|
+
show_runtime_context=False)
|
|
826
|
+
profile = Profile.load(profile_name=selected_profile)
|
|
827
|
+
active = (
|
|
828
|
+
f"CU CLI profile '{profile.profile_name}'"
|
|
829
|
+
if profile.profile_name != "default"
|
|
830
|
+
else "active CU CLI profile"
|
|
831
|
+
)
|
|
832
|
+
return _SideCtx(client=client, endpoint=(endpoint or profile.endpoint or ""),
|
|
833
|
+
resource=None, source_label=active,
|
|
834
|
+
profile_name=profile.profile_name, force_entra=entra,
|
|
835
|
+
api_version=resolved_api_version)
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
def _resolve_copy_api_version(
|
|
839
|
+
*,
|
|
840
|
+
profile_name: str | None,
|
|
841
|
+
api_version: str | None,
|
|
842
|
+
) -> str:
|
|
843
|
+
profile = Profile.load(profile_name=profile_name)
|
|
844
|
+
return resolve_api_version(flag=api_version, profile=profile.api_version)
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def _client_from_resource(resolved: Any, *, api_version: str | None) -> Any:
|
|
848
|
+
"""Build a CU SDK client directly from a :class:`ResolvedResource`.
|
|
849
|
+
|
|
850
|
+
Bypasses ``cu profile`` — the endpoint came from Azure discovery, and the
|
|
851
|
+
Direct resource flows must not create or
|
|
852
|
+
modify profiles. Uses :class:`DefaultAzureCredential` because API keys are
|
|
853
|
+
Foundry-account-scoped and can't be inferred from a discovered account
|
|
854
|
+
without extra key-retrieval calls (which the spec forbids).
|
|
855
|
+
|
|
856
|
+
``api_version`` falls back to the active CU profile's api_version when
|
|
857
|
+
``None`` (so a direct source inherits the caller's usual API version without
|
|
858
|
+
needing an extra ``--api-version`` flag).
|
|
859
|
+
"""
|
|
860
|
+
profile = Profile.load(profile_name=None)
|
|
861
|
+
return build_client(
|
|
862
|
+
profile,
|
|
863
|
+
endpoint_override=resolved.endpoint,
|
|
864
|
+
api_version_override=api_version or profile.api_version,
|
|
865
|
+
force_entra=True,
|
|
866
|
+
)
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def _client_from_named_profile(
|
|
870
|
+
profile_name: str,
|
|
871
|
+
*,
|
|
872
|
+
endpoint: str | None,
|
|
873
|
+
api_key: str | None,
|
|
874
|
+
api_version: str | None,
|
|
875
|
+
entra: bool,
|
|
876
|
+
) -> tuple[Any, str, str]:
|
|
877
|
+
"""Return a named-profile client, endpoint, and effective API version."""
|
|
878
|
+
profile = Profile.load(profile_name=profile_name)
|
|
879
|
+
auth = resolve(
|
|
880
|
+
profile,
|
|
881
|
+
endpoint_override=endpoint,
|
|
882
|
+
api_key_override=api_key,
|
|
883
|
+
api_version_override=api_version,
|
|
884
|
+
force_entra=entra,
|
|
885
|
+
)
|
|
886
|
+
return build_client(
|
|
887
|
+
profile,
|
|
888
|
+
endpoint_override=endpoint,
|
|
889
|
+
api_key_override=api_key,
|
|
890
|
+
api_version_override=api_version,
|
|
891
|
+
force_entra=entra,
|
|
892
|
+
), auth.endpoint, auth.api_version
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def _is_cross_resource(src: _SideCtx, tgt: _SideCtx) -> bool:
|
|
896
|
+
"""Decide whether the copy needs cross-resource orchestration.
|
|
897
|
+
|
|
898
|
+
* If both sides carry resolved resources, compare canonical ARM IDs.
|
|
899
|
+
* If either side lacks a ``resource``, fall back to comparing endpoints —
|
|
900
|
+
profiles without ARM metadata may still point at the same resource.
|
|
901
|
+
* Same client identity is a definite same-resource signal.
|
|
902
|
+
"""
|
|
903
|
+
from ..core.azure_resources import resources_equal
|
|
904
|
+
if src.resource is not None and tgt.resource is not None:
|
|
905
|
+
return not resources_equal(src.resource, tgt.resource)
|
|
906
|
+
if src.client is tgt.client:
|
|
907
|
+
return False
|
|
908
|
+
return (src.endpoint or "").rstrip("/") != (tgt.endpoint or "").rstrip("/")
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
def _ensure_arm_metadata(
|
|
912
|
+
ctx: _SideCtx,
|
|
913
|
+
*,
|
|
914
|
+
side_flag: str,
|
|
915
|
+
subscription_id: str | None,
|
|
916
|
+
) -> _SideCtx:
|
|
917
|
+
"""Resolve a profile-derived endpoint within its authoritative subscription.
|
|
918
|
+
|
|
919
|
+
An explicit side subscription takes precedence; otherwise
|
|
920
|
+
:func:`resolve_resource` uses the active Azure CLI subscription. Discovery
|
|
921
|
+
never fans out to other subscriptions. Direct resource selectors already
|
|
922
|
+
carry metadata and are returned unchanged.
|
|
923
|
+
"""
|
|
924
|
+
if ctx.resource is not None:
|
|
925
|
+
return ctx
|
|
926
|
+
if not ctx.endpoint:
|
|
927
|
+
raise CuCliError(
|
|
928
|
+
f"cannot resolve an Azure resource for {ctx.source_label} because "
|
|
929
|
+
"no endpoint is configured on that side.",
|
|
930
|
+
hint=f"pass {side_flag} with a Foundry endpoint URL, an account name, or a full ARM ID.",
|
|
931
|
+
)
|
|
932
|
+
from ..core.azure_resources import resolve_resource
|
|
933
|
+
scope = (
|
|
934
|
+
f"subscription '{subscription_id}'"
|
|
935
|
+
if subscription_id
|
|
936
|
+
else "the active Azure CLI subscription"
|
|
937
|
+
)
|
|
938
|
+
try:
|
|
939
|
+
resolved = resolve_resource(ctx.endpoint, subscription_id=subscription_id)
|
|
940
|
+
except CuCliError as exc:
|
|
941
|
+
selector_hint = (
|
|
942
|
+
f"Pass {side_flag} explicitly with a URL, name, or ARM ID "
|
|
943
|
+
"(the CU profile does not need to carry ARM ID / region)."
|
|
944
|
+
)
|
|
945
|
+
raise CuCliError(
|
|
946
|
+
f"could not resolve {ctx.source_label} endpoint '{ctx.endpoint}' in "
|
|
947
|
+
f"{scope}: {exc.message}",
|
|
948
|
+
hint=((exc.hint.rstrip() + " ") if exc.hint else "") + selector_hint,
|
|
949
|
+
) from exc
|
|
950
|
+
return _SideCtx(client=ctx.client, endpoint=resolved.endpoint,
|
|
951
|
+
resource=resolved, source_label=ctx.source_label,
|
|
952
|
+
profile_name=ctx.profile_name, force_entra=ctx.force_entra,
|
|
953
|
+
api_version=ctx.api_version)
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def _print_copy_runtime_context(src: _SideCtx, tgt: _SideCtx, *, cross_resource: bool) -> None:
|
|
957
|
+
console.print("[bold]resolved copy sides:[/bold]")
|
|
958
|
+
console.print(f" source: {src.source_label} -> {src.endpoint}")
|
|
959
|
+
console.print(f" api-version: {src.api_version}")
|
|
960
|
+
if src.resource is not None:
|
|
961
|
+
console.print(f" arm-id: {src.resource.arm_id}")
|
|
962
|
+
console.print(f" region: {src.resource.region}")
|
|
963
|
+
console.print(f" destination: {tgt.source_label} -> {tgt.endpoint}")
|
|
964
|
+
console.print(f" api-version: {tgt.api_version}")
|
|
965
|
+
if tgt.resource is not None:
|
|
966
|
+
console.print(f" arm-id: {tgt.resource.arm_id}")
|
|
967
|
+
console.print(f" region: {tgt.resource.region}")
|
|
968
|
+
console.print(f" mode: {'cross-resource' if cross_resource else 'same-resource'}")
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
def _copy_side_cli_option(
|
|
972
|
+
ctx: _SideCtx,
|
|
973
|
+
*,
|
|
974
|
+
side: str,
|
|
975
|
+
subscription_id: str | None = None,
|
|
976
|
+
) -> str:
|
|
977
|
+
"""Return shell-safe options that preserve a copy side's provenance."""
|
|
978
|
+
if ctx.profile_name:
|
|
979
|
+
options = f"--{side}-profile {shlex.quote(ctx.profile_name)}"
|
|
980
|
+
if subscription_id:
|
|
981
|
+
options += f" --{side}-subscription {shlex.quote(subscription_id)}"
|
|
982
|
+
return options
|
|
983
|
+
if ctx.resource is not None:
|
|
984
|
+
return f"--{side}-resource {shlex.quote(ctx.resource.arm_id)}"
|
|
985
|
+
return f"--{side}-resource {shlex.quote(ctx.endpoint or ctx.source_label)}"
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
def _dependency_copy_cli_command(
|
|
989
|
+
analyzer_id: str,
|
|
990
|
+
src_ctx: _SideCtx,
|
|
991
|
+
tgt_ctx: _SideCtx,
|
|
992
|
+
*,
|
|
993
|
+
source_subscription: str | None,
|
|
994
|
+
destination_subscription: str | None,
|
|
995
|
+
api_version: str | None,
|
|
996
|
+
) -> str:
|
|
997
|
+
"""Build a shell-safe dependency-copy command without authentication data."""
|
|
998
|
+
command = (
|
|
999
|
+
f"cu analyzer copy --source {shlex.quote(analyzer_id)} "
|
|
1000
|
+
f"--destination {shlex.quote(analyzer_id)} "
|
|
1001
|
+
f"{_copy_side_cli_option(src_ctx, side='source', subscription_id=source_subscription)} "
|
|
1002
|
+
f"{_copy_side_cli_option(tgt_ctx, side='destination', subscription_id=destination_subscription)}"
|
|
1003
|
+
)
|
|
1004
|
+
if api_version:
|
|
1005
|
+
command += f" --api-version {shlex.quote(api_version)}"
|
|
1006
|
+
return command
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
def _target_cli_options(ctx: _SideCtx) -> str:
|
|
1010
|
+
"""Return non-secret options that qualify a follow-up command to ``ctx``."""
|
|
1011
|
+
options: list[str] = []
|
|
1012
|
+
if ctx.profile_name:
|
|
1013
|
+
options.extend(("--profile", shlex.quote(ctx.profile_name)))
|
|
1014
|
+
if ctx.endpoint:
|
|
1015
|
+
options.extend(("--endpoint", shlex.quote(ctx.endpoint)))
|
|
1016
|
+
if ctx.force_entra:
|
|
1017
|
+
mode = ctx.force_entra if isinstance(ctx.force_entra, str) else "login"
|
|
1018
|
+
options.extend(("--auth-mode", mode))
|
|
1019
|
+
elif ctx.resource is not None:
|
|
1020
|
+
options.extend(("--endpoint", shlex.quote(ctx.endpoint), "--auth-mode", "login"))
|
|
1021
|
+
elif ctx.endpoint:
|
|
1022
|
+
options.extend(("--endpoint", shlex.quote(ctx.endpoint)))
|
|
1023
|
+
if ctx.force_entra:
|
|
1024
|
+
mode = ctx.force_entra if isinstance(ctx.force_entra, str) else "login"
|
|
1025
|
+
options.extend(("--auth-mode", mode))
|
|
1026
|
+
return " ".join(options)
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
def _target_cli_command(action: str, analyzer_id: str, ctx: _SideCtx) -> str:
|
|
1030
|
+
"""Build a destination-qualified analyzer follow-up command without secrets."""
|
|
1031
|
+
options = _target_cli_options(ctx)
|
|
1032
|
+
suffix = f" {options}" if options else ""
|
|
1033
|
+
return f"cu analyzer {action} {shlex.quote(analyzer_id)}{suffix}"
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
# --- Authoring: schema creation -------------------------------------------
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
@analyzer_group.group(
|
|
1040
|
+
"schema",
|
|
1041
|
+
help="Create custom-analyzer schemas.",
|
|
1042
|
+
epilog=common_commands(
|
|
1043
|
+
(
|
|
1044
|
+
"cu analyzer schema create --output-file SCHEMA.json",
|
|
1045
|
+
"Write an editable starter schema.",
|
|
1046
|
+
),
|
|
1047
|
+
(
|
|
1048
|
+
"cu analyzer schema create --from-sample SAMPLE_FILE "
|
|
1049
|
+
"--output-file SCHEMA.json",
|
|
1050
|
+
"Create a schema from one document sample.",
|
|
1051
|
+
),
|
|
1052
|
+
),
|
|
1053
|
+
)
|
|
1054
|
+
def schema_group() -> None:
|
|
1055
|
+
pass
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
def suggest_schema_payload_from_sample(
|
|
1059
|
+
*,
|
|
1060
|
+
sample_path: Path,
|
|
1061
|
+
analyzer_id: str,
|
|
1062
|
+
api_version: str,
|
|
1063
|
+
profile_name: str | None = None,
|
|
1064
|
+
endpoint: str | None = None,
|
|
1065
|
+
api_key: str | None = None,
|
|
1066
|
+
force_entra: bool | str = False,
|
|
1067
|
+
) -> dict[str, Any]:
|
|
1068
|
+
"""Build an extraction schema from one sample via prebuilt-documentFieldSchema.
|
|
1069
|
+
|
|
1070
|
+
Command-layer wrapper: validates the sample, resolves profile/auth, builds a
|
|
1071
|
+
client, and delegates the analyze + field extraction to
|
|
1072
|
+
:func:`cu_cli.core.schema.suggest_schema_from_sample`. MVP behavior
|
|
1073
|
+
intentionally supports exactly one local document sample.
|
|
1074
|
+
"""
|
|
1075
|
+
from cu_cli_core.contracts import AnalyzerSchemaCreateRequest
|
|
1076
|
+
|
|
1077
|
+
validate_document_sample(sample_path)
|
|
1078
|
+
profile = Profile.load(profile_name=profile_name)
|
|
1079
|
+
client = _client(endpoint, api_key, api_version, force_entra, profile_name, False)
|
|
1080
|
+
request = AnalyzerSchemaCreateRequest(
|
|
1081
|
+
from_sample=sample_path,
|
|
1082
|
+
name=analyzer_id,
|
|
1083
|
+
)
|
|
1084
|
+
payload, found = resolve_identifier(ANALYZER_SCHEMA_CREATE.operation)(
|
|
1085
|
+
request,
|
|
1086
|
+
api_version=api_version,
|
|
1087
|
+
completion_model=template_completion_model(profile),
|
|
1088
|
+
client=client,
|
|
1089
|
+
)
|
|
1090
|
+
if not found:
|
|
1091
|
+
console.print(
|
|
1092
|
+
"[yellow]warn:[/yellow] no suggested fields were returned; wrote the default extraction template."
|
|
1093
|
+
)
|
|
1094
|
+
return payload
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
@schema_group.command(
|
|
1098
|
+
"create",
|
|
1099
|
+
help=ANALYZER_SCHEMA_CREATE.help,
|
|
1100
|
+
epilog=common_commands(
|
|
1101
|
+
(
|
|
1102
|
+
"cu analyzer schema create --output-file SCHEMA.json",
|
|
1103
|
+
"Write a document extraction schema.",
|
|
1104
|
+
),
|
|
1105
|
+
(
|
|
1106
|
+
"cu analyzer schema create --type classification "
|
|
1107
|
+
"--output-file SCHEMA.json",
|
|
1108
|
+
"Write a classification schema.",
|
|
1109
|
+
),
|
|
1110
|
+
(
|
|
1111
|
+
"cu analyzer schema create --from-sample SAMPLE_FILE "
|
|
1112
|
+
"--output-file SCHEMA.json",
|
|
1113
|
+
"Derive an extraction schema from one sample.",
|
|
1114
|
+
),
|
|
1115
|
+
),
|
|
1116
|
+
)
|
|
1117
|
+
@with_command_arguments(ANALYZER_SCHEMA_CREATE)
|
|
1118
|
+
@with_auth_options
|
|
1119
|
+
@friendly_errors
|
|
1120
|
+
def cmd_schema_create(
|
|
1121
|
+
from_template,
|
|
1122
|
+
sample_path,
|
|
1123
|
+
analyzer_id,
|
|
1124
|
+
base,
|
|
1125
|
+
modality,
|
|
1126
|
+
out_path,
|
|
1127
|
+
force,
|
|
1128
|
+
template_type,
|
|
1129
|
+
api_version,
|
|
1130
|
+
endpoint,
|
|
1131
|
+
api_key,
|
|
1132
|
+
entra,
|
|
1133
|
+
profile_name,
|
|
1134
|
+
show_runtime_context,
|
|
1135
|
+
show_calling_time,
|
|
1136
|
+
) -> None:
|
|
1137
|
+
try:
|
|
1138
|
+
request = build_request(
|
|
1139
|
+
ANALYZER_SCHEMA_CREATE,
|
|
1140
|
+
{
|
|
1141
|
+
"from_template": from_template,
|
|
1142
|
+
"sample_path": sample_path,
|
|
1143
|
+
"analyzer_id": analyzer_id,
|
|
1144
|
+
"base": base,
|
|
1145
|
+
"modality": modality,
|
|
1146
|
+
"out_path": out_path,
|
|
1147
|
+
"force": force,
|
|
1148
|
+
"template_type": template_type,
|
|
1149
|
+
},
|
|
1150
|
+
)
|
|
1151
|
+
except CommandBindingError as exc:
|
|
1152
|
+
raise CuCliError(str(exc), exit_code=VALIDATION_FAILURE) from exc
|
|
1153
|
+
_require_custom_analyzer_id(request.name)
|
|
1154
|
+
_require_output_available(
|
|
1155
|
+
request.output_file,
|
|
1156
|
+
force=request.force,
|
|
1157
|
+
description="schema output",
|
|
1158
|
+
)
|
|
1159
|
+
profile = Profile.load(profile_name=profile_name)
|
|
1160
|
+
resolved = resolve_api_version(flag=api_version, profile=profile.api_version)
|
|
1161
|
+
|
|
1162
|
+
with calling_time(show_calling_time) as calling_timer:
|
|
1163
|
+
if request.from_sample is None:
|
|
1164
|
+
payload, _ = resolve_identifier(ANALYZER_SCHEMA_CREATE.operation)(
|
|
1165
|
+
request,
|
|
1166
|
+
api_version=resolved,
|
|
1167
|
+
completion_model=_template_completion_model(profile),
|
|
1168
|
+
)
|
|
1169
|
+
source = "starter"
|
|
1170
|
+
else:
|
|
1171
|
+
if show_runtime_context:
|
|
1172
|
+
auth = resolve(
|
|
1173
|
+
profile,
|
|
1174
|
+
endpoint_override=endpoint,
|
|
1175
|
+
api_key_override=api_key,
|
|
1176
|
+
api_version_override=api_version,
|
|
1177
|
+
force_entra=entra,
|
|
1178
|
+
)
|
|
1179
|
+
print_runtime_context(auth, profile)
|
|
1180
|
+
payload = suggest_schema_payload_from_sample(
|
|
1181
|
+
sample_path=request.from_sample,
|
|
1182
|
+
analyzer_id=request.name,
|
|
1183
|
+
api_version=resolved,
|
|
1184
|
+
profile_name=profile_name,
|
|
1185
|
+
endpoint=endpoint,
|
|
1186
|
+
api_key=api_key,
|
|
1187
|
+
force_entra=entra,
|
|
1188
|
+
)
|
|
1189
|
+
source = "sample-derived"
|
|
1190
|
+
_write_json_output(
|
|
1191
|
+
payload,
|
|
1192
|
+
request.output_file,
|
|
1193
|
+
force=request.force,
|
|
1194
|
+
description="schema output",
|
|
1195
|
+
)
|
|
1196
|
+
if request.output_file:
|
|
1197
|
+
console.print(
|
|
1198
|
+
f"[green]ok[/green] wrote {source} schema "
|
|
1199
|
+
f"(apiVersion {resolved}) -> {request.output_file}"
|
|
1200
|
+
)
|
|
1201
|
+
console.print(
|
|
1202
|
+
"[dim]next:[/dim] review fields, then run "
|
|
1203
|
+
f"[cyan]cu analyzer validate {out_path}[/cyan]"
|
|
1204
|
+
)
|
|
1205
|
+
calling_timer.print()
|
|
1206
|
+
|
|
1207
|
+
|
|
1208
|
+
# --- Authoring: validate (offline, exit 2 on error) ------------------------
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
@analyzer_group.command("validate",
|
|
1212
|
+
help=ANALYZER_VALIDATE.help,
|
|
1213
|
+
epilog=common_commands(
|
|
1214
|
+
("cu analyzer validate SCHEMA.json", "Validate a schema offline."),
|
|
1215
|
+
(
|
|
1216
|
+
"cu analyzer validate SCHEMA.json --strict --spec",
|
|
1217
|
+
"Treat warnings as errors and check the service contract.",
|
|
1218
|
+
),
|
|
1219
|
+
))
|
|
1220
|
+
@with_command_arguments(ANALYZER_VALIDATE)
|
|
1221
|
+
@click.option("--api-version", "api_version", default=None,
|
|
1222
|
+
help=API_VERSION_HELP)
|
|
1223
|
+
@friendly_errors
|
|
1224
|
+
def cmd_validate(
|
|
1225
|
+
named_schema_path: Path | None,
|
|
1226
|
+
positional_schema_path: Path | None,
|
|
1227
|
+
json_output: bool,
|
|
1228
|
+
strict: bool,
|
|
1229
|
+
use_spec: bool,
|
|
1230
|
+
api_version: str | None,
|
|
1231
|
+
) -> None:
|
|
1232
|
+
try:
|
|
1233
|
+
request = build_request(
|
|
1234
|
+
ANALYZER_VALIDATE,
|
|
1235
|
+
{
|
|
1236
|
+
"named_schema_path": named_schema_path,
|
|
1237
|
+
"positional_schema_path": positional_schema_path,
|
|
1238
|
+
"json_output": json_output,
|
|
1239
|
+
"strict": strict,
|
|
1240
|
+
"use_spec": use_spec,
|
|
1241
|
+
},
|
|
1242
|
+
)
|
|
1243
|
+
except CommandBindingError as exc:
|
|
1244
|
+
raise CuCliError(str(exc), exit_code=VALIDATION_FAILURE) from exc
|
|
1245
|
+
schema_path = request.schema
|
|
1246
|
+
strict = request.strict
|
|
1247
|
+
profile = Profile.load()
|
|
1248
|
+
try:
|
|
1249
|
+
text = schema_path.read_text(encoding="utf-8")
|
|
1250
|
+
pinned = schema_pinned_version(json.loads(text))
|
|
1251
|
+
except (UnicodeDecodeError, ValueError, json.JSONDecodeError):
|
|
1252
|
+
pinned = None
|
|
1253
|
+
|
|
1254
|
+
resolved = resolve_api_version(
|
|
1255
|
+
flag=api_version,
|
|
1256
|
+
schema_pinned=pinned,
|
|
1257
|
+
profile=profile.api_version,
|
|
1258
|
+
)
|
|
1259
|
+
|
|
1260
|
+
result = resolve_identifier(ANALYZER_VALIDATE.operation)(
|
|
1261
|
+
request,
|
|
1262
|
+
api_version=resolved,
|
|
1263
|
+
)
|
|
1264
|
+
ok = result.ok and (not strict or not result.warnings)
|
|
1265
|
+
|
|
1266
|
+
if json_output:
|
|
1267
|
+
payload = result.as_dict()
|
|
1268
|
+
payload["ok"] = ok
|
|
1269
|
+
payload["strict"] = strict
|
|
1270
|
+
dump_json(payload)
|
|
1271
|
+
raise SystemExit(SUCCESS if ok else VALIDATION_FAILURE)
|
|
1272
|
+
|
|
1273
|
+
if not result.errors and not result.warnings:
|
|
1274
|
+
console.print(f"[green]ok[/green] {schema_path} is a valid analyzer schema "
|
|
1275
|
+
f"(apiVersion {resolved}).")
|
|
1276
|
+
return
|
|
1277
|
+
|
|
1278
|
+
if result.errors:
|
|
1279
|
+
console.print(f"[bold red]{len(result.errors)} error(s)[/bold red] {_esc(str(schema_path))}")
|
|
1280
|
+
for e in result.errors:
|
|
1281
|
+
console.print(f" [red]error[/red] [cyan]{_esc(e.path)}[/cyan] {_esc(e.msg)}")
|
|
1282
|
+
if result.warnings:
|
|
1283
|
+
console.print(f"[bold yellow]{len(result.warnings)} warning(s)[/bold yellow]")
|
|
1284
|
+
for w in result.warnings:
|
|
1285
|
+
console.print(f" [yellow]warn[/yellow] [cyan]{_esc(w.path)}[/cyan] {_esc(w.msg)}")
|
|
1286
|
+
if not result.errors:
|
|
1287
|
+
if strict and result.warnings:
|
|
1288
|
+
console.print(f"[bold red]validation failed under --strict[/bold red] "
|
|
1289
|
+
f"({len(result.warnings)} warning(s) treated as errors).")
|
|
1290
|
+
else:
|
|
1291
|
+
console.print("[dim]validation passed (warnings only). Use --strict to fail on warnings.[/dim]")
|
|
1292
|
+
|
|
1293
|
+
raise SystemExit(SUCCESS if ok else VALIDATION_FAILURE)
|
|
1294
|
+
|
|
1295
|
+
|
|
1296
|
+
# --- Authoring: test (per-field coverage + confidence) ---------------------
|
|
1297
|
+
|
|
1298
|
+
|
|
1299
|
+
@analyzer_group.command("test",
|
|
1300
|
+
help=ANALYZER_TEST.help,
|
|
1301
|
+
epilog=common_commands(
|
|
1302
|
+
(
|
|
1303
|
+
"cu analyzer test ANALYZER_NAME SAMPLE_DIR",
|
|
1304
|
+
"Test an analyzer across local samples.",
|
|
1305
|
+
),
|
|
1306
|
+
(
|
|
1307
|
+
"cu analyzer test --name ANALYZER_NAME --source SAMPLE_DIR "
|
|
1308
|
+
"--json --output-file REPORT.json",
|
|
1309
|
+
"Write a machine-readable quality report.",
|
|
1310
|
+
),
|
|
1311
|
+
))
|
|
1312
|
+
@with_command_arguments(ANALYZER_TEST)
|
|
1313
|
+
@with_auth_options
|
|
1314
|
+
@friendly_errors
|
|
1315
|
+
def cmd_test(
|
|
1316
|
+
positional_analyzer_name,
|
|
1317
|
+
analyzer_name,
|
|
1318
|
+
inputs,
|
|
1319
|
+
files,
|
|
1320
|
+
sources,
|
|
1321
|
+
pattern,
|
|
1322
|
+
recursive,
|
|
1323
|
+
dry_run,
|
|
1324
|
+
json_output,
|
|
1325
|
+
out_path,
|
|
1326
|
+
force,
|
|
1327
|
+
assume_yes,
|
|
1328
|
+
concurrency,
|
|
1329
|
+
endpoint,
|
|
1330
|
+
api_key,
|
|
1331
|
+
api_version,
|
|
1332
|
+
entra,
|
|
1333
|
+
profile_name,
|
|
1334
|
+
show_runtime_context,
|
|
1335
|
+
show_calling_time,
|
|
1336
|
+
) -> None:
|
|
1337
|
+
from .analyze import _print_discovery, _run_one
|
|
1338
|
+
from cu_cli_core.contracts import InputOrigin
|
|
1339
|
+
from cu_cli_core.input_planning import plan_inputs
|
|
1340
|
+
|
|
1341
|
+
if dry_run and assume_yes:
|
|
1342
|
+
raise CuCliError(
|
|
1343
|
+
"--dry-run and --yes cannot be combined.",
|
|
1344
|
+
exit_code=VALIDATION_FAILURE,
|
|
1345
|
+
)
|
|
1346
|
+
try:
|
|
1347
|
+
request = build_request(
|
|
1348
|
+
ANALYZER_TEST,
|
|
1349
|
+
{
|
|
1350
|
+
"positional_analyzer_name": positional_analyzer_name,
|
|
1351
|
+
"analyzer_name": analyzer_name,
|
|
1352
|
+
"inputs": inputs,
|
|
1353
|
+
"files": files,
|
|
1354
|
+
"sources": sources,
|
|
1355
|
+
"pattern": pattern,
|
|
1356
|
+
"recursive": recursive,
|
|
1357
|
+
"dry_run": dry_run,
|
|
1358
|
+
"json_output": json_output,
|
|
1359
|
+
"out_path": out_path,
|
|
1360
|
+
"force": force,
|
|
1361
|
+
"assume_yes": assume_yes,
|
|
1362
|
+
"concurrency": concurrency,
|
|
1363
|
+
},
|
|
1364
|
+
)
|
|
1365
|
+
except CommandBindingError as exc:
|
|
1366
|
+
raise CuCliError(str(exc), exit_code=VALIDATION_FAILURE) from exc
|
|
1367
|
+
if not request.dry_run:
|
|
1368
|
+
_require_output_available(
|
|
1369
|
+
request.output_file,
|
|
1370
|
+
force=request.force,
|
|
1371
|
+
description="analyzer test report",
|
|
1372
|
+
)
|
|
1373
|
+
input_plan = plan_inputs(
|
|
1374
|
+
positional=request.positional_inputs,
|
|
1375
|
+
files=request.files,
|
|
1376
|
+
sources=request.sources,
|
|
1377
|
+
pattern=request.pattern,
|
|
1378
|
+
recursive=request.recursive,
|
|
1379
|
+
)
|
|
1380
|
+
refs = [str(item.path) for item in input_plan.inputs]
|
|
1381
|
+
analyzer_id = request.name
|
|
1382
|
+
if request.dry_run:
|
|
1383
|
+
console.print("[bold cyan]Dry run[/bold cyan]")
|
|
1384
|
+
_print_discovery(input_plan, analyzer_id=analyzer_id)
|
|
1385
|
+
console.print(
|
|
1386
|
+
"[dim]No service calls or files were written. Analyzer existence, "
|
|
1387
|
+
"service-side format acceptance, usage, and cost were not validated.[/dim]"
|
|
1388
|
+
)
|
|
1389
|
+
return
|
|
1390
|
+
|
|
1391
|
+
discovered = any(
|
|
1392
|
+
item.origin in {InputOrigin.POSITIONAL_SOURCE, InputOrigin.NAMED_SOURCE}
|
|
1393
|
+
for item in input_plan.inputs
|
|
1394
|
+
)
|
|
1395
|
+
if not assume_yes and discovered and len(refs) > 1 and sys.stdin.isatty():
|
|
1396
|
+
_print_discovery(input_plan, analyzer_id=analyzer_id)
|
|
1397
|
+
if not click.confirm("proceed?", default=False):
|
|
1398
|
+
raise CuCliError("aborted by user.", hint="narrow the inputs or pass --yes.")
|
|
1399
|
+
|
|
1400
|
+
client = _client(endpoint, api_key, api_version, entra, profile_name, show_runtime_context)
|
|
1401
|
+
|
|
1402
|
+
if not json_output:
|
|
1403
|
+
console.print(f"[bold]testing[/bold] [magenta]{analyzer_id}[/magenta] against "
|
|
1404
|
+
f"[cyan]{len(refs)}[/cyan] sample(s)…")
|
|
1405
|
+
|
|
1406
|
+
with calling_time(show_calling_time) as calling_timer:
|
|
1407
|
+
report = resolve_identifier(ANALYZER_TEST.operation)(
|
|
1408
|
+
client,
|
|
1409
|
+
request,
|
|
1410
|
+
input_plan=input_plan,
|
|
1411
|
+
run=lambda c, j: _run_one(c, j)[1],
|
|
1412
|
+
)
|
|
1413
|
+
any_failed = report["summary"]["samplesFailed"] > 0
|
|
1414
|
+
|
|
1415
|
+
if request.output_file is not None:
|
|
1416
|
+
_write_json_output(
|
|
1417
|
+
report,
|
|
1418
|
+
request.output_file,
|
|
1419
|
+
force=request.force,
|
|
1420
|
+
description="analyzer test report",
|
|
1421
|
+
)
|
|
1422
|
+
|
|
1423
|
+
if json_output:
|
|
1424
|
+
if request.output_file is None:
|
|
1425
|
+
dump_json(report)
|
|
1426
|
+
else:
|
|
1427
|
+
console.print(f"[green]ok[/green] wrote report -> {request.output_file}")
|
|
1428
|
+
calling_timer.print()
|
|
1429
|
+
if any_failed:
|
|
1430
|
+
sys.exit(GENERIC_ERROR)
|
|
1431
|
+
return
|
|
1432
|
+
|
|
1433
|
+
s = report["summary"]
|
|
1434
|
+
console.print(f"\n[bold]Summary[/bold] [green]{s['samplesOk']} ok[/green] / "
|
|
1435
|
+
f"[red]{s['samplesFailed']} failed[/red] / [dim]{s['samplesTotal']} total[/dim]")
|
|
1436
|
+
console.print(f"[dim]Note: {_esc(s['disclaimer'])}[/dim]")
|
|
1437
|
+
if s["fields"]:
|
|
1438
|
+
t = Table(show_lines=False)
|
|
1439
|
+
t.add_column("Field", style="bold")
|
|
1440
|
+
t.add_column("Populated", justify="right")
|
|
1441
|
+
t.add_column("Mean conf.", justify="right")
|
|
1442
|
+
t.add_column("Low-conf hits", justify="right")
|
|
1443
|
+
for fname, fstat in s["fields"].items():
|
|
1444
|
+
mean = fstat["meanConfidence"]
|
|
1445
|
+
mean_str = f"{mean:.2f}" if mean is not None else "—"
|
|
1446
|
+
t.add_row(fname,
|
|
1447
|
+
f"{fstat['populated']}/{s['samplesTotal']} "
|
|
1448
|
+
f"[dim]({fstat['populatedPct']}%)[/dim]",
|
|
1449
|
+
mean_str, str(fstat["lowConfidenceCount"]))
|
|
1450
|
+
console.print(t)
|
|
1451
|
+
console.print(f"[dim]Low-confidence threshold: {s['lowConfidenceThreshold']}. "
|
|
1452
|
+
"Re-run with --json for the full per-sample report.[/dim]")
|
|
1453
|
+
fails = [r for r in report["samples"] if r["status"] != "ok"]
|
|
1454
|
+
if fails:
|
|
1455
|
+
console.print(f"\n[bold red]{len(fails)} failed[/bold red]:")
|
|
1456
|
+
for f in fails[:10]:
|
|
1457
|
+
console.print(f" [red]x[/red] {f['input']} [dim]{f.get('error', '')[:120]}[/dim]")
|
|
1458
|
+
if request.output_file is not None:
|
|
1459
|
+
console.print(f"\n[dim]wrote full report -> {request.output_file}[/dim]")
|
|
1460
|
+
calling_timer.print()
|
|
1461
|
+
if any_failed:
|
|
1462
|
+
sys.exit(GENERIC_ERROR)
|