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
cu_cli/core/analyze.py ADDED
@@ -0,0 +1,44 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT license.
3
+
4
+ """Compatibility imports for the analysis engine now provided by cu-cli-core."""
5
+
6
+ from cu_cli_core.analysis import (
7
+ CONFIRM_THRESHOLD,
8
+ AnalyzeJob,
9
+ AnalyzeOutcome,
10
+ AnalyzeResponse,
11
+ BatchResult,
12
+ analyze_bytes,
13
+ analyze_bytes_inline,
14
+ analyze_bytes_inline_with_usage,
15
+ analyze_bytes_with_usage,
16
+ analyze_many,
17
+ analyze_one,
18
+ analyze_one_inline,
19
+ analyze_one_inline_with_usage,
20
+ analyze_one_with_usage,
21
+ dedupe_same_file,
22
+ disambiguate_collisions,
23
+ plan_jobs,
24
+ )
25
+
26
+ __all__ = [
27
+ "CONFIRM_THRESHOLD",
28
+ "AnalyzeJob",
29
+ "AnalyzeOutcome",
30
+ "AnalyzeResponse",
31
+ "BatchResult",
32
+ "analyze_bytes",
33
+ "analyze_bytes_inline",
34
+ "analyze_bytes_inline_with_usage",
35
+ "analyze_bytes_with_usage",
36
+ "analyze_many",
37
+ "analyze_one",
38
+ "analyze_one_inline",
39
+ "analyze_one_inline_with_usage",
40
+ "analyze_one_with_usage",
41
+ "dedupe_same_file",
42
+ "disambiguate_collisions",
43
+ "plan_jobs",
44
+ ]
@@ -0,0 +1,30 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT license.
3
+
4
+ """Compatibility imports for analyzer operations now provided by cu-cli-core."""
5
+
6
+ from cu_cli_core.operations.analyzer_copy import (
7
+ collect_custom_dependencies,
8
+ copy_analyzer,
9
+ get_copy_source_analyzer,
10
+ preflight_dependencies_on_target,
11
+ )
12
+ from cu_cli_core.operations.analyzers import (
13
+ analyzer_kind,
14
+ create_analyzer,
15
+ delete_analyzer,
16
+ get_analyzer,
17
+ list_analyzers,
18
+ )
19
+
20
+ __all__ = [
21
+ "analyzer_kind",
22
+ "collect_custom_dependencies",
23
+ "copy_analyzer",
24
+ "create_analyzer",
25
+ "delete_analyzer",
26
+ "get_analyzer",
27
+ "get_copy_source_analyzer",
28
+ "list_analyzers",
29
+ "preflight_dependencies_on_target",
30
+ ]
@@ -0,0 +1,486 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT license.
3
+
4
+ """Azure resource resolver for ``cu analyzer copy`` (and future cross-resource commands).
5
+
6
+ Turns a user-supplied ``--from`` / ``--to`` selector (a Foundry endpoint URL, a
7
+ Cognitive Services account name, or a full ARM ID) into a canonical
8
+ :class:`ResolvedResource` — the tuple of ARM ID, region, and CU endpoint that
9
+ the data-plane copy orchestration needs.
10
+
11
+ Resolution rules:
12
+
13
+ * URL → parse hostname → discover the matching Microsoft.CognitiveServices
14
+ account in the explicitly selected or active Azure CLI subscription.
15
+ * Name → search Microsoft.CognitiveServices/accounts in that one subscription;
16
+ an optional ``resource_group`` argument narrows the search further.
17
+ * ARM ID → parse the ``/subscriptions/.../accounts/<name>`` shape and verify
18
+ the account exists via management-plane ``get``.
19
+ * Zero matches → raise :class:`CuCliError` with a hint requesting more scope.
20
+ * Multiple matches → raise :class:`CuCliError` listing candidates; **never
21
+ guess.**
22
+ * Validate CU support — only ``AIServices`` (Foundry) or ``ContentUnderstanding``
23
+ kinds pass through. Non-CU accounts fail with a clear ``metadata mismatch``
24
+ error.
25
+ * Use the signed-in Azure identity (:class:`DefaultAzureCredential`); do **not**
26
+ retrieve account keys.
27
+
28
+ Nothing in this module reads or writes CU profiles.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ from dataclasses import dataclass
34
+ import shutil
35
+ import subprocess
36
+ from typing import TYPE_CHECKING, List, Optional
37
+ from urllib.parse import urlparse
38
+
39
+ from ..errors import CuCliError
40
+
41
+ if TYPE_CHECKING: # pragma: no cover - typing only
42
+ from azure.core.credentials import TokenCredential
43
+
44
+
45
+ # ARM ID pattern (case-insensitive, but Azure canonicalizes to lowercase
46
+ # provider names; we parse loosely).
47
+ _ARM_ID_PREFIX = "/subscriptions/"
48
+
49
+ # Kinds that expose Content Understanding data plane. As of 2026-08-24 CU is
50
+ # accessed via AIServices (Foundry) accounts; ContentUnderstanding is the
51
+ # legacy kind name still present in some resources.
52
+ _CU_KINDS = frozenset({"aiservices", "contentunderstanding"})
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class ResolvedResource:
57
+ """Canonical (ARM ID, region, endpoint) tuple for a CU-capable account.
58
+
59
+ :param arm_id: Full canonical ARM ID
60
+ (``/subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.CognitiveServices/accounts/<name>``).
61
+ :param region: Azure region short name (for example ``eastus``).
62
+ :param endpoint: CU / Foundry service endpoint
63
+ (typically ``https://<name>.services.ai.azure.com/`` or a regional
64
+ ``services.azure.com`` variant returned by ARM).
65
+ :param subscription_id: Subscription containing the account (parsed from
66
+ ``arm_id`` for convenience).
67
+ :param resource_group: Resource group containing the account.
68
+ :param account_name: Bare account name.
69
+ """
70
+
71
+ arm_id: str
72
+ region: str
73
+ endpoint: str
74
+ subscription_id: str
75
+ resource_group: str
76
+ account_name: str
77
+
78
+ def display(self) -> str:
79
+ """Short human-readable label suitable for progress lines and ``--info``."""
80
+ return f"{self.account_name} ({self.region}, sub {self.subscription_id[:8]}…)"
81
+
82
+
83
+ # --- Selector classification -------------------------------------------------
84
+
85
+
86
+ def classify_selector(selector: str) -> str:
87
+ """Classify a raw ``--from`` / ``--to`` value.
88
+
89
+ Returns one of ``"arm-id"``, ``"url"``, ``"name"``. This is a cheap
90
+ string-level classifier — it never touches the network.
91
+ """
92
+ s = selector.strip()
93
+ if not s:
94
+ raise CuCliError("empty resource selector.", hint="pass a Foundry endpoint URL, "
95
+ "an account name, or a full ARM ID.")
96
+ normalized = s.lower()
97
+ if normalized.startswith(_ARM_ID_PREFIX):
98
+ return "arm-id"
99
+ if normalized.startswith(("http://", "https://")):
100
+ return "url"
101
+ if "/" in s or " " in s:
102
+ # Ambiguous: not a URL scheme, not an ARM ID, but contains chars that
103
+ # aren't legal in a CS account name. Fail fast rather than treating as
104
+ # a name and confusing the downstream discovery message.
105
+ raise CuCliError(
106
+ f"cannot classify resource selector '{s}'.",
107
+ hint="pass a Foundry endpoint URL (https://<host>/), a bare Cognitive "
108
+ "Services account name, or a full /subscriptions/... ARM ID.",
109
+ )
110
+ return "name"
111
+
112
+
113
+ # --- ARM ID parsing ----------------------------------------------------------
114
+
115
+
116
+ def parse_arm_id(arm_id: str) -> tuple[str, str, str]:
117
+ """Return ``(subscription_id, resource_group, account_name)`` from an ARM ID.
118
+
119
+ Raises :class:`CuCliError` if the shape isn't
120
+ ``/subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.CognitiveServices/accounts/<name>``.
121
+ """
122
+ parts = arm_id.strip("/").split("/")
123
+ # Expect: subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.CognitiveServices/accounts/<name>
124
+ if (len(parts) != 8
125
+ or parts[0].lower() != "subscriptions"
126
+ or parts[2].lower() != "resourcegroups"
127
+ or parts[4].lower() != "providers"
128
+ or parts[5].lower() != "microsoft.cognitiveservices"
129
+ or parts[6].lower() != "accounts"):
130
+ raise CuCliError(
131
+ f"'{arm_id}' is not a Microsoft.CognitiveServices/accounts ARM ID.",
132
+ hint="expected /subscriptions/<sub>/resourceGroups/<rg>/providers/"
133
+ "Microsoft.CognitiveServices/accounts/<name>.",
134
+ )
135
+ return parts[1], parts[3], parts[7]
136
+
137
+
138
+ def _canonical_arm_id(subscription_id: str, resource_group: str, account_name: str) -> str:
139
+ """Build a canonical ARM ID (used for equality checks between resolved sides)."""
140
+ return (
141
+ f"/subscriptions/{subscription_id}"
142
+ f"/resourceGroups/{resource_group}"
143
+ f"/providers/Microsoft.CognitiveServices/accounts/{account_name}"
144
+ )
145
+
146
+
147
+ # --- Kind validation ---------------------------------------------------------
148
+
149
+
150
+ def _is_cu_capable(kind: Optional[str]) -> bool:
151
+ """Return True if the account's ``kind`` supports Content Understanding."""
152
+ if not kind:
153
+ return False
154
+ return kind.lower() in _CU_KINDS
155
+
156
+
157
+ # --- Endpoint derivation -----------------------------------------------------
158
+
159
+
160
+ def _account_endpoint(account: object) -> str:
161
+ """Return the CU/Foundry endpoint for an account.
162
+
163
+ Prefers ``properties.endpoints["Content Understanding"]`` when the ARM
164
+ response includes it; falls back to ``properties.endpoint`` (older shape);
165
+ falls back to the AIServices default ``https://<name>.services.ai.azure.com/``.
166
+ """
167
+ props = getattr(account, "properties", None)
168
+ name = getattr(account, "name", None) or ""
169
+ if props is not None:
170
+ endpoints = getattr(props, "endpoints", None)
171
+ if endpoints and isinstance(endpoints, dict):
172
+ for key in ("Content Understanding", "ContentUnderstanding", "OpenAI", "Cognitive Services"):
173
+ if key in endpoints and endpoints[key]:
174
+ return endpoints[key].rstrip("/") + "/"
175
+ ep = getattr(props, "endpoint", None)
176
+ if ep:
177
+ return ep.rstrip("/") + "/"
178
+ return f"https://{name}.services.ai.azure.com/"
179
+
180
+
181
+ def _hostname_of(url: str) -> str:
182
+ """Return the lowercase hostname of a URL, without port."""
183
+ parsed = urlparse(url if "://" in url else f"https://{url}")
184
+ host = (parsed.hostname or "").lower()
185
+ return host
186
+
187
+
188
+ # --- Public resolver ---------------------------------------------------------
189
+
190
+
191
+ def resolve_resource(
192
+ selector: str,
193
+ *,
194
+ subscription_id: Optional[str] = None,
195
+ resource_group: Optional[str] = None,
196
+ credential: Optional["TokenCredential"] = None,
197
+ ) -> ResolvedResource:
198
+ """Resolve a ``--from``/``--to`` selector to a canonical :class:`ResolvedResource`.
199
+
200
+ The classifier dispatches to one of the three private helpers. Discovery
201
+ uses :class:`DefaultAzureCredential` by default. The optional
202
+ ``subscription_id`` scopes URL/name discovery. When omitted, the active
203
+ Azure CLI subscription is used. ARM IDs carry their own subscription.
204
+ Discovery never fans out across subscriptions.
205
+ """
206
+ # Lazy imports so `cu` starts fast and users who never call `analyzer copy`
207
+ # aren't forced to have azure-mgmt-* installed at runtime.
208
+ from azure.identity import DefaultAzureCredential
209
+
210
+ cred = credential or DefaultAzureCredential()
211
+ kind = classify_selector(selector)
212
+ if kind == "arm-id":
213
+ return _resolve_arm_id(selector, cred)
214
+ effective_subscription = subscription_id or current_azure_cli_subscription_id()
215
+ if kind == "url":
216
+ return _resolve_url(selector, cred, subscription_id=effective_subscription,
217
+ resource_group=resource_group)
218
+ return _resolve_name(selector, cred, subscription_id=effective_subscription,
219
+ resource_group=resource_group)
220
+
221
+
222
+ def current_azure_cli_subscription_id() -> str:
223
+ """Return the active ``az`` subscription ID or fail with actionable guidance."""
224
+ az = shutil.which("az")
225
+ if not az:
226
+ raise CuCliError(
227
+ "Azure CLI (`az`) is required to determine the active subscription.",
228
+ hint="install Azure CLI and run `az login`, or pass an explicit "
229
+ "`--source-subscription` / `--destination-subscription`.",
230
+ )
231
+ try:
232
+ result = subprocess.run(
233
+ [
234
+ az,
235
+ "account",
236
+ "show",
237
+ "--query",
238
+ "id",
239
+ "-o",
240
+ "tsv",
241
+ "--only-show-errors",
242
+ ],
243
+ capture_output=True,
244
+ text=True,
245
+ timeout=15,
246
+ )
247
+ except subprocess.TimeoutExpired as exc:
248
+ raise CuCliError(
249
+ "timed out while reading the active Azure CLI subscription.",
250
+ hint="run `az account show` to verify Azure CLI, or pass an explicit "
251
+ "`--source-subscription` / `--destination-subscription`.",
252
+ ) from exc
253
+ if result.returncode != 0:
254
+ detail = (result.stderr or result.stdout).strip()
255
+ raise CuCliError(
256
+ "no active Azure CLI subscription is available.",
257
+ hint=(detail + " " if detail else "")
258
+ + "Run `az login` and `az account set --subscription <id>`, "
259
+ "or pass an explicit `--source-subscription` / "
260
+ "`--destination-subscription`.",
261
+ )
262
+ subscription_id = result.stdout.strip()
263
+ if not subscription_id:
264
+ raise CuCliError(
265
+ "`az account show` returned an empty subscription ID.",
266
+ hint="run `az account set --subscription <id>`, or pass an explicit "
267
+ "`--source-subscription` / `--destination-subscription`.",
268
+ )
269
+ return subscription_id
270
+
271
+
272
+ # --- ARM ID resolution -------------------------------------------------------
273
+
274
+
275
+ def _resolve_arm_id(arm_id: str, credential: "TokenCredential") -> ResolvedResource:
276
+ from azure.core.exceptions import HttpResponseError, ResourceNotFoundError
277
+ from azure.mgmt.cognitiveservices import CognitiveServicesManagementClient
278
+
279
+ sub, rg, name = parse_arm_id(arm_id)
280
+ client = CognitiveServicesManagementClient(credential, sub)
281
+ try:
282
+ account = client.accounts.get(rg, name)
283
+ except ResourceNotFoundError as exc:
284
+ raise CuCliError(
285
+ f"account '{name}' was not found in resource group '{rg}' (sub {sub}).",
286
+ hint="verify the ARM ID; run `az cognitiveservices account show "
287
+ f"--name {name} --resource-group {rg} --subscription {sub}` to check "
288
+ "access with the signed-in identity.",
289
+ ) from exc
290
+ except HttpResponseError as exc:
291
+ _reraise_login_or_rbac(exc, hint_scope=f"subscription {sub}")
292
+ raise
293
+ return _account_to_resolved(account)
294
+
295
+
296
+ # --- URL resolution ----------------------------------------------------------
297
+
298
+
299
+ def _resolve_url(
300
+ url: str,
301
+ credential: "TokenCredential",
302
+ *,
303
+ subscription_id: str,
304
+ resource_group: Optional[str] = None,
305
+ ) -> ResolvedResource:
306
+ """Resolve a Foundry endpoint URL by matching its hostname to a discovered account."""
307
+ hostname = _hostname_of(url)
308
+ if not hostname:
309
+ raise CuCliError(
310
+ f"could not parse hostname from '{url}'.",
311
+ hint="pass a Foundry endpoint URL like https://<account>.services.ai.azure.com/.",
312
+ )
313
+ # AIServices/CU endpoints follow one of these hostname shapes:
314
+ # <account>.services.ai.azure.com
315
+ # <account>.cognitiveservices.azure.com
316
+ # We use the leading label as the candidate account name; the full match
317
+ # comes from comparing the account's actual endpoint(s) against the URL.
318
+ candidate_name = hostname.split(".", 1)[0]
319
+ matches = _discover_accounts(
320
+ credential,
321
+ subscription_id=subscription_id,
322
+ resource_group=resource_group,
323
+ name_hint=candidate_name,
324
+ )
325
+ hostname_matches: List[object] = []
326
+ for account in matches:
327
+ try:
328
+ endpoint = _account_endpoint(account)
329
+ if _hostname_of(endpoint) == hostname:
330
+ hostname_matches.append(account)
331
+ except Exception: # pragma: no cover - be permissive during scan
332
+ continue
333
+ # Never fall back from a URL to a merely name-matched account. A typo such
334
+ # as https://acct.evil.example/ must not silently select the Azure account
335
+ # named ``acct``. ``_account_endpoint`` already synthesizes the standard
336
+ # AIServices hostname when endpoint metadata is absent, so valid standard
337
+ # URLs still match without this unsafe fallback.
338
+ _fail_on_ambiguous(hostname_matches, selector=url, hint_extra=(
339
+ "try `--source-subscription <sub>` and `--source-resource-group <rg>` "
340
+ "(or use the full ARM ID) to disambiguate."))
341
+ return _account_to_resolved(hostname_matches[0])
342
+
343
+
344
+ # --- Name resolution ---------------------------------------------------------
345
+
346
+
347
+ def _resolve_name(
348
+ name: str,
349
+ credential: "TokenCredential",
350
+ *,
351
+ subscription_id: str,
352
+ resource_group: Optional[str] = None,
353
+ ) -> ResolvedResource:
354
+ matches = _discover_accounts(
355
+ credential,
356
+ subscription_id=subscription_id,
357
+ resource_group=resource_group,
358
+ name_hint=name,
359
+ )
360
+ # For name resolution we require an *exact* case-insensitive match on
361
+ # account name. Substring candidates are useful only to reduce discovery
362
+ # work; accepting one here would let a typo like ``contoso`` select the
363
+ # sole account ``contoso-prod``.
364
+ exact = [a for a in matches if (getattr(a, "name", "") or "").lower() == name.lower()]
365
+ _fail_on_ambiguous(exact, selector=name, hint_extra=(
366
+ "try `--source-subscription <sub>` and `--source-resource-group <rg>` "
367
+ "to narrow the search, or use the full ARM ID."))
368
+ return _account_to_resolved(exact[0])
369
+
370
+
371
+ # --- Shared discovery + error surfacing --------------------------------------
372
+
373
+
374
+ def _discover_accounts(
375
+ credential: "TokenCredential",
376
+ *,
377
+ subscription_id: str,
378
+ resource_group: Optional[str],
379
+ name_hint: Optional[str] = None,
380
+ ) -> List[object]:
381
+ """Discover Microsoft.CognitiveServices accounts in one subscription.
382
+
383
+ When provided, ``name_hint`` filters account names case-insensitively as a
384
+ substring or exact match. Account ``kind`` is deliberately retained until
385
+ after selector disambiguation so selecting an existing incompatible account
386
+ produces the specific kind/metadata error from ``_account_to_resolved``
387
+ instead of a misleading "no account matched" result.
388
+ """
389
+ from azure.core.exceptions import HttpResponseError
390
+ from azure.mgmt.cognitiveservices import CognitiveServicesManagementClient
391
+ accounts: List[object] = []
392
+ try:
393
+ client = CognitiveServicesManagementClient(credential, subscription_id)
394
+ if resource_group:
395
+ iterator = client.accounts.list_by_resource_group(resource_group)
396
+ else:
397
+ iterator = client.accounts.list()
398
+ for account in iterator:
399
+ nm = (getattr(account, "name", "") or "").lower()
400
+ if name_hint and name_hint.lower() not in nm:
401
+ continue
402
+ accounts.append(account)
403
+ except HttpResponseError as exc:
404
+ _reraise_login_or_rbac(exc, hint_scope=f"subscription {subscription_id}")
405
+ raise
406
+ return accounts
407
+
408
+
409
+ def _fail_on_ambiguous(accounts: List[object], *, selector: str, hint_extra: str) -> None:
410
+ if not accounts:
411
+ raise CuCliError(
412
+ f"no Content Understanding-capable account matched '{selector}'.",
413
+ hint=hint_extra + " Also check you're logged in with `az login` and the "
414
+ "signed-in identity has access to the target subscription.",
415
+ )
416
+ if len(accounts) > 1:
417
+ candidates = "\n".join(
418
+ f" {getattr(a, 'name', '?')} {getattr(a, 'id', '?')}"
419
+ for a in accounts[:10]
420
+ )
421
+ more = f"\n ...+{len(accounts) - 10} more" if len(accounts) > 10 else ""
422
+ raise CuCliError(
423
+ f"multiple accounts matched '{selector}' — refusing to guess. "
424
+ f"Candidates:\n{candidates}{more}",
425
+ hint=hint_extra,
426
+ )
427
+
428
+
429
+ def _reraise_login_or_rbac(exc: BaseException, *, hint_scope: str) -> None:
430
+ """Translate common discovery failures to actionable :class:`CuCliError`."""
431
+ status = getattr(exc, "status_code", None)
432
+ msg = str(exc)
433
+ if status == 404 and "SubscriptionNotFound" in msg:
434
+ raise CuCliError(
435
+ f"Azure {hint_scope} was not found or is not accessible.",
436
+ hint="verify the subscription ID or name and the signed-in identity's access.",
437
+ ) from exc
438
+ if status in (401,) or "AuthenticationFailed" in msg or "unauthorized" in msg.lower():
439
+ raise CuCliError(
440
+ f"Azure login is not active (or the token is invalid) for {hint_scope}.",
441
+ hint="run `az login` and try again.",
442
+ ) from exc
443
+ if status in (403,) or "Forbidden" in msg or "AuthorizationFailed" in msg:
444
+ raise CuCliError(
445
+ f"signed-in identity is not authorized for {hint_scope}.",
446
+ hint="verify the identity has *Reader* on the subscription/resource group "
447
+ "for discovery, plus *Cognitive Services User* on both source and "
448
+ "target CU accounts for the copy itself.",
449
+ ) from exc
450
+
451
+
452
+ def _account_to_resolved(account: object) -> ResolvedResource:
453
+ """Adapt a management-plane ``Account`` object to :class:`ResolvedResource`."""
454
+ arm_id = getattr(account, "id", "") or ""
455
+ name = getattr(account, "name", "") or ""
456
+ region = getattr(account, "location", "") or ""
457
+ kind = getattr(account, "kind", "") or ""
458
+ if not _is_cu_capable(kind):
459
+ raise CuCliError(
460
+ f"account '{name}' has kind '{kind}', which is not Content Understanding-capable.",
461
+ hint="pass a Foundry (AIServices) or ContentUnderstanding account.",
462
+ )
463
+ # Parse ARM ID so downstream compares don't drift on casing.
464
+ try:
465
+ sub, rg, acct = parse_arm_id(arm_id)
466
+ except CuCliError as exc: # ARM should always be well-formed; be defensive
467
+ raise CuCliError(
468
+ f"account '{name}' returned an unexpected ARM ID '{arm_id}'.",
469
+ hint="report this to the CU CLI maintainers.",
470
+ ) from exc
471
+ return ResolvedResource(
472
+ arm_id=_canonical_arm_id(sub, rg, acct),
473
+ region=region,
474
+ endpoint=_account_endpoint(account),
475
+ subscription_id=sub,
476
+ resource_group=rg,
477
+ account_name=acct,
478
+ )
479
+
480
+
481
+ # --- Canonical equality ------------------------------------------------------
482
+
483
+
484
+ def resources_equal(a: ResolvedResource, b: ResolvedResource) -> bool:
485
+ """Return True when two resolved resources refer to the same Azure resource."""
486
+ return a.arm_id.lower() == b.arm_id.lower()
@@ -0,0 +1,18 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT license.
3
+
4
+ """Compatibility imports for defaults operations now provided by cu-cli-core."""
5
+
6
+ from cu_cli_core.defaults import (
7
+ apply_defaults,
8
+ extract_model_deployments,
9
+ is_defaults_not_set,
10
+ parse_model_kv,
11
+ )
12
+
13
+ __all__ = [
14
+ "apply_defaults",
15
+ "extract_model_deployments",
16
+ "is_defaults_not_set",
17
+ "parse_model_kv",
18
+ ]
cu_cli/core/doctor.py ADDED
@@ -0,0 +1,42 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT license.
3
+
4
+ """Doctor checks (Click-free): model-requirement analysis.
5
+
6
+ The ``cu doctor`` command is an interactive diagnostic whose output is printed
7
+ progressively; the reusable, testable pieces are the pure requirement analysis
8
+ here plus :func:`cu_cli.core.defaults.is_defaults_not_set`.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from cu_cli_core.defaults import PREBUILT_COMPLETION_KEY, PREBUILT_COMPLETION_MINI_KEY
14
+ from .defaults import is_defaults_not_set # re-exported for callers
15
+
16
+ __all__ = ["missing_requirements", "is_defaults_not_set"]
17
+
18
+
19
+ def missing_requirements(mapped: dict) -> list[str]:
20
+ """Model requirements not satisfied by *mapped* (model-name -> deployment).
21
+
22
+ CU needs the embedding model plus ANY one supported completion model,
23
+ so a single completion model suffices.
24
+ """
25
+ missing: list[str] = []
26
+ has_embedding = bool(mapped.get("prebuilt-analyzer-embedding")) or any(
27
+ name.startswith("text-embedding-") for name in mapped
28
+ )
29
+ if not has_embedding:
30
+ missing.append("an embeddings model (for example text-embedding-3-large)")
31
+ has_completion = bool(mapped.get(PREBUILT_COMPLETION_KEY)) or any(
32
+ not name.startswith(("prebuilt-analyzer-", "text-embedding-"))
33
+ for name in mapped
34
+ )
35
+ if not has_completion:
36
+ missing.append("a supported large language model (LLM) deployment")
37
+ if not mapped.get(PREBUILT_COMPLETION_MINI_KEY):
38
+ missing.append(
39
+ "Content Understanding's prebuilt analyzer mapping for the selected LLM "
40
+ "(created when defaults are configured)"
41
+ )
42
+ return missing
cu_cli/core/foundry.py ADDED
@@ -0,0 +1,68 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT license.
3
+
4
+ """Foundry endpoint helpers (Click-free, pure).
5
+
6
+ Small URL helpers shared by ``cu infra generate`` and ``cu profile`` for endpoint
7
+ normalization and host-label matching. The az-CLI account-resolution functions
8
+ that shell out live in the command modules (they are coupled to
9
+ process/environment state); these pure helpers are the reusable core.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from urllib.parse import urlparse
15
+
16
+ from ..errors import CuCliError
17
+
18
+
19
+ def endpoint_host(value: str) -> str:
20
+ """Return the lowercased host portion of *value* (no scheme, no trailing /)."""
21
+ parsed = urlparse(value)
22
+ return (parsed.netloc or parsed.path).strip().lower().rstrip("/")
23
+
24
+
25
+ def host_label(host: str) -> str:
26
+ """Return the first dotted label of *host* (e.g. ``x`` from ``x.foo.com``)."""
27
+ return host.split(".", 1)[0].strip().lower()
28
+
29
+
30
+ def normalize_foundry_endpoint(raw: str, *, auth_mode: str | None = None) -> str:
31
+ """Validate and canonicalize an endpoint to ``https://<host>/``.
32
+
33
+ Accept any valid HTTPS hostname instead of requiring a specific domain
34
+ suffix, so custom domains are supported.
35
+ """
36
+ candidate = raw.strip()
37
+ if not candidate:
38
+ raise CuCliError("foundry endpoint cannot be empty.")
39
+ parsed = urlparse(candidate if "://" in candidate else f"https://{candidate}")
40
+ if parsed.scheme and parsed.scheme.lower() != "https":
41
+ if auth_mode == "login":
42
+ raise CuCliError(
43
+ "authentication mode 'login' requires an HTTPS endpoint.",
44
+ hint="update the endpoint to use https://, or select a different endpoint.",
45
+ )
46
+ raise CuCliError(
47
+ "foundry endpoint must use https.",
48
+ hint="example: https://<account>.services.ai.azure.com/",
49
+ )
50
+ if parsed.username is not None or parsed.password is not None:
51
+ raise CuCliError(
52
+ "foundry endpoint must not include username or password information.",
53
+ hint="provide only the service URL, for example: "
54
+ "https://<account>.services.ai.azure.com/",
55
+ )
56
+ hostname = parsed.hostname
57
+ if hostname is None:
58
+ host = ""
59
+ else:
60
+ host = hostname.lower()
61
+ if parsed.port is not None:
62
+ host = f"{host}:{parsed.port}"
63
+ if not host or "." not in host:
64
+ raise CuCliError(
65
+ f"invalid foundry endpoint '{raw}'.",
66
+ hint="example: https://<account>.services.ai.azure.com/",
67
+ )
68
+ return f"https://{host}/"