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,312 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation.
|
|
2
|
+
# Licensed under the MIT license.
|
|
3
|
+
|
|
4
|
+
"""Local, structural schema validation against the bundled rules for an
|
|
5
|
+
api-version.
|
|
6
|
+
|
|
7
|
+
Deterministic and offline — **no service call**. ``validate`` produces detailed,
|
|
8
|
+
actionable messages: on a bad field ``type`` it names the field and lists the
|
|
9
|
+
accepted types, and returns exit code ``2`` on any error (so agents can branch).
|
|
10
|
+
|
|
11
|
+
The accepted types/methods are shared by both MVP api-versions; the
|
|
12
|
+
``api_version`` argument is threaded through so future versions can diverge
|
|
13
|
+
without changing call sites.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import re
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from importlib import resources as ir
|
|
22
|
+
from typing import Any, List, Optional, Tuple
|
|
23
|
+
|
|
24
|
+
from .service_options import DEFAULT_API_VERSION
|
|
25
|
+
|
|
26
|
+
# Accepted field types/methods come from the bundled CU OpenAPI spec (the service
|
|
27
|
+
# contract) so they never drift from the API. A hardcoded fallback keeps the
|
|
28
|
+
# validator working if the bundled spec is unavailable.
|
|
29
|
+
_FALLBACK_TYPES = ["array", "boolean", "date", "integer", "json", "number",
|
|
30
|
+
"object", "string", "time"]
|
|
31
|
+
_FALLBACK_METHODS = ["classify", "extract", "generate"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _spec_enums(api_version: str) -> Tuple[List[str], List[str]]:
|
|
35
|
+
try:
|
|
36
|
+
text = (ir.files("cu_cli_core")
|
|
37
|
+
.joinpath(f"resources/openapi/{api_version}/ContentUnderstanding.json")
|
|
38
|
+
.read_text(encoding="utf-8"))
|
|
39
|
+
defs = json.loads(text).get("definitions", {})
|
|
40
|
+
types = defs.get("ContentFieldType", {}).get("enum")
|
|
41
|
+
methods = defs.get("GenerationMethod", {}).get("enum")
|
|
42
|
+
return (sorted(types) if isinstance(types, list) and types else _FALLBACK_TYPES,
|
|
43
|
+
sorted(methods) if isinstance(methods, list) and methods else _FALLBACK_METHODS)
|
|
44
|
+
except Exception:
|
|
45
|
+
return _FALLBACK_TYPES, _FALLBACK_METHODS
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
ALLOWED_TYPES, ALLOWED_METHODS = _spec_enums(DEFAULT_API_VERSION)
|
|
49
|
+
|
|
50
|
+
_CUSTOM_ANALYZER_ID_RE = re.compile(r"^[a-zA-Z0-9_]{1,64}$")
|
|
51
|
+
_BASE_ANALYZER_ID_RE = re.compile(r"^[a-zA-Z0-9._-]{1,64}$")
|
|
52
|
+
_MAX_NESTING = 4
|
|
53
|
+
_MIN_DESCRIPTION_LEN = 15
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class Finding:
|
|
58
|
+
path: str
|
|
59
|
+
msg: str
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class ValidationResult:
|
|
64
|
+
ok: bool
|
|
65
|
+
errors: List[Finding] = field(default_factory=list)
|
|
66
|
+
warnings: List[Finding] = field(default_factory=list)
|
|
67
|
+
|
|
68
|
+
def as_dict(self) -> dict:
|
|
69
|
+
return {
|
|
70
|
+
"ok": self.ok,
|
|
71
|
+
"errors": [{"path": e.path, "msg": e.msg} for e in self.errors],
|
|
72
|
+
"warnings": [{"path": w.path, "msg": w.msg} for w in self.warnings],
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _types_list() -> str:
|
|
77
|
+
return "[" + ", ".join(ALLOWED_TYPES) + "]"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def custom_analyzer_id_error(value: Any) -> str | None:
|
|
81
|
+
"""Return the user-facing validation error for a custom analyzer ID."""
|
|
82
|
+
if isinstance(value, str) and _CUSTOM_ANALYZER_ID_RE.fullmatch(value):
|
|
83
|
+
return None
|
|
84
|
+
return (
|
|
85
|
+
"must contain 1-64 ASCII letters, numbers, or underscores. "
|
|
86
|
+
"Hyphens are reserved for service-provided prebuilt analyzer IDs "
|
|
87
|
+
"(for example, 'prebuilt-invoice')."
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _validate_field(name: str, defn: Any, path: str, depth: int,
|
|
92
|
+
errors: List[Finding], warnings: List[Finding]) -> None:
|
|
93
|
+
if not isinstance(defn, dict):
|
|
94
|
+
errors.append(Finding(path, f"field '{name}' must be an object."))
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
if "$ref" in defn:
|
|
98
|
+
errors.append(Finding(
|
|
99
|
+
f"{path}.$ref",
|
|
100
|
+
"`$ref` is not currently supported in analyzer field schemas; "
|
|
101
|
+
"inline the field definition.",
|
|
102
|
+
))
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
ftype = defn.get("type")
|
|
106
|
+
|
|
107
|
+
if ftype is None:
|
|
108
|
+
errors.append(Finding(
|
|
109
|
+
f"{path}.type",
|
|
110
|
+
"missing `type`; every field definition requires an explicit type.",
|
|
111
|
+
))
|
|
112
|
+
elif ftype is not None and ftype not in ALLOWED_TYPES:
|
|
113
|
+
errors.append(Finding(
|
|
114
|
+
f"{path}.type",
|
|
115
|
+
f"must be one of {_types_list()}, got '{ftype}'.",
|
|
116
|
+
))
|
|
117
|
+
|
|
118
|
+
desc = defn.get("description")
|
|
119
|
+
if desc is None or (isinstance(desc, str) and not desc.strip()):
|
|
120
|
+
warnings.append(Finding(f"{path}.description",
|
|
121
|
+
"no `description` — the model uses this as a per-field prompt."))
|
|
122
|
+
elif not isinstance(desc, str):
|
|
123
|
+
errors.append(Finding(f"{path}.description", "must be a string."))
|
|
124
|
+
elif len(desc.strip()) < _MIN_DESCRIPTION_LEN:
|
|
125
|
+
warnings.append(Finding(f"{path}.description",
|
|
126
|
+
f"description is very short ({len(desc.strip())} chars); be specific."))
|
|
127
|
+
|
|
128
|
+
method = defn.get("method")
|
|
129
|
+
if method is not None and method not in ALLOWED_METHODS:
|
|
130
|
+
errors.append(Finding(f"{path}.method",
|
|
131
|
+
f"must be one of [{', '.join(ALLOWED_METHODS)}], got '{method}'."))
|
|
132
|
+
if method == "classify":
|
|
133
|
+
enum = defn.get("enum")
|
|
134
|
+
if not isinstance(enum, list) or len({str(e) for e in enum}) < 2:
|
|
135
|
+
errors.append(Finding(f"{path}.enum",
|
|
136
|
+
"`classify` fields require `enum` with >=2 distinct values."))
|
|
137
|
+
|
|
138
|
+
if ftype == "array":
|
|
139
|
+
items = defn.get("items")
|
|
140
|
+
if not isinstance(items, dict):
|
|
141
|
+
errors.append(Finding(f"{path}.items", "`array` fields require an `items` object."))
|
|
142
|
+
else:
|
|
143
|
+
_depth_check(depth + 1, f"{path}.items", errors, warnings)
|
|
144
|
+
_validate_field(f"{name}[]", items, f"{path}.items", depth + 1, errors, warnings)
|
|
145
|
+
|
|
146
|
+
if ftype == "object":
|
|
147
|
+
props = defn.get("properties")
|
|
148
|
+
if not isinstance(props, dict) or not props:
|
|
149
|
+
errors.append(Finding(
|
|
150
|
+
f"{path}.properties",
|
|
151
|
+
"`object` fields require a non-empty `properties` map.",
|
|
152
|
+
))
|
|
153
|
+
else:
|
|
154
|
+
_depth_check(depth + 1, f"{path}.properties", errors, warnings)
|
|
155
|
+
for sub_name, sub_def in props.items():
|
|
156
|
+
_validate_field(sub_name, sub_def, f"{path}.properties.{sub_name}",
|
|
157
|
+
depth + 1, errors, warnings)
|
|
158
|
+
|
|
159
|
+
esc = defn.get("estimateSourceAndConfidence")
|
|
160
|
+
if esc is not None and not isinstance(esc, bool):
|
|
161
|
+
errors.append(Finding(f"{path}.estimateSourceAndConfidence", "must be a boolean."))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _depth_check(depth: int, path: str, errors: List[Finding], warnings: List[Finding]) -> None:
|
|
165
|
+
if depth > _MAX_NESTING:
|
|
166
|
+
warnings.append(Finding(path,
|
|
167
|
+
f"nesting depth {depth}; accuracy drops past depth "
|
|
168
|
+
f"{_MAX_NESTING}. Consider flattening."))
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def validate_schema(body: Any, *, api_version: Optional[str] = None) -> ValidationResult:
|
|
172
|
+
errors: List[Finding] = []
|
|
173
|
+
warnings: List[Finding] = []
|
|
174
|
+
|
|
175
|
+
if not isinstance(body, dict):
|
|
176
|
+
errors.append(Finding("$", "schema root must be a JSON object."))
|
|
177
|
+
return ValidationResult(ok=False, errors=errors, warnings=warnings)
|
|
178
|
+
|
|
179
|
+
aid = body.get("analyzerId") or body.get("analyzer_id")
|
|
180
|
+
if aid is not None:
|
|
181
|
+
aid_error = custom_analyzer_id_error(aid)
|
|
182
|
+
if aid_error:
|
|
183
|
+
errors.append(Finding("analyzerId", aid_error))
|
|
184
|
+
|
|
185
|
+
base = body.get("baseAnalyzerId") or body.get("base_analyzer_id")
|
|
186
|
+
if base is not None and (not isinstance(base, str) or not _BASE_ANALYZER_ID_RE.match(base)):
|
|
187
|
+
errors.append(Finding("baseAnalyzerId", "must match ^[a-zA-Z0-9._-]{1,64}$."))
|
|
188
|
+
elif base is None:
|
|
189
|
+
warnings.append(Finding("baseAnalyzerId",
|
|
190
|
+
"no `baseAnalyzerId`; recommend `prebuilt-document`."))
|
|
191
|
+
|
|
192
|
+
pinned = body.get("apiVersion")
|
|
193
|
+
if pinned is not None and api_version is not None and pinned != api_version:
|
|
194
|
+
# Callers pass the already-reconciled api_version; a residual mismatch
|
|
195
|
+
# here is surfaced so the schema and resolved version never diverge.
|
|
196
|
+
errors.append(Finding("apiVersion",
|
|
197
|
+
f"schema pins '{pinned}' but resolved api-version is '{api_version}'."))
|
|
198
|
+
|
|
199
|
+
models = body.get("models")
|
|
200
|
+
if models is not None and not isinstance(models, dict):
|
|
201
|
+
errors.append(Finding("models", "must be an object mapping role -> model name."))
|
|
202
|
+
|
|
203
|
+
has_content_categories = False
|
|
204
|
+
cfg = body.get("config")
|
|
205
|
+
if cfg is not None:
|
|
206
|
+
if not isinstance(cfg, dict):
|
|
207
|
+
errors.append(Finding("config", "must be an object."))
|
|
208
|
+
else:
|
|
209
|
+
for bool_key in ("enableSegment", "segmentPerPage", "estimateFieldSourceAndConfidence"):
|
|
210
|
+
val = cfg.get(bool_key)
|
|
211
|
+
if val is not None and not isinstance(val, bool):
|
|
212
|
+
errors.append(Finding(f"config.{bool_key}", "must be a boolean."))
|
|
213
|
+
|
|
214
|
+
cats = cfg.get("contentCategories")
|
|
215
|
+
if cats is not None:
|
|
216
|
+
if not isinstance(cats, dict) or not cats:
|
|
217
|
+
errors.append(Finding("config.contentCategories",
|
|
218
|
+
"must be a non-empty object when provided."))
|
|
219
|
+
else:
|
|
220
|
+
has_content_categories = True
|
|
221
|
+
for cat_name, cat_def in cats.items():
|
|
222
|
+
if not isinstance(cat_def, dict):
|
|
223
|
+
errors.append(Finding(
|
|
224
|
+
f"config.contentCategories.{cat_name}",
|
|
225
|
+
"must be an object with category definition fields.",
|
|
226
|
+
))
|
|
227
|
+
continue
|
|
228
|
+
|
|
229
|
+
desc = cat_def.get("description")
|
|
230
|
+
if desc is None or (isinstance(desc, str) and not desc.strip()):
|
|
231
|
+
warnings.append(Finding(
|
|
232
|
+
f"config.contentCategories.{cat_name}.description",
|
|
233
|
+
"no `description` — classification quality improves with clear category intent.",
|
|
234
|
+
))
|
|
235
|
+
elif not isinstance(desc, str):
|
|
236
|
+
errors.append(Finding(
|
|
237
|
+
f"config.contentCategories.{cat_name}.description",
|
|
238
|
+
"must be a string.",
|
|
239
|
+
))
|
|
240
|
+
|
|
241
|
+
analyzer_id = cat_def.get("analyzerId")
|
|
242
|
+
if analyzer_id is not None:
|
|
243
|
+
if not isinstance(analyzer_id, str) or not _BASE_ANALYZER_ID_RE.match(analyzer_id):
|
|
244
|
+
errors.append(Finding(
|
|
245
|
+
f"config.contentCategories.{cat_name}.analyzerId",
|
|
246
|
+
"must match ^[a-zA-Z0-9._-]{1,64}$.",
|
|
247
|
+
))
|
|
248
|
+
|
|
249
|
+
fs = body.get("fieldSchema") or body.get("field_schema")
|
|
250
|
+
if fs is None:
|
|
251
|
+
if not has_content_categories:
|
|
252
|
+
warnings.append(Finding("fieldSchema", "no `fieldSchema`; layout/markdown only."))
|
|
253
|
+
elif not isinstance(fs, dict):
|
|
254
|
+
errors.append(Finding("fieldSchema", "must be an object."))
|
|
255
|
+
else:
|
|
256
|
+
fields = fs.get("fields")
|
|
257
|
+
if fields is None:
|
|
258
|
+
warnings.append(Finding("fieldSchema.fields", "no `fields`; layout only."))
|
|
259
|
+
elif not isinstance(fields, dict):
|
|
260
|
+
errors.append(Finding("fieldSchema.fields", "must be an object."))
|
|
261
|
+
elif fields:
|
|
262
|
+
models_map = models if isinstance(models, dict) else {}
|
|
263
|
+
if not models_map.get("completion"):
|
|
264
|
+
warnings.append(Finding("models.completion",
|
|
265
|
+
"field extraction needs a completion model, e.g. "
|
|
266
|
+
"`\"models\": {\"completion\": \"gpt-5.2\"}`."))
|
|
267
|
+
for name, defn in fields.items():
|
|
268
|
+
_validate_field(name, defn, f"fieldSchema.fields.{name}", 1, errors, warnings)
|
|
269
|
+
|
|
270
|
+
defs = fs.get("definitions")
|
|
271
|
+
if defs is not None:
|
|
272
|
+
if not isinstance(defs, dict):
|
|
273
|
+
errors.append(Finding("fieldSchema.definitions", "must be an object."))
|
|
274
|
+
else:
|
|
275
|
+
for name, defn in defs.items():
|
|
276
|
+
_validate_field(name, defn, f"fieldSchema.definitions.{name}", 1,
|
|
277
|
+
errors, warnings)
|
|
278
|
+
|
|
279
|
+
return ValidationResult(ok=not errors, errors=errors, warnings=warnings)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def schema_pinned_version(body: Any) -> Optional[str]:
|
|
283
|
+
if isinstance(body, dict):
|
|
284
|
+
v = body.get("apiVersion")
|
|
285
|
+
return v if isinstance(v, str) else None
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def first_error_line(result: ValidationResult, schema_path: str) -> str:
|
|
290
|
+
"""The design-doc 'Invalid schema (structural)' one-liner for the first error."""
|
|
291
|
+
e = result.errors[0]
|
|
292
|
+
return (
|
|
293
|
+
f"Schema invalid: {schema_path} — {e.path}: {e.msg} "
|
|
294
|
+
"Run `cu analyzer validate --help` for validation guidance."
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def parse_and_validate(text: str, *, api_version: Optional[str] = None
|
|
299
|
+
) -> Tuple[ValidationResult, Optional[dict]]:
|
|
300
|
+
"""Parse JSON text, then validate. JSON errors are returned as a validation
|
|
301
|
+
error (exit-2 territory) rather than raised."""
|
|
302
|
+
try:
|
|
303
|
+
body = json.loads(text)
|
|
304
|
+
except json.JSONDecodeError as exc:
|
|
305
|
+
res = ValidationResult(
|
|
306
|
+
ok=False,
|
|
307
|
+
errors=[Finding("$", f"file is not valid JSON: {exc.msg} "
|
|
308
|
+
f"at line {exc.lineno} col {exc.colno}.")],
|
|
309
|
+
)
|
|
310
|
+
return res, None
|
|
311
|
+
result = validate_schema(body, api_version=api_version)
|
|
312
|
+
return result, body if isinstance(body, dict) else None
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation.
|
|
2
|
+
# Licensed under the MIT license.
|
|
3
|
+
|
|
4
|
+
"""Convert CU and Azure SDK values into frontend-neutral plain values."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import Mapping, Sequence
|
|
9
|
+
from dataclasses import asdict, is_dataclass
|
|
10
|
+
from datetime import date, datetime, time
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .errors import ValidationError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def to_plain_value(value: Any) -> Any:
|
|
19
|
+
"""Recursively convert supported values to dictionaries, lists, and scalars."""
|
|
20
|
+
|
|
21
|
+
return _to_plain_value(value, seen=set())
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _to_plain_value(value: Any, *, seen: set[int]) -> Any:
|
|
25
|
+
if value is None or isinstance(value, (str, int, float, bool)):
|
|
26
|
+
return value
|
|
27
|
+
if isinstance(value, Enum):
|
|
28
|
+
return _to_plain_value(value.value, seen=seen)
|
|
29
|
+
if isinstance(value, (date, datetime, time)):
|
|
30
|
+
return value.isoformat()
|
|
31
|
+
if isinstance(value, Path):
|
|
32
|
+
return str(value)
|
|
33
|
+
|
|
34
|
+
identity = id(value)
|
|
35
|
+
if identity in seen:
|
|
36
|
+
raise ValidationError("cannot serialize a cyclic result value")
|
|
37
|
+
|
|
38
|
+
if hasattr(value, "as_dict"):
|
|
39
|
+
seen.add(identity)
|
|
40
|
+
try:
|
|
41
|
+
converted = value.as_dict()
|
|
42
|
+
except Exception as exc:
|
|
43
|
+
raise ValidationError(
|
|
44
|
+
f"failed to serialize {type(value).__name__} through as_dict()"
|
|
45
|
+
) from exc
|
|
46
|
+
finally:
|
|
47
|
+
seen.remove(identity)
|
|
48
|
+
return _to_plain_value(converted, seen=seen)
|
|
49
|
+
|
|
50
|
+
if is_dataclass(value) and not isinstance(value, type):
|
|
51
|
+
seen.add(identity)
|
|
52
|
+
try:
|
|
53
|
+
converted = asdict(value)
|
|
54
|
+
finally:
|
|
55
|
+
seen.remove(identity)
|
|
56
|
+
return _to_plain_value(converted, seen=seen)
|
|
57
|
+
|
|
58
|
+
if isinstance(value, Mapping):
|
|
59
|
+
seen.add(identity)
|
|
60
|
+
try:
|
|
61
|
+
return {
|
|
62
|
+
str(key): _to_plain_value(item, seen=seen)
|
|
63
|
+
for key, item in value.items()
|
|
64
|
+
}
|
|
65
|
+
finally:
|
|
66
|
+
seen.remove(identity)
|
|
67
|
+
|
|
68
|
+
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
|
69
|
+
seen.add(identity)
|
|
70
|
+
try:
|
|
71
|
+
return [_to_plain_value(item, seen=seen) for item in value]
|
|
72
|
+
finally:
|
|
73
|
+
seen.remove(identity)
|
|
74
|
+
|
|
75
|
+
raise ValidationError(f"unsupported result value: {type(value).__name__}")
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation.
|
|
2
|
+
# Licensed under the MIT license.
|
|
3
|
+
|
|
4
|
+
"""Static service-option metadata shared by command frontends."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
from .command_spec import ArgumentValueType, SurfaceClassification
|
|
11
|
+
|
|
12
|
+
DEFAULT_API_VERSION = "2025-11-01"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class ServiceOptionSpec:
|
|
17
|
+
key: str
|
|
18
|
+
name: str
|
|
19
|
+
parser_name: str
|
|
20
|
+
help: str
|
|
21
|
+
value_type: ArgumentValueType = ArgumentValueType.STRING
|
|
22
|
+
aliases: tuple[str, ...] = ()
|
|
23
|
+
required: bool = False
|
|
24
|
+
default: object = None
|
|
25
|
+
choices: tuple[str, ...] = ()
|
|
26
|
+
sensitive: bool = False
|
|
27
|
+
classification: SurfaceClassification = SurfaceClassification.COMMON
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
ENDPOINT = ServiceOptionSpec(
|
|
31
|
+
key="endpoint",
|
|
32
|
+
name="--endpoint",
|
|
33
|
+
parser_name="endpoint",
|
|
34
|
+
help="Microsoft Foundry resource endpoint for Content Understanding.",
|
|
35
|
+
)
|
|
36
|
+
API_VERSION = ServiceOptionSpec(
|
|
37
|
+
key="api-version",
|
|
38
|
+
name="--api-version",
|
|
39
|
+
parser_name="api_version",
|
|
40
|
+
help="Content Understanding service API version.",
|
|
41
|
+
default=DEFAULT_API_VERSION,
|
|
42
|
+
)
|
|
43
|
+
AUTH_MODE = ServiceOptionSpec(
|
|
44
|
+
key="auth-mode",
|
|
45
|
+
name="--auth-mode",
|
|
46
|
+
parser_name="auth_mode",
|
|
47
|
+
help="Authentication mode.",
|
|
48
|
+
default="login",
|
|
49
|
+
choices=("login", "key"),
|
|
50
|
+
)
|
|
51
|
+
API_KEY = ServiceOptionSpec(
|
|
52
|
+
key="api-key",
|
|
53
|
+
name="--api-key",
|
|
54
|
+
parser_name="api_key",
|
|
55
|
+
help="Microsoft Foundry resource API key.",
|
|
56
|
+
sensitive=True,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
SERVICE_OPTIONS = (ENDPOINT, API_VERSION, AUTH_MODE, API_KEY)
|
|
60
|
+
_SERVICE_OPTIONS_BY_KEY = {option.key: option for option in SERVICE_OPTIONS}
|
|
61
|
+
|
|
62
|
+
if len(_SERVICE_OPTIONS_BY_KEY) != len(SERVICE_OPTIONS):
|
|
63
|
+
raise RuntimeError("duplicate service option key")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_service_option(key: str) -> ServiceOptionSpec:
|
|
67
|
+
return _SERVICE_OPTIONS_BY_KEY[key]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def service_options_for(keys: tuple[str, ...]) -> tuple[ServiceOptionSpec, ...]:
|
|
71
|
+
return tuple(get_service_option(key) for key in keys)
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation.
|
|
2
|
+
# Licensed under the MIT license.
|
|
3
|
+
|
|
4
|
+
"""Spec-backed structural validation against the bundled CU OpenAPI (Swagger 2.0).
|
|
5
|
+
|
|
6
|
+
The CU service contract is published as ``ContentUnderstanding.json`` (Swagger
|
|
7
|
+
2.0, whose ``definitions`` are JSON Schema Draft 4). We bundle that JSON per
|
|
8
|
+
api-version and validate an analyzer body against the ``ContentAnalyzer``
|
|
9
|
+
definition with ``jsonschema`` — so the structural rules come straight from the
|
|
10
|
+
service contract instead of a hand-maintained list.
|
|
11
|
+
|
|
12
|
+
Two adaptations are applied:
|
|
13
|
+
|
|
14
|
+
* **Create-view** — the ``ContentAnalyzer`` model marks server-generated fields
|
|
15
|
+
(``status``, ``createdAt``, ``lastModifiedAt``, ``analyzerId``) as ``readOnly``
|
|
16
|
+
yet lists them in ``required``. Those don't exist in an authoring/create body,
|
|
17
|
+
so we drop ``readOnly`` properties from every object's ``required`` list.
|
|
18
|
+
* **Graceful degradation** — if the bundled spec or ``jsonschema`` is
|
|
19
|
+
unavailable, ``validate_against_spec`` returns an OK result with no findings so
|
|
20
|
+
the primary (rule-based) validator is never blocked.
|
|
21
|
+
|
|
22
|
+
This module is an *additional* structural gate (opt-in via ``cu analyzer
|
|
23
|
+
validate --spec``); the curated, message-friendly checks in ``schema_validate``
|
|
24
|
+
remain the default.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import copy
|
|
30
|
+
import json
|
|
31
|
+
from functools import lru_cache
|
|
32
|
+
from importlib import resources as ir
|
|
33
|
+
from typing import Any, List
|
|
34
|
+
|
|
35
|
+
from .schema_validation import Finding, ValidationResult
|
|
36
|
+
from .service_options import DEFAULT_API_VERSION
|
|
37
|
+
|
|
38
|
+
_ANALYZER_DEF = "ContentAnalyzer"
|
|
39
|
+
_SPEC_URI = "urn:cu-spec"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _spec_resource(api_version: str) -> str:
|
|
43
|
+
return f"openapi/{api_version}/ContentUnderstanding.json"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@lru_cache(maxsize=None)
|
|
47
|
+
def _load_spec(api_version: str) -> dict | None:
|
|
48
|
+
try:
|
|
49
|
+
text = (ir.files("cu_cli_core")
|
|
50
|
+
.joinpath(f"resources/{_spec_resource(api_version)}")
|
|
51
|
+
.read_text(encoding="utf-8"))
|
|
52
|
+
data = json.loads(text)
|
|
53
|
+
return data if isinstance(data, dict) else None
|
|
54
|
+
except Exception:
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def spec_available(api_version: str = DEFAULT_API_VERSION) -> bool:
|
|
59
|
+
"""True when the bundled OpenAPI spec exists and has the analyzer definition."""
|
|
60
|
+
spec = _load_spec(api_version)
|
|
61
|
+
return bool(spec and isinstance(spec.get("definitions"), dict)
|
|
62
|
+
and _ANALYZER_DEF in spec["definitions"])
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def spec_allowed_types(api_version: str = DEFAULT_API_VERSION) -> List[str]:
|
|
66
|
+
spec = _load_spec(api_version) or {}
|
|
67
|
+
enum = (spec.get("definitions", {}).get("ContentFieldType", {}) or {}).get("enum") or []
|
|
68
|
+
return sorted(str(v) for v in enum)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def spec_allowed_methods(api_version: str = DEFAULT_API_VERSION) -> List[str]:
|
|
72
|
+
spec = _load_spec(api_version) or {}
|
|
73
|
+
enum = (spec.get("definitions", {}).get("GenerationMethod", {}) or {}).get("enum") or []
|
|
74
|
+
return sorted(str(v) for v in enum)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _create_view_defs(defs: dict) -> dict:
|
|
78
|
+
"""Return a copy of *defs* with ``readOnly`` props removed from ``required``."""
|
|
79
|
+
out = copy.deepcopy(defs)
|
|
80
|
+
for d in out.values():
|
|
81
|
+
if isinstance(d, dict) and d.get("type") == "object" and isinstance(d.get("required"), list):
|
|
82
|
+
props = d.get("properties") or {}
|
|
83
|
+
d["required"] = [
|
|
84
|
+
r for r in d["required"]
|
|
85
|
+
if not (isinstance(props.get(r), dict) and props[r].get("readOnly"))
|
|
86
|
+
]
|
|
87
|
+
return out
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@lru_cache(maxsize=None)
|
|
91
|
+
def _build_validator(api_version: str):
|
|
92
|
+
spec = _load_spec(api_version)
|
|
93
|
+
if not spec:
|
|
94
|
+
return None
|
|
95
|
+
defs = spec.get("definitions")
|
|
96
|
+
if not isinstance(defs, dict) or _ANALYZER_DEF not in defs:
|
|
97
|
+
return None
|
|
98
|
+
try:
|
|
99
|
+
from jsonschema import Draft4Validator
|
|
100
|
+
from referencing import Registry, Resource
|
|
101
|
+
from referencing.jsonschema import DRAFT4
|
|
102
|
+
except Exception: # pragma: no cover - deps are declared, defensive only
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
create_view = {"definitions": _create_view_defs(defs)}
|
|
106
|
+
resource = Resource.from_contents(create_view, default_specification=DRAFT4)
|
|
107
|
+
registry = Registry().with_resource(uri=_SPEC_URI, resource=resource)
|
|
108
|
+
return Draft4Validator(
|
|
109
|
+
{"$ref": f"{_SPEC_URI}#/definitions/{_ANALYZER_DEF}"},
|
|
110
|
+
registry=registry,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _dotted(parts: list) -> str:
|
|
115
|
+
out = ""
|
|
116
|
+
for p in parts:
|
|
117
|
+
if isinstance(p, int):
|
|
118
|
+
out += f"[{p}]"
|
|
119
|
+
else:
|
|
120
|
+
out += f".{p}" if out else str(p)
|
|
121
|
+
return out or "$"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def validate_against_spec(body: Any, *, api_version: str = DEFAULT_API_VERSION) -> ValidationResult:
|
|
125
|
+
"""Validate *body* against the bundled CU ``ContentAnalyzer`` (create-view).
|
|
126
|
+
|
|
127
|
+
Returns an OK result with no findings when the spec or ``jsonschema`` are
|
|
128
|
+
unavailable, so it never blocks the primary validator.
|
|
129
|
+
"""
|
|
130
|
+
validator = _build_validator(api_version)
|
|
131
|
+
if validator is None:
|
|
132
|
+
return ValidationResult(ok=True)
|
|
133
|
+
if not isinstance(body, dict):
|
|
134
|
+
return ValidationResult(
|
|
135
|
+
ok=False, errors=[Finding("$", "schema root must be a JSON object.")]
|
|
136
|
+
)
|
|
137
|
+
errors: List[Finding] = []
|
|
138
|
+
for err in sorted(validator.iter_errors(body), key=lambda e: list(e.absolute_path)):
|
|
139
|
+
path = _dotted(list(err.absolute_path))
|
|
140
|
+
errors.append(Finding(path, f"{err.message} (per CU {api_version} spec)"))
|
|
141
|
+
return ValidationResult(ok=not errors, errors=errors)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cu-cli-core
|
|
3
|
+
Version: 0.1.0b1
|
|
4
|
+
Summary: Framework-neutral command contracts and operations for Azure Content Understanding CLIs.
|
|
5
|
+
Author: Microsoft Corporation
|
|
6
|
+
License: MIT
|
|
7
|
+
Classifier: Development Status :: 4 - Beta
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: azure-ai-contentunderstanding>=1.2.0b3
|
|
11
|
+
Requires-Dist: jsonschema>=4.18
|
|
12
|
+
Requires-Dist: referencing>=0.30
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest>=7.4; extra == "dev"
|
|
15
|
+
Requires-Dist: pytest-cov>=4.1; extra == "dev"
|
|
16
|
+
Requires-Dist: mypy<2,>=1.8; extra == "dev"
|
|
17
|
+
Requires-Dist: ruff<0.16,>=0.5; extra == "dev"
|
|
18
|
+
|
|
19
|
+
# CU CLI Core
|
|
20
|
+
|
|
21
|
+
`cu-cli-core` is an implementation dependency shared by official Azure Content
|
|
22
|
+
Understanding command-line frontends. Install
|
|
23
|
+
[`cu-cli`](https://pypi.org/project/cu-cli/) instead.
|
|
24
|
+
|
|
25
|
+
Direct use of this package is unsupported. Its contracts may change between
|
|
26
|
+
compatible CU CLI releases.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
cu_cli_core/__init__.py,sha256=DHWkIHgv3xPo9hd_P15Q0CjynCcSCWi57qlqGCgLy1k,290
|
|
2
|
+
cu_cli_core/analysis.py,sha256=pePCCKLsVZnzTc6tnZqky7BFB_-XU6WYH63evcmBxV0,12500
|
|
3
|
+
cu_cli_core/client.py,sha256=8x1RT_taZeCRdo2mWNjaMWXMZF0K6MitZ2ldEKatDOQ,1177
|
|
4
|
+
cu_cli_core/command_spec.py,sha256=0i3OSa6hwJt325hj4cuSO6xmnnu9vipNFMyV7GfniIs,36043
|
|
5
|
+
cu_cli_core/contracts.py,sha256=OZCQsA4e6hrj747nbKT8-NRx_1diFNQ3f27Vlp87nx4,11016
|
|
6
|
+
cu_cli_core/defaults.py,sha256=EBzIocHnK8Q3L3rYJanBrug0qTD9aMAiORo6izKftD0,4854
|
|
7
|
+
cu_cli_core/environment.py,sha256=ThvebulIgf_kKSyKncxQ_Mw1z4_8PGHYwLIfDmu08XY,3258
|
|
8
|
+
cu_cli_core/errors.py,sha256=wDCZZghsoUarc47k2drvbTaxYQAUUdz7sc_WdxC1GMQ,1728
|
|
9
|
+
cu_cli_core/input_planning.py,sha256=xrlHZ_8H25VEOu0waMICcCOgueYI4EZ9_WpGPp9Q3eQ,11147
|
|
10
|
+
cu_cli_core/profiles.py,sha256=4z6H1hy2QLwZIcp6e5rV71-bX4ur3H4rmBRtxjouYSU,22800
|
|
11
|
+
cu_cli_core/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
cu_cli_core/schema.py,sha256=uJj4PlNi920m6jWoj0VF3vC6vsr8dbhpoJbEYkko2Y8,14137
|
|
13
|
+
cu_cli_core/schema_validation.py,sha256=GR2z4SG7CDbNMbOgSaI72A8BVG9KYjh7mQIrpGDdfY8,13442
|
|
14
|
+
cu_cli_core/serialization.py,sha256=NPNlZQesjsPuJB2nbfalcRrQQ72sSICkK4W_Rbki0B4,2330
|
|
15
|
+
cu_cli_core/service_options.py,sha256=hXQJ8kfQlgWROxY3RGkHOowd292KYxu9OWF9xnJBoy8,1944
|
|
16
|
+
cu_cli_core/spec_validation.py,sha256=De-YAVxfqeEmzR_HKLqVy01vdNLfq7U4RCgdMFDppbM,5385
|
|
17
|
+
cu_cli_core/operations/__init__.py,sha256=a9Po_SPpPzPCFtAF8RNG5FwsFz7gDYir6-vmTydCMww,130
|
|
18
|
+
cu_cli_core/operations/analysis.py,sha256=yCaWq7vimXI-V2ClXnC3mf4445LnZNu-6TMpFsOzHck,9376
|
|
19
|
+
cu_cli_core/operations/analyzer_copy.py,sha256=uvTgVb3Lf5TpvhoPXvVzQj_8rry1EF3JHKgqJJhMk0Q,13751
|
|
20
|
+
cu_cli_core/operations/analyzers.py,sha256=HGICe9b91kFVRRVYltObRRr5M_4mtmAGisvLVhm1OCI,2715
|
|
21
|
+
cu_cli_core/operations/profiles.py,sha256=VPZppZ0blbFBKamZvBnhwbtfgO8rW3NXEQJokLDD-So,3708
|
|
22
|
+
cu_cli_core/operations/schema.py,sha256=3ILQTqlzvS14wzWGhBELkRPeUKQNZ5FkHKiGiPYUu0M,1315
|
|
23
|
+
cu_cli_core/operations/validation.py,sha256=WbfSvO9hPdc5uZnyYzH0jNZSub0eFHTwtjUQthrAQv8,1383
|
|
24
|
+
cu_cli_core/resources/openapi/2025-11-01/ContentUnderstanding.json,sha256=_lEnj8-0aEn7mSQUPtZsHHffSKFlciZUgNKw4__CFhQ,111923
|
|
25
|
+
cu_cli_core/resources/openapi/2026-06-01-preview/ContentUnderstanding.json,sha256=xb8oVxyD0q_j7bmvr1LUEwXUWO7aaR3mL73PUPucXhQ,127937
|
|
26
|
+
cu_cli_core-0.1.0b1.dist-info/METADATA,sha256=lpiAjBT_QbuOvEhSqGuzr5OpDzVjGg1HHwoXcYDniwA,925
|
|
27
|
+
cu_cli_core-0.1.0b1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
28
|
+
cu_cli_core-0.1.0b1.dist-info/top_level.txt,sha256=bvblrW7CaZDgJVrnNCtKh5AACehffTYE3-xTNIEIpFw,12
|
|
29
|
+
cu_cli_core-0.1.0b1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cu_cli_core
|