cu-cli 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/__init__.py +17 -0
- cu_cli/__main__.py +11 -0
- cu_cli/apiversion.py +124 -0
- cu_cli/cli.py +138 -0
- cu_cli/client.py +138 -0
- cu_cli/commands/__init__.py +4 -0
- cu_cli/commands/_command_spec.py +94 -0
- cu_cli/commands/_help.py +33 -0
- cu_cli/commands/_infra_models.py +184 -0
- cu_cli/commands/_infra_wizard.py +630 -0
- cu_cli/commands/_model_setup.py +46 -0
- cu_cli/commands/_options.py +112 -0
- cu_cli/commands/analyze.py +631 -0
- cu_cli/commands/analyzer.py +1462 -0
- cu_cli/commands/defaults.py +172 -0
- cu_cli/commands/doctor.py +166 -0
- cu_cli/commands/env_var.py +67 -0
- cu_cli/commands/infra.py +302 -0
- cu_cli/commands/profile_cmd.py +525 -0
- cu_cli/commands/upgrade.py +120 -0
- cu_cli/core/__init__.py +17 -0
- cu_cli/core/analyze.py +44 -0
- cu_cli/core/analyzers.py +30 -0
- cu_cli/core/azure_resources.py +486 -0
- cu_cli/core/defaults.py +18 -0
- cu_cli/core/doctor.py +42 -0
- cu_cli/core/foundry.py +68 -0
- cu_cli/core/infra_models.py +367 -0
- cu_cli/core/inputs.py +209 -0
- cu_cli/core/schema.py +24 -0
- cu_cli/errors.py +174 -0
- cu_cli/exit_codes.py +20 -0
- cu_cli/modality.py +24 -0
- cu_cli/output.py +179 -0
- cu_cli/profile.py +30 -0
- cu_cli/py.typed +0 -0
- cu_cli/resources/__init__.py +4 -0
- cu_cli/resources/azd_template/README.md +187 -0
- cu_cli/resources/azd_template/azure.yaml +27 -0
- cu_cli/resources/azd_template/hooks/postprovision.ps1 +320 -0
- cu_cli/resources/azd_template/hooks/postprovision.sh +299 -0
- cu_cli/resources/azd_template/infra/main.bicep +115 -0
- cu_cli/resources/azd_template/infra/main.parameters.json +30 -0
- cu_cli/resources/azd_template/infra/models.json +1 -0
- cu_cli/resources/azd_template/infra/modules/foundry.bicep +122 -0
- cu_cli/schema_validate.py +28 -0
- cu_cli/spec_validate.py +18 -0
- cu_cli/telemetry.py +42 -0
- cu_cli/update_check.py +154 -0
- cu_cli/update_provider.py +92 -0
- cu_cli/windows_self_upgrade.py +245 -0
- cu_cli-0.1.0b1.dist-info/METADATA +345 -0
- cu_cli-0.1.0b1.dist-info/RECORD +56 -0
- cu_cli-0.1.0b1.dist-info/WHEEL +5 -0
- cu_cli-0.1.0b1.dist-info/entry_points.txt +3 -0
- cu_cli-0.1.0b1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation.
|
|
2
|
+
# Licensed under the MIT license.
|
|
3
|
+
|
|
4
|
+
"""Live CU model discovery and deployment helpers for generated infrastructure."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import subprocess
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Iterable
|
|
13
|
+
|
|
14
|
+
from ..errors import CuCliError
|
|
15
|
+
|
|
16
|
+
SKU_PREFERENCE_ORDER = ("GlobalStandard", "DataZoneStandard", "Standard")
|
|
17
|
+
DEFAULT_COMPLETION_PREFERENCE = (
|
|
18
|
+
"gpt-5.2",
|
|
19
|
+
"gpt-5.1",
|
|
20
|
+
"gpt-5",
|
|
21
|
+
"gpt-5-mini",
|
|
22
|
+
)
|
|
23
|
+
DEFAULT_EMBEDDING_PREFERENCE = (
|
|
24
|
+
"text-embedding-3-large",
|
|
25
|
+
"text-embedding-3-small",
|
|
26
|
+
"text-embedding-ada-002",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class DeployableModel:
|
|
32
|
+
name: str
|
|
33
|
+
version: str
|
|
34
|
+
format: str
|
|
35
|
+
kind: str
|
|
36
|
+
sku_name: str
|
|
37
|
+
sku_capacity: int
|
|
38
|
+
is_default_version: bool = False
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def selector(self) -> str:
|
|
42
|
+
return f"{self.name}@{self.version}"
|
|
43
|
+
|
|
44
|
+
def to_template_entry(self) -> dict[str, Any]:
|
|
45
|
+
return {
|
|
46
|
+
"name": self.name,
|
|
47
|
+
"model": self.name,
|
|
48
|
+
"version": self.version,
|
|
49
|
+
"format": self.format,
|
|
50
|
+
"skuName": self.sku_name,
|
|
51
|
+
"skuCapacity": self.sku_capacity,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _value(value: Any, snake: str, camel: str) -> Any:
|
|
56
|
+
if isinstance(value, dict):
|
|
57
|
+
return value.get(snake, value.get(camel))
|
|
58
|
+
return getattr(value, snake, getattr(value, camel, None))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def supported_model_names(analyzer: Any) -> dict[str, set[str]]:
|
|
62
|
+
"""Return completion/embedding model names from an analyzer response."""
|
|
63
|
+
supported = _value(analyzer, "supported_models", "supportedModels")
|
|
64
|
+
if supported is None:
|
|
65
|
+
raise CuCliError(
|
|
66
|
+
"prebuilt-document did not return supportedModels.",
|
|
67
|
+
hint="verify that the selected CU API version exposes analyzer model metadata.",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
result: dict[str, set[str]] = {}
|
|
71
|
+
for kind in ("completion", "embedding"):
|
|
72
|
+
raw = _value(supported, kind, kind)
|
|
73
|
+
if raw is None:
|
|
74
|
+
raw = []
|
|
75
|
+
if not isinstance(raw, (list, tuple, set)):
|
|
76
|
+
raise CuCliError(f"prebuilt-document returned invalid supportedModels.{kind}.")
|
|
77
|
+
result[kind] = {
|
|
78
|
+
str(name).strip().lower()
|
|
79
|
+
for name in raw
|
|
80
|
+
if str(name).strip()
|
|
81
|
+
}
|
|
82
|
+
if not result["completion"] and not result["embedding"]:
|
|
83
|
+
raise CuCliError("prebuilt-document returned an empty supportedModels catalog.")
|
|
84
|
+
return result
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _choose_sku(skus: Iterable[dict[str, Any]]) -> tuple[str, int] | None:
|
|
88
|
+
parsed: dict[str, tuple[str, int]] = {}
|
|
89
|
+
for sku in skus:
|
|
90
|
+
if not isinstance(sku, dict):
|
|
91
|
+
continue
|
|
92
|
+
name = str(sku.get("name") or "").strip()
|
|
93
|
+
if not name:
|
|
94
|
+
continue
|
|
95
|
+
capacity = sku.get("capacity")
|
|
96
|
+
if isinstance(capacity, dict):
|
|
97
|
+
raw_default = capacity.get("default")
|
|
98
|
+
else:
|
|
99
|
+
raw_default = sku.get("defaultCapacity")
|
|
100
|
+
try:
|
|
101
|
+
default_capacity = int(str(raw_default))
|
|
102
|
+
except (TypeError, ValueError):
|
|
103
|
+
default_capacity = 1
|
|
104
|
+
parsed[name.lower()] = (name, max(default_capacity, 1))
|
|
105
|
+
|
|
106
|
+
for preferred in SKU_PREFERENCE_ORDER:
|
|
107
|
+
match = parsed.get(preferred.lower())
|
|
108
|
+
if match:
|
|
109
|
+
return match
|
|
110
|
+
if not parsed:
|
|
111
|
+
return None
|
|
112
|
+
return sorted(parsed.values(), key=lambda item: item[0].lower())[0]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def deployable_models(
|
|
116
|
+
arm_payload: Any,
|
|
117
|
+
supported: dict[str, set[str]],
|
|
118
|
+
) -> list[DeployableModel]:
|
|
119
|
+
"""Intersect live ARM model metadata with CU-supported model names."""
|
|
120
|
+
if not isinstance(arm_payload, list):
|
|
121
|
+
raise CuCliError("Azure returned an invalid model catalog.")
|
|
122
|
+
|
|
123
|
+
kind_by_name = {
|
|
124
|
+
name: kind
|
|
125
|
+
for kind, names in supported.items()
|
|
126
|
+
for name in names
|
|
127
|
+
}
|
|
128
|
+
result: list[DeployableModel] = []
|
|
129
|
+
for row in arm_payload:
|
|
130
|
+
if not isinstance(row, dict):
|
|
131
|
+
continue
|
|
132
|
+
name = str(row.get("name") or "").strip()
|
|
133
|
+
version = str(row.get("version") or "").strip()
|
|
134
|
+
model_format = str(row.get("format") or "").strip()
|
|
135
|
+
kind = kind_by_name.get(name.lower())
|
|
136
|
+
sku = _choose_sku(row.get("skus") or [])
|
|
137
|
+
if not name or not version or not model_format or not kind or sku is None:
|
|
138
|
+
continue
|
|
139
|
+
result.append(
|
|
140
|
+
DeployableModel(
|
|
141
|
+
name=name,
|
|
142
|
+
version=version,
|
|
143
|
+
format=model_format,
|
|
144
|
+
kind=kind,
|
|
145
|
+
sku_name=sku[0],
|
|
146
|
+
sku_capacity=sku[1],
|
|
147
|
+
is_default_version=bool(
|
|
148
|
+
row.get("isDefaultVersion", row.get("is_default_version", False))
|
|
149
|
+
),
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
return sorted(result, key=lambda model: (model.kind, model.name.lower(), model.version))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def fetch_account_models(
|
|
156
|
+
resource_group: str,
|
|
157
|
+
account_name: str,
|
|
158
|
+
subscription_id: str,
|
|
159
|
+
) -> list[dict[str, Any]]:
|
|
160
|
+
"""Read live deployable model metadata for an existing Microsoft Foundry resource."""
|
|
161
|
+
result = subprocess.run(
|
|
162
|
+
[
|
|
163
|
+
"az",
|
|
164
|
+
"cognitiveservices",
|
|
165
|
+
"account",
|
|
166
|
+
"list-models",
|
|
167
|
+
"--resource-group",
|
|
168
|
+
resource_group,
|
|
169
|
+
"--name",
|
|
170
|
+
account_name,
|
|
171
|
+
"--subscription",
|
|
172
|
+
subscription_id,
|
|
173
|
+
"--output",
|
|
174
|
+
"json",
|
|
175
|
+
],
|
|
176
|
+
capture_output=True,
|
|
177
|
+
text=True,
|
|
178
|
+
)
|
|
179
|
+
if result.returncode != 0:
|
|
180
|
+
detail = result.stderr.strip() or "Azure CLI returned no error detail."
|
|
181
|
+
raise CuCliError(f"could not read the Foundry model catalog: {detail}")
|
|
182
|
+
try:
|
|
183
|
+
payload = json.loads(result.stdout or "[]")
|
|
184
|
+
except json.JSONDecodeError as exc:
|
|
185
|
+
raise CuCliError("Azure CLI returned invalid JSON for the Foundry model catalog.") from exc
|
|
186
|
+
if not isinstance(payload, list):
|
|
187
|
+
raise CuCliError("Azure CLI returned an invalid Foundry model catalog.")
|
|
188
|
+
return payload
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def select_requested_models(
|
|
192
|
+
candidates: list[DeployableModel],
|
|
193
|
+
requested: Iterable[str],
|
|
194
|
+
) -> list[DeployableModel]:
|
|
195
|
+
"""Resolve model or model@version selectors without guessing versions."""
|
|
196
|
+
by_name: dict[str, list[DeployableModel]] = {}
|
|
197
|
+
by_selector = {model.selector.lower(): model for model in candidates}
|
|
198
|
+
for candidate in candidates:
|
|
199
|
+
by_name.setdefault(candidate.name.lower(), []).append(candidate)
|
|
200
|
+
|
|
201
|
+
selected: list[DeployableModel] = []
|
|
202
|
+
for raw in requested:
|
|
203
|
+
selector = raw.strip().lower()
|
|
204
|
+
if not selector:
|
|
205
|
+
continue
|
|
206
|
+
if "@" in selector:
|
|
207
|
+
selected_model = by_selector.get(selector)
|
|
208
|
+
if selected_model is None:
|
|
209
|
+
raise CuCliError(f"model '{raw}' is not supported and deployable on this account.")
|
|
210
|
+
else:
|
|
211
|
+
versions = by_name.get(selector, [])
|
|
212
|
+
if not versions:
|
|
213
|
+
raise CuCliError(f"model '{raw}' is not supported and deployable on this account.")
|
|
214
|
+
if len(versions) > 1:
|
|
215
|
+
choices = ", ".join(model.selector for model in versions)
|
|
216
|
+
raise CuCliError(
|
|
217
|
+
f"model '{raw}' has multiple deployable versions.",
|
|
218
|
+
hint=f"select one explicitly: {choices}",
|
|
219
|
+
)
|
|
220
|
+
selected_model = versions[0]
|
|
221
|
+
if any(existing.name.lower() == selected_model.name.lower() for existing in selected):
|
|
222
|
+
raise CuCliError(f"model family '{selected_model.name}' was selected more than once.")
|
|
223
|
+
selected.append(selected_model)
|
|
224
|
+
return selected
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def recommended_models(candidates: list[DeployableModel]) -> list[DeployableModel]:
|
|
228
|
+
"""Choose one completion and embedding model from live candidates."""
|
|
229
|
+
selected: list[DeployableModel] = []
|
|
230
|
+
for kind, preference in (
|
|
231
|
+
("completion", DEFAULT_COMPLETION_PREFERENCE),
|
|
232
|
+
("embedding", DEFAULT_EMBEDDING_PREFERENCE),
|
|
233
|
+
):
|
|
234
|
+
options = [model for model in candidates if model.kind == kind]
|
|
235
|
+
chosen = None
|
|
236
|
+
for name in preference:
|
|
237
|
+
family = [model for model in options if model.name.lower() == name]
|
|
238
|
+
if not family:
|
|
239
|
+
continue
|
|
240
|
+
defaults = [model for model in family if model.is_default_version]
|
|
241
|
+
if len(defaults) == 1:
|
|
242
|
+
chosen = defaults[0]
|
|
243
|
+
elif len(family) == 1:
|
|
244
|
+
chosen = family[0]
|
|
245
|
+
else:
|
|
246
|
+
choices = ", ".join(model.selector for model in family)
|
|
247
|
+
raise CuCliError(
|
|
248
|
+
f"recommended model '{name}' has multiple deployable versions.",
|
|
249
|
+
hint=f"select one explicitly: {choices}",
|
|
250
|
+
)
|
|
251
|
+
break
|
|
252
|
+
if chosen is None and options:
|
|
253
|
+
if len(options) > 1:
|
|
254
|
+
raise CuCliError(
|
|
255
|
+
f"no unambiguous recommended {kind} model is available.",
|
|
256
|
+
hint="select an explicit model@version.",
|
|
257
|
+
)
|
|
258
|
+
chosen = options[0]
|
|
259
|
+
if chosen is not None:
|
|
260
|
+
selected.append(chosen)
|
|
261
|
+
if not selected:
|
|
262
|
+
raise CuCliError("no CU-supported models are deployable on this account.")
|
|
263
|
+
return selected
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def write_models_file(path: Path, models: Iterable[DeployableModel]) -> None:
|
|
267
|
+
"""Persist selected models atomically for future azd/Bicep runs."""
|
|
268
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
269
|
+
temporary = path.with_suffix(path.suffix + ".tmp")
|
|
270
|
+
temporary.write_text(
|
|
271
|
+
json.dumps([model.to_template_entry() for model in models], indent=2) + "\n",
|
|
272
|
+
encoding="utf-8",
|
|
273
|
+
)
|
|
274
|
+
temporary.replace(path)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def deploy_models(
|
|
278
|
+
resource_group: str,
|
|
279
|
+
account_name: str,
|
|
280
|
+
subscription_id: str,
|
|
281
|
+
models: Iterable[DeployableModel],
|
|
282
|
+
) -> None:
|
|
283
|
+
"""Deploy selected models sequentially through Azure CLI."""
|
|
284
|
+
existing_result = subprocess.run(
|
|
285
|
+
[
|
|
286
|
+
"az",
|
|
287
|
+
"cognitiveservices",
|
|
288
|
+
"account",
|
|
289
|
+
"deployment",
|
|
290
|
+
"list",
|
|
291
|
+
"--resource-group",
|
|
292
|
+
resource_group,
|
|
293
|
+
"--name",
|
|
294
|
+
account_name,
|
|
295
|
+
"--subscription",
|
|
296
|
+
subscription_id,
|
|
297
|
+
"--output",
|
|
298
|
+
"json",
|
|
299
|
+
],
|
|
300
|
+
capture_output=True,
|
|
301
|
+
text=True,
|
|
302
|
+
)
|
|
303
|
+
if existing_result.returncode != 0:
|
|
304
|
+
detail = existing_result.stderr.strip() or "Azure CLI returned no error detail."
|
|
305
|
+
raise CuCliError(f"could not inspect existing model deployments: {detail}")
|
|
306
|
+
try:
|
|
307
|
+
existing_payload = json.loads(existing_result.stdout or "[]")
|
|
308
|
+
except json.JSONDecodeError as exc:
|
|
309
|
+
raise CuCliError("Azure CLI returned invalid model deployment JSON.") from exc
|
|
310
|
+
existing: dict[str, tuple[str, str]] = {}
|
|
311
|
+
for deployment in existing_payload if isinstance(existing_payload, list) else []:
|
|
312
|
+
if not isinstance(deployment, dict):
|
|
313
|
+
continue
|
|
314
|
+
deployment_name = str(deployment.get("name") or "").strip().lower()
|
|
315
|
+
properties = deployment.get("properties") or {}
|
|
316
|
+
model = properties.get("model") if isinstance(properties, dict) else {}
|
|
317
|
+
if deployment_name and isinstance(model, dict):
|
|
318
|
+
existing[deployment_name] = (
|
|
319
|
+
str(model.get("name") or "").strip().lower(),
|
|
320
|
+
str(model.get("version") or "").strip(),
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
for model in models:
|
|
324
|
+
deployed = existing.get(model.name.lower())
|
|
325
|
+
requested = (model.name.lower(), model.version)
|
|
326
|
+
if deployed == requested:
|
|
327
|
+
continue
|
|
328
|
+
if deployed is not None:
|
|
329
|
+
raise CuCliError(
|
|
330
|
+
f"deployment '{model.name}' already exists with model "
|
|
331
|
+
f"'{deployed[0]}@{deployed[1]}'.",
|
|
332
|
+
hint="choose another model version or delete the existing deployment explicitly; "
|
|
333
|
+
"live setup never replaces deployments.",
|
|
334
|
+
)
|
|
335
|
+
result = subprocess.run(
|
|
336
|
+
[
|
|
337
|
+
"az",
|
|
338
|
+
"cognitiveservices",
|
|
339
|
+
"account",
|
|
340
|
+
"deployment",
|
|
341
|
+
"create",
|
|
342
|
+
"--resource-group",
|
|
343
|
+
resource_group,
|
|
344
|
+
"--name",
|
|
345
|
+
account_name,
|
|
346
|
+
"--subscription",
|
|
347
|
+
subscription_id,
|
|
348
|
+
"--deployment-name",
|
|
349
|
+
model.name,
|
|
350
|
+
"--model-name",
|
|
351
|
+
model.name,
|
|
352
|
+
"--model-version",
|
|
353
|
+
model.version,
|
|
354
|
+
"--model-format",
|
|
355
|
+
model.format,
|
|
356
|
+
"--sku-name",
|
|
357
|
+
model.sku_name,
|
|
358
|
+
"--sku-capacity",
|
|
359
|
+
str(model.sku_capacity),
|
|
360
|
+
"--only-show-errors",
|
|
361
|
+
],
|
|
362
|
+
capture_output=True,
|
|
363
|
+
text=True,
|
|
364
|
+
)
|
|
365
|
+
if result.returncode != 0:
|
|
366
|
+
detail = result.stderr.strip() or "Azure CLI returned no error detail."
|
|
367
|
+
raise CuCliError(f"could not deploy model '{model.selector}': {detail}")
|
cu_cli/core/inputs.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation.
|
|
2
|
+
# Licensed under the MIT license.
|
|
3
|
+
|
|
4
|
+
"""Input discovery and result-path planning for ``cu analyze`` (Click-free).
|
|
5
|
+
|
|
6
|
+
Expands user-supplied inputs (files, directories, globs) into concrete local
|
|
7
|
+
file paths and plans where each result file is written. Soft warnings (empty
|
|
8
|
+
directories, non-matching globs) are returned as **data** on
|
|
9
|
+
:class:`ExpandResult` so the command layer can render them; the hard case (no
|
|
10
|
+
inputs matched at all) raises :class:`~cu_cli.errors.CuCliError`.
|
|
11
|
+
|
|
12
|
+
Result files are written next to the input with a ``.result`` suffix so they
|
|
13
|
+
never clobber the source: ``report.pdf`` -> ``report.pdf.result.md`` /
|
|
14
|
+
``report.pdf.result.json``. The full filename (extension included) is kept so
|
|
15
|
+
inputs that share a stem but differ by extension (``note.mp3`` vs ``note.pdf``)
|
|
16
|
+
never collide.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import glob as _glob
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Iterable
|
|
25
|
+
|
|
26
|
+
from ..errors import CuCliError
|
|
27
|
+
from ..modality import KNOWN_SERVICE_INPUT_EXTS
|
|
28
|
+
|
|
29
|
+
RESULT_SUFFIXES = (".result.md", ".result.json")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class ExpandResult:
|
|
34
|
+
"""Concrete input files and their source-relative output paths."""
|
|
35
|
+
|
|
36
|
+
files: list[str] = field(default_factory=list)
|
|
37
|
+
source_relative_paths: dict[str, Path] = field(default_factory=dict)
|
|
38
|
+
warnings: list[str] = field(default_factory=list)
|
|
39
|
+
# (path, reason) for inputs dropped during a directory walk for a reportable
|
|
40
|
+
# reason (unsupported extension, or a hidden file at a visible path —
|
|
41
|
+
# regardless of extension) so the command layer can name them instead of
|
|
42
|
+
# dropping them silently (regression).
|
|
43
|
+
skipped: list[tuple[str, str]] = field(default_factory=list)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_result_file(name: str) -> bool:
|
|
47
|
+
"""True for the CLI's own ``*.result.md`` / ``*.result.json`` outputs."""
|
|
48
|
+
low = name.lower()
|
|
49
|
+
return any(low.endswith(suffix) for suffix in RESULT_SUFFIXES)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _unsupported_reason(path: Path) -> str:
|
|
53
|
+
ext = path.suffix.lower() or "no extension"
|
|
54
|
+
return f"unsupported file type ({ext})"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _is_supported_local_file(path: Path) -> bool:
|
|
58
|
+
return path.suffix.lower() in KNOWN_SERVICE_INPUT_EXTS
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _glob_source_root(pattern: str) -> Path:
|
|
62
|
+
"""Return the non-pattern path prefix that anchors a glob's result layout."""
|
|
63
|
+
prefix: list[str] = []
|
|
64
|
+
for part in Path(pattern).parts:
|
|
65
|
+
if any(ch in part for ch in "*?["):
|
|
66
|
+
break
|
|
67
|
+
prefix.append(part)
|
|
68
|
+
return Path(*prefix) if prefix else Path(".")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def expand_dir(path: Path, skipped: list[tuple[str, str]] | None = None) -> list[str]:
|
|
72
|
+
"""Recursively collect supported files under *path*.
|
|
73
|
+
|
|
74
|
+
Skips hidden directories/files discovered during the walk (``.git``,
|
|
75
|
+
``.venv``, ``.cu`` …) and never re-ingests the CLI's own ``*.result.*``
|
|
76
|
+
outputs — either would silently amplify cost on reruns.
|
|
77
|
+
|
|
78
|
+
When *skipped* is provided, files dropped for a **reportable** reason — an
|
|
79
|
+
unsupported extension, or a hidden file sitting at an otherwise-visible path
|
|
80
|
+
(e.g. ``.hidden.pdf`` **or** ``.DS_Store``, regardless of extension) — are
|
|
81
|
+
appended as ``(path, reason)`` so the command layer can name them. Files
|
|
82
|
+
buried under a hidden *directory* stay quiet to avoid infrastructure noise
|
|
83
|
+
(``.git`` etc.).
|
|
84
|
+
"""
|
|
85
|
+
out: list[str] = []
|
|
86
|
+
for p in sorted(path.rglob("*")):
|
|
87
|
+
if not p.is_file():
|
|
88
|
+
continue
|
|
89
|
+
if is_result_file(p.name):
|
|
90
|
+
continue
|
|
91
|
+
rel_parts = p.relative_to(path).parts
|
|
92
|
+
if any(part.startswith(".") for part in rel_parts[:-1]):
|
|
93
|
+
continue # buried under a hidden directory — always silent
|
|
94
|
+
supported = _is_supported_local_file(p)
|
|
95
|
+
if p.name.startswith("."):
|
|
96
|
+
# A hidden file at an otherwise-visible path is skipped regardless of
|
|
97
|
+
# its extension; report it either way so nothing is dropped silently
|
|
98
|
+
# (Regression: e.g. ``.DS_Store`` was previously omitted because it also
|
|
99
|
+
# lacks a supported extension). Files buried under a hidden *directory*
|
|
100
|
+
# were already filtered above and stay quiet (infrastructure noise).
|
|
101
|
+
if skipped is not None:
|
|
102
|
+
skipped.append((str(p), "hidden file skipped"))
|
|
103
|
+
continue
|
|
104
|
+
if not supported:
|
|
105
|
+
if skipped is not None:
|
|
106
|
+
ext = p.suffix.lower() or "no extension"
|
|
107
|
+
skipped.append((str(p), f"unsupported file type ({ext})"))
|
|
108
|
+
continue
|
|
109
|
+
out.append(str(p))
|
|
110
|
+
return out
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def expand_inputs(items: Iterable[str]) -> ExpandResult:
|
|
114
|
+
"""Expand directories and globs into concrete local file paths.
|
|
115
|
+
|
|
116
|
+
URLs are rejected — the MVP is local-only for now (URL support is deferred).
|
|
117
|
+
Returns an :class:`ExpandResult` with the deduped file list and any soft
|
|
118
|
+
warnings; raises :class:`~cu_cli.errors.CuCliError` when nothing matched.
|
|
119
|
+
"""
|
|
120
|
+
out: list[str] = []
|
|
121
|
+
source_relative_paths: dict[str, Path] = {}
|
|
122
|
+
warnings: list[str] = []
|
|
123
|
+
unmatched: list[str] = []
|
|
124
|
+
skipped: list[tuple[str, str]] = []
|
|
125
|
+
|
|
126
|
+
def add(ref: str, relative_path: Path) -> None:
|
|
127
|
+
out.append(ref)
|
|
128
|
+
source_relative_paths.setdefault(ref, relative_path)
|
|
129
|
+
|
|
130
|
+
for it in items:
|
|
131
|
+
low = it.lower()
|
|
132
|
+
if low.startswith(("http://", "https://")):
|
|
133
|
+
raise CuCliError(
|
|
134
|
+
f"URL inputs are not supported in this release: {it}",
|
|
135
|
+
hint="MVP analyzes local files, directories, and globs only. "
|
|
136
|
+
"Download the file first, or wait for URL support (Phase 2).",
|
|
137
|
+
)
|
|
138
|
+
if any(ch in it for ch in "*?["):
|
|
139
|
+
matches = sorted(_glob.glob(it, recursive=True))
|
|
140
|
+
if not matches:
|
|
141
|
+
unmatched.append(it)
|
|
142
|
+
source_root = _glob_source_root(it)
|
|
143
|
+
for m in matches:
|
|
144
|
+
p = Path(m)
|
|
145
|
+
if p.is_dir():
|
|
146
|
+
for ref in expand_dir(p, skipped):
|
|
147
|
+
add(ref, Path(ref).relative_to(source_root))
|
|
148
|
+
elif is_result_file(p.name):
|
|
149
|
+
continue # don't re-ingest our own outputs on a glob
|
|
150
|
+
elif _is_supported_local_file(p):
|
|
151
|
+
add(m, p.relative_to(source_root))
|
|
152
|
+
else:
|
|
153
|
+
skipped.append((str(p), _unsupported_reason(p)))
|
|
154
|
+
else:
|
|
155
|
+
p = Path(it)
|
|
156
|
+
if p.is_dir():
|
|
157
|
+
expanded = expand_dir(p, skipped)
|
|
158
|
+
if expanded:
|
|
159
|
+
for ref in expanded:
|
|
160
|
+
add(ref, Path(ref).relative_to(p))
|
|
161
|
+
else:
|
|
162
|
+
warnings.append(f"no supported files in '{it}'.")
|
|
163
|
+
elif p.exists():
|
|
164
|
+
# An explicitly named file reflects user intent. Let the service
|
|
165
|
+
# authoritatively validate formats that this client may not know
|
|
166
|
+
# yet; the allowlist is only a safety filter for discovery.
|
|
167
|
+
add(it, Path(p.name))
|
|
168
|
+
else:
|
|
169
|
+
unmatched.append(it)
|
|
170
|
+
if unmatched and not out:
|
|
171
|
+
raise CuCliError(f"no files matched: {', '.join(unmatched)}",
|
|
172
|
+
hint="check the path or glob pattern.")
|
|
173
|
+
for pat in unmatched:
|
|
174
|
+
warnings.append(f"no matches for '{pat}' — skipping.")
|
|
175
|
+
seen: set[str] = set()
|
|
176
|
+
deduped: list[str] = []
|
|
177
|
+
deduped_relative_paths: dict[str, Path] = {}
|
|
178
|
+
for it in out:
|
|
179
|
+
if it not in seen:
|
|
180
|
+
seen.add(it)
|
|
181
|
+
deduped.append(it)
|
|
182
|
+
deduped_relative_paths[it] = source_relative_paths[it]
|
|
183
|
+
# Dedupe the skip list and never report a path that also resolved to a real
|
|
184
|
+
# input (a file reachable both directly and via a directory walk).
|
|
185
|
+
file_set = set(deduped)
|
|
186
|
+
seen_skip: set[str] = set()
|
|
187
|
+
deduped_skipped: list[tuple[str, str]] = []
|
|
188
|
+
for spath, reason in skipped:
|
|
189
|
+
if spath in file_set or spath in seen_skip:
|
|
190
|
+
continue
|
|
191
|
+
seen_skip.add(spath)
|
|
192
|
+
deduped_skipped.append((spath, reason))
|
|
193
|
+
return ExpandResult(
|
|
194
|
+
files=deduped,
|
|
195
|
+
source_relative_paths=deduped_relative_paths,
|
|
196
|
+
warnings=warnings,
|
|
197
|
+
skipped=deduped_skipped,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def result_path(ref: str, fmt: str) -> Path:
|
|
202
|
+
"""``report.pdf`` -> ``report.pdf.result.md`` / ``report.pdf.result.json``.
|
|
203
|
+
|
|
204
|
+
The full input filename (extension included) is preserved so same-stem
|
|
205
|
+
inputs with different extensions never overwrite each other's results.
|
|
206
|
+
"""
|
|
207
|
+
p = Path(ref)
|
|
208
|
+
ext = "md" if fmt == "markdown" else "json"
|
|
209
|
+
return p.with_name(f"{p.name}.result.{ext}")
|
cu_cli/core/schema.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation.
|
|
2
|
+
# Licensed under the MIT license.
|
|
3
|
+
|
|
4
|
+
"""Compatibility imports for schema operations now provided by cu-cli-core."""
|
|
5
|
+
|
|
6
|
+
from cu_cli_core.schema import (
|
|
7
|
+
FIELD_SCHEMA_SUGGEST_ANALYZER_ID,
|
|
8
|
+
MODALITY_BASE,
|
|
9
|
+
starter_schema,
|
|
10
|
+
suggest_schema_from_sample,
|
|
11
|
+
suggested_fields_from_result,
|
|
12
|
+
template_completion_model,
|
|
13
|
+
validate_document_sample,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"FIELD_SCHEMA_SUGGEST_ANALYZER_ID",
|
|
18
|
+
"MODALITY_BASE",
|
|
19
|
+
"starter_schema",
|
|
20
|
+
"suggest_schema_from_sample",
|
|
21
|
+
"suggested_fields_from_result",
|
|
22
|
+
"template_completion_model",
|
|
23
|
+
"validate_document_sample",
|
|
24
|
+
]
|