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,302 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT license.
3
+
4
+ """``cu infra generate`` — generate an azd template for CU infrastructure."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+ import json
10
+ from pathlib import Path
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+
15
+ import rich_click as click
16
+
17
+ from ..apiversion import API_VERSION_HELP, DEFAULT_API_VERSION, ensure_supported
18
+ from ..core.foundry import endpoint_host, host_label, normalize_foundry_endpoint
19
+ from ..errors import CuCliError, friendly_errors
20
+ from ..exit_codes import VALIDATION_FAILURE
21
+ from ..output import console
22
+ from ._help import common_commands
23
+ from ._infra_wizard import _validate_azd_environment_name, run_wizard
24
+
25
+ AZURE_SIGNUP_URL = "https://azure.microsoft.com/free/"
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class AzureAccount:
30
+ subscription_id: str
31
+ subscription_name: str
32
+ tenant_id: str
33
+
34
+
35
+ def _check_az_subscription(subscription: str | None = None) -> AzureAccount:
36
+ az = shutil.which("az")
37
+ if not az:
38
+ raise CuCliError(
39
+ "Provisioning requires Azure CLI (`az`), which was not found on PATH.",
40
+ hint=f"install it (https://aka.ms/azcli), sign up at {AZURE_SIGNUP_URL}, "
41
+ "then rerun `cu infra generate`.",
42
+ )
43
+ command = [az, "account", "show"]
44
+ if subscription:
45
+ command.extend(["--subscription", subscription])
46
+ command.extend(["--output", "json"])
47
+ try:
48
+ result = subprocess.run(command, capture_output=True, text=True)
49
+ except OSError as exc:
50
+ raise CuCliError(f"could not run `az account show`: {exc}") from exc
51
+ if result.returncode != 0:
52
+ target = f" '{subscription}'" if subscription else ""
53
+ raise CuCliError(
54
+ f"Azure subscription{target} is not accessible.",
55
+ hint=f"run `az login`, select a subscription, or sign up at "
56
+ f"{AZURE_SIGNUP_URL}, then rerun `cu infra generate`.",
57
+ )
58
+ try:
59
+ payload = json.loads(result.stdout or "")
60
+ except json.JSONDecodeError as exc:
61
+ raise CuCliError("`az account show` returned invalid JSON.") from exc
62
+ if not isinstance(payload, dict):
63
+ raise CuCliError("`az account show` returned an invalid account record.")
64
+ subscription_id = str(payload.get("id") or "").strip()
65
+ subscription_name = str(payload.get("name") or "").strip()
66
+ tenant_id = str(payload.get("tenantId") or "").strip()
67
+ if not subscription_id or not subscription_name or not tenant_id:
68
+ raise CuCliError(
69
+ "`az account show` did not return subscription id, name, and tenant id."
70
+ )
71
+ return AzureAccount(subscription_id, subscription_name, tenant_id)
72
+
73
+
74
+ def _parse_models(value: str | None) -> list[str] | None:
75
+ if value is None:
76
+ return None
77
+ parts = [part.strip() for part in value.split(",")]
78
+ if any(not part for part in parts):
79
+ raise CuCliError(
80
+ "--models requires 'recommended', 'none', or one or more "
81
+ "comma-separated model names; empty entries are not allowed.",
82
+ hint="use `--models none` to deploy no models, or omit `--models` "
83
+ "for the interactive picker.",
84
+ exit_code=VALIDATION_FAILURE,
85
+ )
86
+ special = {"none", "recommended"}
87
+ if len(parts) > 1 and special.intersection(parts):
88
+ raise CuCliError(
89
+ "'none' and 'recommended' must each be used alone with --models.",
90
+ hint="use one special value or provide only comma-separated model names.",
91
+ exit_code=VALIDATION_FAILURE,
92
+ )
93
+ return parts
94
+
95
+
96
+ def _resolve_existing_foundry_account(
97
+ foundry_endpoint: str,
98
+ subscription_id: str,
99
+ ) -> tuple[str, str, str | None]:
100
+ az = shutil.which("az")
101
+ if not az:
102
+ raise CuCliError(
103
+ "resolving an existing Foundry endpoint requires Azure CLI (`az`)."
104
+ )
105
+ result = subprocess.run(
106
+ [
107
+ az,
108
+ "cognitiveservices",
109
+ "account",
110
+ "list",
111
+ "--subscription",
112
+ subscription_id,
113
+ "--output",
114
+ "json",
115
+ ],
116
+ capture_output=True,
117
+ text=True,
118
+ )
119
+ if result.returncode != 0:
120
+ raise CuCliError(
121
+ "could not list Cognitive Services accounts to resolve the endpoint.",
122
+ hint=(result.stderr or result.stdout).strip()
123
+ or "run `az account show` and retry.",
124
+ )
125
+ try:
126
+ accounts = json.loads(result.stdout or "[]")
127
+ except json.JSONDecodeError as exc:
128
+ raise CuCliError(
129
+ "Azure CLI returned invalid JSON while resolving the Foundry endpoint."
130
+ ) from exc
131
+ target = foundry_endpoint.rstrip("/").lower()
132
+ target_host = endpoint_host(foundry_endpoint)
133
+ target_label = host_label(target_host)
134
+ for account in accounts:
135
+ if not isinstance(account, dict):
136
+ continue
137
+ properties_value = account.get("properties")
138
+ properties = (
139
+ properties_value if isinstance(properties_value, dict) else {}
140
+ )
141
+ account_endpoint = str(
142
+ properties.get("endpoint") or account.get("endpoint") or ""
143
+ )
144
+ name = str(account.get("name") or "").strip()
145
+ resource_group = str(account.get("resourceGroup") or "").strip()
146
+ if not name or not resource_group:
147
+ continue
148
+ exact_match = account_endpoint.rstrip("/").lower() == target
149
+ host_match = False
150
+ if target_host.endswith(".services.ai.azure.com"):
151
+ candidates = {name.lower()}
152
+ custom_subdomain = str(
153
+ properties.get("customSubDomainName") or ""
154
+ ).strip().lower()
155
+ if custom_subdomain:
156
+ candidates.add(custom_subdomain)
157
+ properties_host = endpoint_host(
158
+ str(properties.get("endpoint") or "")
159
+ )
160
+ if properties_host:
161
+ candidates.add(host_label(properties_host))
162
+ host_match = bool(target_label and target_label in candidates)
163
+ if exact_match or host_match:
164
+ location = str(account.get("location") or "").strip()
165
+ return name, resource_group, location or None
166
+ raise CuCliError(
167
+ f"no Microsoft Foundry resource matched endpoint '{foundry_endpoint}'.",
168
+ hint="verify the endpoint, subscription, and your access with Azure CLI.",
169
+ )
170
+
171
+
172
+ @click.group(
173
+ "infra",
174
+ help=(
175
+ "Generate infrastructure-as-code used to provision and configure "
176
+ "Content Understanding resources."
177
+ ),
178
+ epilog=common_commands(
179
+ (
180
+ "cu infra generate",
181
+ "Generate an azd/Bicep project. Run azd up to provision Azure resources.",
182
+ ),
183
+ ),
184
+ )
185
+ def infra_group() -> None:
186
+ """Infrastructure-as-code generation commands."""
187
+
188
+
189
+ @infra_group.command(
190
+ "generate",
191
+ help=(
192
+ "Generate an azd/Bicep project used to provision a Microsoft Foundry "
193
+ "resource and configure Content Understanding. This command writes files "
194
+ "only; run `azd up` to provision Azure resources."
195
+ ),
196
+ epilog=common_commands(
197
+ (
198
+ "cu infra generate",
199
+ "Generate a project for a new resource and optional model deployments. "
200
+ "Run azd up to provision it.",
201
+ ),
202
+ (
203
+ "cu infra generate --foundry-endpoint URL",
204
+ "Generate a project for optional model deployments and defaults on an "
205
+ "existing resource. Run azd up to apply it.",
206
+ ),
207
+ ),
208
+ )
209
+ @click.option(
210
+ "-d",
211
+ "--output-dir",
212
+ type=click.Path(path_type=Path, file_okay=False),
213
+ default=Path("provision"),
214
+ show_default=True,
215
+ help="Directory where the azd template is generated.",
216
+ )
217
+ @click.option("-e", "--environment", default=None, help="azd environment name.")
218
+ @click.option("-l", "--location", default=None, help="Azure region.")
219
+ @click.option("--subscription", default=None, help="Azure subscription name or ID.")
220
+ @click.option("--api-version", default=None, help=API_VERSION_HELP)
221
+ @click.option(
222
+ "--models",
223
+ default=None,
224
+ help=(
225
+ "Comma-separated model names, 'recommended', or 'none'; omit for an interactive "
226
+ "picker. Explicit names are validated against the live model catalog during azd up."
227
+ ),
228
+ )
229
+ @click.option(
230
+ "--foundry-endpoint",
231
+ default=None,
232
+ help="Existing Microsoft Foundry resource endpoint; only selected models are deployed.",
233
+ )
234
+ @click.option(
235
+ "--foundry-prefix",
236
+ default=None,
237
+ help="Prefix for a new globally unique Microsoft Foundry resource name.",
238
+ )
239
+ @click.option("--force", is_flag=True, help="Overwrite an existing generated template.")
240
+ @friendly_errors
241
+ def cmd_infra_generate(
242
+ output_dir: Path,
243
+ environment: str | None,
244
+ location: str | None,
245
+ subscription: str | None,
246
+ api_version: str | None,
247
+ models: str | None,
248
+ foundry_endpoint: str | None,
249
+ foundry_prefix: str | None,
250
+ force: bool,
251
+ ) -> None:
252
+ selected_models = _parse_models(models)
253
+ version = ensure_supported(api_version or DEFAULT_API_VERSION)
254
+ if environment is not None:
255
+ environment = _validate_azd_environment_name(environment)
256
+ if foundry_endpoint and foundry_prefix:
257
+ raise CuCliError(
258
+ "--foundry-prefix cannot be used with --foundry-endpoint.",
259
+ hint="omit the prefix when targeting an existing Foundry resource.",
260
+ )
261
+
262
+ normalized_endpoint = (
263
+ normalize_foundry_endpoint(foundry_endpoint)
264
+ if foundry_endpoint
265
+ else None
266
+ )
267
+ account = _check_az_subscription(subscription)
268
+ existing_resource_group: str | None = None
269
+ if normalized_endpoint:
270
+ _, existing_resource_group, existing_location = (
271
+ _resolve_existing_foundry_account(
272
+ normalized_endpoint,
273
+ account.subscription_id,
274
+ )
275
+ )
276
+ if location is None:
277
+ location = existing_location
278
+
279
+ target = output_dir.resolve()
280
+ target.parent.mkdir(parents=True, exist_ok=True)
281
+ console.print(f"\n[bold]cu infra generate[/bold] -> [cyan]{target}[/cyan]")
282
+ console.print(
283
+ f" [dim]Azure subscription: {account.subscription_name} "
284
+ f"({account.subscription_id})[/dim]"
285
+ )
286
+ run_wizard(
287
+ target,
288
+ interactive=sys.stdin.isatty(),
289
+ already_opted_in=True,
290
+ env=environment,
291
+ location=location,
292
+ api_version=version,
293
+ subscription_id=account.subscription_id,
294
+ subscription_name=account.subscription_name,
295
+ tenant_id=account.tenant_id,
296
+ foundry_account_prefix=foundry_prefix,
297
+ foundry_endpoint=normalized_endpoint,
298
+ foundry_resource_group=existing_resource_group,
299
+ models=selected_models,
300
+ assign_roles=None,
301
+ force=force,
302
+ )