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,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
|
+
)
|