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,630 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT license.
3
+
4
+ """Infrastructure wizard for ``cu infra generate``.
5
+
6
+ Drops a self-contained `azd` template under the requested output directory so
7
+ the developer can run `azd up` to provision Foundry, discover the live
8
+ CU model catalog, and optionally deploy selected models.
9
+
10
+ Surface:
11
+ - run_wizard(target, *, interactive, env, location, api_version, models, assign_roles,
12
+ force) -> bool # True if files were written
13
+ - InfraChoices # dataclass of resolved inputs
14
+
15
+ The wizard is *advisory* — calling code (`cu infra generate`) is responsible for
16
+ deciding whether to invoke it (TTY check, --no-infra flag, etc.).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import re
23
+ import shlex
24
+ import stat
25
+ import subprocess
26
+ import sys
27
+ from dataclasses import dataclass
28
+ from importlib import resources as ir
29
+ from pathlib import Path
30
+ from typing import Iterable
31
+
32
+ import rich_click as click
33
+
34
+ from ..errors import CuCliError
35
+ from ..output import console
36
+
37
+
38
+ @dataclass
39
+ class InfraChoices:
40
+ env: str
41
+ location: str
42
+ api_version: str
43
+ subscription_id: str
44
+ subscription_name: str
45
+ tenant_id: str
46
+ foundry_account_prefix: str | None
47
+ foundry_endpoint: str | None
48
+ foundry_resource_group: str | None
49
+ model_selection: str
50
+ assign_roles: bool
51
+ force_profile_setup: bool
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Public entry point
56
+ # ---------------------------------------------------------------------------
57
+
58
+ def run_wizard(
59
+ target: Path,
60
+ *,
61
+ interactive: bool,
62
+ already_opted_in: bool = False,
63
+ env: str | None,
64
+ location: str | None,
65
+ api_version: str,
66
+ subscription_id: str,
67
+ subscription_name: str,
68
+ tenant_id: str,
69
+ foundry_account_prefix: str | None,
70
+ foundry_endpoint: str | None,
71
+ foundry_resource_group: str | None,
72
+ models: list[str] | None,
73
+ assign_roles: bool | None,
74
+ force: bool,
75
+ ) -> bool:
76
+ """Prompt the user (if interactive) and materialize an azd template.
77
+
78
+ Returns True if anything was written, False if the user declined or no
79
+ template files exist.
80
+ """
81
+ choices = _resolve_choices(
82
+ interactive=interactive,
83
+ already_opted_in=already_opted_in,
84
+ env=env,
85
+ location=location,
86
+ api_version=api_version,
87
+ subscription_id=subscription_id,
88
+ subscription_name=subscription_name,
89
+ tenant_id=tenant_id,
90
+ foundry_account_prefix=foundry_account_prefix,
91
+ foundry_endpoint=foundry_endpoint,
92
+ foundry_resource_group=foundry_resource_group,
93
+ models=models,
94
+ assign_roles=assign_roles,
95
+ force_profile_setup=force,
96
+ )
97
+ if choices is None:
98
+ return False
99
+
100
+ _write_template(target, choices, force=force)
101
+ _print_next_steps(target, choices)
102
+ return True
103
+
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # Prompting
107
+ # ---------------------------------------------------------------------------
108
+
109
+ DEFAULT_ENV = "dev"
110
+ DEFAULT_LOCATION = "eastus2"
111
+ AZD_ENV_NAME_MAX_LENGTH = 64
112
+ _AZD_ENV_NAME_RE = re.compile(r"^[a-z0-9()_.-]{1,64}$")
113
+ _AZD_ENV_ASSIGNMENT_RE = re.compile(
114
+ r"^\s*(?:export\s+)?(?P<key>[A-Za-z_][A-Za-z0-9_]*)\s*="
115
+ )
116
+ _AZD_ENV_MANAGED_KEYS = (
117
+ "AZURE_ENV_NAME",
118
+ "AZURE_LOCATION",
119
+ "AZURE_SUBSCRIPTION_ID",
120
+ "AZURE_TENANT_ID",
121
+ "CU_API_VERSION",
122
+ "CU_MODEL_SELECTION",
123
+ "CU_MODEL_SETUP_COMPLETE",
124
+ "FOUNDRY_RESOURCE_PREFIX",
125
+ "FOUNDRY_EXISTING_ENDPOINT",
126
+ "FOUNDRY_EXISTING_RESOURCE_GROUP",
127
+ "AZD_ASSIGN_ROLES",
128
+ "CU_PROFILE_SETUP_FORCE",
129
+ )
130
+
131
+ CU_REGION_SUPPORT_URL = (
132
+ "https://learn.microsoft.com/azure/ai-services/content-understanding/"
133
+ "language-region-support"
134
+ )
135
+
136
+ # Regions where Content Understanding is available (GA).
137
+ # Keep this list in sync with the current availability at CU_REGION_SUPPORT_URL.
138
+ CU_SUPPORTED_REGIONS: list[str] = [
139
+ "australiaeast",
140
+ "eastus",
141
+ "eastus2",
142
+ "japaneast",
143
+ "northeurope",
144
+ "southcentralus",
145
+ "southeastasia",
146
+ "swedencentral",
147
+ "uksouth",
148
+ "westeurope",
149
+ "westus",
150
+ "westus3",
151
+ ]
152
+
153
+ def _resolve_choices(
154
+ *,
155
+ interactive: bool,
156
+ already_opted_in: bool = False,
157
+ env: str | None,
158
+ location: str | None,
159
+ api_version: str,
160
+ subscription_id: str,
161
+ subscription_name: str,
162
+ tenant_id: str,
163
+ foundry_account_prefix: str | None,
164
+ foundry_endpoint: str | None,
165
+ foundry_resource_group: str | None,
166
+ models: list[str] | None,
167
+ assign_roles: bool | None,
168
+ force_profile_setup: bool,
169
+ ) -> InfraChoices | None:
170
+ """Combine CLI flags with interactive prompts. Returns None if the user declines."""
171
+
172
+ if interactive and not already_opted_in:
173
+ console.print()
174
+ proceed = click.confirm(
175
+ "Provision a Microsoft Foundry resource, optionally deploy selected supported "
176
+ "large language models (LLMs) and embeddings models, and configure "
177
+ "Content Understanding defaults now?\n"
178
+ " This writes an `azd` template under ./provision/ that you run yourself.",
179
+ default=True,
180
+ )
181
+ if not proceed:
182
+ return None
183
+
184
+ resolved_env = env or (
185
+ _prompt_env()
186
+ if interactive else DEFAULT_ENV
187
+ )
188
+ resolved_env = _validate_azd_environment_name(resolved_env)
189
+ use_existing_foundry = bool(foundry_endpoint)
190
+ resolved_location = location or (
191
+ _prompt_location()
192
+ if interactive and not use_existing_foundry else DEFAULT_LOCATION
193
+ )
194
+ if resolved_location not in CU_SUPPORTED_REGIONS:
195
+ raise CuCliError(
196
+ f"'{resolved_location}' is not a CU-supported region",
197
+ hint=(
198
+ "supported: " + ", ".join(CU_SUPPORTED_REGIONS)
199
+ + f"\nSee {CU_REGION_SUPPORT_URL}"
200
+ ),
201
+ )
202
+ resolved_prefix = None if use_existing_foundry else _resolve_foundry_account_prefix(
203
+ foundry_account_prefix,
204
+ interactive=interactive,
205
+ )
206
+
207
+ model_selection = _resolve_model_selection(models, interactive=interactive)
208
+
209
+ resolved_assign_roles = False if use_existing_foundry else (
210
+ assign_roles if assign_roles is not None
211
+ else (_prompt_assign_roles() if interactive else False)
212
+ )
213
+ return InfraChoices(
214
+ env=resolved_env,
215
+ location=resolved_location.strip(),
216
+ api_version=api_version,
217
+ subscription_id=subscription_id,
218
+ subscription_name=subscription_name,
219
+ tenant_id=tenant_id,
220
+ foundry_account_prefix=resolved_prefix,
221
+ foundry_endpoint=foundry_endpoint,
222
+ foundry_resource_group=foundry_resource_group,
223
+ model_selection=model_selection,
224
+ assign_roles=resolved_assign_roles,
225
+ force_profile_setup=force_profile_setup,
226
+ )
227
+
228
+
229
+ def _prompt_env() -> str:
230
+ console.print()
231
+ console.print(
232
+ "[bold]`cu infra generate`[/bold] generates an [cyan]azd[/cyan] template. After "
233
+ "the Microsoft Foundry resource is provisioned, its post-provision script "
234
+ "can optionally deploy supported chat completion and embeddings models for "
235
+ "prebuilt analyzers such as [cyan]prebuilt-invoice[/cyan] and for custom analyzers."
236
+ )
237
+ console.print(
238
+ "[dim]It doesn't create any Azure resources itself — you run "
239
+ "[/dim][cyan]azd up[/cyan][dim] afterwards to do the actual provisioning.[/dim]"
240
+ )
241
+ console.print()
242
+ console.print("Input your [bold]azd environment name[/bold]. An azd environment name:")
243
+ console.print(
244
+ " [dim]•[/dim] lets azd store this deployment's config + outputs under "
245
+ "[cyan]provision/.azure/<env>/[/cyan]."
246
+ )
247
+ console.print(
248
+ " [dim]•[/dim] seeds your Azure resource names "
249
+ "(e.g. [cyan]rg-<env>[/cyan], [cyan]proj-<env>[/cyan])."
250
+ )
251
+ console.print(
252
+ " [dim]•[/dim] keeps separate stacks apart, such as "
253
+ "[cyan]dev[/cyan], [cyan]test[/cyan], or [cyan]prod[/cyan]."
254
+ )
255
+ return click.prompt(
256
+ "Enter your environment name (1-64 letters, numbers, -, _, ., or parentheses)",
257
+ default=DEFAULT_ENV,
258
+ show_default=True,
259
+ )
260
+
261
+
262
+ def _prompt_location() -> str:
263
+ console.print()
264
+ console.print("[bold]Content Understanding supported regions[/bold]")
265
+ console.print(
266
+ f"[dim]Check the latest region support at {CU_REGION_SUPPORT_URL}[/dim]"
267
+ )
268
+ console.print()
269
+ cols = 3
270
+ for i in range(0, len(CU_SUPPORTED_REGIONS), cols):
271
+ row = CU_SUPPORTED_REGIONS[i : i + cols]
272
+ console.print(" " + " ".join(f"{r:<20}" for r in row))
273
+ console.print()
274
+ while True:
275
+ raw = click.prompt(
276
+ "Azure region (where the Foundry resource is created)",
277
+ default=DEFAULT_LOCATION,
278
+ show_default=True,
279
+ )
280
+ candidate = raw.strip().lower()
281
+ if candidate in CU_SUPPORTED_REGIONS:
282
+ return candidate
283
+ console.print(
284
+ f"[red]'{candidate}' is not a CU-supported region.[/red] "
285
+ "Choose one from the list above."
286
+ )
287
+
288
+
289
+ def _resolve_foundry_account_prefix(
290
+ prefix: str | None,
291
+ *,
292
+ interactive: bool,
293
+ ) -> str | None:
294
+ if prefix is not None:
295
+ return _validate_foundry_account_prefix(prefix)
296
+
297
+ if not interactive:
298
+ return None
299
+
300
+ console.print()
301
+ console.print(
302
+ "[bold]Microsoft Foundry resource naming[/bold]: the resource name becomes part of the "
303
+ "public endpoint host (for example, [cyan]https://<name>.services.ai.azure.com[/cyan]), "
304
+ "so it must be globally unique."
305
+ )
306
+ console.print(
307
+ "[dim]If you provide a prefix, azd constructs the resource name as "
308
+ "<prefix>-<unique-suffix>. Without a prefix, it uses aif-<unique-suffix>.[/dim]"
309
+ )
310
+ raw = click.prompt(
311
+ "Optional Microsoft Foundry resource name prefix "
312
+ "(lowercase letters, numbers, hyphen; blank to skip)",
313
+ default="",
314
+ show_default=False,
315
+ )
316
+ return _validate_foundry_account_prefix(raw)
317
+
318
+
319
+ def _validate_foundry_account_prefix(raw: str) -> str | None:
320
+ candidate = raw.strip().lower()
321
+ if not candidate:
322
+ return None
323
+
324
+ if len(candidate) > 20:
325
+ raise CuCliError(
326
+ "invalid Microsoft Foundry resource prefix",
327
+ hint="use 1-20 chars: lowercase letters, digits, hyphen (no leading or trailing hyphen)",
328
+ )
329
+ if candidate[0] == "-" or candidate[-1] == "-":
330
+ raise CuCliError(
331
+ "invalid Microsoft Foundry resource prefix",
332
+ hint="prefix cannot start or end with '-'.",
333
+ )
334
+ allowed = set("abcdefghijklmnopqrstuvwxyz0123456789-")
335
+ if any(ch not in allowed for ch in candidate):
336
+ raise CuCliError(
337
+ "invalid Microsoft Foundry resource prefix",
338
+ hint="use only lowercase letters (a-z), digits (0-9), and hyphen (-).",
339
+ )
340
+
341
+ return candidate
342
+
343
+
344
+ def _prompt_assign_roles() -> bool:
345
+ console.print()
346
+ console.print(
347
+ "[bold]RBAC roles[/bold]: required for Entra-based auth from `cu`. "
348
+ "Needs Owner, User Access Administrator, or Role Based Access Control "
349
+ "Administrator on the subscription. "
350
+ "Pick 'n' if you only have Contributor — cu can use the resource "
351
+ "API key instead."
352
+ )
353
+ return click.confirm("Assign RBAC roles to your user on the Microsoft Foundry resource?",
354
+ default=False)
355
+
356
+
357
+ def _resolve_model_selection(models: list[str] | None, *, interactive: bool) -> str:
358
+ """Describe how the post-provision live model picker should behave."""
359
+ if not models:
360
+ return "prompt" if interactive else "recommended"
361
+ normalized = [model.strip() for model in models if model.strip()]
362
+ none_selected = [model for model in normalized if model.lower() == "none"]
363
+ if none_selected:
364
+ if len(normalized) != 1:
365
+ raise CuCliError(
366
+ "'none' cannot be combined with model names in --models."
367
+ )
368
+ return "none"
369
+ return ",".join(normalized)
370
+
371
+
372
+ # ---------------------------------------------------------------------------
373
+ # File materialization
374
+ # ---------------------------------------------------------------------------
375
+
376
+ def _template_root():
377
+ """importlib.resources Traversable for the bundled azd_template."""
378
+ return ir.files("cu_cli.resources").joinpath("azd_template")
379
+
380
+
381
+ def _iter_template_files(root) -> Iterable[tuple[str, bytes]]:
382
+ """Yield (relative_path_with_forward_slashes, bytes) for every bundled file."""
383
+ def _walk(node, prefix: str):
384
+ for child in node.iterdir():
385
+ rel = f"{prefix}/{child.name}" if prefix else child.name
386
+ if child.is_dir():
387
+ yield from _walk(child, rel)
388
+ else:
389
+ yield rel, child.read_bytes()
390
+ yield from _walk(root, "")
391
+
392
+
393
+ def _ensure_executable_if_shell_script(path: Path, rel_path: str) -> None:
394
+ if not rel_path.endswith('.sh'):
395
+ return
396
+ mode = path.stat().st_mode
397
+ path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
398
+
399
+
400
+ def _write_template(target: Path, choices: InfraChoices, *, force: bool) -> None:
401
+ choices.env = _validate_azd_environment_name(choices.env)
402
+ env_dir = _safe_env_directory(target, choices.env)
403
+
404
+ target_has_content = target.exists() and any(target.iterdir())
405
+ is_existing_template = (
406
+ (target / "azure.yaml").exists()
407
+ and (target / "infra" / "main.bicep").exists()
408
+ )
409
+ if target_has_content and not force and not is_existing_template:
410
+ raise CuCliError(
411
+ f"{target} already exists and is non-empty",
412
+ hint=(
413
+ "use an existing `provision/` directory, pass --force to overwrite it, "
414
+ "or move/remove the directory."
415
+ ),
416
+ )
417
+ reused_existing = target_has_content and is_existing_template and not force
418
+ env_path = env_dir / ".env"
419
+ config_path = target / ".azure" / "config.json"
420
+ existing_env = _read_existing_azd_env(env_path) if reused_existing else None
421
+ azd_config = _load_existing_azd_config(config_path) if reused_existing else {}
422
+ azd_config.update({"version": 1, "defaultEnvironment": choices.env})
423
+
424
+ target.mkdir(parents=True, exist_ok=True)
425
+ template_root = _template_root()
426
+ written: list[str] = []
427
+ if not reused_existing:
428
+ for rel, data in _iter_template_files(template_root):
429
+ dest = target / Path(rel)
430
+ dest.parent.mkdir(parents=True, exist_ok=True)
431
+ dest.write_bytes(data)
432
+ _ensure_executable_if_shell_script(dest, rel)
433
+ written.append(rel)
434
+
435
+ # The first azd provision creates the resource without models. The
436
+ # post-provision hook fills this file from live CU + ARM discovery.
437
+ models_path = target / "infra" / "models.json"
438
+ if not models_path.exists() or force:
439
+ models_path.write_text("[]\n", encoding="utf-8")
440
+
441
+ # Pre-populate `.azure/<env>/.env` with the resolved choices so `azd up`
442
+ # picks them up without further `azd env set` calls.
443
+ env_dir.mkdir(parents=True, exist_ok=True)
444
+ env_body = (
445
+ _merge_azd_env(existing_env, choices)
446
+ if existing_env is not None
447
+ else _render_azd_env(choices)
448
+ )
449
+ _write_utf8(env_path, env_body)
450
+ _write_utf8(config_path, json.dumps(azd_config, indent=2) + "\n")
451
+
452
+ console.print()
453
+ heading = "updated" if reused_existing else "wrote"
454
+ console.print(f"[bold]{heading}[/bold] [cyan]{target}[/cyan]")
455
+ for rel in sorted(written):
456
+ console.print(f" [green]created[/green] provision/{rel}")
457
+ if reused_existing:
458
+ console.print(" [cyan]reused[/cyan] existing provision directory")
459
+ console.print(" [cyan]preserved[/cyan] provision/infra/models.json")
460
+ console.print(f" [green]merged[/green] provision/.azure/{choices.env}/.env")
461
+ console.print(" [green]updated[/green] provision/.azure/config.json")
462
+ else:
463
+ console.print(" [green]created[/green] provision/infra/models.json"
464
+ " [dim](live selection runs after the Foundry resource exists)[/dim]")
465
+ console.print(f" [green]created[/green] provision/.azure/{choices.env}/.env")
466
+ console.print(" [green]created[/green] provision/.azure/config.json")
467
+
468
+
469
+ def _validate_azd_environment_name(raw: str) -> str:
470
+ """Return the normalized azd environment name or reject unsafe input."""
471
+ name = raw.strip().lower()
472
+ if name in {".", ".."} or not _AZD_ENV_NAME_RE.fullmatch(name):
473
+ raise CuCliError(
474
+ "invalid azd environment name.",
475
+ hint=(
476
+ f"use 1-{AZD_ENV_NAME_MAX_LENGTH} letters, numbers, hyphens (-), "
477
+ "underscores (_), periods (.), or parentheses; '.' and '..' are not allowed."
478
+ ),
479
+ )
480
+ return name
481
+
482
+
483
+ def _safe_env_directory(target: Path, env_name: str) -> Path:
484
+ """Return the direct `.azure/<env>` child after resolving existing symlinks."""
485
+ target_root = target.resolve(strict=False)
486
+ azure_root = target_root / ".azure"
487
+ resolved_azure_root = azure_root.resolve(strict=False)
488
+ resolved_env_dir = (azure_root / env_name).resolve(strict=False)
489
+ if resolved_azure_root != azure_root or resolved_env_dir.parent != azure_root:
490
+ raise CuCliError(
491
+ "refusing to write the azd environment outside provision/.azure.",
492
+ hint="remove path redirections under provision/.azure and try again.",
493
+ )
494
+ return azure_root / env_name
495
+
496
+
497
+ def _read_existing_azd_env(path: Path) -> str | None:
498
+ if not path.exists():
499
+ return None
500
+ try:
501
+ with path.open("r", encoding="utf-8", newline="") as stream:
502
+ return stream.read()
503
+ except (OSError, UnicodeDecodeError) as exc:
504
+ raise CuCliError(
505
+ f"could not read existing azd environment file: {path}",
506
+ hint="repair or remove the file, or pass --force to replace it.",
507
+ ) from exc
508
+
509
+
510
+ def _load_existing_azd_config(path: Path) -> dict:
511
+ if not path.exists():
512
+ return {}
513
+ try:
514
+ value = json.loads(path.read_text(encoding="utf-8"))
515
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
516
+ raise CuCliError(
517
+ f"could not read existing azd config: {path}",
518
+ hint="repair or remove the file, or pass --force to replace it.",
519
+ ) from exc
520
+ if not isinstance(value, dict):
521
+ raise CuCliError(
522
+ f"existing azd config must contain a JSON object: {path}",
523
+ hint="repair or remove the file, or pass --force to replace it.",
524
+ )
525
+ return value
526
+
527
+
528
+ def _write_utf8(path: Path, content: str) -> None:
529
+ with path.open("w", encoding="utf-8", newline="") as stream:
530
+ stream.write(content)
531
+
532
+
533
+ def _render_azd_env(choices: InfraChoices) -> str:
534
+ return "\n".join([*_render_azd_env_assignments(choices).values(), ""])
535
+
536
+
537
+ def _render_azd_env_assignments(choices: InfraChoices) -> dict[str, str]:
538
+ values = (
539
+ choices.env,
540
+ choices.location,
541
+ choices.subscription_id,
542
+ choices.tenant_id,
543
+ choices.api_version,
544
+ choices.model_selection,
545
+ "false",
546
+ choices.foundry_account_prefix or "",
547
+ choices.foundry_endpoint or "",
548
+ choices.foundry_resource_group or "",
549
+ str(choices.assign_roles).lower(),
550
+ str(choices.force_profile_setup).lower(),
551
+ )
552
+ return {
553
+ key: f'{key}="{value}"'
554
+ for key, value in zip(_AZD_ENV_MANAGED_KEYS, values)
555
+ }
556
+
557
+
558
+ def _merge_azd_env(existing: str, choices: InfraChoices) -> str:
559
+ """Update CU-managed assignments while preserving all other dotenv content."""
560
+ assignments = _render_azd_env_assignments(choices)
561
+ seen: set[str] = set()
562
+ output: list[str] = []
563
+ newline = "\r\n" if "\r\n" in existing else "\n"
564
+
565
+ for line in existing.splitlines(keepends=True):
566
+ match = _AZD_ENV_ASSIGNMENT_RE.match(line)
567
+ key = match.group("key") if match else None
568
+ if key not in assignments:
569
+ output.append(line)
570
+ continue
571
+ if key not in seen:
572
+ line_ending = "\r\n" if line.endswith("\r\n") else (
573
+ "\n" if line.endswith("\n") else newline
574
+ )
575
+ output.append(
576
+ line
577
+ if key == "CU_MODEL_SETUP_COMPLETE"
578
+ else assignments[key] + line_ending
579
+ )
580
+ seen.add(key)
581
+
582
+ missing = [key for key in _AZD_ENV_MANAGED_KEYS if key not in seen]
583
+ if missing and output and not output[-1].endswith(("\r", "\n")):
584
+ output.append(newline)
585
+ output.extend(assignments[key] + newline for key in missing)
586
+ return "".join(output)
587
+
588
+
589
+ def _print_next_steps(target: Path, choices: InfraChoices) -> None:
590
+ sep = "\\" if sys.platform == "win32" else "/"
591
+ try:
592
+ relative = target.relative_to(Path.cwd())
593
+ rel = str(relative).replace("/", sep)
594
+ except ValueError:
595
+ rel = str(target)
596
+ quoted_rel = (
597
+ subprocess.list2cmdline([rel])
598
+ if sys.platform == "win32"
599
+ else shlex.quote(rel)
600
+ )
601
+ console.print()
602
+ console.print("[bold]Next:[/bold]")
603
+ console.print(f" [cyan]cd {quoted_rel}[/cyan]")
604
+ console.print(" [cyan]azd auth login[/cyan] [dim](one-time)[/dim]")
605
+ if choices.foundry_endpoint:
606
+ console.print(
607
+ f" [cyan]azd up[/cyan] [dim]configures Content Understanding and "
608
+ "optionally deploys selected supported LLMs and embeddings models on the "
609
+ f"existing Microsoft Foundry resource "
610
+ f"({choices.foundry_endpoint})[/dim]"
611
+ )
612
+ else:
613
+ action = (
614
+ "provisions a Microsoft Foundry resource without model deployments"
615
+ if choices.model_selection == "none"
616
+ else "provisions a Microsoft Foundry resource, optionally deploys selected "
617
+ "supported LLMs and embeddings models, and configures Content Understanding "
618
+ "defaults"
619
+ )
620
+ console.print(f" [cyan]azd up[/cyan] [dim]{action}[/dim]")
621
+ if choices.model_selection not in {"none", "prompt", "recommended"}:
622
+ console.print(
623
+ " [dim]The explicit model names are validated against the live "
624
+ "CU-supported model catalog during azd up, after the Microsoft Foundry "
625
+ "resource is available.[/dim]"
626
+ )
627
+ console.print(
628
+ " [dim]The post-provision hook prints verified Content Understanding setup "
629
+ "and test commands.[/dim]"
630
+ )
@@ -0,0 +1,46 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT license.
3
+
4
+ """Shared model setup guidance for Content Understanding commands."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from ..output import console
9
+
10
+
11
+ def print_model_setup_steps(
12
+ endpoint: str,
13
+ *,
14
+ profile_name: str | None = None,
15
+ heading: str = "Next steps:",
16
+ ) -> None:
17
+ sync_command = "cu profile sync-defaults"
18
+ if profile_name:
19
+ sync_command += f" --name {profile_name}"
20
+ console.print(
21
+ f"\n[bold]{heading}[/bold]\n\n"
22
+ "1. If the required models are not already deployed, provision the "
23
+ "recommended models:\n\n"
24
+ " [cyan]cu infra generate \\\n"
25
+ f" --foundry-endpoint {endpoint} \\\n"
26
+ " --models recommended[/cyan]\n\n"
27
+ " [cyan]cd provision\n"
28
+ " azd up[/cyan]\n\n"
29
+ " Omit [cyan]--models recommended[/cyan] to choose your own models in "
30
+ "the text-based wizard.\n\n"
31
+ "2. Configure Content Understanding defaults. Replace both model names "
32
+ "and deployment names with the models and deployments you selected:\n\n"
33
+ " [cyan]cu defaults set \\\n"
34
+ " --model gpt-5.2=<your-gpt-5.2-deployment> \\\n"
35
+ " --model text-embedding-3-large=<your-embedding-deployment>[/cyan]\n\n"
36
+ "3. Copy the resource's Content Understanding defaults into the "
37
+ "selected local profile:\n\n"
38
+ f" [cyan]{sync_command}[/cyan]"
39
+ )
40
+
41
+
42
+ def print_model_free_analyzers() -> None:
43
+ console.print(
44
+ "\n[bold]Available without model deployments:[/bold]\n"
45
+ " prebuilt-digitalParse, prebuilt-read, prebuilt-layout"
46
+ )