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,12 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT license.
3
+
4
+ """Framework-neutral command contracts and operations for CU frontends."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from importlib.metadata import version
9
+
10
+ __version__ = version("cu-cli-core")
11
+
12
+ __all__ = ["__version__"]
@@ -0,0 +1,374 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT license.
3
+
4
+ """The reusable analyze engine (Click-free, concurrent).
5
+
6
+ This module owns everything about *running* analyze jobs: planning where each
7
+ result goes, calling the SDK, and submitting many jobs concurrently. Nothing
8
+ here prints, writes result files, or exits — callers decide how to render,
9
+ persist, and report.
10
+
11
+ :func:`analyze_many` is the reusable concurrency primitive: hand it a built
12
+ client and a list of :class:`AnalyzeJob`, and it fans them out across a thread
13
+ pool, isolating per-job failures and invoking an optional ``on_result``
14
+ callback as each job completes (so callers can stream writes/progress without
15
+ buffering every result in memory).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import os
22
+ from collections import Counter
23
+ from concurrent.futures import ThreadPoolExecutor, as_completed
24
+ from dataclasses import dataclass, field
25
+ from pathlib import Path
26
+ from typing import Any, Callable, Mapping, Optional, Sequence
27
+
28
+ RESULT_SUFFIXES = (".result.md", ".result.json")
29
+
30
+
31
+ def result_path(path: str, fmt: str) -> Path:
32
+ suffix = ".result.json" if fmt == "json" else ".result.md"
33
+ return Path(f"{path}{suffix}")
34
+
35
+ # Prompt before very large batches (interactive TTY only) as a cost safety net.
36
+ # The engine never enforces this — the command layer decides when to confirm.
37
+ CONFIRM_THRESHOLD = 50
38
+
39
+
40
+ @dataclass
41
+ class AnalyzeJob:
42
+ """A single unit of work: analyze *input_ref* and (maybe) write a result."""
43
+
44
+ input_ref: str
45
+ analyzer_id: str
46
+ out_path: Optional[Path] = None # None => caller streams to stdout
47
+ output_format: str = "markdown"
48
+
49
+
50
+ @dataclass
51
+ class AnalyzeOutcome:
52
+ """The result of running one :class:`AnalyzeJob` (success xor error)."""
53
+
54
+ job: AnalyzeJob
55
+ result: Any = None
56
+ error: Optional[BaseException] = None
57
+
58
+ @property
59
+ def ok(self) -> bool:
60
+ return self.error is None
61
+
62
+
63
+ @dataclass
64
+ class AnalyzeResponse:
65
+ """Completed analysis result plus request usage metadata."""
66
+
67
+ result: Any
68
+ usage: Any = None
69
+
70
+
71
+ @dataclass
72
+ class BatchResult:
73
+ """Aggregate outcome of :func:`analyze_many`."""
74
+
75
+ successes: list[AnalyzeOutcome] = field(default_factory=list)
76
+ failures: list[AnalyzeOutcome] = field(default_factory=list)
77
+
78
+
79
+ def _result_under(out_dir: Path, relative_ref: str | Path, fmt: str) -> Path:
80
+ """Place a source-relative result path beneath *out_dir*."""
81
+ relative = Path(relative_ref)
82
+ if relative.is_absolute() or relative.anchor or ".." in relative.parts:
83
+ raise ValueError(f"result path must be source-relative: {relative_ref}")
84
+ return out_dir / result_path(str(relative), fmt)
85
+
86
+
87
+ def plan_jobs(
88
+ refs: Sequence[str],
89
+ *,
90
+ analyzer_id: str,
91
+ out_dir: Optional[Path],
92
+ fmt: str,
93
+ to_stdout: bool,
94
+ source_relative_paths: Optional[Mapping[str, Path]] = None,
95
+ ) -> list[AnalyzeJob]:
96
+ """Build one :class:`AnalyzeJob` per input reference.
97
+
98
+ :param refs: Input file paths to analyze (already expanded and deduped).
99
+ :param analyzer_id: Analyzer to use for every job.
100
+ :param out_dir: When set, every result file is written under this directory,
101
+ relative to the source file or directory that selected it.
102
+ :param fmt: Output format (``"markdown"`` or ``"json"``); selects the
103
+ result-file extension (``.result.md`` vs ``.result.json``).
104
+ :param to_stdout: When ``True`` (single input, no ``--out``) no result
105
+ path is assigned — the caller streams the result to stdout instead.
106
+ :param source_relative_paths: Per-input paths relative to their selecting
107
+ source roots. Direct files default to their basename.
108
+ """
109
+ jobs: list[AnalyzeJob] = []
110
+ for ref in refs:
111
+ if to_stdout:
112
+ out_path: Optional[Path] = None
113
+ elif out_dir is not None:
114
+ relative_ref = (
115
+ source_relative_paths.get(ref, Path(ref).name)
116
+ if source_relative_paths is not None
117
+ else Path(ref).name
118
+ )
119
+ out_path = _result_under(out_dir, relative_ref, fmt)
120
+ else:
121
+ out_path = result_path(ref, fmt)
122
+ jobs.append(
123
+ AnalyzeJob(
124
+ input_ref=ref,
125
+ analyzer_id=analyzer_id,
126
+ out_path=out_path,
127
+ output_format=fmt,
128
+ )
129
+ )
130
+ return jobs
131
+
132
+
133
+ def _file_identity(ref: str) -> object:
134
+ """A stable key for the *physical* file *ref* points to.
135
+
136
+ Prefers the OS device+inode pair, which collapses different spellings of
137
+ the same file (``report.pdf`` vs ``../dir/report.pdf``), symlinks, and
138
+ hardlinks onto one identity. Falls back to the resolved absolute path when
139
+ ``stat`` fails (missing file) or the inode is unavailable (``0`` on some
140
+ Windows/filesystems).
141
+ """
142
+ try:
143
+ st = os.stat(ref)
144
+ except OSError:
145
+ return os.path.realpath(ref)
146
+ if st.st_ino:
147
+ return (st.st_dev, st.st_ino)
148
+ return os.path.realpath(ref)
149
+
150
+
151
+ def dedupe_same_file(jobs: list[AnalyzeJob]) -> list[tuple[AnalyzeJob, AnalyzeJob]]:
152
+ """Drop jobs whose input is the *same physical file* as an earlier job.
153
+
154
+ Two inputs can point at one file via different path spellings (``a.pdf``
155
+ and ``../dir/a.pdf``), a symlink and its target, or a hardlink. Analyzing
156
+ it twice would double the billed API calls and write duplicate results, so
157
+ this keeps the first occurrence in planned order and removes the rest.
158
+
159
+ *jobs* is mutated in place. Returns ``(dropped, kept)`` pairs so the caller
160
+ can warn — the engine never prints. Identity is decided by
161
+ :func:`_file_identity` (device+inode, resolved-path fallback).
162
+ """
163
+ seen: dict[object, AnalyzeJob] = {}
164
+ kept: list[AnalyzeJob] = []
165
+ dropped: list[tuple[AnalyzeJob, AnalyzeJob]] = []
166
+ for j in jobs:
167
+ key = _file_identity(j.input_ref)
168
+ original = seen.get(key)
169
+ if original is not None:
170
+ dropped.append((j, original))
171
+ else:
172
+ seen[key] = j
173
+ kept.append(j)
174
+ jobs[:] = kept
175
+ return dropped
176
+
177
+
178
+ def disambiguate_collisions(jobs: list[AnalyzeJob]) -> int:
179
+ """Ensure distinct inputs never share a result path.
180
+
181
+ Direct files with the same basename, or files from different source roots
182
+ with the same relative path, can map to one output. Returns the number
183
+ adjusted.
184
+ """
185
+ counts = Counter(j.out_path for j in jobs if j.out_path is not None)
186
+ collided = {p for p, n in counts.items() if n > 1}
187
+ if not collided:
188
+ return 0
189
+ adjusted = 0
190
+ for j in jobs:
191
+ if j.out_path is None or j.out_path not in collided:
192
+ continue
193
+ digest = hashlib.sha1(j.input_ref.encode("utf-8")).hexdigest()[:8]
194
+ name = j.out_path.name
195
+ for suffix in RESULT_SUFFIXES:
196
+ if name.endswith(suffix):
197
+ base = name[: -len(suffix)]
198
+ j.out_path = j.out_path.with_name(f"{base}.{digest}{suffix}")
199
+ adjusted += 1
200
+ break
201
+ return adjusted
202
+
203
+
204
+ def _capture_raw_response(
205
+ pipeline_response: Any,
206
+ deserialized: Any,
207
+ _response_headers: dict[str, Any],
208
+ ) -> tuple[Any, Any]:
209
+ """Retain the SDK model and raw HTTP response from an SDK ``cls`` callback."""
210
+ return deserialized, pipeline_response.http_response
211
+
212
+
213
+ def analyze_bytes(
214
+ client: Any,
215
+ analyzer_id: str,
216
+ data: bytes,
217
+ *,
218
+ raw_json: bool = False,
219
+ ) -> Any:
220
+ """Analyze raw *data* with *analyzer_id* and return the completed result."""
221
+ return analyze_bytes_with_usage(
222
+ client,
223
+ analyzer_id,
224
+ data,
225
+ raw_json=raw_json,
226
+ ).result
227
+
228
+
229
+ def analyze_bytes_with_usage(
230
+ client: Any,
231
+ analyzer_id: str,
232
+ data: bytes,
233
+ *,
234
+ raw_json: bool = False,
235
+ ) -> AnalyzeResponse:
236
+ """Analyze raw *data* and retain usage metadata from the completed poller."""
237
+ kwargs = {"cls": _capture_raw_response} if raw_json else {}
238
+ poller = client.begin_analyze_binary(
239
+ analyzer_id=analyzer_id,
240
+ binary_input=data,
241
+ **kwargs,
242
+ )
243
+ completed = poller.result()
244
+ if raw_json:
245
+ _, raw_response = completed
246
+ result = raw_response.json()
247
+ else:
248
+ result = completed
249
+ return AnalyzeResponse(result=result, usage=getattr(poller, "usage", None))
250
+
251
+
252
+ def analyze_bytes_inline(
253
+ client: Any,
254
+ analyzer_id: str,
255
+ data: bytes,
256
+ *,
257
+ raw_json: bool = False,
258
+ ) -> Any:
259
+ """Analyze raw *data* synchronously and return the inline result."""
260
+ return analyze_bytes_inline_with_usage(
261
+ client,
262
+ analyzer_id,
263
+ data,
264
+ raw_json=raw_json,
265
+ ).result
266
+
267
+
268
+ def analyze_bytes_inline_with_usage(
269
+ client: Any,
270
+ analyzer_id: str,
271
+ data: bytes,
272
+ *,
273
+ raw_json: bool = False,
274
+ ) -> AnalyzeResponse:
275
+ """Analyze raw *data* synchronously and retain inline usage metadata."""
276
+ kwargs = {"cls": _capture_raw_response} if raw_json else {}
277
+ completed = client.analyze_binary_inline(
278
+ analyzer_id=analyzer_id,
279
+ binary_input=data,
280
+ **kwargs,
281
+ )
282
+ if raw_json:
283
+ response, raw_response = completed
284
+ result = raw_response.json()
285
+ else:
286
+ response = completed
287
+ result = response.result
288
+ return AnalyzeResponse(result=result, usage=getattr(response, "usage", None))
289
+
290
+
291
+ def analyze_one(client: Any, job: AnalyzeJob) -> Any:
292
+ """Run a single :class:`AnalyzeJob`, returning the completed SDK result."""
293
+ data = Path(job.input_ref).read_bytes()
294
+ return analyze_bytes(
295
+ client,
296
+ job.analyzer_id,
297
+ data,
298
+ raw_json=job.output_format == "json",
299
+ )
300
+
301
+
302
+ def analyze_one_inline(client: Any, job: AnalyzeJob) -> Any:
303
+ """Run a single job synchronously through the inline analyze API."""
304
+ data = Path(job.input_ref).read_bytes()
305
+ return analyze_bytes_inline(
306
+ client,
307
+ job.analyzer_id,
308
+ data,
309
+ raw_json=job.output_format == "json",
310
+ )
311
+
312
+
313
+ def analyze_one_with_usage(client: Any, job: AnalyzeJob) -> AnalyzeResponse:
314
+ """Run one long-running analysis and retain its usage metadata."""
315
+ data = Path(job.input_ref).read_bytes()
316
+ return analyze_bytes_with_usage(
317
+ client,
318
+ job.analyzer_id,
319
+ data,
320
+ raw_json=job.output_format == "json",
321
+ )
322
+
323
+
324
+ def analyze_one_inline_with_usage(client: Any, job: AnalyzeJob) -> AnalyzeResponse:
325
+ """Run one inline analysis and retain its usage metadata."""
326
+ data = Path(job.input_ref).read_bytes()
327
+ return analyze_bytes_inline_with_usage(
328
+ client,
329
+ job.analyzer_id,
330
+ data,
331
+ raw_json=job.output_format == "json",
332
+ )
333
+
334
+
335
+ def analyze_many(
336
+ client: Any,
337
+ jobs: Sequence[AnalyzeJob],
338
+ *,
339
+ concurrency: int = 4,
340
+ on_result: Optional[Callable[[AnalyzeOutcome], None]] = None,
341
+ run: Optional[Callable[[Any, AnalyzeJob], Any]] = None,
342
+ ) -> BatchResult:
343
+ """Run *jobs* concurrently, isolating per-job failures.
344
+
345
+ Each job is submitted to a :class:`~concurrent.futures.ThreadPoolExecutor`
346
+ with up to *concurrency* workers. As each completes, an
347
+ :class:`AnalyzeOutcome` is produced (result on success, ``error`` on
348
+ failure) and, if given, ``on_result`` is invoked with it on the calling
349
+ thread — so callers can persist/print incrementally in a single-threaded,
350
+ deterministic-enough order without buffering results.
351
+
352
+ *run* is the per-job runner (dependency injection point); it defaults to
353
+ :func:`analyze_one` and must have the signature ``run(client, job) ->
354
+ result``. Callers can inject a custom runner (e.g. a caching or dry-run
355
+ variant) without touching the concurrency machinery.
356
+
357
+ Exceptions raised by *on_result* propagate to the caller (the callback is
358
+ the caller's own code); analyze failures never do — they surface as
359
+ ``outcome.error``.
360
+ """
361
+ runner = run or analyze_one
362
+ result = BatchResult()
363
+ with ThreadPoolExecutor(max_workers=concurrency) as ex:
364
+ futs = {ex.submit(runner, client, j): j for j in jobs}
365
+ for fut in as_completed(futs):
366
+ job = futs[fut]
367
+ try:
368
+ outcome = AnalyzeOutcome(job=job, result=fut.result())
369
+ except Exception as exc: # noqa: BLE001 — per-job isolation
370
+ outcome = AnalyzeOutcome(job=job, error=exc)
371
+ (result.successes if outcome.ok else result.failures).append(outcome)
372
+ if on_result is not None:
373
+ on_result(outcome)
374
+ return result
cu_cli_core/client.py ADDED
@@ -0,0 +1,39 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT license.
3
+
4
+ """Lazy Content Understanding client construction with injected credentials."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any
9
+
10
+ from .errors import ValidationError
11
+
12
+ LRO_POLLING_INTERVAL_SECONDS = 1
13
+
14
+
15
+ def build_content_understanding_client(
16
+ *,
17
+ endpoint: str,
18
+ credential: Any,
19
+ api_version: str,
20
+ user_agent: str = "",
21
+ polling_interval: int = LRO_POLLING_INTERVAL_SECONDS,
22
+ ) -> Any:
23
+ normalized_endpoint = endpoint.strip().rstrip("/")
24
+ if not normalized_endpoint:
25
+ raise ValidationError("Content Understanding endpoint cannot be empty")
26
+ if not api_version.strip():
27
+ raise ValidationError("Content Understanding API version cannot be empty")
28
+ if credential is None:
29
+ raise ValidationError("Content Understanding credential cannot be empty")
30
+
31
+ from azure.ai.contentunderstanding import ContentUnderstandingClient
32
+
33
+ return ContentUnderstandingClient(
34
+ endpoint=normalized_endpoint,
35
+ credential=credential,
36
+ api_version=api_version,
37
+ user_agent=user_agent,
38
+ polling_interval=polling_interval,
39
+ )