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,525 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT license.
3
+
4
+ """``cu profile`` — manage resource-specific settings in Azure CLI configuration."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import shutil
10
+ import subprocess
11
+ from typing import Any
12
+
13
+ import rich_click as click
14
+ from azure.core.exceptions import HttpResponseError
15
+ from click.exceptions import Exit
16
+ from cu_cli_core.command_spec import (
17
+ PROFILE_COPY,
18
+ PROFILE_CREATE,
19
+ PROFILE_DELETE,
20
+ PROFILE_GET,
21
+ PROFILE_LIST,
22
+ PROFILE_RENAME,
23
+ PROFILE_SET,
24
+ PROFILE_SET_ACTIVE,
25
+ PROFILE_SHOW,
26
+ PROFILE_SYNC_DEFAULTS,
27
+ PROFILE_UNSET,
28
+ CommandBindingError,
29
+ build_request,
30
+ resolve_identifier,
31
+ )
32
+ from cu_cli_core.defaults import (
33
+ extract_model_deployments,
34
+ with_prebuilt_default_mappings,
35
+ )
36
+
37
+ from ..apiversion import ensure_supported
38
+ from ..client import build_client
39
+ from ..core.foundry import (
40
+ endpoint_host,
41
+ host_label,
42
+ normalize_foundry_endpoint,
43
+ )
44
+ from ..core.defaults import is_defaults_not_set
45
+ from ..errors import CuCliError, friendly_errors
46
+ from ..exit_codes import GENERIC_ERROR, VALIDATION_FAILURE
47
+ from ..output import console, kv_table, result_console
48
+ from ..profile import Profile, ProfileStore
49
+ from ._command_spec import with_command_arguments
50
+ from ._help import common_commands
51
+ from ._model_setup import print_model_free_analyzers, print_model_setup_steps
52
+ from ._options import CALLING_TIME_OPTION, calling_time
53
+
54
+
55
+ def _request(spec, values: dict[str, Any]):
56
+ try:
57
+ return build_request(spec, values)
58
+ except CommandBindingError as exc:
59
+ raise CuCliError(str(exc), exit_code=VALIDATION_FAILURE) from exc
60
+
61
+
62
+ def _resolve_foundry_account(endpoint: str) -> tuple[str, str] | None:
63
+ az = shutil.which("az")
64
+ if not az:
65
+ return None
66
+ result = subprocess.run(
67
+ [az, "cognitiveservices", "account", "list", "-o", "json"],
68
+ capture_output=True,
69
+ text=True,
70
+ )
71
+ if result.returncode != 0:
72
+ return None
73
+ try:
74
+ accounts = json.loads(result.stdout or "[]")
75
+ except json.JSONDecodeError:
76
+ return None
77
+
78
+ target = endpoint.rstrip("/").lower()
79
+ target_host = endpoint_host(endpoint)
80
+ target_label = host_label(target_host)
81
+ for account in accounts:
82
+ if not isinstance(account, dict):
83
+ continue
84
+ properties_value = account.get("properties")
85
+ properties: dict[str, Any] = (
86
+ properties_value if isinstance(properties_value, dict) else {}
87
+ )
88
+ account_endpoint = str(
89
+ properties.get("endpoint") or account.get("endpoint") or ""
90
+ )
91
+ name = str(account.get("name") or "").strip()
92
+ resource_group = str(account.get("resourceGroup") or "").strip()
93
+ if not name or not resource_group:
94
+ continue
95
+ if account_endpoint.rstrip("/").lower() == target:
96
+ return name, resource_group
97
+ if target_host.endswith(".services.ai.azure.com"):
98
+ candidates = {name.lower()}
99
+ custom_subdomain = str(
100
+ properties.get("customSubDomainName") or ""
101
+ ).strip().lower()
102
+ if custom_subdomain:
103
+ candidates.add(custom_subdomain)
104
+ properties_host = endpoint_host(
105
+ str(properties.get("endpoint") or "")
106
+ )
107
+ if properties_host:
108
+ candidates.add(host_label(properties_host))
109
+ if target_label and target_label in candidates:
110
+ return name, resource_group
111
+ return None
112
+
113
+
114
+ def _live_foundry_deployments(profile: Profile) -> dict[str, str] | None:
115
+ if not profile.endpoint:
116
+ console.print(
117
+ "[yellow]warn:[/yellow] live deployments unavailable "
118
+ "(no endpoint is saved in this profile)."
119
+ )
120
+ return None
121
+ az = shutil.which("az")
122
+ if not az:
123
+ console.print(
124
+ "[yellow]warn:[/yellow] live deployments unavailable "
125
+ "(Azure CLI `az` was not found)."
126
+ )
127
+ return None
128
+ resolved = _resolve_foundry_account(profile.endpoint)
129
+ if not resolved:
130
+ console.print(
131
+ "[yellow]warn:[/yellow] live deployments unavailable "
132
+ "(the Microsoft Foundry resource could not be resolved from the endpoint)."
133
+ )
134
+ return None
135
+ account_name, resource_group = resolved
136
+ result = subprocess.run(
137
+ [
138
+ az,
139
+ "cognitiveservices",
140
+ "account",
141
+ "deployment",
142
+ "list",
143
+ "--resource-group",
144
+ resource_group,
145
+ "--name",
146
+ account_name,
147
+ "--output",
148
+ "json",
149
+ ],
150
+ capture_output=True,
151
+ text=True,
152
+ )
153
+ if result.returncode != 0:
154
+ console.print(
155
+ "[yellow]warn:[/yellow] live deployments unavailable "
156
+ f"(Azure CLI returned: {(result.stderr or 'unknown error').strip()})."
157
+ )
158
+ return None
159
+ try:
160
+ payload = json.loads(result.stdout or "[]")
161
+ except json.JSONDecodeError as exc:
162
+ raise CuCliError(
163
+ "Azure CLI returned invalid JSON while listing Foundry deployments."
164
+ ) from exc
165
+ rows: dict[str, str] = {}
166
+ for item in payload:
167
+ if not isinstance(item, dict):
168
+ continue
169
+ deployment_name = str(item.get("name") or "").strip()
170
+ if not deployment_name:
171
+ continue
172
+ properties_value = item.get("properties")
173
+ properties: dict[str, Any] = (
174
+ properties_value if isinstance(properties_value, dict) else {}
175
+ )
176
+ model_value = properties.get("model")
177
+ model: dict[str, Any] = model_value if isinstance(model_value, dict) else {}
178
+ sku_value = item.get("sku")
179
+ sku: dict[str, Any] = sku_value if isinstance(sku_value, dict) else {}
180
+ rows[f"deployment={deployment_name}"] = (
181
+ f"model={model.get('name') or '?'}, version={model.get('version') or '?'}, "
182
+ f"sku={sku.get('name') or '?'}, capacity={sku.get('capacity') or '?'}"
183
+ )
184
+ return dict(sorted(rows.items()))
185
+
186
+
187
+ @click.group(
188
+ "profile",
189
+ help="Manage local CU CLI settings for Microsoft Foundry resources.",
190
+ epilog=common_commands(
191
+ ("cu profile show", "Show the active CU CLI profile."),
192
+ ("cu profile set endpoint URL", "Configure the default CU CLI profile."),
193
+ ("cu profile create dev", "Create a CU CLI profile for another resource."),
194
+ ("cu profile set-active dev", "Select a saved CU CLI profile."),
195
+ ),
196
+ )
197
+ def profile_group() -> None:
198
+ pass
199
+
200
+
201
+ @profile_group.command(
202
+ "_has-values",
203
+ hidden=True,
204
+ epilog=common_commands(("cu profile show --name default", "Show the default profile.")),
205
+ )
206
+ @click.option("--name", "profile_name", default="default")
207
+ def cmd_has_values(profile_name: str) -> None:
208
+ if not ProfileStore.load().get_profile(profile_name):
209
+ raise click.exceptions.Exit(3)
210
+
211
+
212
+ @profile_group.command(
213
+ "show",
214
+ help=PROFILE_SHOW.help,
215
+ epilog=common_commands(
216
+ ("cu profile show", "Show the active CU CLI profile."),
217
+ ("cu profile show --name dev", "Inspect a CU CLI profile without activating it."),
218
+ ("cu profile show --deployments", "Also list live Foundry deployments."),
219
+ ),
220
+ )
221
+ @with_command_arguments(PROFILE_SHOW)
222
+ @CALLING_TIME_OPTION
223
+ @friendly_errors
224
+ def cmd_show(
225
+ profile_name: str | None,
226
+ deployments: bool,
227
+ show_calling_time: bool,
228
+ ) -> None:
229
+ request = _request(
230
+ PROFILE_SHOW,
231
+ {"profile_name": profile_name, "deployments": deployments},
232
+ )
233
+ active_name = ProfileStore.load().get_active_name()
234
+ profile = resolve_identifier(PROFILE_SHOW.operation)(request)
235
+ title = f"CU CLI profile: {profile.profile_name}"
236
+ if profile_name is None:
237
+ title += " (active)"
238
+ else:
239
+ console.print(f"[dim]view only; active CU CLI profile remains:[/dim] {active_name}")
240
+ result_console.print(kv_table(profile.to_public_dict(), title=title))
241
+ if deployments:
242
+ with calling_time(show_calling_time) as timer:
243
+ live = _live_foundry_deployments(profile)
244
+ if live:
245
+ result_console.print(kv_table(live, title="foundry deployments (live)"))
246
+ timer.print()
247
+
248
+
249
+ @profile_group.command(
250
+ "list",
251
+ help=PROFILE_LIST.help,
252
+ epilog=common_commands(("cu profile list", "List profiles and mark the active one.")),
253
+ )
254
+ @friendly_errors
255
+ def cmd_list() -> None:
256
+ request = build_request(PROFILE_LIST, {})
257
+ active, names = resolve_identifier(PROFILE_LIST.operation)(request)
258
+ rows = {name: "(active)" if name == active else "" for name in names}
259
+ result_console.print(kv_table(rows, title="cu profile list"))
260
+
261
+
262
+ @profile_group.command(
263
+ "get",
264
+ help=PROFILE_GET.help,
265
+ epilog=common_commands(
266
+ ("cu profile get endpoint", "Print the active CU CLI profile's endpoint."),
267
+ ),
268
+ )
269
+ @with_command_arguments(PROFILE_GET)
270
+ @friendly_errors
271
+ def cmd_get(
272
+ profile_key: str | None,
273
+ positional_profile_key: str | None,
274
+ profile_name: str | None,
275
+ ) -> None:
276
+ request = _request(PROFILE_GET, locals())
277
+ value = resolve_identifier(PROFILE_GET.operation)(request)
278
+ if request.key == "api_key" and value:
279
+ value = "***redacted***"
280
+ click.echo("" if value is None else str(value))
281
+
282
+
283
+ @profile_group.command(
284
+ "set",
285
+ help=PROFILE_SET.help,
286
+ epilog=common_commands(
287
+ ("cu profile set endpoint URL", "Save the default CU CLI profile endpoint."),
288
+ (
289
+ "cu profile set auth_mode login --name dev",
290
+ "Use Azure login authentication for a named CU CLI profile.",
291
+ ),
292
+ (
293
+ "cu profile set model_deployments.gpt-5.2 DEPLOYMENT",
294
+ "Save one model deployment mapping.",
295
+ ),
296
+ ),
297
+ )
298
+ @with_command_arguments(PROFILE_SET)
299
+ @friendly_errors
300
+ def cmd_set(
301
+ profile_key: str | None,
302
+ positional_profile_key: str | None,
303
+ profile_value: str | None,
304
+ positional_profile_value: str | None,
305
+ profile_name: str | None,
306
+ ) -> None:
307
+ request = _request(PROFILE_SET, locals())
308
+ value = request.value
309
+ if request.key == "endpoint":
310
+ value = normalize_foundry_endpoint(value)
311
+ elif request.key == "api_version":
312
+ value = ensure_supported(value)
313
+ request = type(request)(key=request.key, value=value, name=request.name)
314
+ path = resolve_identifier(PROFILE_SET.operation)(request)
315
+ target = request.name or ProfileStore.load().get_active_name()
316
+ console.print(
317
+ f"[green]ok[/green] saved {request.key} for CU CLI profile '{target}' -> {path}"
318
+ )
319
+ if request.key == "endpoint":
320
+ console.print(
321
+ "[dim]next:[/dim] authenticate if needed, then run "
322
+ "`cu profile sync-defaults` to import Content Understanding defaults."
323
+ )
324
+
325
+
326
+ @profile_group.command(
327
+ "unset",
328
+ help=PROFILE_UNSET.help,
329
+ epilog=common_commands(
330
+ ("cu profile unset api_key", "Remove a saved key and return to login auth."),
331
+ ),
332
+ )
333
+ @with_command_arguments(PROFILE_UNSET)
334
+ @friendly_errors
335
+ def cmd_unset(
336
+ profile_key: str | None,
337
+ positional_profile_key: str | None,
338
+ profile_name: str | None,
339
+ ) -> None:
340
+ request = _request(PROFILE_UNSET, locals())
341
+ path = resolve_identifier(PROFILE_UNSET.operation)(request)
342
+ target = request.name or ProfileStore.load().get_active_name()
343
+ console.print(
344
+ f"[green]ok[/green] unset {request.key} for CU CLI profile '{target}' -> {path}"
345
+ )
346
+
347
+
348
+ @profile_group.command(
349
+ "create",
350
+ help=PROFILE_CREATE.help,
351
+ epilog=common_commands(
352
+ ("cu profile create dev", "Create an empty named profile."),
353
+ ),
354
+ )
355
+ @with_command_arguments(PROFILE_CREATE)
356
+ @friendly_errors
357
+ def cmd_create(
358
+ profile_name: str | None,
359
+ positional_profile_name: str | None,
360
+ ) -> None:
361
+ request = _request(PROFILE_CREATE, locals())
362
+ path = resolve_identifier(PROFILE_CREATE.operation)(request)
363
+ console.print(f"[green]ok[/green] created CU CLI profile '{request.name}' -> {path}")
364
+
365
+
366
+ @profile_group.command(
367
+ "delete",
368
+ help=PROFILE_DELETE.help,
369
+ epilog=common_commands(
370
+ ("cu profile delete dev", "Delete an inactive named profile."),
371
+ ),
372
+ )
373
+ @with_command_arguments(PROFILE_DELETE)
374
+ @friendly_errors
375
+ def cmd_delete(
376
+ profile_name: str | None,
377
+ positional_profile_name: str | None,
378
+ ) -> None:
379
+ request = _request(PROFILE_DELETE, locals())
380
+ path = resolve_identifier(PROFILE_DELETE.operation)(request)
381
+ console.print(f"[green]ok[/green] deleted CU CLI profile '{request.name}' -> {path}")
382
+
383
+
384
+ @profile_group.command(
385
+ "copy",
386
+ help=PROFILE_COPY.help,
387
+ epilog=common_commands(
388
+ ("cu profile copy dev test", "Copy one profile to a new name."),
389
+ (
390
+ "cu profile copy --source dev --destination test",
391
+ "Copy using canonical named selectors.",
392
+ ),
393
+ (
394
+ "cu profile copy --destination test",
395
+ "Copy the active CU CLI profile.",
396
+ ),
397
+ ),
398
+ )
399
+ @with_command_arguments(PROFILE_COPY)
400
+ @friendly_errors
401
+ def cmd_copy(
402
+ source_profile: str | None,
403
+ destination_profile: str | None,
404
+ positional_source_profile: str | None,
405
+ positional_destination_profile: str | None,
406
+ ) -> None:
407
+ request = _request(PROFILE_COPY, locals())
408
+ path, source = resolve_identifier(PROFILE_COPY.operation)(request)
409
+ console.print(
410
+ f"[green]ok[/green] copied CU CLI profile '{source}' -> "
411
+ f"'{request.destination}' -> {path}"
412
+ )
413
+
414
+
415
+ @profile_group.command(
416
+ "rename",
417
+ help=PROFILE_RENAME.help,
418
+ epilog=common_commands(
419
+ ("cu profile rename dev prod", "Rename a saved CU CLI profile."),
420
+ ),
421
+ )
422
+ @with_command_arguments(PROFILE_RENAME)
423
+ @friendly_errors
424
+ def cmd_rename(
425
+ source_profile: str | None,
426
+ destination_profile: str | None,
427
+ positional_source_profile: str | None,
428
+ positional_destination_profile: str | None,
429
+ ) -> None:
430
+ request = _request(PROFILE_RENAME, locals())
431
+ path = resolve_identifier(PROFILE_RENAME.operation)(request)
432
+ console.print(
433
+ f"[green]ok[/green] renamed CU CLI profile '{request.source}' -> "
434
+ f"'{request.destination}' -> {path}"
435
+ )
436
+
437
+
438
+ @profile_group.command(
439
+ "set-active",
440
+ help=PROFILE_SET_ACTIVE.help,
441
+ epilog=common_commands(
442
+ ("cu profile set-active dev", "Select a saved CU CLI profile."),
443
+ ),
444
+ )
445
+ @with_command_arguments(PROFILE_SET_ACTIVE)
446
+ @friendly_errors
447
+ def cmd_set_active(
448
+ profile_name: str | None,
449
+ positional_profile_name: str | None,
450
+ ) -> None:
451
+ request = _request(PROFILE_SET_ACTIVE, locals())
452
+ path = resolve_identifier(PROFILE_SET_ACTIVE.operation)(request)
453
+ console.print(f"[green]ok[/green] active CU CLI profile -> {request.name} -> {path}")
454
+
455
+
456
+ @profile_group.command(
457
+ "sync-defaults",
458
+ help=PROFILE_SYNC_DEFAULTS.help,
459
+ epilog=common_commands(
460
+ (
461
+ "cu profile sync-defaults --name dev",
462
+ "Refresh model mappings using the profile's saved endpoint.",
463
+ ),
464
+ ),
465
+ )
466
+ @with_command_arguments(PROFILE_SYNC_DEFAULTS)
467
+ @click.option("--auth-mode", type=click.Choice(["login", "key"]), default=None)
468
+ @click.option("--api-key", default=None, help="Override the profile API key.")
469
+ @CALLING_TIME_OPTION
470
+ @friendly_errors
471
+ def cmd_sync_defaults(
472
+ profile_name: str | None,
473
+ auth_mode: str | None,
474
+ api_key: str | None,
475
+ show_calling_time: bool,
476
+ ) -> None:
477
+ request = _request(PROFILE_SYNC_DEFAULTS, {"profile_name": profile_name})
478
+ saved = Profile.load_saved(profile_name=request.name)
479
+ if not saved.endpoint:
480
+ raise CuCliError(
481
+ f"no endpoint is saved in CU CLI profile '{saved.profile_name}'.",
482
+ hint="set it with `cu profile set endpoint URL` before synchronizing.",
483
+ )
484
+ effective = Profile.load(profile_name=request.name)
485
+ effective.endpoint = saved.endpoint
486
+ with calling_time(show_calling_time) as timer:
487
+ try:
488
+ defaults = build_client(
489
+ effective,
490
+ api_key_override=api_key,
491
+ auth_mode_override=auth_mode,
492
+ ).get_defaults()
493
+ except HttpResponseError as exc:
494
+ if not is_defaults_not_set(exc):
495
+ raise
496
+ _print_sync_defaults_not_configured(saved)
497
+ models = with_prebuilt_default_mappings(
498
+ extract_model_deployments(defaults)
499
+ )
500
+ if not models:
501
+ _print_sync_defaults_not_configured(saved)
502
+ path, target = resolve_identifier(PROFILE_SYNC_DEFAULTS.operation)(
503
+ request,
504
+ models,
505
+ )
506
+ console.print(
507
+ f"[green]ok[/green] synchronized {len(models)} model mapping(s) "
508
+ f"for CU CLI profile '{target}' -> {path}"
509
+ )
510
+ timer.print()
511
+
512
+
513
+ def _print_sync_defaults_not_configured(profile: Profile) -> None:
514
+ console.print(
515
+ "[yellow]Content Understanding defaults are not configured on this "
516
+ "resource.[/yellow]\n\n"
517
+ f"No changes were made to CU CLI profile '{profile.profile_name}'."
518
+ )
519
+ print_model_free_analyzers()
520
+ print_model_setup_steps(
521
+ profile.endpoint or "",
522
+ profile_name=profile.profile_name,
523
+ heading="To use analyzers that require generative AI:",
524
+ )
525
+ raise Exit(GENERIC_ERROR)
@@ -0,0 +1,120 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT license.
3
+
4
+ """``cu upgrade`` — pip-convention self-update helper.
5
+
6
+ ``cu upgrade --check`` reports whether the installed update provider has a newer
7
+ release without changing anything. ``cu upgrade`` prints the exact ``pip``
8
+ command and, on a TTY, offers to run it. The CLI **never auto-updates** — the
9
+ user is always in control.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import subprocess
16
+ import sys
17
+ from importlib.metadata import PackageNotFoundError, version as _pkg_version
18
+
19
+ import rich_click as click
20
+
21
+ from .. import __version__
22
+ from ..errors import friendly_errors
23
+ from ..output import console
24
+ from ..update_check import fetch_latest_version_detailed, is_newer, upgrade_hint
25
+ from ..update_provider import get_update_provider, pip_install_args
26
+ from ..windows_self_upgrade import is_windows, run_windows_upgrade
27
+
28
+
29
+ @click.command(
30
+ "upgrade",
31
+ help="Check for and install a newer cu-cli release (never automatic).",
32
+ epilog="[bold cyan]Common commands:[/bold cyan]\n\n"
33
+ "[bold green]cu upgrade[/bold green] [bold cyan]--check[/bold cyan]\n\n"
34
+ "[white]\u00a0\u00a0Check for a newer release without installing it.[/white]\n\n"
35
+ "[bold green]cu upgrade[/bold green]\n\n"
36
+ "[white]\u00a0\u00a0Check for a newer release and offer to install it.[/white]",
37
+ )
38
+ @click.option("--check", is_flag=True,
39
+ help="Only report whether a newer version exists; don't install.")
40
+ @click.option("--yes", is_flag=True, help="Run the upgrade without prompting.")
41
+ @friendly_errors
42
+ def cmd_upgrade(check: bool, yes: bool) -> None:
43
+ console.print(f"[bold]current:[/bold] cu-cli {__version__}")
44
+ provider = get_update_provider()
45
+ latest, reason = fetch_latest_version_detailed(use_cache=False)
46
+
47
+ if latest is None:
48
+ if reason == "not_published":
49
+ console.print(f"[yellow]cu-cli is not published to {provider.name} yet.[/yellow]")
50
+ console.print(
51
+ f"[dim]install or update from source:[/dim] {provider.source_install_hint}"
52
+ )
53
+ elif reason == "disabled":
54
+ console.print("[dim]update checks are disabled (CU_NO_UPDATE_CHECK).[/dim]")
55
+ else:
56
+ console.print(
57
+ f"[yellow]could not reach {provider.name} to check for updates.[/yellow]"
58
+ )
59
+ console.print(f"[dim]check your network, or install from source:[/dim] "
60
+ f"{provider.source_install_hint}")
61
+ return
62
+
63
+ if not is_newer(latest):
64
+ console.print(f"[green]up to date[/green] (latest: {latest}).")
65
+ return
66
+
67
+ console.print(upgrade_hint(latest, provider.release_notes_url))
68
+ pip_args = pip_install_args(latest)
69
+ if check:
70
+ # `--check` is report-only; exit 0 so scripts can parse the message.
71
+ return
72
+
73
+ if not yes:
74
+ if not sys.stdin.isatty():
75
+ console.print(f"[dim]run:[/dim] {' '.join(pip_args)}")
76
+ return
77
+ if not click.confirm(f"Upgrade cu-cli {__version__} -> {latest} now?", default=True):
78
+ console.print(f"[dim]skipped. Upgrade later with:[/dim] {' '.join(pip_args)}")
79
+ return
80
+
81
+ console.print(f"[dim]source:[/dim] {provider.name}")
82
+
83
+ if is_windows():
84
+ # On Windows, `cu.exe` holds an exclusive lock on its own executable
85
+ # image while running. `pip install --upgrade` is non-atomic
86
+ # (uninstall then install), so running it in-process here can
87
+ # uninstall the current cu-cli and then fail to replace the locked
88
+ # file, leaving the environment without an importable cu_cli at all
89
+ # (regression). Instead, hand off to a detached helper process that
90
+ # waits for this process to exit before upgrading, and rolls back to
91
+ # the current version automatically if the upgrade fails.
92
+ try:
93
+ core_version = _pkg_version("cu-cli-core")
94
+ except PackageNotFoundError:
95
+ core_version = None
96
+ exit_code, log_path = run_windows_upgrade(
97
+ current_version=__version__,
98
+ core_version=core_version,
99
+ pip_args=pip_args,
100
+ pip_env=provider.pip_environment(),
101
+ )
102
+ console.print(
103
+ "[dim]upgrade will continue after cu exits (Windows cannot replace "
104
+ "its own running executable in-process).[/dim]"
105
+ )
106
+ console.print(f"[dim]progress log:[/dim] {log_path}")
107
+ console.print(
108
+ f"[green]ok[/green] upgrade to {latest} started. "
109
+ f"Run [bold]cu --version[/bold] after a moment to confirm."
110
+ )
111
+ sys.exit(exit_code)
112
+
113
+ console.print(f"[dim]running:[/dim] {' '.join(pip_args)}")
114
+ result = subprocess.run(pip_args, env={**os.environ, **provider.pip_environment()})
115
+ if result.returncode == 0:
116
+ console.print(f"[green]ok[/green] upgraded to {latest}. "
117
+ f"Release notes: {provider.release_notes_url}")
118
+ else:
119
+ console.print("[yellow]pip exited non-zero; upgrade may not have completed.[/yellow]")
120
+ sys.exit(result.returncode)
@@ -0,0 +1,17 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT license.
3
+
4
+ """Click-free, reusable business logic for the CU CLI.
5
+
6
+ Modules under ``cu_cli.core`` contain the real work behind each command: input
7
+ discovery, the concurrent analyze engine, analyzer CRUD, schema authoring,
8
+ service defaults, doctor checks, and infrastructure-generation helpers.
9
+
10
+ The boundary rule: functions here accept an already-built SDK ``client`` and/or
11
+ plain parameters and return data or typed result objects. They never call
12
+ ``console.print``, ``sys.exit``, ``click.confirm``, or ``click.prompt`` — all
13
+ presentation, prompting, and exit-code handling lives in ``cu_cli.commands``.
14
+ This keeps the logic independently testable and reusable from other tools.
15
+ """
16
+
17
+ from __future__ import annotations