jnav 1.0.0__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.
jnav/log_list_view.py ADDED
@@ -0,0 +1,292 @@
1
+ from bisect import bisect_left
2
+ from typing import TYPE_CHECKING, ClassVar, override
3
+
4
+ from rich.console import RenderableType
5
+ from rich.style import Style
6
+ from textual.binding import BindingType
7
+ from textual.events import Key
8
+
9
+ from .filter_provider import FilterProvider
10
+ from .filtering import text_search_expr
11
+ from .key_sequences import KeySequence, KeySequenceMixin
12
+ from .log_entry_item import LEVEL_COMPONENTS
13
+ from .log_entry_renderer import EntryStyles, LogEntryRenderer
14
+ from .log_model import LogModel
15
+ from .role_mapper import RoleMapper
16
+ from .search_engine import SearchEngine
17
+ from .selector_provider import SelectorProvider
18
+ from .store import IndexedEntry
19
+ from .text_input_screen import TextInputScreen
20
+ from .virtual_list_view import VirtualListView
21
+
22
+ if TYPE_CHECKING:
23
+ from textual import getters
24
+ from textual.app import App
25
+
26
+
27
+ class LogListView(KeySequenceMixin, VirtualListView[IndexedEntry]):
28
+ if TYPE_CHECKING:
29
+ app = getters.app(App[None])
30
+
31
+ COMPONENT_CLASSES: ClassVar[set[str]] = {
32
+ "summary--level-error",
33
+ "summary--level-fatal",
34
+ "summary--level-critical",
35
+ "summary--level-warning",
36
+ "summary--level-info",
37
+ "summary--level-debug",
38
+ "summary--level-trace",
39
+ "summary--text",
40
+ "summary--search-highlight",
41
+ "summary--cursor",
42
+ "tree--key",
43
+ "tree--key-selected",
44
+ "tree--value",
45
+ "tree--value-null",
46
+ "tree--json-string",
47
+ "tree--search-highlight",
48
+ "tree--background",
49
+ }
50
+
51
+ BINDINGS: ClassVar[list[BindingType]] = []
52
+
53
+ SEQUENCES: ClassVar[list[KeySequence]] = [
54
+ KeySequence("gg", "jump_top", "jump to top"),
55
+ KeySequence("ft", "text_filter", "text filter"),
56
+ KeySequence("fp", "toggle_filters_pause", "pause filters"),
57
+ KeySequence("vi", "toggle_expanded", "toggle inline tree"),
58
+ ]
59
+ SEQUENCE_GROUPS: ClassVar[dict[str, str]] = {"f": "filter ▸", "v": "view ▸"}
60
+
61
+ DEFAULT_CSS = """
62
+ LogListView {
63
+ height: 1fr;
64
+ background: $background;
65
+ & > .summary--level-error { color: $error; text-style: bold; }
66
+ & > .summary--level-fatal { color: $error; text-style: bold; }
67
+ & > .summary--level-critical { color: $error; text-style: bold; }
68
+ & > .summary--level-warning { color: $warning; text-style: bold; }
69
+ & > .summary--level-info { color: $primary; text-style: bold; }
70
+ & > .summary--level-debug { color: $success; text-style: bold; }
71
+ & > .summary--level-trace { color: $success; text-style: bold; }
72
+ & > .summary--text { color: $foreground; }
73
+ & > .summary--cursor { background: $primary 30%; }
74
+ & > .tree--background { background: $background-darken-1; }
75
+ }
76
+ """
77
+
78
+ def __init__(
79
+ self,
80
+ *,
81
+ model: LogModel,
82
+ role_mapper: RoleMapper,
83
+ selectors: SelectorProvider,
84
+ search: SearchEngine,
85
+ filter_provider: FilterProvider,
86
+ id: str | None = None,
87
+ follow: bool,
88
+ ) -> None:
89
+ super().__init__(
90
+ model=model,
91
+ render_item=self._render_entry,
92
+ id=id,
93
+ follow=follow,
94
+ )
95
+ self._log_model = model
96
+ self._role_mapper = role_mapper
97
+ self._selectors = selectors
98
+ self._search = search
99
+ self._filter_provider = filter_provider
100
+ self._expanded_mode: bool = True
101
+ self._renderer = LogEntryRenderer(
102
+ search=search,
103
+ role_mapper=role_mapper,
104
+ selectors=selectors,
105
+ )
106
+ self._entry_styles: EntryStyles | None = None
107
+ self._saved_store_idx: int = 0
108
+ self._saved_offset: int = 0
109
+
110
+ def _resolve_styles(self) -> EntryStyles:
111
+ base_bg = self.styles.background
112
+ cursor_color = self.get_component_styles("summary--cursor").background
113
+ blended = base_bg.blend(cursor_color, cursor_color.a)
114
+ return EntryStyles(
115
+ text=self.get_component_rich_style("summary--text", partial=True),
116
+ levels={
117
+ comp: self.get_component_rich_style(comp, partial=True)
118
+ for comp in LEVEL_COMPONENTS.values()
119
+ },
120
+ highlight=self.get_component_rich_style(
121
+ "summary--search-highlight", partial=True
122
+ ),
123
+ cursor_bg=Style(bgcolor=blended.rich_color),
124
+ tree_key=self.get_component_rich_style("tree--key", partial=True),
125
+ tree_key_selected=self.get_component_rich_style(
126
+ "tree--key-selected", partial=True
127
+ ),
128
+ tree_value=self.get_component_rich_style("tree--value", partial=True),
129
+ tree_value_null=self.get_component_rich_style(
130
+ "tree--value-null", partial=True
131
+ ),
132
+ tree_json_string=self.get_component_rich_style(
133
+ "tree--json-string", partial=True
134
+ ),
135
+ tree_search_highlight=self.get_component_rich_style(
136
+ "tree--search-highlight", partial=True
137
+ ),
138
+ tree_bg=self.get_component_styles("tree--background").background,
139
+ cursor_color=cursor_color,
140
+ )
141
+
142
+ @override
143
+ def render(self) -> RenderableType:
144
+ self._entry_styles = self._resolve_styles()
145
+ return super().render()
146
+
147
+ def _render_entry(self, ie: IndexedEntry, index: int) -> RenderableType:
148
+ assert self._entry_styles is not None
149
+ return self._renderer.render(
150
+ ie,
151
+ styles=self._entry_styles,
152
+ is_cursor=index == self.index,
153
+ expanded=self._expanded_mode,
154
+ width=self.size.width,
155
+ )
156
+
157
+ @override
158
+ async def on_mount(self) -> None:
159
+ await self._log_model.on_append.subscribe_async(self._on_append_discover)
160
+ await self._log_model.on_will_rebuild.subscribe_async(self._on_will_rebuild)
161
+ await self._log_model.on_rebuild.subscribe_async(self._on_rebuild)
162
+ await self._role_mapper.on_change.subscribe_async(
163
+ self._on_fields_or_search_changed
164
+ )
165
+ await self._selectors.on_change.subscribe_async(
166
+ self._on_fields_or_search_changed
167
+ )
168
+ await self._search.on_change.subscribe_async(self._on_fields_or_search_changed)
169
+
170
+ async def _on_fields_or_search_changed(self, _: None) -> None:
171
+ self.refresh()
172
+ self.refresh_bindings()
173
+
174
+ def on_focus(self) -> None:
175
+ if self.parent is not None:
176
+ self.parent.add_class("focused")
177
+
178
+ def on_blur(self) -> None:
179
+ if self.parent is not None:
180
+ self.parent.remove_class("focused")
181
+
182
+ def set_expanded_mode(self, expanded: bool) -> None:
183
+ offset = self.cursor_viewport_offset()
184
+ self._expanded_mode = expanded
185
+ self.scroll_to_cursor_offset(offset)
186
+ self.refresh()
187
+
188
+ @property
189
+ def expanded_mode(self) -> bool:
190
+ return self._expanded_mode
191
+
192
+ async def initial_build(self) -> None:
193
+ await self._role_mapper.discover(self._log_model.all())
194
+ self._rebuild()
195
+ if not self._log_model.is_empty():
196
+ self.index = 0
197
+
198
+ def current_index(self) -> int:
199
+ visible = self._log_model.visible_indices
200
+ if self.index < len(visible):
201
+ return visible[self.index]
202
+ return 0
203
+
204
+ def jump_to_index(self, store_idx: int) -> None:
205
+ visible = self._log_model.visible_indices
206
+ try:
207
+ self.index = visible.index(store_idx)
208
+ except ValueError:
209
+ pass
210
+
211
+ async def _on_append_discover(self, new_entries: list[IndexedEntry]) -> None:
212
+ await self._role_mapper.discover(new_entries)
213
+
214
+ async def _on_will_rebuild(self, _: None) -> None:
215
+ # Snapshot cursor state from the OLD view, before the model rebuilds.
216
+ if self._log_model.is_empty():
217
+ self._saved_store_idx = 0
218
+ else:
219
+ self._saved_store_idx = self._log_model.get(self.index).index
220
+ self._saved_offset = self.cursor_viewport_offset()
221
+
222
+ async def _on_rebuild(self, _: None) -> None:
223
+ self._rebuild()
224
+
225
+ def _rebuild(self) -> None:
226
+ if not self._log_model.is_empty():
227
+ # Find closest entry to previously highlighted store index and highlight it
228
+ self.index = self._closest_list_index(self._saved_store_idx)
229
+
230
+ # Scroll to approximately same viewport offset as before
231
+ self.scroll_to_cursor_offset(self._saved_offset)
232
+ else:
233
+ self.index = 0
234
+ self._scroll_top_index = 0
235
+ self._scroll_line_offset = 0
236
+
237
+ if self.is_mounted:
238
+ self.refresh()
239
+
240
+ def _closest_list_index(self, store_idx: int) -> int:
241
+ indices = self._log_model.visible_indices
242
+ if not indices:
243
+ return 0
244
+ pos = bisect_left(indices, store_idx)
245
+
246
+ # store_idx is at or before the first item
247
+ if pos == 0:
248
+ return 0
249
+
250
+ # store_idx is after all items
251
+ if pos >= len(indices):
252
+ return len(indices) - 1
253
+
254
+ # store_idx falls between two items; pick the closer one
255
+ before = store_idx - indices[pos - 1]
256
+ after = indices[pos] - store_idx
257
+ return pos if after < before else pos - 1
258
+
259
+ async def on_key(self, event: Key) -> None:
260
+ if await self._handle_sequence_key(event):
261
+ return
262
+
263
+ @override
264
+ def check_action(
265
+ self,
266
+ action: str,
267
+ parameters: tuple[object, ...],
268
+ ) -> bool | None:
269
+ del parameters # unused
270
+ if action == "toggle_expanded":
271
+ return bool(self._selectors.active_selectors)
272
+ return True
273
+
274
+ def action_toggle_expanded(self) -> None:
275
+ self.set_expanded_mode(not self._expanded_mode)
276
+
277
+ def action_text_filter(self) -> None:
278
+ async def on_dismiss(term: str | None) -> None:
279
+ if term:
280
+ expr = text_search_expr(term)
281
+ await self._filter_provider.add_filter(expr, label=f"text: {term}")
282
+
283
+ self.app.push_screen(TextInputScreen("Text filter"), on_dismiss)
284
+
285
+ async def action_toggle_filters_pause(self) -> None:
286
+ if not self._filter_provider.root.children:
287
+ return
288
+ await self._log_model.set_filtering_enabled(
289
+ not self._log_model.filtering_enabled
290
+ )
291
+ state = "active" if self._log_model.filtering_enabled else "paused"
292
+ self.app.notify(f"Filters {state}", timeout=2)
jnav/log_model.py ADDED
@@ -0,0 +1,114 @@
1
+ from typing import override
2
+
3
+ import aioreactive as rx
4
+ from aioreactive import AsyncSubject, pipe
5
+
6
+ from jnav.filter_provider import FilterProvider
7
+ from jnav.filtering import apply_filter_tree
8
+ from jnav.model import Model
9
+ from jnav.store import IndexedEntry, Store
10
+
11
+
12
+ class LogModel(Model[IndexedEntry]):
13
+ _on_append: AsyncSubject[list[IndexedEntry]]
14
+ on_will_rebuild: AsyncSubject[None]
15
+ on_rebuild: AsyncSubject[None]
16
+ _filtering_enabled: bool
17
+ _view: list[int]
18
+
19
+ def __init__(
20
+ self,
21
+ store: Store,
22
+ filter_provider: FilterProvider,
23
+ ):
24
+ self._store = store
25
+ self._filter_provider = filter_provider
26
+ self._on_append = AsyncSubject[list[IndexedEntry]]()
27
+ self.on_will_rebuild = AsyncSubject[None]()
28
+ self.on_rebuild = AsyncSubject[None]()
29
+ self._filtering_enabled = True
30
+ self._view = []
31
+
32
+ @property
33
+ @override
34
+ def on_append(self) -> AsyncSubject[list[IndexedEntry]]:
35
+ return self._on_append
36
+
37
+ async def start(self) -> None:
38
+ await self._filter_provider.on_change.subscribe_async(self.refilter)
39
+ filtered_append = pipe(
40
+ self._store.on_append,
41
+ rx.map(self._filter_batch),
42
+ rx.filter(lambda batch: len(batch) > 0),
43
+ )
44
+ await filtered_append.subscribe_async(self._append_to_view)
45
+
46
+ async def _append_to_view(self, batch: list[IndexedEntry]) -> None:
47
+ self._view.extend(ie.index for ie in batch)
48
+ await self._on_append.asend(batch)
49
+
50
+ def _filter_batch(self, batch: list[IndexedEntry]) -> list[IndexedEntry]:
51
+ return [i for i in batch if self._apply_filters(i)]
52
+
53
+ def _apply_filters(self, indexed_entry: IndexedEntry) -> bool:
54
+ if not self._filtering_enabled:
55
+ return True
56
+ root = self._filter_provider.root
57
+ matched, error = apply_filter_tree(root, [indexed_entry.entry.expanded])
58
+ return error is not None or len(matched) > 0
59
+
60
+ def _rebuild_view(self) -> None:
61
+ if not self._filtering_enabled:
62
+ self._view = list(range(len(self._store)))
63
+ else:
64
+ root = self._filter_provider.root
65
+ entries = [e.entry.expanded for e in self._store.all()]
66
+ matched, _error = apply_filter_tree(root, entries)
67
+ self._view = matched
68
+
69
+ def total_count(self) -> int:
70
+ return len(self._store)
71
+
72
+ @override
73
+ def count(self) -> int:
74
+ return len(self._view)
75
+
76
+ def all(self) -> list[IndexedEntry]:
77
+ return self._store.all()
78
+
79
+ @override
80
+ def is_empty(self) -> bool:
81
+ return len(self._view) == 0
82
+
83
+ @override
84
+ def get(self, pos: int) -> IndexedEntry:
85
+ return self._store.get(self._view[pos])
86
+
87
+ @property
88
+ def visible_entries(self) -> list[IndexedEntry]:
89
+ return [self._store.get(i) for i in self._view]
90
+
91
+ @property
92
+ def visible_indices(self) -> list[int]:
93
+ return list(self._view)
94
+
95
+ @property
96
+ def filtering_enabled(self) -> bool:
97
+ return self._filtering_enabled
98
+
99
+ async def set_filtering_enabled(self, value: bool) -> None:
100
+ await self.on_will_rebuild.asend(None)
101
+ self._filtering_enabled = value
102
+ self._rebuild_view()
103
+ await self.on_rebuild.asend(None)
104
+
105
+ async def pause_filtering(self) -> None:
106
+ await self.set_filtering_enabled(False)
107
+
108
+ async def resume_filtering(self) -> None:
109
+ await self.set_filtering_enabled(True)
110
+
111
+ async def refilter(self, _: None) -> None:
112
+ await self.on_will_rebuild.asend(None)
113
+ self._rebuild_view()
114
+ await self.on_rebuild.asend(None)
jnav/logging.py ADDED
@@ -0,0 +1,52 @@
1
+ import logging
2
+ from datetime import UTC, datetime
3
+ from logging import FileHandler
4
+ from typing import override
5
+
6
+ from pythonjsonlogger.core import LogData
7
+ from pythonjsonlogger.json import JsonFormatter
8
+
9
+
10
+ class HLJsonFormatter(JsonFormatter):
11
+ @override
12
+ def process_log_record(self, log_data: LogData) -> LogData:
13
+ log_data = super().process_log_record(log_data)
14
+ log_data["caller"] = f"{log_data['filename']}:{log_data['lineno']}"
15
+ log_data.pop("filename", None)
16
+ log_data.pop("lineno", None)
17
+ log_data.pop("funcName", None)
18
+ if log_data.get("exc_info") is None:
19
+ log_data.pop("exc_info", None)
20
+ log_data["ts"] = datetime.fromtimestamp(log_data["created"], tz=UTC).isoformat()
21
+ log_data.pop("created", None)
22
+ return log_data
23
+
24
+
25
+ def init_logging() -> None:
26
+ file_handler = FileHandler("jnav.log", mode="a", encoding="utf-8")
27
+ file_handler.setFormatter(
28
+ HLJsonFormatter(
29
+ [
30
+ "message",
31
+ "levelname",
32
+ "filename",
33
+ "lineno",
34
+ "funcName",
35
+ "name",
36
+ "exc_info",
37
+ "caller",
38
+ "created",
39
+ ],
40
+ rename_fields={
41
+ "levelname": "level",
42
+ "name": "logger",
43
+ },
44
+ )
45
+ )
46
+ file_handler.setLevel(logging.DEBUG)
47
+
48
+ logging.basicConfig(
49
+ level=logging.NOTSET,
50
+ format="%(message)s",
51
+ handlers=[file_handler],
52
+ )
@@ -0,0 +1,27 @@
1
+ from typing import override
2
+
3
+ from rich.text import Text
4
+ from textual.app import ComposeResult
5
+ from textual.widgets import Footer
6
+
7
+
8
+ class WrappingFooter(Footer):
9
+ def __init__(self, *, columns: int = 4) -> None:
10
+ super().__init__()
11
+ self._columns = columns
12
+
13
+ @override
14
+ def compose(self) -> ComposeResult:
15
+ yield from super().compose()
16
+ self.styles.grid_size_columns = self._columns
17
+
18
+
19
+ def list_option_prompt(label: str, enabled: bool, combine: str = "and") -> Text:
20
+ marker = "\u2713" if enabled else " "
21
+ style = "" if enabled else "dim"
22
+ prefix = "OR " if combine == "or" else " "
23
+ return Text.assemble(
24
+ (prefix, "italic" if combine == "or" else "dim"),
25
+ (f"{marker} ", style),
26
+ (label, style),
27
+ )
jnav/model.py ADDED
@@ -0,0 +1,13 @@
1
+ from collections.abc import Sequence
2
+ from typing import Protocol
3
+
4
+ from aioreactive import AsyncObservable
5
+
6
+
7
+ class Model[T](Protocol):
8
+ @property
9
+ def on_append(self) -> AsyncObservable[Sequence[T]]: ...
10
+
11
+ def get(self, pos: int) -> T: ...
12
+ def count(self) -> int: ...
13
+ def is_empty(self) -> bool: ...
jnav/offset_group.py ADDED
@@ -0,0 +1,30 @@
1
+ from collections.abc import Generator
2
+
3
+ from rich.console import Console, ConsoleOptions, Group, RenderableType
4
+ from rich.segment import Segment
5
+
6
+
7
+ class OffsetGroup:
8
+ """A Rich renderable that renders a group of items, skipping the first N lines.
9
+
10
+ Used by VirtualListView to handle partial visibility of the topmost item
11
+ when the viewport is scrolled partway into it.
12
+ """
13
+
14
+ def __init__(
15
+ self,
16
+ renderables: list[RenderableType],
17
+ skip_lines: int,
18
+ ) -> None:
19
+ self._group = Group(*renderables)
20
+ self._skip = skip_lines
21
+
22
+ def __rich_console__(
23
+ self, console: Console, options: ConsoleOptions
24
+ ) -> Generator[Segment]:
25
+ segments = console.render(self._group, options)
26
+ for i, line in enumerate(Segment.split_lines(segments)):
27
+ if i < self._skip:
28
+ continue
29
+ yield from line
30
+ yield Segment.line()
jnav/parsing.py ADDED
@@ -0,0 +1,101 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Any, cast
3
+
4
+ import orjson
5
+
6
+
7
+ @dataclass
8
+ class ParsedEntry:
9
+ raw: str
10
+ expanded: dict[str, Any]
11
+ expanded_paths: set[str] = field(default_factory=set)
12
+
13
+
14
+ def parse_entry(line: str) -> ParsedEntry | None:
15
+ """Parse a JSON line into a ``ParsedEntry`` with nested JSON-encoded
16
+ strings expanded in place. Returns ``None`` for blank lines, invalid
17
+ JSON, and JSON that isn't an object."""
18
+ stripped = line.strip()
19
+ if not stripped:
20
+ return None
21
+ parsed = _try_parse_json(stripped)
22
+ if not isinstance(parsed, dict):
23
+ return None
24
+ expanded_paths: set[str] = set()
25
+ _expand_in_place(
26
+ parsed,
27
+ path="",
28
+ expanded_paths=expanded_paths,
29
+ )
30
+ return ParsedEntry(
31
+ raw=stripped,
32
+ expanded=parsed,
33
+ expanded_paths=expanded_paths,
34
+ )
35
+
36
+
37
+ def _expand_in_place(
38
+ obj: dict[str, Any] | list[Any],
39
+ *,
40
+ path: str,
41
+ expanded_paths: set[str],
42
+ ) -> None:
43
+ """Recursively replace JSON-encoded string values with their parsed
44
+ objects, mutating dicts and lists in place."""
45
+ match obj:
46
+ case dict():
47
+ for k, v in obj.items():
48
+ _expand_slot(
49
+ obj,
50
+ key=k,
51
+ value=v,
52
+ child_path=f"{path}.{k}" if path else k,
53
+ expanded_paths=expanded_paths,
54
+ )
55
+ case list():
56
+ for i, v in enumerate(obj):
57
+ _expand_slot(
58
+ obj,
59
+ key=i,
60
+ value=v,
61
+ child_path=f"{path}[{i}]",
62
+ expanded_paths=expanded_paths,
63
+ )
64
+
65
+
66
+ def _expand_slot(
67
+ container: dict[str, Any] | list[Any],
68
+ *,
69
+ key: str | int,
70
+ value: object,
71
+ child_path: str,
72
+ expanded_paths: set[str],
73
+ ) -> None:
74
+ # If the value is a string, try to parse it as JSON
75
+ if isinstance(value, str):
76
+ parsed = _try_parse_json(value)
77
+ if parsed is not None:
78
+ container[key] = parsed # pyright: ignore[reportArgumentType, reportCallIssue]
79
+ expanded_paths.add(child_path)
80
+ value = parsed
81
+
82
+ # If the value is now a dict or list, recursively expand it
83
+ if isinstance(value, (dict, list)):
84
+ value = cast(dict[str, Any] | list[Any], value)
85
+ _expand_in_place(
86
+ value,
87
+ path=child_path,
88
+ expanded_paths=expanded_paths,
89
+ )
90
+
91
+
92
+ def _try_parse_json(value: str) -> dict[str, Any] | list[Any] | None:
93
+ if not value or value[0] not in ("{", "["):
94
+ return None
95
+ try:
96
+ parsed = orjson.loads(value)
97
+ except orjson.JSONDecodeError, ValueError:
98
+ return None
99
+ if isinstance(parsed, (dict, list)) and parsed:
100
+ return cast(dict[str, Any] | list[Any], parsed)
101
+ return None
jnav/reading.py ADDED
@@ -0,0 +1,52 @@
1
+ import asyncio
2
+ import logging
3
+ import os
4
+ import sys
5
+ from collections.abc import AsyncIterator
6
+ from io import BufferedReader
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ async def read_file(path: str, *, tail: bool = True) -> AsyncIterator[str]:
12
+ """Yield lines from a file, optionally tailing for new content at EOF."""
13
+ with open(path, mode="r", errors="replace") as f: # noqa: ASYNC230
14
+ while True:
15
+ line = f.readline()
16
+
17
+ if not line:
18
+ if not tail:
19
+ return
20
+ await asyncio.sleep(0.1)
21
+ continue
22
+
23
+ yield line
24
+
25
+
26
+ async def read_pipe(pipe: BufferedReader) -> AsyncIterator[str]:
27
+ """Yield lines from a pipe using asyncio stream reader."""
28
+ loop = asyncio.get_running_loop()
29
+ reader = asyncio.StreamReader()
30
+ protocol = asyncio.StreamReaderProtocol(reader)
31
+ await loop.connect_read_pipe(lambda: protocol, pipe)
32
+ while not reader.at_eof():
33
+ line_bytes = await reader.readline()
34
+ if line_bytes:
35
+ yield line_bytes.decode(errors="replace")
36
+
37
+
38
+ def setup_stdin_pipe() -> BufferedReader:
39
+ """Duplicate stdin to a separate file descriptor.
40
+ This is required for TUI input handling, while we read piped input.
41
+ """
42
+ pipe = os.fdopen(os.dup(sys.stdin.fileno()), "rb")
43
+ sys.stdin.close()
44
+ try:
45
+ tty = open("/dev/tty") # noqa: SIM115, replaces sys.stdin, must outlive this function
46
+ except OSError:
47
+ pipe.close()
48
+ raise
49
+ sys.stdin = tty
50
+ sys.__stdin__ = tty
51
+
52
+ return pipe