cu-cli-core 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_core/__init__.py +12 -0
- cu_cli_core/analysis.py +374 -0
- cu_cli_core/client.py +39 -0
- cu_cli_core/command_spec.py +1099 -0
- cu_cli_core/contracts.py +404 -0
- cu_cli_core/defaults.py +150 -0
- cu_cli_core/environment.py +102 -0
- cu_cli_core/errors.py +77 -0
- cu_cli_core/input_planning.py +322 -0
- cu_cli_core/operations/__init__.py +4 -0
- cu_cli_core/operations/analysis.py +294 -0
- cu_cli_core/operations/analyzer_copy.py +327 -0
- cu_cli_core/operations/analyzers.py +85 -0
- cu_cli_core/operations/profiles.py +146 -0
- cu_cli_core/operations/schema.py +48 -0
- cu_cli_core/operations/validation.py +41 -0
- cu_cli_core/profiles.py +604 -0
- cu_cli_core/py.typed +0 -0
- cu_cli_core/resources/openapi/2025-11-01/ContentUnderstanding.json +3643 -0
- cu_cli_core/resources/openapi/2026-06-01-preview/ContentUnderstanding.json +4050 -0
- cu_cli_core/schema.py +389 -0
- cu_cli_core/schema_validation.py +312 -0
- cu_cli_core/serialization.py +75 -0
- cu_cli_core/service_options.py +71 -0
- cu_cli_core/spec_validation.py +141 -0
- cu_cli_core-0.1.0b1.dist-info/METADATA +26 -0
- cu_cli_core-0.1.0b1.dist-info/RECORD +29 -0
- cu_cli_core-0.1.0b1.dist-info/WHEEL +5 -0
- cu_cli_core-0.1.0b1.dist-info/top_level.txt +1 -0
cu_cli_core/profiles.py
ADDED
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation.
|
|
2
|
+
# Licensed under the MIT license.
|
|
3
|
+
|
|
4
|
+
"""Shared CU profile storage and resolution over Azure CLI configuration."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import configparser
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
import copy
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
import hashlib
|
|
13
|
+
from io import StringIO
|
|
14
|
+
import os
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
import re
|
|
17
|
+
import sys
|
|
18
|
+
import tempfile
|
|
19
|
+
from typing import Iterator, Mapping
|
|
20
|
+
|
|
21
|
+
from .errors import ConflictError, LocalIOError, NotFoundError, ValidationError
|
|
22
|
+
from .service_options import DEFAULT_API_VERSION
|
|
23
|
+
|
|
24
|
+
AZURE_CONFIG_DIR_ENV = "AZURE_CONFIG_DIR"
|
|
25
|
+
CU_SECTION = "cu"
|
|
26
|
+
DEFAULT_PROFILE_NAME = "default"
|
|
27
|
+
ACTIVE_PROFILE_KEY = "active_profile"
|
|
28
|
+
PROFILE_MARKER = "_created"
|
|
29
|
+
RESERVED_PROFILE_NAMES = frozenset({DEFAULT_PROFILE_NAME, "model_deployments"})
|
|
30
|
+
KNOWN_PROFILE_KEYS = {
|
|
31
|
+
"endpoint",
|
|
32
|
+
"auth_mode",
|
|
33
|
+
"api_key",
|
|
34
|
+
"api_version",
|
|
35
|
+
"default_analyzer",
|
|
36
|
+
}
|
|
37
|
+
MODEL_DEPLOYMENTS_PREFIX = "model_deployments."
|
|
38
|
+
MODEL_ENV_OVERRIDES: Mapping[str, str] = {
|
|
39
|
+
"gpt-5.2": "GPT_5_2_DEPLOYMENT",
|
|
40
|
+
"gpt-4.1": "GPT_4_1_DEPLOYMENT",
|
|
41
|
+
"text-embedding-3-large": "TEXT_EMBEDDING_3_LARGE_DEPLOYMENT",
|
|
42
|
+
}
|
|
43
|
+
PROFILE_NAME_HINT = (
|
|
44
|
+
"use 1-64 ASCII letters or numbers, with hyphens (-) or underscores (_) "
|
|
45
|
+
"only between characters; for example 'dev', 'West_US2', or 'test-01'. "
|
|
46
|
+
"'default' and 'model_deployments' are reserved."
|
|
47
|
+
)
|
|
48
|
+
_PROFILE_NAME_PATTERN = re.compile(
|
|
49
|
+
r"^[A-Za-z0-9](?:[A-Za-z0-9_-]{0,62}[A-Za-z0-9])?$"
|
|
50
|
+
)
|
|
51
|
+
_MODEL_NAME_PATTERN = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$")
|
|
52
|
+
_SECTION_PATTERN = re.compile(
|
|
53
|
+
r"^[ \t]*\[(?P<name>[^\]\r\n]+)\][ \t]*(?:[;#].*)?(?:\r?\n|$)",
|
|
54
|
+
re.MULTILINE,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _CaseSensitiveConfigParser(configparser.ConfigParser):
|
|
59
|
+
def optionxform(self, optionstr: str) -> str:
|
|
60
|
+
return optionstr
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def azure_config_path() -> Path:
|
|
64
|
+
"""Return the active Azure CLI configuration file path."""
|
|
65
|
+
|
|
66
|
+
configured = os.getenv(AZURE_CONFIG_DIR_ENV)
|
|
67
|
+
config_dir = Path(configured).expanduser() if configured else Path.home() / ".azure"
|
|
68
|
+
return config_dir / "config"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def normalize_profile_name(name: str) -> str:
|
|
72
|
+
"""Validate a mutable named profile without changing its case."""
|
|
73
|
+
|
|
74
|
+
if (
|
|
75
|
+
_PROFILE_NAME_PATTERN.fullmatch(name) is None
|
|
76
|
+
or name.casefold() in RESERVED_PROFILE_NAMES
|
|
77
|
+
):
|
|
78
|
+
raise ValidationError(f"invalid profile name '{name}'.", hint=PROFILE_NAME_HINT)
|
|
79
|
+
return name
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def validate_profile_name(name: str) -> str:
|
|
83
|
+
if name == DEFAULT_PROFILE_NAME:
|
|
84
|
+
return name
|
|
85
|
+
return normalize_profile_name(name)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def is_valid_profile_name(name: str, *, allow_default: bool = True) -> bool:
|
|
89
|
+
if _PROFILE_NAME_PATTERN.fullmatch(name) is None:
|
|
90
|
+
return False
|
|
91
|
+
folded = name.casefold()
|
|
92
|
+
if folded == DEFAULT_PROFILE_NAME:
|
|
93
|
+
return allow_default and name == DEFAULT_PROFILE_NAME
|
|
94
|
+
return folded not in RESERVED_PROFILE_NAMES
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def validate_profile_key(key: str) -> str:
|
|
98
|
+
if key.startswith(MODEL_DEPLOYMENTS_PREFIX):
|
|
99
|
+
model = key.removeprefix(MODEL_DEPLOYMENTS_PREFIX)
|
|
100
|
+
if _MODEL_NAME_PATTERN.fullmatch(model) is not None:
|
|
101
|
+
return key
|
|
102
|
+
raise ValidationError(
|
|
103
|
+
f"invalid model deployment key '{key}'.",
|
|
104
|
+
hint=(
|
|
105
|
+
"use model_deployments.<model> with letters, numbers, dots, "
|
|
106
|
+
"hyphens, or underscores; for example model_deployments.gpt-5.2."
|
|
107
|
+
),
|
|
108
|
+
)
|
|
109
|
+
if key not in KNOWN_PROFILE_KEYS:
|
|
110
|
+
known = ", ".join(sorted(KNOWN_PROFILE_KEYS))
|
|
111
|
+
raise ValidationError(
|
|
112
|
+
f"unknown profile key '{key}'.",
|
|
113
|
+
hint=f"known keys: {known}, or model_deployments.<model>.",
|
|
114
|
+
)
|
|
115
|
+
return key
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def validate_profile_value(key: str, value: str) -> str:
|
|
119
|
+
cleaned = value.strip()
|
|
120
|
+
if not cleaned:
|
|
121
|
+
raise ValidationError(f"value for '{key}' must not be empty.")
|
|
122
|
+
if "\n" in cleaned or "\r" in cleaned:
|
|
123
|
+
raise ValidationError(f"value for '{key}' must be a single line.")
|
|
124
|
+
if key == "auth_mode" and cleaned not in {"login", "key"}:
|
|
125
|
+
raise ValidationError("auth_mode must be 'login' or 'key'.")
|
|
126
|
+
return cleaned
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _read_config(path: Path) -> tuple[str, dict[str, str], str | None]:
|
|
130
|
+
try:
|
|
131
|
+
raw = path.read_bytes()
|
|
132
|
+
except FileNotFoundError:
|
|
133
|
+
return "", {}, None
|
|
134
|
+
except OSError as exc:
|
|
135
|
+
raise LocalIOError(
|
|
136
|
+
f"could not read Azure CLI configuration '{path}': {exc}.",
|
|
137
|
+
hint="check the file permissions and try again.",
|
|
138
|
+
) from exc
|
|
139
|
+
try:
|
|
140
|
+
text = raw.decode("utf-8")
|
|
141
|
+
except UnicodeDecodeError as exc:
|
|
142
|
+
raise ValidationError(
|
|
143
|
+
f"Azure CLI configuration '{path}' is not valid UTF-8.",
|
|
144
|
+
hint="fix or move the file before changing CU profiles.",
|
|
145
|
+
) from exc
|
|
146
|
+
|
|
147
|
+
parser = _CaseSensitiveConfigParser(interpolation=None, strict=True)
|
|
148
|
+
try:
|
|
149
|
+
parser.read_string(text)
|
|
150
|
+
except configparser.Error as exc:
|
|
151
|
+
raise ValidationError(
|
|
152
|
+
f"Azure CLI configuration '{path}' is not valid INI: {exc}.",
|
|
153
|
+
hint="fix the configuration before changing CU profiles.",
|
|
154
|
+
) from exc
|
|
155
|
+
values = dict(parser.items(CU_SECTION)) if parser.has_section(CU_SECTION) else {}
|
|
156
|
+
return text, values, hashlib.sha256(raw).hexdigest()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@contextmanager
|
|
160
|
+
def _exclusive_config_lock(path: Path) -> Iterator[None]:
|
|
161
|
+
lock_path = path.with_name(f"{path.name}.lock")
|
|
162
|
+
with open(lock_path, "a+b") as lock_file:
|
|
163
|
+
if sys.platform == "win32":
|
|
164
|
+
import msvcrt
|
|
165
|
+
|
|
166
|
+
lock_file.seek(0, os.SEEK_END)
|
|
167
|
+
if lock_file.tell() == 0:
|
|
168
|
+
lock_file.write(b"\0")
|
|
169
|
+
lock_file.flush()
|
|
170
|
+
lock_file.seek(0)
|
|
171
|
+
msvcrt.locking(lock_file.fileno(), msvcrt.LK_LOCK, 1)
|
|
172
|
+
try:
|
|
173
|
+
yield
|
|
174
|
+
finally:
|
|
175
|
+
lock_file.seek(0)
|
|
176
|
+
msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
|
|
177
|
+
else:
|
|
178
|
+
import fcntl
|
|
179
|
+
|
|
180
|
+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
|
181
|
+
try:
|
|
182
|
+
yield
|
|
183
|
+
finally:
|
|
184
|
+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _render_cu_section(values: Mapping[str, str], newline: str) -> str:
|
|
188
|
+
if not values:
|
|
189
|
+
return ""
|
|
190
|
+
parser = _CaseSensitiveConfigParser(interpolation=None)
|
|
191
|
+
parser.add_section(CU_SECTION)
|
|
192
|
+
for key in sorted(values):
|
|
193
|
+
parser.set(CU_SECTION, key, values[key])
|
|
194
|
+
stream = StringIO()
|
|
195
|
+
parser.write(stream, space_around_delimiters=True)
|
|
196
|
+
rendered = stream.getvalue()
|
|
197
|
+
return rendered.replace("\n", newline)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _replace_cu_section(original: str, values: Mapping[str, str]) -> str:
|
|
201
|
+
newline = "\r\n" if "\r\n" in original else "\n"
|
|
202
|
+
matches = list(_SECTION_PATTERN.finditer(original))
|
|
203
|
+
cu_match_index = next(
|
|
204
|
+
(
|
|
205
|
+
index
|
|
206
|
+
for index, match in enumerate(matches)
|
|
207
|
+
if match.group("name").strip().casefold() == CU_SECTION
|
|
208
|
+
),
|
|
209
|
+
None,
|
|
210
|
+
)
|
|
211
|
+
replacement = _render_cu_section(values, newline)
|
|
212
|
+
if cu_match_index is None:
|
|
213
|
+
if not replacement:
|
|
214
|
+
return original
|
|
215
|
+
separator = "" if not original else newline if original.endswith(("\n", "\r")) else newline * 2
|
|
216
|
+
return f"{original}{separator}{replacement}"
|
|
217
|
+
|
|
218
|
+
start = matches[cu_match_index].start()
|
|
219
|
+
end = (
|
|
220
|
+
matches[cu_match_index + 1].start()
|
|
221
|
+
if cu_match_index + 1 < len(matches)
|
|
222
|
+
else len(original)
|
|
223
|
+
)
|
|
224
|
+
suffix = original[end:]
|
|
225
|
+
if replacement and suffix and not replacement.endswith(("\n", "\r")):
|
|
226
|
+
replacement += newline
|
|
227
|
+
return f"{original[:start]}{replacement}{suffix}"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _profile_prefix(name: str) -> str:
|
|
231
|
+
return f"{name}."
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _is_profile_setting(key: str) -> bool:
|
|
235
|
+
return key in KNOWN_PROFILE_KEYS or key.startswith(MODEL_DEPLOYMENTS_PREFIX)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@dataclass
|
|
239
|
+
class ProfileStore:
|
|
240
|
+
"""Read and atomically update CU profiles in Azure CLI configuration."""
|
|
241
|
+
|
|
242
|
+
path: Path
|
|
243
|
+
values: dict[str, str] = field(default_factory=dict)
|
|
244
|
+
_source_text: str = field(default="", repr=False)
|
|
245
|
+
_source_digest: str | None = field(default=None, repr=False)
|
|
246
|
+
|
|
247
|
+
@classmethod
|
|
248
|
+
def load(cls, path: Path | None = None) -> "ProfileStore":
|
|
249
|
+
resolved_path = path or azure_config_path()
|
|
250
|
+
text, values, digest = _read_config(resolved_path)
|
|
251
|
+
store = cls(
|
|
252
|
+
path=resolved_path,
|
|
253
|
+
values=values,
|
|
254
|
+
_source_text=text,
|
|
255
|
+
_source_digest=digest,
|
|
256
|
+
)
|
|
257
|
+
store._validate()
|
|
258
|
+
return store
|
|
259
|
+
|
|
260
|
+
def _validate(self) -> None:
|
|
261
|
+
active = self.values.get(ACTIVE_PROFILE_KEY)
|
|
262
|
+
if active is not None:
|
|
263
|
+
validate_profile_name(active)
|
|
264
|
+
for key in self.values:
|
|
265
|
+
if key == ACTIVE_PROFILE_KEY:
|
|
266
|
+
continue
|
|
267
|
+
if _is_profile_setting(key):
|
|
268
|
+
validate_profile_key(key)
|
|
269
|
+
continue
|
|
270
|
+
name, separator, setting = key.partition(".")
|
|
271
|
+
if not separator:
|
|
272
|
+
continue
|
|
273
|
+
validate_profile_name(name)
|
|
274
|
+
if setting != PROFILE_MARKER:
|
|
275
|
+
validate_profile_key(setting)
|
|
276
|
+
|
|
277
|
+
def list_names(self) -> list[str]:
|
|
278
|
+
names = {DEFAULT_PROFILE_NAME}
|
|
279
|
+
for key in self.values:
|
|
280
|
+
name, separator, setting = key.partition(".")
|
|
281
|
+
if separator and (
|
|
282
|
+
setting == PROFILE_MARKER or _is_profile_setting(setting)
|
|
283
|
+
):
|
|
284
|
+
names.add(name)
|
|
285
|
+
return sorted(names)
|
|
286
|
+
|
|
287
|
+
def has_name(self, name: str) -> bool:
|
|
288
|
+
if name == DEFAULT_PROFILE_NAME:
|
|
289
|
+
return True
|
|
290
|
+
prefix = _profile_prefix(name)
|
|
291
|
+
return any(key.startswith(prefix) for key in self.values)
|
|
292
|
+
|
|
293
|
+
def get_active_name(self) -> str:
|
|
294
|
+
active = self.values.get(ACTIVE_PROFILE_KEY, DEFAULT_PROFILE_NAME)
|
|
295
|
+
if not self.has_name(active):
|
|
296
|
+
raise NotFoundError(
|
|
297
|
+
f"active CU CLI profile '{active}' was not found.",
|
|
298
|
+
hint="run `cu profile set-active default` or select another listed profile.",
|
|
299
|
+
)
|
|
300
|
+
return active
|
|
301
|
+
|
|
302
|
+
def set_active_name(self, name: str) -> None:
|
|
303
|
+
target = validate_profile_name(name)
|
|
304
|
+
if not self.has_name(target):
|
|
305
|
+
raise NotFoundError(
|
|
306
|
+
f"profile '{target}' was not found.",
|
|
307
|
+
hint="run `cu profile list` to see available profiles.",
|
|
308
|
+
)
|
|
309
|
+
if target == DEFAULT_PROFILE_NAME:
|
|
310
|
+
self.values.pop(ACTIVE_PROFILE_KEY, None)
|
|
311
|
+
else:
|
|
312
|
+
self.values[ACTIVE_PROFILE_KEY] = target
|
|
313
|
+
|
|
314
|
+
def _base_profile(self) -> dict[str, str]:
|
|
315
|
+
return {
|
|
316
|
+
key: value
|
|
317
|
+
for key, value in self.values.items()
|
|
318
|
+
if _is_profile_setting(key)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
def get_explicit_profile(self, name: str) -> dict[str, str]:
|
|
322
|
+
target = validate_profile_name(name)
|
|
323
|
+
prefix = _profile_prefix(target)
|
|
324
|
+
return {
|
|
325
|
+
key.removeprefix(prefix): value
|
|
326
|
+
for key, value in self.values.items()
|
|
327
|
+
if key.startswith(prefix)
|
|
328
|
+
and key.removeprefix(prefix) != PROFILE_MARKER
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
def get_profile(self, name: str | None = None) -> dict[str, object]:
|
|
332
|
+
target = self.get_active_name() if name is None else validate_profile_name(name)
|
|
333
|
+
if not self.has_name(target):
|
|
334
|
+
raise NotFoundError(
|
|
335
|
+
f"profile '{target}' was not found.",
|
|
336
|
+
hint="run `cu profile list` to see available profiles.",
|
|
337
|
+
)
|
|
338
|
+
flat = {**self._base_profile(), **self.get_explicit_profile(target)}
|
|
339
|
+
models = {
|
|
340
|
+
key.removeprefix(MODEL_DEPLOYMENTS_PREFIX): value
|
|
341
|
+
for key, value in flat.items()
|
|
342
|
+
if key.startswith(MODEL_DEPLOYMENTS_PREFIX)
|
|
343
|
+
}
|
|
344
|
+
profile: dict[str, object] = {
|
|
345
|
+
key: value for key, value in flat.items() if key in KNOWN_PROFILE_KEYS
|
|
346
|
+
}
|
|
347
|
+
if models:
|
|
348
|
+
profile["model_deployments"] = models
|
|
349
|
+
return profile
|
|
350
|
+
|
|
351
|
+
def create_name(self, name: str) -> None:
|
|
352
|
+
target = normalize_profile_name(name)
|
|
353
|
+
if self.has_name(target):
|
|
354
|
+
raise ConflictError(f"profile '{target}' already exists.")
|
|
355
|
+
self.values[f"{target}.{PROFILE_MARKER}"] = "true"
|
|
356
|
+
|
|
357
|
+
def get(self, key: str, *, name: str | None = None) -> str | None:
|
|
358
|
+
setting = validate_profile_key(key)
|
|
359
|
+
target = self.get_active_name() if name is None else validate_profile_name(name)
|
|
360
|
+
if not self.has_name(target):
|
|
361
|
+
raise NotFoundError(f"profile '{target}' was not found.")
|
|
362
|
+
return self.values.get(f"{target}.{setting}") or self.values.get(setting)
|
|
363
|
+
|
|
364
|
+
def set(self, key: str, value: str, *, name: str | None = None) -> None:
|
|
365
|
+
setting = validate_profile_key(key)
|
|
366
|
+
cleaned = validate_profile_value(setting, value)
|
|
367
|
+
target = self.get_active_name() if name is None else validate_profile_name(name)
|
|
368
|
+
if not self.has_name(target):
|
|
369
|
+
raise NotFoundError(
|
|
370
|
+
f"profile '{target}' was not found.",
|
|
371
|
+
hint=f"create it with `cu profile create {target}` first.",
|
|
372
|
+
)
|
|
373
|
+
self.values[f"{target}.{setting}"] = cleaned
|
|
374
|
+
if setting == "api_key":
|
|
375
|
+
self.values[f"{target}.auth_mode"] = "key"
|
|
376
|
+
|
|
377
|
+
def unset(self, key: str, *, name: str | None = None) -> bool:
|
|
378
|
+
setting = validate_profile_key(key)
|
|
379
|
+
target = self.get_active_name() if name is None else validate_profile_name(name)
|
|
380
|
+
if not self.has_name(target):
|
|
381
|
+
raise NotFoundError(f"profile '{target}' was not found.")
|
|
382
|
+
removed = self.values.pop(f"{target}.{setting}", None) is not None
|
|
383
|
+
if setting == "api_key":
|
|
384
|
+
self.values[f"{target}.auth_mode"] = "login"
|
|
385
|
+
return removed
|
|
386
|
+
|
|
387
|
+
def replace_model_deployments(
|
|
388
|
+
self,
|
|
389
|
+
model_deployments: Mapping[str, str],
|
|
390
|
+
*,
|
|
391
|
+
name: str | None = None,
|
|
392
|
+
) -> None:
|
|
393
|
+
target = self.get_active_name() if name is None else validate_profile_name(name)
|
|
394
|
+
prefix = f"{target}.{MODEL_DEPLOYMENTS_PREFIX}"
|
|
395
|
+
for key in tuple(self.values):
|
|
396
|
+
if key.startswith(prefix):
|
|
397
|
+
del self.values[key]
|
|
398
|
+
for model, deployment in model_deployments.items():
|
|
399
|
+
self.set(
|
|
400
|
+
f"{MODEL_DEPLOYMENTS_PREFIX}{model}",
|
|
401
|
+
str(deployment),
|
|
402
|
+
name=target,
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
def has_explicit_model_deployments(self, name: str | None = None) -> bool:
|
|
406
|
+
target = self.get_active_name() if name is None else validate_profile_name(name)
|
|
407
|
+
prefix = f"{target}.{MODEL_DEPLOYMENTS_PREFIX}"
|
|
408
|
+
return any(key.startswith(prefix) for key in self.values)
|
|
409
|
+
|
|
410
|
+
def copy_name(self, source: str, destination: str) -> None:
|
|
411
|
+
source_name = validate_profile_name(source)
|
|
412
|
+
destination_name = normalize_profile_name(destination)
|
|
413
|
+
if not self.has_name(source_name):
|
|
414
|
+
raise NotFoundError(f"profile '{source_name}' was not found.")
|
|
415
|
+
if self.has_name(destination_name):
|
|
416
|
+
raise ConflictError(f"profile '{destination_name}' already exists.")
|
|
417
|
+
self.create_name(destination_name)
|
|
418
|
+
for key, value in self.get_explicit_profile(source_name).items():
|
|
419
|
+
self.values[f"{destination_name}.{key}"] = copy.deepcopy(value)
|
|
420
|
+
|
|
421
|
+
def rename_name(self, source: str, destination: str) -> None:
|
|
422
|
+
source_name = normalize_profile_name(source)
|
|
423
|
+
destination_name = normalize_profile_name(destination)
|
|
424
|
+
if not self.has_name(source_name):
|
|
425
|
+
raise NotFoundError(f"profile '{source_name}' was not found.")
|
|
426
|
+
if self.has_name(destination_name):
|
|
427
|
+
raise ConflictError(f"profile '{destination_name}' already exists.")
|
|
428
|
+
source_prefix = _profile_prefix(source_name)
|
|
429
|
+
moved = {
|
|
430
|
+
f"{destination_name}.{key.removeprefix(source_prefix)}": value
|
|
431
|
+
for key, value in self.values.items()
|
|
432
|
+
if key.startswith(source_prefix)
|
|
433
|
+
}
|
|
434
|
+
for key in tuple(self.values):
|
|
435
|
+
if key.startswith(source_prefix):
|
|
436
|
+
del self.values[key]
|
|
437
|
+
self.values.update(moved)
|
|
438
|
+
if self.values.get(ACTIVE_PROFILE_KEY) == source_name:
|
|
439
|
+
self.values[ACTIVE_PROFILE_KEY] = destination_name
|
|
440
|
+
|
|
441
|
+
def delete_name(self, name: str) -> None:
|
|
442
|
+
target = normalize_profile_name(name)
|
|
443
|
+
if not self.has_name(target):
|
|
444
|
+
raise NotFoundError(f"profile '{target}' was not found.")
|
|
445
|
+
if self.get_active_name() == target:
|
|
446
|
+
raise ConflictError(
|
|
447
|
+
f"cannot delete active CU CLI profile '{target}'.",
|
|
448
|
+
hint="activate another profile before deleting it.",
|
|
449
|
+
)
|
|
450
|
+
prefix = _profile_prefix(target)
|
|
451
|
+
for key in tuple(self.values):
|
|
452
|
+
if key.startswith(prefix):
|
|
453
|
+
del self.values[key]
|
|
454
|
+
|
|
455
|
+
def save(self) -> Path:
|
|
456
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
457
|
+
with _exclusive_config_lock(self.path):
|
|
458
|
+
try:
|
|
459
|
+
current_raw = self.path.read_bytes()
|
|
460
|
+
except FileNotFoundError:
|
|
461
|
+
current_digest = None
|
|
462
|
+
except OSError as exc:
|
|
463
|
+
raise LocalIOError(
|
|
464
|
+
f"could not verify Azure CLI configuration '{self.path}': {exc}."
|
|
465
|
+
) from exc
|
|
466
|
+
else:
|
|
467
|
+
current_digest = hashlib.sha256(current_raw).hexdigest()
|
|
468
|
+
if current_digest != self._source_digest:
|
|
469
|
+
raise ConflictError(
|
|
470
|
+
f"Azure CLI configuration '{self.path}' changed after it was loaded.",
|
|
471
|
+
hint="reload the profile and retry the command.",
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
updated = _replace_cu_section(self._source_text, self.values)
|
|
475
|
+
encoded = updated.encode("utf-8")
|
|
476
|
+
fd, temporary_name = tempfile.mkstemp(
|
|
477
|
+
prefix=f".{self.path.name}.",
|
|
478
|
+
dir=self.path.parent,
|
|
479
|
+
)
|
|
480
|
+
try:
|
|
481
|
+
with os.fdopen(fd, "wb") as handle:
|
|
482
|
+
handle.write(encoded)
|
|
483
|
+
handle.flush()
|
|
484
|
+
os.fsync(handle.fileno())
|
|
485
|
+
os.replace(temporary_name, self.path)
|
|
486
|
+
self.path.chmod(0o600)
|
|
487
|
+
except Exception:
|
|
488
|
+
Path(temporary_name).unlink(missing_ok=True)
|
|
489
|
+
raise
|
|
490
|
+
self._source_text = updated
|
|
491
|
+
self._source_digest = hashlib.sha256(encoded).hexdigest()
|
|
492
|
+
return self.path
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
@dataclass
|
|
496
|
+
class Profile:
|
|
497
|
+
"""Effective CU settings after built-in, saved-profile, and environment precedence."""
|
|
498
|
+
|
|
499
|
+
endpoint: str | None = None
|
|
500
|
+
auth_mode: str = "login"
|
|
501
|
+
api_key: str | None = None
|
|
502
|
+
api_version: str = DEFAULT_API_VERSION
|
|
503
|
+
default_analyzer: str | None = None
|
|
504
|
+
model_deployments: dict[str, str] = field(default_factory=dict)
|
|
505
|
+
profile_name: str = DEFAULT_PROFILE_NAME
|
|
506
|
+
path: Path = field(default_factory=azure_config_path)
|
|
507
|
+
|
|
508
|
+
@classmethod
|
|
509
|
+
def load_saved(
|
|
510
|
+
cls,
|
|
511
|
+
*,
|
|
512
|
+
profile_name: str | None = None,
|
|
513
|
+
path: Path | None = None,
|
|
514
|
+
) -> "Profile":
|
|
515
|
+
store = ProfileStore.load(path)
|
|
516
|
+
selected = store.get_active_name() if profile_name is None else validate_profile_name(profile_name)
|
|
517
|
+
profile = cls(profile_name=selected, path=store.path)
|
|
518
|
+
profile._overlay(store.get_profile(selected), source=f"profile '{selected}'")
|
|
519
|
+
return profile
|
|
520
|
+
|
|
521
|
+
@classmethod
|
|
522
|
+
def load(
|
|
523
|
+
cls,
|
|
524
|
+
*,
|
|
525
|
+
profile_name: str | None = None,
|
|
526
|
+
path: Path | None = None,
|
|
527
|
+
) -> "Profile":
|
|
528
|
+
profile = cls.load_saved(profile_name=profile_name, path=path)
|
|
529
|
+
profile._apply_environment()
|
|
530
|
+
return profile
|
|
531
|
+
|
|
532
|
+
def _apply_environment(self) -> None:
|
|
533
|
+
endpoint = os.getenv("CU_ENDPOINT") or os.getenv("CONTENTUNDERSTANDING_ENDPOINT")
|
|
534
|
+
if endpoint:
|
|
535
|
+
self.endpoint = endpoint
|
|
536
|
+
auth_mode = os.getenv("CU_AUTH_MODE")
|
|
537
|
+
if auth_mode:
|
|
538
|
+
self.auth_mode = validate_profile_value("auth_mode", auth_mode)
|
|
539
|
+
api_key = os.getenv("CU_API_KEY") or os.getenv("CONTENTUNDERSTANDING_KEY")
|
|
540
|
+
if api_key:
|
|
541
|
+
self.api_key = api_key
|
|
542
|
+
self.auth_mode = "key"
|
|
543
|
+
api_version = os.getenv("CU_API_VERSION")
|
|
544
|
+
if api_version:
|
|
545
|
+
self.api_version = api_version
|
|
546
|
+
for model, environment_name in MODEL_ENV_OVERRIDES.items():
|
|
547
|
+
deployment = os.getenv(environment_name)
|
|
548
|
+
if deployment:
|
|
549
|
+
self.model_deployments[model] = deployment
|
|
550
|
+
|
|
551
|
+
@staticmethod
|
|
552
|
+
def _optional_string(
|
|
553
|
+
values: Mapping[str, object],
|
|
554
|
+
key: str,
|
|
555
|
+
*,
|
|
556
|
+
source: str,
|
|
557
|
+
) -> str | None:
|
|
558
|
+
value = values.get(key)
|
|
559
|
+
if value is None:
|
|
560
|
+
return None
|
|
561
|
+
if not isinstance(value, str) or not value.strip():
|
|
562
|
+
raise ValidationError(
|
|
563
|
+
f"{source} has an invalid '{key}' value; expected a non-empty string."
|
|
564
|
+
)
|
|
565
|
+
return value
|
|
566
|
+
|
|
567
|
+
def _overlay(self, values: Mapping[str, object], *, source: str) -> None:
|
|
568
|
+
endpoint = self._optional_string(values, "endpoint", source=source)
|
|
569
|
+
if endpoint is not None:
|
|
570
|
+
self.endpoint = endpoint
|
|
571
|
+
auth_mode = self._optional_string(values, "auth_mode", source=source)
|
|
572
|
+
if auth_mode is not None:
|
|
573
|
+
self.auth_mode = validate_profile_value("auth_mode", auth_mode)
|
|
574
|
+
api_key = self._optional_string(values, "api_key", source=source)
|
|
575
|
+
if api_key is not None:
|
|
576
|
+
self.api_key = api_key
|
|
577
|
+
if auth_mode is None:
|
|
578
|
+
self.auth_mode = "key"
|
|
579
|
+
api_version = self._optional_string(values, "api_version", source=source)
|
|
580
|
+
if api_version is not None:
|
|
581
|
+
self.api_version = api_version
|
|
582
|
+
default_analyzer = self._optional_string(values, "default_analyzer", source=source)
|
|
583
|
+
if default_analyzer is not None:
|
|
584
|
+
self.default_analyzer = default_analyzer
|
|
585
|
+
models = values.get("model_deployments")
|
|
586
|
+
if models is not None:
|
|
587
|
+
if not isinstance(models, Mapping):
|
|
588
|
+
raise ValidationError(
|
|
589
|
+
f"{source} has invalid model_deployments; expected a mapping."
|
|
590
|
+
)
|
|
591
|
+
self.model_deployments.update(
|
|
592
|
+
{str(key): str(value) for key, value in models.items()}
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
def to_public_dict(self) -> dict[str, object]:
|
|
596
|
+
return {
|
|
597
|
+
"profile": self.profile_name,
|
|
598
|
+
"endpoint": self.endpoint,
|
|
599
|
+
"auth_mode": self.auth_mode,
|
|
600
|
+
"api_key": "***redacted***" if self.api_key else None,
|
|
601
|
+
"api_version": self.api_version,
|
|
602
|
+
"default_analyzer": self.default_analyzer,
|
|
603
|
+
"model_deployments": dict(self.model_deployments),
|
|
604
|
+
}
|
cu_cli_core/py.typed
ADDED
|
File without changes
|