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.
@@ -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