nooa-cli 0.0.7__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,527 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Core Stream type with method chaining — async-native."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import re as _re
8
+ from collections import deque
9
+ from collections.abc import AsyncIterator, Callable
10
+ from pathlib import Path
11
+ from typing import Any, TypeVar
12
+
13
+ from nooa_cli.tools.pyp.errors import Result
14
+
15
+ T = TypeVar("T")
16
+
17
+
18
+ def _to_async_iter(iterable) -> AsyncIterator:
19
+ """Convert a sync iterable to an async iterator."""
20
+
21
+ async def _gen():
22
+ for item in iterable:
23
+ yield item
24
+
25
+ return _gen()
26
+
27
+
28
+ class Stream:
29
+ """
30
+ Lazy async stream of items (typically strings) with method-chaining transforms.
31
+
32
+ Wraps any iterable or async iterable. Transforms return new Streams
33
+ (lazy async generators). Consumption happens at terminal operations
34
+ (await stream.collect(), etc).
35
+
36
+ Usage:
37
+ await cat("file.txt").grep("ERROR").head(10).collect()
38
+ await run("ls -la").grep(r"\\.py$").sort().collect()
39
+ await find("src", name="*.py").sort().text()
40
+ """
41
+
42
+ def __init__(self, iterable=None, *, _steps: list | None = None, _meta: dict | None = None):
43
+ if iterable is None:
44
+ self._aiterable = _to_async_iter([])
45
+ elif hasattr(iterable, "__aiter__"):
46
+ self._aiterable = iterable
47
+ else:
48
+ self._aiterable = _to_async_iter(iterable)
49
+ self._steps: list[str] = _steps or []
50
+ self._meta: dict = _meta or {}
51
+
52
+ def __aiter__(self) -> AsyncIterator:
53
+ return self._aiterable.__aiter__()
54
+
55
+ def __or__(self, other):
56
+ """Pipe operator: stream | callable for custom transforms."""
57
+ if callable(other):
58
+ return Stream(
59
+ other(self._aiterable), _steps=self._steps + [repr(other)], _meta=self._meta
60
+ )
61
+ return NotImplemented
62
+
63
+ def __repr__(self) -> str:
64
+ steps = ".".join(self._steps) if self._steps else "Stream(empty)"
65
+ return f"Stream({steps})"
66
+
67
+ # ─── Transforms (return new Stream) ────────────────────────────────
68
+
69
+ def grep(
70
+ self, pattern: str, *, invert: bool = False, ignore_case: bool = False, fixed: bool = False
71
+ ) -> Stream:
72
+ """Filter lines matching a pattern (like grep)."""
73
+ if fixed:
74
+
75
+ def _match(line):
76
+ if ignore_case:
77
+ return pattern.lower() in line.lower()
78
+ return pattern in line
79
+ else:
80
+ flags = _re.IGNORECASE if ignore_case else 0
81
+ regex = _re.compile(pattern, flags)
82
+
83
+ def _match(line):
84
+ return bool(regex.search(line))
85
+
86
+ parent = self
87
+
88
+ async def _gen():
89
+ async for line in parent:
90
+ matched = _match(line)
91
+ if matched != invert:
92
+ yield line
93
+
94
+ step = f"grep({pattern!r})"
95
+ return Stream(_gen(), _steps=self._steps + [step], _meta=self._meta)
96
+
97
+ def head(self, n: int = 10) -> Stream:
98
+ """Take the first N items (like head)."""
99
+ parent = self
100
+
101
+ async def _gen():
102
+ count = 0
103
+ async for item in parent:
104
+ if count >= n:
105
+ break
106
+ yield item
107
+ count += 1
108
+
109
+ return Stream(_gen(), _steps=self._steps + [f"head({n})"], _meta=self._meta)
110
+
111
+ def tail(self, n: int = 10) -> Stream:
112
+ """Take the last N items (like tail)."""
113
+ parent = self
114
+
115
+ async def _gen():
116
+ buf = deque(maxlen=n)
117
+ async for item in parent:
118
+ buf.append(item)
119
+ for item in buf:
120
+ yield item
121
+
122
+ return Stream(_gen(), _steps=self._steps + [f"tail({n})"], _meta=self._meta)
123
+
124
+ def sort(
125
+ self, *, key: Callable | None = None, reverse: bool = False, numeric: bool = False
126
+ ) -> Stream:
127
+ """Sort items (like sort). Buffering."""
128
+ parent = self
129
+
130
+ async def _gen():
131
+ items = []
132
+ async for item in parent:
133
+ items.append(item)
134
+ sort_key = key
135
+ if numeric and sort_key is None:
136
+
137
+ def _num_key(x):
138
+ try:
139
+ return float(x)
140
+ except (ValueError, TypeError):
141
+ return float("inf")
142
+
143
+ sort_key = _num_key
144
+ items.sort(key=sort_key, reverse=reverse)
145
+ for item in items:
146
+ yield item
147
+
148
+ return Stream(_gen(), _steps=self._steps + ["sort()"], _meta=self._meta)
149
+
150
+ def uniq(self, *, all_unique: bool = False, count: bool = False) -> Stream:
151
+ """Remove duplicates (consecutive or all)."""
152
+ parent = self
153
+
154
+ async def _gen_consecutive():
155
+ _sentinel = object()
156
+ prev = _sentinel
157
+ cnt = 0
158
+ async for item in parent:
159
+ if item == prev:
160
+ cnt += 1
161
+ else:
162
+ if prev is not _sentinel and cnt > 0:
163
+ if count:
164
+ yield f"{cnt} {prev}"
165
+ else:
166
+ yield prev
167
+ prev = item
168
+ cnt = 1
169
+ if cnt > 0:
170
+ if count:
171
+ yield f"{cnt} {prev}"
172
+ else:
173
+ yield prev
174
+
175
+ async def _gen_all():
176
+ seen: set = set()
177
+ async for item in parent:
178
+ if item not in seen:
179
+ seen.add(item)
180
+ yield item
181
+
182
+ gen = _gen_all if all_unique else _gen_consecutive
183
+ return Stream(gen(), _steps=self._steps + ["uniq()"], _meta=self._meta)
184
+
185
+ def cut(
186
+ self, fields: list[int], *, sep: str | None = None, out_sep: str | None = None
187
+ ) -> Stream:
188
+ """Extract fields from each line (like cut)."""
189
+ actual_out_sep = out_sep or sep or "\t"
190
+ parent = self
191
+
192
+ async def _gen():
193
+ async for line in parent:
194
+ parts = line.split(sep) if sep else line.split()
195
+ selected = []
196
+ for f in fields:
197
+ if f < len(parts):
198
+ selected.append(parts[f])
199
+ else:
200
+ selected.append("")
201
+ yield actual_out_sep.join(selected)
202
+
203
+ return Stream(_gen(), _steps=self._steps + [f"cut({fields})"], _meta=self._meta)
204
+
205
+ def sed(self, pattern: str, repl: str, *, count: int = 0) -> Stream:
206
+ """Regex substitution on each line (like sed s/pat/repl/)."""
207
+ regex = _re.compile(pattern)
208
+ parent = self
209
+
210
+ async def _gen():
211
+ async for line in parent:
212
+ yield regex.sub(repl, line, count=count)
213
+
214
+ return Stream(_gen(), _steps=self._steps + [f"sed({pattern!r})"], _meta=self._meta)
215
+
216
+ def map(self, fn: Callable[[Any], Any]) -> Stream:
217
+ """Apply a function to each item."""
218
+ parent = self
219
+
220
+ async def _gen():
221
+ async for item in parent:
222
+ yield fn(item)
223
+
224
+ return Stream(_gen(), _steps=self._steps + ["map(...)"], _meta=self._meta)
225
+
226
+ def filter(self, fn: Callable[[Any], bool]) -> Stream:
227
+ """Keep items where predicate is true."""
228
+ parent = self
229
+
230
+ async def _gen():
231
+ async for item in parent:
232
+ if fn(item):
233
+ yield item
234
+
235
+ return Stream(_gen(), _steps=self._steps + ["filter(...)"], _meta=self._meta)
236
+
237
+ def wc(self, *, lines_only: bool = True) -> Stream:
238
+ """Count lines/words/chars (like wc). Yields a single summary line."""
239
+ parent = self
240
+
241
+ async def _gen():
242
+ line_count = 0
243
+ word_count = 0
244
+ char_count = 0
245
+ async for line in parent:
246
+ line_count += 1
247
+ if not lines_only:
248
+ word_count += len(str(line).split())
249
+ char_count += len(str(line)) + 1
250
+ if lines_only:
251
+ yield str(line_count)
252
+ else:
253
+ yield f"{line_count} {word_count} {char_count}"
254
+
255
+ return Stream(_gen(), _steps=self._steps + ["wc()"], _meta=self._meta)
256
+
257
+ def skip(self, n: int = 1) -> Stream:
258
+ """Skip the first N items."""
259
+ parent = self
260
+
261
+ async def _gen():
262
+ count = 0
263
+ async for item in parent:
264
+ if count >= n:
265
+ yield item
266
+ count += 1
267
+
268
+ return Stream(_gen(), _steps=self._steps + [f"skip({n})"], _meta=self._meta)
269
+
270
+ def tee(self, path: str, *, append: bool = False) -> Stream:
271
+ """Pass items through while writing a copy to a file (like tee)."""
272
+ import asyncio as _asyncio
273
+
274
+ parent = self
275
+
276
+ async def _gen():
277
+ mode = "a" if append else "w"
278
+ fh = await _asyncio.to_thread(open, path, mode)
279
+ try:
280
+ async for item in parent:
281
+ item_str = str(item)
282
+ line = item_str if item_str.endswith("\n") else item_str + "\n"
283
+ await _asyncio.to_thread(fh.write, line)
284
+ yield item
285
+ finally:
286
+ await _asyncio.to_thread(fh.close)
287
+
288
+ return Stream(_gen(), _steps=self._steps + [f"tee({path!r})"], _meta=self._meta)
289
+
290
+ def flatten(self, *, sep: str = "\n") -> Stream:
291
+ """Split each line on a separator and yield individual items."""
292
+ parent = self
293
+
294
+ async def _gen():
295
+ async for item in parent:
296
+ parts = str(item).split(sep)
297
+ for part in parts:
298
+ if part:
299
+ yield part
300
+
301
+ return Stream(_gen(), _steps=self._steps + ["flatten()"], _meta=self._meta)
302
+
303
+ def strip(self, chars: str | None = None) -> Stream:
304
+ """Strip whitespace (or given chars) from each line."""
305
+ parent = self
306
+
307
+ async def _gen():
308
+ async for item in parent:
309
+ yield str(item).strip(chars)
310
+
311
+ return Stream(_gen(), _steps=self._steps + ["strip()"], _meta=self._meta)
312
+
313
+ def take_while(self, fn: Callable[[Any], bool]) -> Stream:
314
+ """Yield items while predicate is true, then stop."""
315
+ parent = self
316
+
317
+ async def _gen():
318
+ async for item in parent:
319
+ if not fn(item):
320
+ break
321
+ yield item
322
+
323
+ return Stream(_gen(), _steps=self._steps + ["take_while(...)"], _meta=self._meta)
324
+
325
+ def drop_while(self, fn: Callable[[Any], bool]) -> Stream:
326
+ """Skip items while predicate is true, then yield the rest."""
327
+ parent = self
328
+
329
+ async def _gen():
330
+ dropping = True
331
+ async for item in parent:
332
+ if dropping and fn(item):
333
+ continue
334
+ dropping = False
335
+ yield item
336
+
337
+ return Stream(_gen(), _steps=self._steps + ["drop_while(...)"], _meta=self._meta)
338
+
339
+ def pipe(self, *transforms) -> Stream:
340
+ """Apply a sequence of transforms (for reusable pipelines)."""
341
+ s = self
342
+ for t in transforms:
343
+ if callable(t):
344
+ s = s | t
345
+ else:
346
+ raise TypeError(f"Transform must be callable, got {type(t)}")
347
+ return s
348
+
349
+ def xargs(self, fn: Callable) -> Stream:
350
+ """Apply a function to each item, yielding results (like xargs).
351
+
352
+ Supports both sync and async callables. Items where fn returns None
353
+ are skipped.
354
+
355
+ Args:
356
+ fn: Function taking a single string argument. Can be sync or async.
357
+
358
+ Usage:
359
+ run("find . -name '*.py'").xargs(process_file).head(10)
360
+ """
361
+ import inspect as _inspect
362
+
363
+ parent = self
364
+ is_async = _inspect.iscoroutinefunction(fn)
365
+
366
+ async def _gen():
367
+ async for item in parent:
368
+ if is_async:
369
+ result = await fn(item)
370
+ else:
371
+ result = fn(item)
372
+ if result is not None:
373
+ yield str(result)
374
+
375
+ return Stream(_gen(), _steps=self._steps + ["xargs(...)"], _meta=self._meta)
376
+
377
+ # ─── Terminal operations (sinks) — all async ───────────────────────
378
+
379
+ def __await__(self):
380
+ """Awaiting a Stream collects it — ``await find(...)`` ≡ ``await find(...).collect()``.
381
+
382
+ Lets a Stream be the awaitable terminal directly when you just want the
383
+ list, without remembering which sink verb to call.
384
+ """
385
+ return self.collect().__await__()
386
+
387
+ async def paths(self) -> list[Path]:
388
+ """Terminal: collect the stream as ``Path`` objects.
389
+
390
+ Symmetric with ``rg(...).matches()`` — the natural sink for ``find(...)``
391
+ when you want real paths rather than strings::
392
+
393
+ for p in await shell.find("src", name="*.py").paths():
394
+ ...
395
+ """
396
+ return [Path(line) for line in await self.collect()]
397
+
398
+ async def collect(self) -> list[str]:
399
+ """Consume the stream and return all items as a list."""
400
+ result = []
401
+ async for item in self._aiterable:
402
+ result.append(item)
403
+ return result
404
+
405
+ async def result(self) -> Result:
406
+ """Consume the stream and return a Result with metadata."""
407
+ items = []
408
+ async for item in self._aiterable:
409
+ items.append(item)
410
+ rc = self._meta.get("returncode", 0)
411
+ stderr = self._meta.get("stderr", "")
412
+ return Result(lines=items, returncode=rc, stderr=stderr)
413
+
414
+ async def first(self) -> str | None:
415
+ """Return the first item or None."""
416
+ async for item in self._aiterable:
417
+ return item
418
+ return None
419
+
420
+ async def last(self) -> str | None:
421
+ """Return the last item or None."""
422
+ last_item = None
423
+ async for x in self._aiterable:
424
+ last_item = x
425
+ return last_item
426
+
427
+ async def count(self) -> int:
428
+ """Count items in the stream (consumes it)."""
429
+ n = 0
430
+ async for _ in self._aiterable:
431
+ n += 1
432
+ return n
433
+
434
+ async def write(self, path: str | Path, *, mode: str = "w") -> int:
435
+ """Write stream lines to a file (non-blocking). Returns number of lines written."""
436
+ import asyncio as _asyncio
437
+
438
+ p = Path(path)
439
+ fh = await _asyncio.to_thread(open, p, mode)
440
+ n = 0
441
+ try:
442
+ async for line in self._aiterable:
443
+ line_str = str(line)
444
+ await _asyncio.to_thread(
445
+ fh.write, line_str if line_str.endswith("\n") else line_str + "\n"
446
+ )
447
+ n += 1
448
+ finally:
449
+ await _asyncio.to_thread(fh.close)
450
+ return n
451
+
452
+ async def print(self, *, end: str = "\n") -> None:
453
+ """Print each item to stdout."""
454
+ import builtins
455
+
456
+ async for item in self._aiterable:
457
+ builtins.print(item, end=end)
458
+
459
+ async def json(self) -> Any:
460
+ """Parse the collected text as JSON."""
461
+ import json as _json
462
+
463
+ items = await self.collect()
464
+ return _json.loads("\n".join(str(line) for line in items))
465
+
466
+ async def to_set(self) -> set[str]:
467
+ """Collect into a set."""
468
+ result = set()
469
+ async for item in self._aiterable:
470
+ result.add(item)
471
+ return result
472
+
473
+ async def to_dict(self, sep: str = "=") -> dict[str, str]:
474
+ """Split each line on sep and collect into a dict."""
475
+ d = {}
476
+ async for line in self._aiterable:
477
+ line_str = str(line)
478
+ if sep in line_str:
479
+ k, v = line_str.split(sep, 1)
480
+ d[k.strip()] = v.strip()
481
+ return d
482
+
483
+ async def text(self) -> str:
484
+ """Collect and join lines with newlines into a single string."""
485
+ items = await self.collect()
486
+ return "\n".join(str(item) for item in items)
487
+
488
+ async def table(self, *, sep: str | None = None, headers: list[str] | None = None) -> str:
489
+ """
490
+ Format stream as an aligned text table.
491
+
492
+ Each line is split into columns. Columns are padded to align.
493
+ """
494
+ rows = []
495
+ async for line in self._aiterable:
496
+ line_str = str(line)
497
+ cols = line_str.split(sep) if sep else line_str.split()
498
+ rows.append([c.strip() for c in cols])
499
+
500
+ if not rows:
501
+ return ""
502
+
503
+ all_rows = rows
504
+ if headers:
505
+ all_rows = [headers] + rows
506
+
507
+ max_cols = max(len(r) for r in all_rows)
508
+ widths = [0] * max_cols
509
+ for row in all_rows:
510
+ for i, col in enumerate(row):
511
+ if i < max_cols:
512
+ widths[i] = max(widths[i], len(col))
513
+
514
+ out_lines = []
515
+ if headers:
516
+ header_line = " ".join(h.ljust(widths[i]) for i, h in enumerate(headers))
517
+ out_lines.append(header_line)
518
+ out_lines.append(" ".join("-" * widths[i] for i in range(max_cols)))
519
+
520
+ for row in rows:
521
+ padded = []
522
+ for i in range(max_cols):
523
+ val = row[i] if i < len(row) else ""
524
+ padded.append(val.ljust(widths[i]))
525
+ out_lines.append(" ".join(padded))
526
+
527
+ return "\n".join(out_lines)
File without changes