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.
- nooa_cli/AGENTS.md +162 -0
- nooa_cli/__init__.py +70 -0
- nooa_cli/__main__.py +8 -0
- nooa_cli/_common.py +45 -0
- nooa_cli/commands/__init__.py +106 -0
- nooa_cli/commands/_otlp_helpers.py +122 -0
- nooa_cli/commands/_template.py +89 -0
- nooa_cli/commands/config.py +356 -0
- nooa_cli/commands/delete_traces.py +63 -0
- nooa_cli/commands/eval.py +68 -0
- nooa_cli/commands/import_harbor.py +512 -0
- nooa_cli/commands/import_traces.py +189 -0
- nooa_cli/commands/start_dev.py +145 -0
- nooa_cli/commands/traces.py +316 -0
- nooa_cli/completion.py +253 -0
- nooa_cli/tools/__init__.py +0 -0
- nooa_cli/tools/_tree_sitter_backend.py +351 -0
- nooa_cli/tools/pyp/__init__.py +71 -0
- nooa_cli/tools/pyp/errors.py +92 -0
- nooa_cli/tools/pyp/sources.py +458 -0
- nooa_cli/tools/pyp/stream.py +527 -0
- nooa_cli/tools/pyp/tests/__init__.py +0 -0
- nooa_cli/tools/pyp/tests/test_pyp.py +394 -0
- nooa_cli/tools/repo_tools.py +902 -0
- nooa_cli-0.0.7.dist-info/METADATA +52 -0
- nooa_cli-0.0.7.dist-info/RECORD +28 -0
- nooa_cli-0.0.7.dist-info/WHEEL +4 -0
- nooa_cli-0.0.7.dist-info/entry_points.txt +5 -0
|
@@ -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
|