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
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
|
+
]
|
cu_cli/core/analyzers.py
ADDED
|
@@ -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()
|
cu_cli/core/defaults.py
ADDED
|
@@ -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}/"
|