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.
Files changed (56) hide show
  1. cu_cli/__init__.py +17 -0
  2. cu_cli/__main__.py +11 -0
  3. cu_cli/apiversion.py +124 -0
  4. cu_cli/cli.py +138 -0
  5. cu_cli/client.py +138 -0
  6. cu_cli/commands/__init__.py +4 -0
  7. cu_cli/commands/_command_spec.py +94 -0
  8. cu_cli/commands/_help.py +33 -0
  9. cu_cli/commands/_infra_models.py +184 -0
  10. cu_cli/commands/_infra_wizard.py +630 -0
  11. cu_cli/commands/_model_setup.py +46 -0
  12. cu_cli/commands/_options.py +112 -0
  13. cu_cli/commands/analyze.py +631 -0
  14. cu_cli/commands/analyzer.py +1462 -0
  15. cu_cli/commands/defaults.py +172 -0
  16. cu_cli/commands/doctor.py +166 -0
  17. cu_cli/commands/env_var.py +67 -0
  18. cu_cli/commands/infra.py +302 -0
  19. cu_cli/commands/profile_cmd.py +525 -0
  20. cu_cli/commands/upgrade.py +120 -0
  21. cu_cli/core/__init__.py +17 -0
  22. cu_cli/core/analyze.py +44 -0
  23. cu_cli/core/analyzers.py +30 -0
  24. cu_cli/core/azure_resources.py +486 -0
  25. cu_cli/core/defaults.py +18 -0
  26. cu_cli/core/doctor.py +42 -0
  27. cu_cli/core/foundry.py +68 -0
  28. cu_cli/core/infra_models.py +367 -0
  29. cu_cli/core/inputs.py +209 -0
  30. cu_cli/core/schema.py +24 -0
  31. cu_cli/errors.py +174 -0
  32. cu_cli/exit_codes.py +20 -0
  33. cu_cli/modality.py +24 -0
  34. cu_cli/output.py +179 -0
  35. cu_cli/profile.py +30 -0
  36. cu_cli/py.typed +0 -0
  37. cu_cli/resources/__init__.py +4 -0
  38. cu_cli/resources/azd_template/README.md +187 -0
  39. cu_cli/resources/azd_template/azure.yaml +27 -0
  40. cu_cli/resources/azd_template/hooks/postprovision.ps1 +320 -0
  41. cu_cli/resources/azd_template/hooks/postprovision.sh +299 -0
  42. cu_cli/resources/azd_template/infra/main.bicep +115 -0
  43. cu_cli/resources/azd_template/infra/main.parameters.json +30 -0
  44. cu_cli/resources/azd_template/infra/models.json +1 -0
  45. cu_cli/resources/azd_template/infra/modules/foundry.bicep +122 -0
  46. cu_cli/schema_validate.py +28 -0
  47. cu_cli/spec_validate.py +18 -0
  48. cu_cli/telemetry.py +42 -0
  49. cu_cli/update_check.py +154 -0
  50. cu_cli/update_provider.py +92 -0
  51. cu_cli/windows_self_upgrade.py +245 -0
  52. cu_cli-0.1.0b1.dist-info/METADATA +345 -0
  53. cu_cli-0.1.0b1.dist-info/RECORD +56 -0
  54. cu_cli-0.1.0b1.dist-info/WHEEL +5 -0
  55. cu_cli-0.1.0b1.dist-info/entry_points.txt +3 -0
  56. 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)