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/__init__.py ADDED
File without changes
jnav/__main__.py ADDED
@@ -0,0 +1,108 @@
1
+ import asyncio
2
+ import hashlib
3
+ import logging
4
+ import sys
5
+ from collections.abc import AsyncIterator
6
+ from pathlib import Path
7
+ from typing import cast
8
+
9
+ import aioreactive as rx
10
+ import click
11
+ from platformdirs import user_data_dir
12
+
13
+ from jnav.filter_provider import FilterProvider
14
+ from jnav.log_model import LogModel
15
+ from jnav.role_mapper import RoleMapper
16
+ from jnav.search_engine import SearchEngine
17
+ from jnav.selector_provider import SelectorProvider
18
+ from jnav.store import Store
19
+
20
+ from .app import JnavApp
21
+ from .buffer import buffer_time_or_count
22
+ from .logging import init_logging
23
+ from .parsing import ParsedEntry, parse_entry
24
+ from .reading import read_file, read_pipe, setup_stdin_pipe
25
+
26
+ logging.getLogger("aioreactive").setLevel(logging.WARNING)
27
+
28
+ _STATE_DIR = Path(user_data_dir("jnav"))
29
+
30
+
31
+ def _state_file_for(file_path: str) -> Path:
32
+ key = hashlib.sha256(str(Path(file_path).resolve()).encode()).hexdigest()[:16]
33
+ return _STATE_DIR / f"{key}.json"
34
+
35
+
36
+ def _get_input_iterator(file: str | None) -> AsyncIterator[str] | None:
37
+ if file:
38
+ return read_file(file)
39
+ elif not sys.stdin.isatty():
40
+ pipe = setup_stdin_pipe()
41
+ return read_pipe(pipe)
42
+
43
+ return None
44
+
45
+
46
+ async def _run(file: str | None, follow: bool) -> None:
47
+ lines = _get_input_iterator(file)
48
+
49
+ if lines is None:
50
+ click.echo("Usage: jnav [FILE] or pipe JSONL via stdin", err=True)
51
+ raise SystemExit(1)
52
+
53
+ filter_provider = FilterProvider()
54
+ role_mapper = RoleMapper()
55
+ selectors = SelectorProvider()
56
+ store = Store()
57
+ model = LogModel(
58
+ store=store,
59
+ filter_provider=filter_provider,
60
+ )
61
+
62
+ search = SearchEngine(model)
63
+
64
+ await model.start()
65
+ await search.start()
66
+
67
+ entry_stream = rx.from_async_iterable(
68
+ buffer_time_or_count(
69
+ rx.pipe(
70
+ rx.from_async_iterable(lines),
71
+ rx.map(lambda line: parse_entry(line)),
72
+ rx.filter(lambda result: result is not None),
73
+ rx.map(lambda entry: cast(ParsedEntry, entry)),
74
+ ),
75
+ max_count=10000,
76
+ timeout=0.1,
77
+ )
78
+ )
79
+
80
+ await entry_stream.subscribe_async(store.append_entries)
81
+
82
+ state_file = _state_file_for(file) if file else None
83
+ app = JnavApp(
84
+ model=model,
85
+ filter_provider=filter_provider,
86
+ role_mapper=role_mapper,
87
+ selectors=selectors,
88
+ search=search,
89
+ state_file=state_file,
90
+ follow=follow,
91
+ file_name=file if file else "<stdin>",
92
+ )
93
+ app.title = "jnav"
94
+ await app.run_async()
95
+
96
+
97
+ @click.command()
98
+ @click.argument("file", required=False, type=click.Path(exists=True))
99
+ @click.option("--follow", "-f", is_flag=True, help="Follow the file for new entries")
100
+ @click.version_option()
101
+ def main(file: str | None, follow: bool) -> None:
102
+ """Interactive JSON log viewer with jq filtering."""
103
+ init_logging()
104
+ asyncio.run(_run(file, follow=follow))
105
+
106
+
107
+ if __name__ == "__main__":
108
+ main()
jnav/app.py ADDED
@@ -0,0 +1,398 @@
1
+ import json
2
+ import logging
3
+ from pathlib import Path
4
+ from typing import ClassVar, override
5
+
6
+ from textual import on
7
+ from textual.app import App, ComposeResult
8
+ from textual.binding import Binding, BindingType
9
+ from textual.containers import Horizontal, Vertical
10
+ from textual.theme import Theme
11
+ from textual.widgets import Footer, Static
12
+
13
+ from jnav.filter_manager_screen import FilterManagerScreen
14
+ from jnav.filter_provider import FilterProvider
15
+ from jnav.header import Header
16
+ from jnav.help_screen import HelpScreen
17
+ from jnav.log_model import LogModel
18
+ from jnav.role_mapper import RoleMapper
19
+ from jnav.search_engine import SearchEngine
20
+ from jnav.selector_manager_screen import SelectorManagerScreen
21
+ from jnav.selector_provider import SelectorProvider
22
+ from jnav.text_input_screen import TextInputScreen
23
+ from jnav.virtual_list_view import VirtualListView
24
+
25
+ from .detail_tree import DetailTree
26
+ from .log_list_view import LogListView
27
+ from .store import IndexedEntry
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ class FilterBar(Static):
33
+ pass
34
+
35
+
36
+ class JnavApp(App[None]):
37
+ ENABLE_COMMAND_PALETTE = False
38
+
39
+ DEFAULT_CSS = """
40
+ JnavApp Footer {
41
+ background: $surface;
42
+ }
43
+ JnavApp FooterKey {
44
+ margin: 0 1 0 0;
45
+ }
46
+ JnavApp FooterKey .footer-key--key {
47
+ color: $primary;
48
+ }
49
+ """
50
+
51
+ CSS = """
52
+ * {
53
+ scrollbar-size-vertical: 1;
54
+ scrollbar-color: $surface-lighten-2;
55
+ scrollbar-color-hover: $surface-lighten-3;
56
+ scrollbar-color-active: $foreground-darken-2;
57
+ scrollbar-background: $surface;
58
+ scrollbar-background-hover: $surface;
59
+ border-title-color: $primary;
60
+ }
61
+ ModalScreen {
62
+ background: $background 0%;
63
+ }
64
+ HeaderIcon { display: none; }
65
+ Header.-tall { height: 1; }
66
+ .tree--key { color: $primary; text-style: italic; }
67
+ .tree--key-selected { color: $primary; text-style: bold; }
68
+ .tree--value { color: $foreground; }
69
+ .tree--value-null { color: $foreground; text-style: dim italic; }
70
+ .tree--json-string { color: $warning; text-style: italic; }
71
+ .tree--search-highlight { color: $background; background: $accent; }
72
+ .summary--search-highlight { color: $background; background: $accent; }
73
+ #content-area {
74
+ height: 1fr;
75
+ }
76
+ #filter-bar {
77
+ height: 1;
78
+ padding: 0 1;
79
+ color: $text-muted;
80
+ background: $background;
81
+ }
82
+ #log-panel {
83
+ opacity: 0.75;
84
+ }
85
+ #log-panel.focused {
86
+ opacity: 1.0;
87
+ }
88
+ #log-panel.focused > #filter-bar {
89
+ color: $primary;
90
+ }
91
+ #log-list:focus {
92
+ background-tint: transparent;
93
+ }
94
+ #detail-panel {
95
+ width: 40%;
96
+ border: round $background-lighten-2;
97
+ border-title-align: center;
98
+ background: $background;
99
+ }
100
+ #detail-panel.focused {
101
+ border: round $primary;
102
+ }
103
+ #detail-tree {
104
+ background: $background;
105
+ }
106
+ #detail-tree:focus {
107
+ background-tint: transparent;
108
+ }
109
+ #detail-tree > .tree--guides {
110
+ color: $background-lighten-2;
111
+ }
112
+ #detail-tree > .tree--guides-selected {
113
+ color: $foreground;
114
+ }
115
+ """
116
+
117
+ BINDINGS: ClassVar[list[BindingType]] = [
118
+ Binding("F", "open_filters", "Filters ≡"),
119
+ Binding("S", "open_columns", "Selectors ≡"),
120
+ Binding("d", "toggle_detail", "Toggle Detail"),
121
+ Binding("r", "reset", "Reset"),
122
+ Binding("question_mark", "show_help", "Help", key_display="?"),
123
+ Binding("q", "quit", "Quit", show=False),
124
+ Binding("slash", "start_search", "Search", key_display="/", show=False),
125
+ Binding("h", "focus_list", show=False),
126
+ Binding("l", "focus_detail", show=False),
127
+ Binding("n", "search_next", show=False),
128
+ Binding("N", "search_prev", show=False),
129
+ Binding("escape", "escape", show=False),
130
+ Binding("enter", "inspect", "Inspect", show=False),
131
+ ]
132
+
133
+ def __init__(
134
+ self,
135
+ model: LogModel,
136
+ filter_provider: FilterProvider,
137
+ role_mapper: RoleMapper,
138
+ selectors: SelectorProvider,
139
+ search: SearchEngine,
140
+ file_name: str,
141
+ state_file: Path | None = None,
142
+ follow: bool = False,
143
+ ) -> None:
144
+ super().__init__()
145
+ self.register_theme(
146
+ Theme(
147
+ name="jnav",
148
+ primary="#339af0",
149
+ accent="#20c997",
150
+ error="#ff6b6b",
151
+ warning="#ff922b",
152
+ success="#20c997",
153
+ surface="#15191d",
154
+ background="#212529",
155
+ foreground="#f8fafb",
156
+ dark=True,
157
+ )
158
+ )
159
+ self.theme = "jnav"
160
+ self._model = model
161
+ self._filter_provider = filter_provider
162
+ self._role_mapper = role_mapper
163
+ self._selectors = selectors
164
+ self._search = search
165
+ self._search_pos: int = -1
166
+ self._expanded_mode: bool = True
167
+ self._state_file: Path | None = state_file
168
+ self._detail_visible_on_load: bool = False
169
+ self._show_selected_only_on_load: bool = False
170
+ self._start_following = follow
171
+ self._file_name = file_name
172
+ self.sub_title = file_name
173
+
174
+ @override
175
+ def compose(self) -> ComposeResult:
176
+ yield Header(self._file_name)
177
+ yield Horizontal(
178
+ Vertical(
179
+ LogListView(
180
+ model=self._model,
181
+ role_mapper=self._role_mapper,
182
+ selectors=self._selectors,
183
+ search=self._search,
184
+ filter_provider=self._filter_provider,
185
+ id="log-list",
186
+ follow=self._start_following,
187
+ ),
188
+ FilterBar(id="filter-bar"),
189
+ id="log-panel",
190
+ ),
191
+ Vertical(
192
+ DetailTree(
193
+ "entry",
194
+ selectors=self._selectors,
195
+ filters=self._filter_provider,
196
+ search=self._search,
197
+ role_mapper=self._role_mapper,
198
+ id="detail-tree",
199
+ ),
200
+ id="detail-panel",
201
+ ),
202
+ id="content-area",
203
+ )
204
+ yield Footer()
205
+
206
+ async def on_mount(self) -> None:
207
+ await self._load_state()
208
+
209
+ await self._model.on_append.subscribe_async(self._on_entries_changed)
210
+ await self._model.on_rebuild.subscribe_async(self._on_entries_changed)
211
+ await self._search.on_change.subscribe_async(self._on_search_changed)
212
+
213
+ lv = self.query_one("#log-list", LogListView)
214
+ lv.set_expanded_mode(self._expanded_mode)
215
+ lv.focus()
216
+
217
+ detail_panel = self.query_one("#detail-panel")
218
+ detail_panel.border_title = "Detail"
219
+ detail_panel.display = self._detail_visible_on_load
220
+ detail_tree = self.query_one("#detail-tree", DetailTree)
221
+ detail_tree.show_selected_only = self._show_selected_only_on_load
222
+
223
+ self.call_after_refresh(self._initial_build)
224
+
225
+ async def _initial_build(self) -> None:
226
+ await self.query_one("#log-list", LogListView).initial_build()
227
+ self._update_filter_bar()
228
+
229
+ async def _load_state(self) -> None:
230
+ if not self._state_file or not self._state_file.exists():
231
+ return
232
+ try:
233
+ state = json.loads(self._state_file.read_text())
234
+ except json.JSONDecodeError, OSError:
235
+ return
236
+ if "filter_tree" in state:
237
+ await self._filter_provider.load_root(state["filter_tree"])
238
+ await self._selectors.set_selectors(state.get("selectors", []))
239
+ await self._role_mapper.set_mapping(state.get("role_mapping"))
240
+ self._expanded_mode = state.get("expanded_mode", False)
241
+ await self._model.set_filtering_enabled(not state.get("filters_paused", False))
242
+ await self._search.set_term(state.get("search_term", ""))
243
+ self._detail_visible_on_load = state.get("detail_visible", False)
244
+ self._show_selected_only_on_load = state.get("show_selected_only", False)
245
+
246
+ def _save_state(self) -> None:
247
+ if not self._state_file:
248
+ return
249
+ detail = self.query_one("#detail-tree", DetailTree)
250
+ panel = self.query_one("#detail-panel")
251
+ lv = self.query_one("#log-list", LogListView)
252
+ state = {
253
+ "filter_tree": self._filter_provider.dump_root(),
254
+ "selectors": self._selectors.selectors,
255
+ "role_mapping": self._role_mapper.mapping.model_dump(),
256
+ "expanded_mode": lv.expanded_mode,
257
+ "filters_paused": self._model.filtering_enabled is False,
258
+ "search_term": self._search.term,
259
+ "entry_index": lv.current_index(),
260
+ "detail_visible": panel.display,
261
+ "show_selected_only": detail.show_selected_only,
262
+ }
263
+ try:
264
+ self._state_file.parent.mkdir(parents=True, exist_ok=True)
265
+ self._state_file.write_text(json.dumps(state))
266
+ except OSError:
267
+ pass
268
+
269
+ async def _on_entries_changed(self, _: object) -> None:
270
+ self._update_filter_bar()
271
+
272
+ async def _on_search_changed(self, _: None) -> None:
273
+ self._search_pos = -1
274
+ self._update_filter_bar()
275
+
276
+ def _update_filter_bar(self) -> None:
277
+ bar = self.query_one("#filter-bar", FilterBar)
278
+ total = self._model.total_count()
279
+ shown = len(self._model.visible_indices)
280
+ n_cols = sum(1 for s in self._selectors.selectors if s["enabled"])
281
+
282
+ parts: list[str] = [f"Showing {shown}/{total}"]
283
+ if n_cols:
284
+ parts.append(f"{n_cols} field{'s' if n_cols != 1 else ''}")
285
+ if self._search.term:
286
+ total = len(self._search.matches)
287
+ pos = self._search_pos + 1 if total else 0
288
+ parts.append(f"/{self._search.term} ({pos}/{total})")
289
+
290
+ bar.update(" \u2502 ".join(parts))
291
+
292
+ @override
293
+ async def action_quit(self) -> None:
294
+ self._save_state()
295
+ self.workers.cancel_all()
296
+ self.exit()
297
+
298
+ def _focus_main(self) -> None:
299
+ self.query_one("#log-list", LogListView).focus()
300
+
301
+ def action_open_filters(self) -> None:
302
+ self.push_screen(FilterManagerScreen(self._filter_provider))
303
+
304
+ def action_open_columns(self) -> None:
305
+ self.push_screen(SelectorManagerScreen(self._selectors))
306
+
307
+ def action_start_search(self) -> None:
308
+ async def on_dismiss(term: str | None) -> None:
309
+ if not term:
310
+ return
311
+ await self._search.set_term(term)
312
+ if self._search.matches:
313
+ self._search_pos = 0
314
+ self.query_one("#log-list", LogListView).jump_to_index(
315
+ self._search.matches[0]
316
+ )
317
+ else:
318
+ self.notify("No matches found", timeout=2)
319
+
320
+ self.push_screen(TextInputScreen(), on_dismiss)
321
+
322
+ def action_search_next(self) -> None:
323
+ if not self._search.matches:
324
+ return
325
+ lv = self.query_one("#log-list", LogListView)
326
+ current = lv.current_index()
327
+ for i, store_idx in enumerate(self._search.matches):
328
+ if store_idx > current:
329
+ self._search_pos = i
330
+ lv.jump_to_index(store_idx)
331
+ self._update_filter_bar()
332
+ return
333
+ self.notify("No more matches", timeout=1)
334
+
335
+ def action_search_prev(self) -> None:
336
+ if not self._search.matches:
337
+ return
338
+ lv = self.query_one("#log-list", LogListView)
339
+ current = lv.current_index()
340
+ for i in range(len(self._search.matches) - 1, -1, -1):
341
+ if self._search.matches[i] < current:
342
+ self._search_pos = i
343
+ lv.jump_to_index(self._search.matches[i])
344
+ self._update_filter_bar()
345
+ return
346
+ self.notify("No more matches", timeout=1)
347
+
348
+ def action_toggle_detail(self) -> None:
349
+ panel = self.query_one("#detail-panel")
350
+ if panel.display:
351
+ panel.display = False
352
+ self._focus_main()
353
+ else:
354
+ panel.display = True
355
+
356
+ def action_inspect(self) -> None:
357
+ lv = self.query_one("#log-list", LogListView)
358
+ if self.focused != lv:
359
+ return
360
+ panel = self.query_one("#detail-panel")
361
+ if not panel.display:
362
+ panel.display = True
363
+ self.query_one("#detail-tree", DetailTree).focus()
364
+
365
+ async def action_reset(self) -> None:
366
+ await self._selectors.clear_selectors()
367
+ await self._search.clear()
368
+ await self._filter_provider.clear_filters()
369
+ self.notify("Filters and fields cleared", timeout=2)
370
+
371
+ def action_focus_list(self) -> None:
372
+ if self.query_one("#detail-panel").display:
373
+ self.query_one("#log-list", LogListView).focus()
374
+
375
+ def action_focus_detail(self) -> None:
376
+ if self.query_one("#detail-panel").display:
377
+ self.query_one("#detail-tree", DetailTree).focus()
378
+
379
+ def action_show_help(self) -> None:
380
+ self.push_screen(HelpScreen())
381
+
382
+ async def action_escape(self) -> None:
383
+ tree = self.query_one("#detail-tree", DetailTree)
384
+ if self.focused == tree:
385
+ self._focus_main()
386
+ elif self._search.active:
387
+ await self._search.clear()
388
+
389
+ @on(VirtualListView.Highlighted, "#log-list")
390
+ def on_log_highlighted(self, event: VirtualListView.Highlighted) -> None:
391
+ ie = event.item
392
+ if isinstance(ie, IndexedEntry):
393
+ self.query_one("#detail-tree", DetailTree).show_entry(ie.entry, ie.index)
394
+
395
+ @on(VirtualListView.Selected, "#log-list")
396
+ def on_log_selected(self, event: VirtualListView.Selected) -> None:
397
+ del event # unused
398
+ self.action_inspect()
jnav/buffer.py ADDED
@@ -0,0 +1,48 @@
1
+ import asyncio
2
+ from collections.abc import AsyncIterable
3
+
4
+ from aioreactive import AsyncObservable, to_async_iterable
5
+
6
+
7
+ async def buffer_time_or_count[T](
8
+ source: AsyncObservable[T],
9
+ max_count: int = 500,
10
+ timeout: float = 0.1,
11
+ ) -> AsyncIterable[list[T]]:
12
+ batch: list[T] = []
13
+ iterator = aiter(to_async_iterable(source))
14
+ next_task: asyncio.Task[T] | None = None
15
+ try:
16
+ while True:
17
+ # Create a new task if we don't already have one pending
18
+ if next_task is None:
19
+ next_task = asyncio.ensure_future(anext(iterator))
20
+
21
+ # Wait for either the next item or the timeout
22
+ done, _ = await asyncio.wait({next_task}, timeout=timeout)
23
+
24
+ # If the next item is ready, add it to the batch
25
+ if next_task in done:
26
+ # If the iterator is exhausted, yield any remaining batch and exit
27
+ try:
28
+ entry = next_task.result()
29
+ except StopAsyncIteration:
30
+ if batch:
31
+ yield batch
32
+ return
33
+
34
+ # Append the item to the batch and reset the next task
35
+ next_task = None
36
+ batch.append(entry)
37
+
38
+ # Yield the batch if we've reached the max count
39
+ if len(batch) >= max_count:
40
+ yield batch
41
+ batch = []
42
+ # If the timeout expired, yield the current batch if it's not empty
43
+ elif batch:
44
+ yield batch
45
+ batch = []
46
+ finally:
47
+ if next_task is not None and not next_task.done():
48
+ next_task.cancel()