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.
Files changed (56) hide show
  1. cu_cli/__init__.py +17 -0
  2. cu_cli/__main__.py +11 -0
  3. cu_cli/apiversion.py +124 -0
  4. cu_cli/cli.py +138 -0
  5. cu_cli/client.py +138 -0
  6. cu_cli/commands/__init__.py +4 -0
  7. cu_cli/commands/_command_spec.py +94 -0
  8. cu_cli/commands/_help.py +33 -0
  9. cu_cli/commands/_infra_models.py +184 -0
  10. cu_cli/commands/_infra_wizard.py +630 -0
  11. cu_cli/commands/_model_setup.py +46 -0
  12. cu_cli/commands/_options.py +112 -0
  13. cu_cli/commands/analyze.py +631 -0
  14. cu_cli/commands/analyzer.py +1462 -0
  15. cu_cli/commands/defaults.py +172 -0
  16. cu_cli/commands/doctor.py +166 -0
  17. cu_cli/commands/env_var.py +67 -0
  18. cu_cli/commands/infra.py +302 -0
  19. cu_cli/commands/profile_cmd.py +525 -0
  20. cu_cli/commands/upgrade.py +120 -0
  21. cu_cli/core/__init__.py +17 -0
  22. cu_cli/core/analyze.py +44 -0
  23. cu_cli/core/analyzers.py +30 -0
  24. cu_cli/core/azure_resources.py +486 -0
  25. cu_cli/core/defaults.py +18 -0
  26. cu_cli/core/doctor.py +42 -0
  27. cu_cli/core/foundry.py +68 -0
  28. cu_cli/core/infra_models.py +367 -0
  29. cu_cli/core/inputs.py +209 -0
  30. cu_cli/core/schema.py +24 -0
  31. cu_cli/errors.py +174 -0
  32. cu_cli/exit_codes.py +20 -0
  33. cu_cli/modality.py +24 -0
  34. cu_cli/output.py +179 -0
  35. cu_cli/profile.py +30 -0
  36. cu_cli/py.typed +0 -0
  37. cu_cli/resources/__init__.py +4 -0
  38. cu_cli/resources/azd_template/README.md +187 -0
  39. cu_cli/resources/azd_template/azure.yaml +27 -0
  40. cu_cli/resources/azd_template/hooks/postprovision.ps1 +320 -0
  41. cu_cli/resources/azd_template/hooks/postprovision.sh +299 -0
  42. cu_cli/resources/azd_template/infra/main.bicep +115 -0
  43. cu_cli/resources/azd_template/infra/main.parameters.json +30 -0
  44. cu_cli/resources/azd_template/infra/models.json +1 -0
  45. cu_cli/resources/azd_template/infra/modules/foundry.bicep +122 -0
  46. cu_cli/schema_validate.py +28 -0
  47. cu_cli/spec_validate.py +18 -0
  48. cu_cli/telemetry.py +42 -0
  49. cu_cli/update_check.py +154 -0
  50. cu_cli/update_provider.py +92 -0
  51. cu_cli/windows_self_upgrade.py +245 -0
  52. cu_cli-0.1.0b1.dist-info/METADATA +345 -0
  53. cu_cli-0.1.0b1.dist-info/RECORD +56 -0
  54. cu_cli-0.1.0b1.dist-info/WHEEL +5 -0
  55. cu_cli-0.1.0b1.dist-info/entry_points.txt +3 -0
  56. 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
+ ]