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,404 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT license.
3
+
4
+ """Typed request and planning contracts shared by CU command frontends."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass, field
9
+ from enum import Enum
10
+ from pathlib import Path
11
+ from typing import Any, Mapping
12
+
13
+
14
+ class ResultView(str, Enum):
15
+ LLM_INPUT = "llm-input"
16
+ FULL = "full"
17
+
18
+
19
+ class SelectionMode(str, Enum):
20
+ POSITIONAL = "positional"
21
+ NAMED_FILES = "named-files"
22
+ NAMED_SOURCES = "named-sources"
23
+
24
+
25
+ class InputOrigin(str, Enum):
26
+ POSITIONAL_FILE = "positional-file"
27
+ POSITIONAL_SOURCE = "positional-source"
28
+ NAMED_FILE = "named-file"
29
+ NAMED_SOURCE = "named-source"
30
+
31
+
32
+ class ExistingResultPolicy(str, Enum):
33
+ ERROR = "error"
34
+ SKIP = "skip"
35
+ REANALYZE = "reanalyze"
36
+
37
+
38
+ class AuthMode(str, Enum):
39
+ LOGIN = "login"
40
+ KEY = "key"
41
+
42
+
43
+ class OutcomeStatus(str, Enum):
44
+ SUCCEEDED = "succeeded"
45
+ FAILED = "failed"
46
+ SKIPPED = "skipped"
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class ProfileShowRequest:
51
+ name: str | None = None
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class ProfileListRequest:
56
+ pass
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class ProfileGetRequest:
61
+ key: str
62
+ name: str | None = None
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class ProfileSetRequest:
67
+ key: str
68
+ value: str
69
+ name: str | None = None
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class ProfileUnsetRequest:
74
+ key: str
75
+ name: str | None = None
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class ProfileCreateRequest:
80
+ name: str
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class ProfileDeleteRequest:
85
+ name: str
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class ProfileCopyRequest:
90
+ destination: str
91
+ source: str | None = None
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class ProfileRenameRequest:
96
+ source: str
97
+ destination: str
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class ProfileSetActiveRequest:
102
+ name: str
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class ProfileSyncModelsRequest:
107
+ name: str | None = None
108
+
109
+
110
+ @dataclass(frozen=True)
111
+ class AnalyzerShowRequest:
112
+ """Canonical request for retrieving one analyzer."""
113
+
114
+ name: str
115
+
116
+ def __post_init__(self) -> None:
117
+ normalized = self.name.strip()
118
+ if not normalized:
119
+ raise ValueError("analyzer name cannot be empty.")
120
+ object.__setattr__(self, "name", normalized)
121
+
122
+
123
+ @dataclass(frozen=True)
124
+ class AnalyzerListRequest:
125
+ kind: str = "all"
126
+ sort_by: str = "analyzerId"
127
+
128
+ def __post_init__(self) -> None:
129
+ if self.kind not in {"all", "prebuilt", "custom"}:
130
+ raise ValueError(f"invalid analyzer kind: {self.kind}")
131
+ if self.sort_by not in {"analyzerId", "createdAt", "lastModifiedAt"}:
132
+ raise ValueError(f"invalid analyzer sort field: {self.sort_by}")
133
+
134
+
135
+ @dataclass(frozen=True)
136
+ class AnalyzerCreateRequest:
137
+ name: str
138
+ schema: Path
139
+
140
+ def __post_init__(self) -> None:
141
+ normalized = self.name.strip()
142
+ if not normalized:
143
+ raise ValueError("analyzer name cannot be empty.")
144
+ object.__setattr__(self, "name", normalized)
145
+ object.__setattr__(self, "schema", Path(self.schema))
146
+
147
+
148
+ @dataclass(frozen=True)
149
+ class AnalyzerDeleteRequest:
150
+ name: str
151
+ yes: bool = False
152
+
153
+ def __post_init__(self) -> None:
154
+ normalized = self.name.strip()
155
+ if not normalized:
156
+ raise ValueError("analyzer name cannot be empty.")
157
+ object.__setattr__(self, "name", normalized)
158
+
159
+
160
+ @dataclass(frozen=True)
161
+ class AnalyzeRequest:
162
+ positional_inputs: tuple[Path, ...] = ()
163
+ files: tuple[Path, ...] = ()
164
+ sources: tuple[Path, ...] = ()
165
+ pattern: str | None = None
166
+ recursive: bool = False
167
+ analyzer: str | None = None
168
+ inline: bool = False
169
+ usage: bool = False
170
+ llm_input: bool = False
171
+ output_file: Path | None = None
172
+ output_dir: Path | None = None
173
+ on_existing: ExistingResultPolicy | None = None
174
+ dry_run: bool = False
175
+ yes: bool = False
176
+ report_file: Path | None = None
177
+ concurrency: int = 4
178
+
179
+ def __post_init__(self) -> None:
180
+ object.__setattr__(
181
+ self,
182
+ "positional_inputs",
183
+ tuple(Path(path) for path in self.positional_inputs),
184
+ )
185
+ object.__setattr__(self, "files", tuple(Path(path) for path in self.files))
186
+ object.__setattr__(self, "sources", tuple(Path(path) for path in self.sources))
187
+ if self.output_file is not None:
188
+ object.__setattr__(self, "output_file", Path(self.output_file))
189
+ if self.output_dir is not None:
190
+ object.__setattr__(self, "output_dir", Path(self.output_dir))
191
+ if self.report_file is not None:
192
+ object.__setattr__(self, "report_file", Path(self.report_file))
193
+ if isinstance(self.on_existing, str):
194
+ object.__setattr__(
195
+ self,
196
+ "on_existing",
197
+ ExistingResultPolicy(self.on_existing),
198
+ )
199
+ if not 1 <= self.concurrency <= 32:
200
+ raise ValueError("concurrency must be between 1 and 32.")
201
+
202
+
203
+ @dataclass(frozen=True)
204
+ class AnalyzerTestRequest:
205
+ name: str
206
+ positional_inputs: tuple[Path, ...] = ()
207
+ files: tuple[Path, ...] = ()
208
+ sources: tuple[Path, ...] = ()
209
+ pattern: str | None = None
210
+ recursive: bool = False
211
+ dry_run: bool = False
212
+ output_file: Path | None = None
213
+ force: bool = False
214
+ yes: bool = False
215
+ concurrency: int = 4
216
+
217
+ def __post_init__(self) -> None:
218
+ normalized = self.name.strip()
219
+ if not normalized:
220
+ raise ValueError("analyzer name cannot be empty.")
221
+ object.__setattr__(self, "name", normalized)
222
+ object.__setattr__(
223
+ self,
224
+ "positional_inputs",
225
+ tuple(Path(path) for path in self.positional_inputs),
226
+ )
227
+ object.__setattr__(self, "files", tuple(Path(path) for path in self.files))
228
+ object.__setattr__(self, "sources", tuple(Path(path) for path in self.sources))
229
+ if self.output_file is not None:
230
+ object.__setattr__(self, "output_file", Path(self.output_file))
231
+ if not 1 <= self.concurrency <= 16:
232
+ raise ValueError("concurrency must be between 1 and 16.")
233
+
234
+
235
+ @dataclass(frozen=True)
236
+ class AnalyzerCopyRequest:
237
+ source: str
238
+ destination: str
239
+ source_resource: str | None = None
240
+ source_subscription: str | None = None
241
+ source_resource_group: str | None = None
242
+ source_profile: str | None = None
243
+ destination_resource: str | None = None
244
+ destination_subscription: str | None = None
245
+ destination_resource_group: str | None = None
246
+ destination_profile: str | None = None
247
+
248
+ def __post_init__(self) -> None:
249
+ source = self.source.strip()
250
+ destination = self.destination.strip()
251
+ if not source or not destination:
252
+ raise ValueError("source and destination analyzer names are required.")
253
+ object.__setattr__(self, "source", source)
254
+ object.__setattr__(self, "destination", destination)
255
+ if self.source_resource and self.source_profile:
256
+ raise ValueError(
257
+ "--source-resource and --source-profile are mutually exclusive."
258
+ )
259
+ if self.destination_resource and self.destination_profile:
260
+ raise ValueError(
261
+ "--destination-resource and --destination-profile are mutually exclusive."
262
+ )
263
+
264
+
265
+ @dataclass(frozen=True)
266
+ class AnalyzerValidateRequest:
267
+ schema: Path
268
+ strict: bool = False
269
+ spec: bool = False
270
+ api_version: str | None = None
271
+
272
+ def __post_init__(self) -> None:
273
+ object.__setattr__(self, "schema", Path(self.schema))
274
+
275
+
276
+ @dataclass(frozen=True)
277
+ class AnalyzerSchemaCreateRequest:
278
+ from_template: bool = False
279
+ from_sample: Path | None = None
280
+ name: str = "my_analyzer_v1"
281
+ base: str | None = None
282
+ modality: str = "document"
283
+ output_file: Path | None = None
284
+ template_type: str = "extraction"
285
+ force: bool = False
286
+
287
+ def __post_init__(self) -> None:
288
+ name = self.name.strip()
289
+ if not name:
290
+ raise ValueError("analyzer name cannot be empty.")
291
+ object.__setattr__(self, "name", name)
292
+ if self.from_sample is not None:
293
+ object.__setattr__(self, "from_sample", Path(self.from_sample))
294
+ if self.output_file is not None:
295
+ object.__setattr__(self, "output_file", Path(self.output_file))
296
+ if self.from_template and self.from_sample is not None:
297
+ raise ValueError("--from-template and --from-sample cannot be combined.")
298
+ if self.modality not in {"document", "image", "audio", "video"}:
299
+ raise ValueError(f"invalid modality: {self.modality}")
300
+ if self.template_type not in {"extraction", "classification"}:
301
+ raise ValueError(f"invalid schema type: {self.template_type}")
302
+ if self.from_sample is not None and (
303
+ self.template_type != "extraction"
304
+ or self.modality != "document"
305
+ or self.base is not None
306
+ ):
307
+ raise ValueError(
308
+ "--from-sample cannot be combined with --type classification, "
309
+ "--modality, or --base."
310
+ )
311
+
312
+
313
+ @dataclass(frozen=True)
314
+ class DefaultsShowRequest:
315
+ pass
316
+
317
+
318
+ @dataclass(frozen=True)
319
+ class DefaultsSetRequest:
320
+ models: tuple[str, ...] = ()
321
+ from_profile: bool = False
322
+ replace: bool = False
323
+
324
+
325
+ @dataclass(frozen=True)
326
+ class EnvironmentVariableListRequest:
327
+ pass
328
+
329
+
330
+ @dataclass(frozen=True)
331
+ class PlannedInput:
332
+ path: Path
333
+ source_root: Path
334
+ relative_path: Path
335
+ origin: InputOrigin
336
+ size_bytes: int
337
+
338
+
339
+ @dataclass(frozen=True)
340
+ class SkippedInput:
341
+ path: Path
342
+ reason: str
343
+
344
+
345
+ @dataclass(frozen=True)
346
+ class InputPlan:
347
+ inputs: tuple[PlannedInput, ...]
348
+ mode: SelectionMode
349
+ recursive: bool
350
+ pattern: str | None
351
+ total_bytes: int
352
+ extension_counts: Mapping[str, int] = field(default_factory=dict)
353
+ skipped: tuple[SkippedInput, ...] = ()
354
+
355
+
356
+ @dataclass(frozen=True)
357
+ class PlannedOutput:
358
+ source: PlannedInput
359
+ path: Path | None
360
+ exists: bool = False
361
+ skipped: bool = False
362
+
363
+
364
+ @dataclass(frozen=True)
365
+ class ExecutionPlan:
366
+ input_plan: InputPlan
367
+ outputs: tuple[PlannedOutput, ...]
368
+ on_existing: ExistingResultPolicy
369
+ dry_run: bool
370
+
371
+
372
+ @dataclass(frozen=True)
373
+ class ErrorDetail:
374
+ code: str | None = None
375
+ message: str | None = None
376
+ target: str | None = None
377
+
378
+
379
+ @dataclass(frozen=True)
380
+ class FileOutcome:
381
+ source: Path
382
+ status: OutcomeStatus
383
+ analyzer: str
384
+ output_path: Path | None = None
385
+ usage: Mapping[str, Any] | None = None
386
+ error: ErrorDetail | None = None
387
+ payload: Any = None
388
+
389
+
390
+ @dataclass(frozen=True)
391
+ class BatchReport:
392
+ outcomes: tuple[FileOutcome, ...]
393
+
394
+ @property
395
+ def succeeded(self) -> int:
396
+ return sum(item.status is OutcomeStatus.SUCCEEDED for item in self.outcomes)
397
+
398
+ @property
399
+ def failed(self) -> int:
400
+ return sum(item.status is OutcomeStatus.FAILED for item in self.outcomes)
401
+
402
+ @property
403
+ def skipped(self) -> int:
404
+ return sum(item.status is OutcomeStatus.SKIPPED for item in self.outcomes)
@@ -0,0 +1,150 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT license.
3
+
4
+ """Client-injected CU service-default operations."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any, Mapping, Sequence
9
+
10
+ from .errors import NotFoundError, ValidationError
11
+
12
+ PREFERRED_EMBEDDING_MODEL = "text-embedding-3-large"
13
+ COMPLETION_MODEL_PREFERENCE = ("gpt-5.2",)
14
+ PREBUILT_COMPLETION_KEY = "prebuilt-analyzer-completion"
15
+ PREBUILT_COMPLETION_MINI_KEY = "prebuilt-analyzer-completion-mini"
16
+ PREBUILT_EMBEDDING_KEY = "prebuilt-analyzer-embedding"
17
+
18
+
19
+ def with_prebuilt_default_mappings(
20
+ model_deployments: Mapping[str, str],
21
+ ) -> dict[str, str]:
22
+ """Return model mappings enriched with the aliases required by prebuilt analyzers."""
23
+
24
+ out = {str(key): str(value) for key, value in model_deployments.items()}
25
+ model_names = list(out)
26
+
27
+ def is_alias(name: str) -> bool:
28
+ return name.startswith("prebuilt-analyzer-")
29
+
30
+ completion_model = next(
31
+ (model for model in COMPLETION_MODEL_PREFERENCE if model in out),
32
+ None,
33
+ )
34
+ if completion_model is None:
35
+ completion_model = next(
36
+ (
37
+ name
38
+ for name in model_names
39
+ if not is_alias(name)
40
+ and not name.startswith("text-embedding-")
41
+ and not name.endswith("-mini")
42
+ ),
43
+ None,
44
+ )
45
+
46
+ mini_model = next(
47
+ (
48
+ model
49
+ for model in ("gpt-5.2-mini", "gpt-4.1-mini", "gpt-4o-mini")
50
+ if model in out
51
+ ),
52
+ None,
53
+ )
54
+ if mini_model is None:
55
+ mini_model = next(
56
+ (name for name in model_names if not is_alias(name) and name.endswith("-mini")),
57
+ None,
58
+ )
59
+
60
+ embedding_model = next(
61
+ (
62
+ name
63
+ for name in (PREFERRED_EMBEDDING_MODEL, *sorted(model_names))
64
+ if name in out and name.startswith("text-embedding-")
65
+ ),
66
+ None,
67
+ )
68
+
69
+ if completion_model is None and mini_model is not None:
70
+ completion_model = mini_model
71
+ if completion_model is not None:
72
+ out[PREBUILT_COMPLETION_KEY] = out[completion_model]
73
+ out[PREBUILT_COMPLETION_MINI_KEY] = (
74
+ out[mini_model] if mini_model else out[completion_model]
75
+ )
76
+ if embedding_model is not None:
77
+ out[PREBUILT_EMBEDDING_KEY] = out[embedding_model]
78
+ return out
79
+
80
+
81
+ def is_defaults_not_set(exc: Exception) -> bool:
82
+ """Return whether a service exception means defaults have not been configured."""
83
+
84
+ message = getattr(exc, "message", None) or str(exc)
85
+ return "DefaultsNotSet" in message or "Defaults have not yet been set" in message
86
+
87
+
88
+ def get_defaults(client: Any) -> Any:
89
+ """Return service defaults from an injected client."""
90
+
91
+ from azure.core.exceptions import HttpResponseError
92
+
93
+ try:
94
+ return client.get_defaults()
95
+ except HttpResponseError as exc:
96
+ if is_defaults_not_set(exc):
97
+ raise NotFoundError(
98
+ "defaults are not set yet on this resource.",
99
+ hint="Configure model deployments, then run defaults set.",
100
+ ) from exc
101
+ raise
102
+
103
+
104
+ def extract_model_deployments(defaults_obj: Any) -> dict[str, str]:
105
+ """Return a defaults object's model-deployment mapping as strings."""
106
+
107
+ mapped = getattr(defaults_obj, "model_deployments", None) or {}
108
+ return {str(key): str(value) for key, value in mapped.items()}
109
+
110
+
111
+ def parse_model_kv(values: Sequence[str]) -> dict[str, str]:
112
+ """Parse repeatable ``MODEL=DEPLOYMENT`` values."""
113
+
114
+ parsed: dict[str, str] = {}
115
+ for raw in values:
116
+ if "=" not in raw:
117
+ raise ValidationError(
118
+ f"invalid --model mapping '{raw}'.",
119
+ hint="use --model MODEL=DEPLOYMENT (repeatable).",
120
+ )
121
+ model, deployment = (part.strip() for part in raw.split("=", 1))
122
+ if not model or not deployment:
123
+ raise ValidationError(
124
+ f"invalid --model mapping '{raw}'.",
125
+ hint="use --model MODEL=DEPLOYMENT (repeatable).",
126
+ )
127
+ parsed[model] = deployment
128
+ return parsed
129
+
130
+
131
+ def apply_defaults(
132
+ client: Any,
133
+ desired: Mapping[str, str],
134
+ *,
135
+ replace: bool,
136
+ ) -> tuple[Any, dict[str, str]]:
137
+ """Merge or replace service defaults and return the result and final mapping."""
138
+
139
+ existing: dict[str, str] = {}
140
+ if not replace:
141
+ from azure.core.exceptions import HttpResponseError
142
+
143
+ try:
144
+ existing = extract_model_deployments(client.get_defaults())
145
+ except HttpResponseError as exc:
146
+ if not is_defaults_not_set(exc):
147
+ raise
148
+ merged = dict(desired) if replace else {**existing, **desired}
149
+ merged = with_prebuilt_default_mappings(merged)
150
+ return client.update_defaults(model_deployments=merged), merged
@@ -0,0 +1,102 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT license.
3
+
4
+ """Static environment-variable metadata and redacted process inspection."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+ import os
10
+ from typing import Mapping
11
+
12
+ REDACTED_VALUE = "********"
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class EnvironmentVariableSpec:
17
+ name: str
18
+ description: str
19
+ accepted_values: str
20
+ default: str
21
+ scope: str
22
+ precedence: str
23
+ sensitive: bool = False
24
+
25
+
26
+ ENVIRONMENT_VARIABLES = (
27
+ EnvironmentVariableSpec(
28
+ name="CU_ENDPOINT",
29
+ description="Microsoft Foundry resource endpoint for Content Understanding.",
30
+ accepted_values="HTTPS URL",
31
+ default="not set",
32
+ scope="service commands",
33
+ precedence="Overrides the endpoint in the selected CU CLI profile.",
34
+ ),
35
+ EnvironmentVariableSpec(
36
+ name="CU_API_KEY",
37
+ description="API key used for key authentication.",
38
+ accepted_values="secret string",
39
+ default="not set",
40
+ scope="authentication",
41
+ precedence="Overrides the selected CU CLI profile and implies key authentication.",
42
+ sensitive=True,
43
+ ),
44
+ EnvironmentVariableSpec(
45
+ name="CU_AUTH_MODE",
46
+ description="Authentication mode.",
47
+ accepted_values="login | key",
48
+ default="login",
49
+ scope="authentication",
50
+ precedence="Overrides authentication in the selected CU CLI profile.",
51
+ ),
52
+ EnvironmentVariableSpec(
53
+ name="CU_API_VERSION",
54
+ description="Content Understanding API version.",
55
+ accepted_values="supported API version",
56
+ default="2025-11-01",
57
+ scope="service commands",
58
+ precedence="Overrides the API version in the selected CU CLI profile.",
59
+ ),
60
+ EnvironmentVariableSpec(
61
+ name="CU_TELEMETRY",
62
+ description="Controls the cu-cli User-Agent adoption marker.",
63
+ accepted_values="off | 0 | false | no to disable",
64
+ default="on",
65
+ scope="service commands",
66
+ precedence="Controls CLI telemetry behavior directly.",
67
+ ),
68
+ EnvironmentVariableSpec(
69
+ name="CU_NO_UPDATE_CHECK",
70
+ description="Disables the daily package update check.",
71
+ accepted_values="1 | true | yes | on to disable",
72
+ default="off",
73
+ scope="update check",
74
+ precedence="Controls update-check behavior directly.",
75
+ ),
76
+ EnvironmentVariableSpec(
77
+ name="CU_ON_EXISTS",
78
+ description="Chooses how analyze handles an existing result file.",
79
+ accepted_values="error | skip | reanalyze",
80
+ default="error",
81
+ scope="analyze",
82
+ precedence="Used when --on-existing is not specified.",
83
+ ),
84
+ )
85
+
86
+
87
+ def list_set_environment_variables(
88
+ environ: Mapping[str, str] | None = None,
89
+ ) -> list[dict[str, object]]:
90
+ """Return recognized variables currently set, redacting sensitive values."""
91
+
92
+ values = os.environ if environ is None else environ
93
+ return [
94
+ {
95
+ "name": spec.name,
96
+ "value": REDACTED_VALUE if spec.sensitive else values[spec.name],
97
+ "scope": spec.scope,
98
+ "sensitive": spec.sensitive,
99
+ }
100
+ for spec in ENVIRONMENT_VARIABLES
101
+ if spec.name in values
102
+ ]