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
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation.
|
|
2
|
+
# Licensed under the MIT license.
|
|
3
|
+
|
|
4
|
+
"""Analyzer management service (Click-free, client-injected).
|
|
5
|
+
|
|
6
|
+
Thin wrappers over the CU SDK's analyzer CRUD that add the small pieces of
|
|
7
|
+
domain logic the CLI needs: stable sorting, custom-only filtering, treating a
|
|
8
|
+
create that lands in ``FAILED`` as an error, and treating a delete of a missing
|
|
9
|
+
analyzer as an error (server-side DELETE is idempotent, which otherwise makes a
|
|
10
|
+
typo look like a success). Callers pass a built client; nothing here prints.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import shlex
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from ..errors import (
|
|
19
|
+
AuthenticationError,
|
|
20
|
+
ConflictError,
|
|
21
|
+
NotFoundError,
|
|
22
|
+
ServiceError,
|
|
23
|
+
UsageError,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_copy_source_analyzer(client: Any, analyzer_id: str) -> Any:
|
|
28
|
+
"""Fetch a copy source with copy-specific 404 and auth guidance.
|
|
29
|
+
|
|
30
|
+
The command layer also needs the source definition for cross-resource
|
|
31
|
+
dependency preflight. Keeping the fetch and translation here ensures that
|
|
32
|
+
both that path and :func:`copy_analyzer` surface the same actionable errors.
|
|
33
|
+
"""
|
|
34
|
+
from azure.core.exceptions import HttpResponseError, ResourceNotFoundError
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
return client.get_analyzer(analyzer_id)
|
|
38
|
+
except ResourceNotFoundError as exc:
|
|
39
|
+
raise NotFoundError(
|
|
40
|
+
f"source analyzer '{analyzer_id}' was not found; nothing to copy.",
|
|
41
|
+
hint="check the source ID with `cu analyzer list` on the source resource.",
|
|
42
|
+
) from exc
|
|
43
|
+
except HttpResponseError as exc:
|
|
44
|
+
if getattr(exc, "status_code", None) in (401, 403):
|
|
45
|
+
raise AuthenticationError(
|
|
46
|
+
"source analyzer lookup failed with an auth error on the source resource.",
|
|
47
|
+
hint="the signed-in identity needs *Cognitive Services User* on the "
|
|
48
|
+
"**source** account. Run `az login` if needed.",
|
|
49
|
+
) from exc
|
|
50
|
+
raise
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _target_cli_command(
|
|
54
|
+
action: str,
|
|
55
|
+
analyzer_id: str,
|
|
56
|
+
*,
|
|
57
|
+
cross_resource: bool,
|
|
58
|
+
target_cli_options: str | None,
|
|
59
|
+
) -> str | None:
|
|
60
|
+
"""Build a destination-safe follow-up command, or ``None`` if it cannot be scoped."""
|
|
61
|
+
if cross_resource and not target_cli_options:
|
|
62
|
+
return None
|
|
63
|
+
suffix = f" {target_cli_options.strip()}" if target_cli_options else ""
|
|
64
|
+
return f"cu analyzer {action} {shlex.quote(analyzer_id)}{suffix}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _target_exists_error(
|
|
68
|
+
target_analyzer_id: str,
|
|
69
|
+
*,
|
|
70
|
+
cross_resource: bool,
|
|
71
|
+
target_cli_options: str | None,
|
|
72
|
+
) -> ConflictError:
|
|
73
|
+
"""Return the non-destructive collision error used by preflight and 409 handling."""
|
|
74
|
+
delete_command = _target_cli_command(
|
|
75
|
+
"delete",
|
|
76
|
+
target_analyzer_id,
|
|
77
|
+
cross_resource=cross_resource,
|
|
78
|
+
target_cli_options=target_cli_options,
|
|
79
|
+
)
|
|
80
|
+
overwrite_hint = (
|
|
81
|
+
f"If you really want to overwrite, delete it explicitly at the destination "
|
|
82
|
+
f"with `{delete_command}`, then re-run copy."
|
|
83
|
+
if delete_command
|
|
84
|
+
else "If you really want to overwrite, explicitly delete it at the destination "
|
|
85
|
+
"resource, then re-run copy."
|
|
86
|
+
)
|
|
87
|
+
return ConflictError(
|
|
88
|
+
f"destination analyzer '{target_analyzer_id}' already exists.",
|
|
89
|
+
hint=f"Content Understanding has no in-place replace. Prefer a versioned "
|
|
90
|
+
f"destination name (for example `{target_analyzer_id}_v2`) so the old copy "
|
|
91
|
+
f"stays available for validation. {overwrite_hint}",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def copy_analyzer(
|
|
96
|
+
client: Any,
|
|
97
|
+
source_analyzer_id: str,
|
|
98
|
+
target_analyzer_id: str,
|
|
99
|
+
*,
|
|
100
|
+
target_client: Any = None,
|
|
101
|
+
source_azure_resource_id: str | None = None,
|
|
102
|
+
source_region: str | None = None,
|
|
103
|
+
target_azure_resource_id: str | None = None,
|
|
104
|
+
target_region: str | None = None,
|
|
105
|
+
progress: Any = None,
|
|
106
|
+
source_analyzer: Any = None,
|
|
107
|
+
target_cli_options: str | None = None,
|
|
108
|
+
) -> Any:
|
|
109
|
+
"""Copy an analyzer, same-resource or cross-resource.
|
|
110
|
+
|
|
111
|
+
**Same-resource** (default): only ``client`` is provided. The call goes to
|
|
112
|
+
``client.begin_copy_analyzer(analyzer_id=TARGET, source_analyzer_id=SOURCE)``
|
|
113
|
+
and the service defaults source coordinates to the current resource.
|
|
114
|
+
|
|
115
|
+
**Cross-resource**: ``target_client`` is a second SDK client bound to the
|
|
116
|
+
destination resource, and the four ARM-ID + region args identify the two
|
|
117
|
+
resources canonically. Orchestration:
|
|
118
|
+
|
|
119
|
+
1. ``client.grant_copy_authorization(analyzer_id=SOURCE,
|
|
120
|
+
target_azure_resource_id=<destination ARM>,
|
|
121
|
+
target_region=<destination region>)``
|
|
122
|
+
on the *source* client.
|
|
123
|
+
2. ``target_client.begin_copy_analyzer(analyzer_id=TARGET,
|
|
124
|
+
source_analyzer_id=SOURCE, source_azure_resource_id=<source ARM>,
|
|
125
|
+
source_region=<source region>)`` on the *destination* client.
|
|
126
|
+
|
|
127
|
+
The authorization record is destination-scoped, time-limited, and never printed
|
|
128
|
+
or persisted — the service stores the grant server-side. The optional
|
|
129
|
+
``progress`` callable, if supplied, is invoked with a one-line status
|
|
130
|
+
string before each data-plane call so callers can render live progress
|
|
131
|
+
without this module depending on ``rich``.
|
|
132
|
+
|
|
133
|
+
``source_analyzer`` may carry the definition already fetched by the command
|
|
134
|
+
layer's dependency preflight, avoiding a duplicate source request.
|
|
135
|
+
``target_cli_options`` contains non-secret CLI options that qualify recovery
|
|
136
|
+
commands to the destination (for example ``--profile prod`` or
|
|
137
|
+
``--endpoint https://... --auth-mode login``).
|
|
138
|
+
|
|
139
|
+
Raises a typed core error on 404 or when the destination exists;
|
|
140
|
+
Content Understanding has no in-place replace. Also raises when the LRO
|
|
141
|
+
lands in ``FAILED`` or when authorization expires before the copy completes.
|
|
142
|
+
"""
|
|
143
|
+
from azure.core.exceptions import HttpResponseError, ResourceNotFoundError
|
|
144
|
+
|
|
145
|
+
cross_resource = target_client is not None and target_client is not client
|
|
146
|
+
copier = target_client if cross_resource else client
|
|
147
|
+
|
|
148
|
+
if cross_resource and not (
|
|
149
|
+
source_azure_resource_id
|
|
150
|
+
and source_region
|
|
151
|
+
and target_azure_resource_id
|
|
152
|
+
and target_region
|
|
153
|
+
):
|
|
154
|
+
raise UsageError(
|
|
155
|
+
"cross-resource copy requires source and destination Azure resource IDs and regions.",
|
|
156
|
+
hint="this is an internal error — the CLI should have resolved them "
|
|
157
|
+
"via --source-resource/--destination-resource before calling copy_analyzer.",
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# 1. Confirm the source exists up-front. The service returns 400
|
|
161
|
+
# (InvalidRequest) rather than 404 on a missing source_analyzer_id,
|
|
162
|
+
# which is harder to interpret than an explicit lookup. The command
|
|
163
|
+
# layer may already have fetched it for dependency inspection.
|
|
164
|
+
if source_analyzer is None:
|
|
165
|
+
if progress is not None:
|
|
166
|
+
progress(f"checking source analyzer '{source_analyzer_id}'")
|
|
167
|
+
source_analyzer = get_copy_source_analyzer(client, source_analyzer_id)
|
|
168
|
+
|
|
169
|
+
# 2. Confirm the destination is absent *before* granting authorization. This is
|
|
170
|
+
# both safer and more actionable than waiting for begin_copy_analyzer to
|
|
171
|
+
# return 409. Keep the 409 handler below for races between this lookup
|
|
172
|
+
# and LRO creation.
|
|
173
|
+
if progress is not None:
|
|
174
|
+
progress(f"checking destination analyzer '{target_analyzer_id}'")
|
|
175
|
+
try:
|
|
176
|
+
copier.get_analyzer(target_analyzer_id)
|
|
177
|
+
except ResourceNotFoundError:
|
|
178
|
+
pass
|
|
179
|
+
except HttpResponseError as exc:
|
|
180
|
+
status = getattr(exc, "status_code", None)
|
|
181
|
+
if status == 404:
|
|
182
|
+
pass
|
|
183
|
+
elif status in (401, 403):
|
|
184
|
+
raise AuthenticationError(
|
|
185
|
+
"destination analyzer lookup failed with an auth error "
|
|
186
|
+
"on the destination resource.",
|
|
187
|
+
hint="the signed-in identity needs *Cognitive Services User* on the "
|
|
188
|
+
"**destination** account. Run `az login` if needed.",
|
|
189
|
+
) from exc
|
|
190
|
+
else:
|
|
191
|
+
raise
|
|
192
|
+
else:
|
|
193
|
+
raise _target_exists_error(
|
|
194
|
+
target_analyzer_id,
|
|
195
|
+
cross_resource=cross_resource,
|
|
196
|
+
target_cli_options=target_cli_options,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# 3. Cross-resource: grant destination-scoped authorization first, so the
|
|
200
|
+
# subsequent begin_copy_analyzer at the destination can pull the source.
|
|
201
|
+
if cross_resource:
|
|
202
|
+
if progress is not None:
|
|
203
|
+
progress("granting temporary copy authorization for the cross-resource copy")
|
|
204
|
+
try:
|
|
205
|
+
client.grant_copy_authorization(
|
|
206
|
+
analyzer_id=source_analyzer_id,
|
|
207
|
+
target_azure_resource_id=target_azure_resource_id,
|
|
208
|
+
target_region=target_region,
|
|
209
|
+
)
|
|
210
|
+
except HttpResponseError as exc:
|
|
211
|
+
status = getattr(exc, "status_code", None)
|
|
212
|
+
if status in (401, 403):
|
|
213
|
+
raise AuthenticationError(
|
|
214
|
+
"grant_copy_authorization failed with an auth error on the source resource.",
|
|
215
|
+
hint="the signed-in identity needs *Cognitive Services User* on the "
|
|
216
|
+
"**source** account. Run `az login` if needed.",
|
|
217
|
+
) from exc
|
|
218
|
+
raise
|
|
219
|
+
# Note: we do *not* print the returned CopyAuthorization record
|
|
220
|
+
# (source, target_azure_resource_id, expires_at). The service stores
|
|
221
|
+
# the grant server-side for the target's subsequent copy request.
|
|
222
|
+
|
|
223
|
+
# 4. Start the copy LRO on the destination client and wait.
|
|
224
|
+
copy_kwargs: dict[str, Any] = {
|
|
225
|
+
"analyzer_id": target_analyzer_id,
|
|
226
|
+
"source_analyzer_id": source_analyzer_id,
|
|
227
|
+
}
|
|
228
|
+
if cross_resource:
|
|
229
|
+
copy_kwargs["source_azure_resource_id"] = source_azure_resource_id
|
|
230
|
+
copy_kwargs["source_region"] = source_region
|
|
231
|
+
if progress is not None:
|
|
232
|
+
target_suffix = " on the destination resource" if cross_resource else ""
|
|
233
|
+
progress(
|
|
234
|
+
f"copying '{source_analyzer_id}' -> '{target_analyzer_id}'"
|
|
235
|
+
f"{target_suffix}; this may take several minutes"
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
poller = copier.begin_copy_analyzer(**copy_kwargs)
|
|
240
|
+
result = poller.result()
|
|
241
|
+
except HttpResponseError as exc:
|
|
242
|
+
status = getattr(exc, "status_code", None)
|
|
243
|
+
if status == 409:
|
|
244
|
+
raise _target_exists_error(
|
|
245
|
+
target_analyzer_id,
|
|
246
|
+
cross_resource=cross_resource,
|
|
247
|
+
target_cli_options=target_cli_options,
|
|
248
|
+
) from exc
|
|
249
|
+
if cross_resource and status in (401, 403):
|
|
250
|
+
raise AuthenticationError(
|
|
251
|
+
"copy failed with an auth error on the destination resource.",
|
|
252
|
+
hint="the signed-in identity needs *Cognitive Services User* on the "
|
|
253
|
+
"**destination** account. If authorization has expired between grant "
|
|
254
|
+
"and copy, simply re-run — the CLI grants a fresh one each call.",
|
|
255
|
+
) from exc
|
|
256
|
+
raise
|
|
257
|
+
|
|
258
|
+
# 5. If the copy landed in a terminal FAILED state, treat like create_analyzer.
|
|
259
|
+
final_id = getattr(result, "analyzer_id", target_analyzer_id)
|
|
260
|
+
status = getattr(result, "status", None)
|
|
261
|
+
if status and str(status).lower().endswith("failed"):
|
|
262
|
+
show_command = _target_cli_command(
|
|
263
|
+
"show",
|
|
264
|
+
final_id,
|
|
265
|
+
cross_resource=cross_resource,
|
|
266
|
+
target_cli_options=target_cli_options,
|
|
267
|
+
)
|
|
268
|
+
raise ServiceError(
|
|
269
|
+
f"analyzer '{final_id}' was copied but its status is FAILED.",
|
|
270
|
+
hint=(f"run `{show_command}` for details."
|
|
271
|
+
if show_command
|
|
272
|
+
else "inspect the analyzer explicitly at the destination for details."),
|
|
273
|
+
)
|
|
274
|
+
return result
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def collect_custom_dependencies(source_analyzer: Any) -> list[str]:
|
|
278
|
+
"""Return the list of *custom* analyzer IDs referenced by classifier / segmentation categories.
|
|
279
|
+
|
|
280
|
+
Walks ``source.config.content_categories`` (the SDK models this as a mapping
|
|
281
|
+
from category name to ``ContentCategoryDefinition``; list-shaped payloads
|
|
282
|
+
are accepted for compatibility). Each definition may reference another
|
|
283
|
+
analyzer via ``analyzer_id``. Prebuilt refs
|
|
284
|
+
(``prebuilt-<something>``) are skipped—the destination has the same
|
|
285
|
+
prebuilt catalog. Only *custom* refs need to exist at the destination before
|
|
286
|
+
the parent analyzer can be copied.
|
|
287
|
+
|
|
288
|
+
Returns an empty list when the analyzer has no categories or no refs.
|
|
289
|
+
"""
|
|
290
|
+
result: list[str] = []
|
|
291
|
+
cfg = getattr(source_analyzer, "config", None)
|
|
292
|
+
if cfg is None:
|
|
293
|
+
return result
|
|
294
|
+
categories = getattr(cfg, "content_categories", None) or {}
|
|
295
|
+
category_definitions = categories.values() if isinstance(categories, dict) else categories
|
|
296
|
+
for cat in category_definitions:
|
|
297
|
+
aid = getattr(cat, "analyzer_id", None) or (cat.get("analyzer_id") if isinstance(cat, dict) else None)
|
|
298
|
+
if not aid:
|
|
299
|
+
continue
|
|
300
|
+
if aid.startswith("prebuilt-"):
|
|
301
|
+
continue
|
|
302
|
+
if aid not in result:
|
|
303
|
+
result.append(aid)
|
|
304
|
+
return result
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def preflight_dependencies_on_target(
|
|
308
|
+
target_client: Any,
|
|
309
|
+
dependencies: list[str],
|
|
310
|
+
) -> list[str]:
|
|
311
|
+
"""Return the subset of ``dependencies`` that are missing on ``target_client``.
|
|
312
|
+
|
|
313
|
+
Empty result means the target already has every custom analyzer the source
|
|
314
|
+
depends on. This is a black-box existence check — we call
|
|
315
|
+
``target_client.get_analyzer(aid)`` and treat 404 as missing. Any other
|
|
316
|
+
error is surfaced verbatim so a broken target endpoint doesn't get
|
|
317
|
+
misreported as a missing dependency.
|
|
318
|
+
"""
|
|
319
|
+
from azure.core.exceptions import ResourceNotFoundError
|
|
320
|
+
|
|
321
|
+
missing: list[str] = []
|
|
322
|
+
for aid in dependencies:
|
|
323
|
+
try:
|
|
324
|
+
target_client.get_analyzer(aid)
|
|
325
|
+
except ResourceNotFoundError:
|
|
326
|
+
missing.append(aid)
|
|
327
|
+
return missing
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation.
|
|
2
|
+
# Licensed under the MIT license.
|
|
3
|
+
|
|
4
|
+
"""Client-injected analyzer operations."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ..errors import ConflictError, NotFoundError, ServiceError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _value(analyzer: Any, *keys: str) -> str:
|
|
14
|
+
for key in keys:
|
|
15
|
+
value = getattr(analyzer, key, None)
|
|
16
|
+
if value is not None:
|
|
17
|
+
return str(value)
|
|
18
|
+
value = analyzer.as_dict() if hasattr(analyzer, "as_dict") else {}
|
|
19
|
+
if isinstance(value, dict):
|
|
20
|
+
for key in keys:
|
|
21
|
+
if value.get(key) is not None:
|
|
22
|
+
return str(value[key])
|
|
23
|
+
return ""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def list_analyzers(
|
|
27
|
+
client: Any,
|
|
28
|
+
*,
|
|
29
|
+
kind: str = "all",
|
|
30
|
+
sort_by: str = "analyzerId",
|
|
31
|
+
) -> list[Any]:
|
|
32
|
+
items = list(client.list_analyzers())
|
|
33
|
+
if kind != "all":
|
|
34
|
+
items = [item for item in items if analyzer_kind(item) == kind]
|
|
35
|
+
sort_keys = {
|
|
36
|
+
"analyzerId": ("analyzer_id", "analyzerId"),
|
|
37
|
+
"createdAt": ("created_at", "createdAt"),
|
|
38
|
+
"lastModifiedAt": ("last_modified_at", "lastModifiedAt"),
|
|
39
|
+
}
|
|
40
|
+
return sorted(items, key=lambda item: _value(item, *sort_keys[sort_by]))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def analyzer_kind(analyzer: Any) -> str:
|
|
44
|
+
analyzer_id = _value(analyzer, "analyzer_id", "analyzerId")
|
|
45
|
+
return "prebuilt" if analyzer_id.startswith("prebuilt-") else "custom"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_analyzer(client: Any, analyzer_id: str) -> Any:
|
|
49
|
+
return client.get_analyzer(analyzer_id)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def create_analyzer(client: Any, analyzer_id: str, body: dict[str, Any]) -> Any:
|
|
53
|
+
from azure.core.exceptions import HttpResponseError
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
poller = client.begin_create_analyzer(analyzer_id, body)
|
|
57
|
+
result = poller.result()
|
|
58
|
+
except HttpResponseError as exc:
|
|
59
|
+
if exc.status_code == 409:
|
|
60
|
+
raise ConflictError(
|
|
61
|
+
f"analyzer '{analyzer_id}' already exists.",
|
|
62
|
+
hint="Delete the existing analyzer explicitly or choose a versioned name.",
|
|
63
|
+
status_code=409,
|
|
64
|
+
) from exc
|
|
65
|
+
raise
|
|
66
|
+
status = getattr(result, "status", None)
|
|
67
|
+
if status and str(status).lower().endswith("failed"):
|
|
68
|
+
raise ServiceError(
|
|
69
|
+
f"analyzer '{getattr(result, 'analyzer_id', analyzer_id)}' "
|
|
70
|
+
"was created but its status is FAILED."
|
|
71
|
+
)
|
|
72
|
+
return result
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def delete_analyzer(client: Any, analyzer_id: str) -> None:
|
|
76
|
+
from azure.core.exceptions import ResourceNotFoundError
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
client.get_analyzer(analyzer_id)
|
|
80
|
+
except ResourceNotFoundError as exc:
|
|
81
|
+
raise NotFoundError(
|
|
82
|
+
f"analyzer '{analyzer_id}' was not found; nothing was deleted.",
|
|
83
|
+
status_code=404,
|
|
84
|
+
) from exc
|
|
85
|
+
client.delete_analyzer(analyzer_id)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation.
|
|
2
|
+
# Licensed under the MIT license.
|
|
3
|
+
|
|
4
|
+
"""Shared profile operations with frontend-injected storage and discovered models."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Mapping
|
|
10
|
+
|
|
11
|
+
from ..contracts import (
|
|
12
|
+
ProfileCopyRequest,
|
|
13
|
+
ProfileCreateRequest,
|
|
14
|
+
ProfileDeleteRequest,
|
|
15
|
+
ProfileGetRequest,
|
|
16
|
+
ProfileListRequest,
|
|
17
|
+
ProfileRenameRequest,
|
|
18
|
+
ProfileSetActiveRequest,
|
|
19
|
+
ProfileSetRequest,
|
|
20
|
+
ProfileShowRequest,
|
|
21
|
+
ProfileSyncModelsRequest,
|
|
22
|
+
ProfileUnsetRequest,
|
|
23
|
+
)
|
|
24
|
+
from ..errors import NotFoundError
|
|
25
|
+
from ..profiles import Profile, ProfileStore, validate_profile_name
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def show_profile(
|
|
29
|
+
request: ProfileShowRequest,
|
|
30
|
+
*,
|
|
31
|
+
path: Path | None = None,
|
|
32
|
+
include_environment: bool = True,
|
|
33
|
+
) -> Profile:
|
|
34
|
+
loader = Profile.load if include_environment else Profile.load_saved
|
|
35
|
+
return loader(profile_name=request.name, path=path)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def list_profiles(
|
|
39
|
+
request: ProfileListRequest,
|
|
40
|
+
*,
|
|
41
|
+
path: Path | None = None,
|
|
42
|
+
) -> tuple[str, tuple[str, ...]]:
|
|
43
|
+
del request
|
|
44
|
+
store = ProfileStore.load(path)
|
|
45
|
+
return store.get_active_name(), tuple(store.list_names())
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_profile_value(
|
|
49
|
+
request: ProfileGetRequest,
|
|
50
|
+
*,
|
|
51
|
+
path: Path | None = None,
|
|
52
|
+
) -> str | None:
|
|
53
|
+
return ProfileStore.load(path).get(request.key, name=request.name)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def set_profile_value(
|
|
57
|
+
request: ProfileSetRequest,
|
|
58
|
+
*,
|
|
59
|
+
path: Path | None = None,
|
|
60
|
+
) -> Path:
|
|
61
|
+
store = ProfileStore.load(path)
|
|
62
|
+
store.set(request.key, request.value, name=request.name)
|
|
63
|
+
return store.save()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def unset_profile_value(
|
|
67
|
+
request: ProfileUnsetRequest,
|
|
68
|
+
*,
|
|
69
|
+
path: Path | None = None,
|
|
70
|
+
) -> Path:
|
|
71
|
+
store = ProfileStore.load(path)
|
|
72
|
+
if not store.unset(request.key, name=request.name):
|
|
73
|
+
target = request.name or store.get_active_name()
|
|
74
|
+
raise NotFoundError(
|
|
75
|
+
f"'{request.key}' is not saved in profile '{target}'.",
|
|
76
|
+
hint="`cu profile show` includes inherited defaults and environment overrides; "
|
|
77
|
+
"only explicitly saved values can be unset.",
|
|
78
|
+
)
|
|
79
|
+
return store.save()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def create_profile(
|
|
83
|
+
request: ProfileCreateRequest,
|
|
84
|
+
*,
|
|
85
|
+
path: Path | None = None,
|
|
86
|
+
) -> Path:
|
|
87
|
+
store = ProfileStore.load(path)
|
|
88
|
+
store.create_name(request.name)
|
|
89
|
+
return store.save()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def delete_profile(
|
|
93
|
+
request: ProfileDeleteRequest,
|
|
94
|
+
*,
|
|
95
|
+
path: Path | None = None,
|
|
96
|
+
) -> Path:
|
|
97
|
+
store = ProfileStore.load(path)
|
|
98
|
+
store.delete_name(request.name)
|
|
99
|
+
return store.save()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def copy_profile(
|
|
103
|
+
request: ProfileCopyRequest,
|
|
104
|
+
*,
|
|
105
|
+
path: Path | None = None,
|
|
106
|
+
) -> tuple[Path, str]:
|
|
107
|
+
store = ProfileStore.load(path)
|
|
108
|
+
source = (
|
|
109
|
+
store.get_active_name()
|
|
110
|
+
if request.source is None
|
|
111
|
+
else validate_profile_name(request.source)
|
|
112
|
+
)
|
|
113
|
+
store.copy_name(source, request.destination)
|
|
114
|
+
return store.save(), source
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def rename_profile(
|
|
118
|
+
request: ProfileRenameRequest,
|
|
119
|
+
*,
|
|
120
|
+
path: Path | None = None,
|
|
121
|
+
) -> Path:
|
|
122
|
+
store = ProfileStore.load(path)
|
|
123
|
+
store.rename_name(request.source, request.destination)
|
|
124
|
+
return store.save()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def set_active_profile(
|
|
128
|
+
request: ProfileSetActiveRequest,
|
|
129
|
+
*,
|
|
130
|
+
path: Path | None = None,
|
|
131
|
+
) -> Path:
|
|
132
|
+
store = ProfileStore.load(path)
|
|
133
|
+
store.set_active_name(request.name)
|
|
134
|
+
return store.save()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def sync_profile_models(
|
|
138
|
+
request: ProfileSyncModelsRequest,
|
|
139
|
+
model_deployments: Mapping[str, str],
|
|
140
|
+
*,
|
|
141
|
+
path: Path | None = None,
|
|
142
|
+
) -> tuple[Path, str]:
|
|
143
|
+
store = ProfileStore.load(path)
|
|
144
|
+
target = store.get_active_name() if request.name is None else request.name
|
|
145
|
+
store.replace_model_deployments(model_deployments, name=target)
|
|
146
|
+
return store.save(), target
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation.
|
|
2
|
+
# Licensed under the MIT license.
|
|
3
|
+
|
|
4
|
+
"""Shared schema-create operation."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ..contracts import AnalyzerSchemaCreateRequest
|
|
11
|
+
from ..errors import UsageError
|
|
12
|
+
from ..schema import (
|
|
13
|
+
MODALITY_BASE,
|
|
14
|
+
starter_schema,
|
|
15
|
+
suggest_schema_from_sample,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def create_schema(
|
|
20
|
+
request: AnalyzerSchemaCreateRequest,
|
|
21
|
+
*,
|
|
22
|
+
api_version: str,
|
|
23
|
+
completion_model: str,
|
|
24
|
+
client: Any | None = None,
|
|
25
|
+
) -> tuple[dict[str, Any], bool]:
|
|
26
|
+
"""Create a starter or sample-derived schema from a normalized request."""
|
|
27
|
+
|
|
28
|
+
if request.from_sample is not None:
|
|
29
|
+
if client is None:
|
|
30
|
+
raise UsageError("sample-derived schema creation requires a CU client.")
|
|
31
|
+
return suggest_schema_from_sample(
|
|
32
|
+
client,
|
|
33
|
+
sample_path=request.from_sample,
|
|
34
|
+
analyzer_id=request.name,
|
|
35
|
+
api_version=api_version,
|
|
36
|
+
completion_model=completion_model,
|
|
37
|
+
)
|
|
38
|
+
return (
|
|
39
|
+
starter_schema(
|
|
40
|
+
request.name,
|
|
41
|
+
request.base or MODALITY_BASE[request.modality],
|
|
42
|
+
request.modality,
|
|
43
|
+
api_version,
|
|
44
|
+
completion_model=completion_model,
|
|
45
|
+
template_type=request.template_type,
|
|
46
|
+
),
|
|
47
|
+
True,
|
|
48
|
+
)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation.
|
|
2
|
+
# Licensed under the MIT license.
|
|
3
|
+
|
|
4
|
+
"""Frontend-neutral schema validation."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from ..contracts import AnalyzerValidateRequest
|
|
9
|
+
from ..schema_validation import Finding, ValidationResult, parse_and_validate
|
|
10
|
+
from ..spec_validation import validate_against_spec
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def validate_schema(
|
|
14
|
+
request: AnalyzerValidateRequest,
|
|
15
|
+
*,
|
|
16
|
+
api_version: str,
|
|
17
|
+
) -> ValidationResult:
|
|
18
|
+
"""Validate a schema with curated rules and the optional service contract."""
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
text = request.schema.read_text(encoding="utf-8")
|
|
22
|
+
except (UnicodeDecodeError, ValueError) as exc:
|
|
23
|
+
return ValidationResult(
|
|
24
|
+
ok=False,
|
|
25
|
+
errors=[
|
|
26
|
+
Finding(
|
|
27
|
+
"$",
|
|
28
|
+
f"{request.schema.name} is not a UTF-8 JSON schema file "
|
|
29
|
+
f"({exc.__class__.__name__}); it looks like a binary or non-text file. "
|
|
30
|
+
"Pass a JSON analyzer schema.",
|
|
31
|
+
)
|
|
32
|
+
],
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
result, body = parse_and_validate(text, api_version=api_version)
|
|
36
|
+
if request.spec and body is not None:
|
|
37
|
+
spec_result = validate_against_spec(body, api_version=api_version)
|
|
38
|
+
result.errors.extend(spec_result.errors)
|
|
39
|
+
result.warnings.extend(spec_result.warnings)
|
|
40
|
+
result.ok = not result.errors
|
|
41
|
+
return result
|