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 +0 -0
- jnav/__main__.py +108 -0
- jnav/app.py +398 -0
- jnav/buffer.py +48 -0
- jnav/detail_tree.py +242 -0
- jnav/field_mapping.py +136 -0
- jnav/filter_manager_screen.py +100 -0
- jnav/filter_provider.py +103 -0
- jnav/filter_tree.py +344 -0
- jnav/filtering.py +191 -0
- jnav/header.py +48 -0
- jnav/help_screen.py +79 -0
- jnav/inline_tree.py +75 -0
- jnav/key_sequences.py +211 -0
- jnav/log_entry_item.py +96 -0
- jnav/log_entry_renderer.py +90 -0
- jnav/log_list_view.py +292 -0
- jnav/log_model.py +114 -0
- jnav/logging.py +52 -0
- jnav/manager_screen_common.py +27 -0
- jnav/model.py +13 -0
- jnav/offset_group.py +30 -0
- jnav/parsing.py +101 -0
- jnav/reading.py +52 -0
- jnav/role_mapper.py +53 -0
- jnav/scrollbar_overlay.py +79 -0
- jnav/search_engine.py +87 -0
- jnav/selector_manager_screen.py +187 -0
- jnav/selector_provider.py +64 -0
- jnav/store.py +37 -0
- jnav/text_input_screen.py +63 -0
- jnav/tree_rendering.py +194 -0
- jnav/virtual_list_view.py +383 -0
- jnav-1.0.0.dist-info/METADATA +186 -0
- jnav-1.0.0.dist-info/RECORD +37 -0
- jnav-1.0.0.dist-info/WHEEL +4 -0
- jnav-1.0.0.dist-info/entry_points.txt +3 -0
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
|