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/inline_tree.py ADDED
@@ -0,0 +1,75 @@
1
+ from rich.style import Style
2
+ from rich.text import Text
3
+ from rich.tree import Tree as RichTree
4
+
5
+ from .filtering import get_nested
6
+ from .parsing import ParsedEntry
7
+ from .search_engine import SearchEngine
8
+ from .tree_rendering import TreeBuildVisitor, walk_tree
9
+
10
+
11
+ def _add_branch(
12
+ parent: RichTree,
13
+ label: Text,
14
+ path: str,
15
+ value: object,
16
+ ) -> RichTree:
17
+ del path, value # unused
18
+ return parent.add(label)
19
+
20
+
21
+ def _add_leaf(
22
+ parent: RichTree,
23
+ label: Text,
24
+ path: str,
25
+ value: object,
26
+ ) -> None:
27
+ del path, value # unused
28
+ parent.add(label)
29
+
30
+
31
+ def render_inline_tree(
32
+ parsed: ParsedEntry,
33
+ *,
34
+ custom_fields: set[str],
35
+ search: SearchEngine | None = None,
36
+ key_style: Style | None = None,
37
+ selected_style: Style | None = None,
38
+ value_style: Style | None = None,
39
+ value_null_style: Style | None = None,
40
+ json_string_style: Style | None = None,
41
+ search_highlight_style: Style | None = None,
42
+ ) -> RichTree | None:
43
+ if not custom_fields:
44
+ return None
45
+ _default = Style()
46
+ values = [(f, get_nested(parsed.expanded, f)) for f in custom_fields]
47
+
48
+ # Filter out selectors with no matches
49
+ filtered = {f: v for f, v in values if v is not None}
50
+
51
+ # Don't render the tree if there are no selectors with matches
52
+ if not filtered:
53
+ return None
54
+
55
+ tree = RichTree("", guide_style="dim", hide_root=True)
56
+ visitor = TreeBuildVisitor(
57
+ root=tree,
58
+ add_branch=_add_branch,
59
+ add_leaf=_add_leaf,
60
+ selected=custom_fields,
61
+ key_style=key_style or _default,
62
+ selected_style=selected_style or _default,
63
+ value_style=value_style or _default,
64
+ value_null_style=value_null_style or _default,
65
+ json_string_style=json_string_style or _default,
66
+ search_highlight_style=search_highlight_style or _default,
67
+ search_term=search.term if search else None,
68
+ )
69
+ walk_tree(
70
+ value=filtered,
71
+ path="",
72
+ visitor=visitor,
73
+ json_paths=parsed.expanded_paths,
74
+ )
75
+ return tree
jnav/key_sequences.py ADDED
@@ -0,0 +1,211 @@
1
+ from dataclasses import dataclass
2
+ from typing import TYPE_CHECKING, ClassVar
3
+
4
+ from textual.binding import Binding, BindingsMap
5
+
6
+ if TYPE_CHECKING:
7
+ from textual.events import Key
8
+
9
+
10
+ # pyright: reportUninitializedInstanceVariable=false
11
+ # pyright: reportAttributeAccessIssue=false
12
+ # pyright: reportUnknownMemberType=false
13
+ # pyright: reportUnknownVariableType=false
14
+ # pyright: reportUnknownArgumentType=false
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class KeySequence:
19
+ keys: str
20
+ action: str
21
+ description: str = ""
22
+ show: bool = True
23
+ id: str | None = None
24
+
25
+
26
+ class KeySequenceMixin:
27
+ """Mixin that adds multi-key sequence support to a Textual widget.
28
+
29
+ Usage::
30
+
31
+ class MyWidget(KeySequenceMixin, Tree):
32
+ SEQUENCES = [
33
+ KeySequence("ff", "filter_and", "Filter AND"),
34
+ KeySequence("fo", "filter_or", "Filter OR"),
35
+ ]
36
+ SEQUENCE_GROUPS = {"f": "Filter..."}
37
+
38
+ async def on_key(self, event: Key) -> None:
39
+ if await self._handle_sequence_key(event):
40
+ return
41
+
42
+ SEQUENCES maps key strings (min 2 chars) to actions.
43
+ SEQUENCE_GROUPS maps a prefix character to a label shown in the
44
+ footer (e.g. ``"f"`` -> ``"Filter..."``). Prefixes without an
45
+ entry still work but won't show a footer hint.
46
+ """
47
+
48
+ SEQUENCES: ClassVar[list[KeySequence]] = []
49
+ SEQUENCE_GROUPS: ClassVar[dict[str, str]] = {}
50
+
51
+ _seq_buffer: str = ""
52
+ _seq_pending: bool = False
53
+ _seq_saved_bindings: BindingsMap | None = None
54
+ _seq_saved_ancestor_bindings: list[tuple[object, BindingsMap]] = [] # noqa: RUF012
55
+ _seq_keymap: dict[str, str] = {} # noqa: RUF012
56
+ _seq_lookup: dict[str, KeySequence] = {} # noqa: RUF012
57
+ _seq_prefixes: dict[str, str] = {} # noqa: RUF012
58
+ _seq_base_bindings: BindingsMap | None = None
59
+
60
+ async def on_mount(self) -> None:
61
+ self._seq_buffer = ""
62
+ self._seq_pending = False
63
+ self._seq_saved_bindings = None
64
+ self._seq_base_bindings = None
65
+ self._rebuild_sequences()
66
+
67
+ def _rebuild_sequences(self) -> None:
68
+ if self._seq_pending:
69
+ self._seq_pending = False
70
+ self._seq_buffer = ""
71
+ if self._seq_saved_bindings is not None:
72
+ self._bindings = self._seq_saved_bindings
73
+ self._seq_saved_bindings = None
74
+
75
+ self._seq_lookup = self._build_seq_lookup()
76
+ self._seq_prefixes = self._build_seq_prefixes()
77
+
78
+ if self._seq_base_bindings is None:
79
+ self._seq_base_bindings = self._bindings.copy()
80
+ else:
81
+ self._bindings = self._seq_base_bindings.copy()
82
+
83
+ self._inject_prefix_bindings()
84
+
85
+ def _build_seq_lookup(self) -> dict[str, KeySequence]:
86
+ lookup: dict[str, KeySequence] = {}
87
+ for seq in self.SEQUENCES:
88
+ keys = seq.keys
89
+ if seq.id and seq.id in self._seq_keymap:
90
+ keys = self._seq_keymap[seq.id]
91
+ if len(keys) < 2:
92
+ msg = f"Sequence keys must be at least 2 characters, got {keys!r}"
93
+ raise ValueError(msg)
94
+ lookup[keys] = seq
95
+ return lookup
96
+
97
+ def _build_seq_prefixes(self) -> dict[str, str]:
98
+ prefixes: dict[str, str] = {}
99
+ for keys in self._seq_lookup:
100
+ char = keys[0]
101
+ if char not in prefixes:
102
+ prefixes[char] = self.SEQUENCE_GROUPS.get(char, "")
103
+ return prefixes
104
+
105
+ def _inject_prefix_bindings(self) -> None:
106
+ # Build prefix bindings first, then prepend them so they appear before
107
+ # the widget's own bindings in the footer.
108
+ prefix_entries: list[tuple[str, Binding]] = []
109
+ for char, label in self._seq_prefixes.items():
110
+ self._bindings.key_to_bindings.pop(char, None)
111
+ if label:
112
+ prefix_entries.append((
113
+ char,
114
+ Binding(char, f"_seq_prefix_{char}", label),
115
+ ))
116
+ if not prefix_entries:
117
+ return
118
+ existing = dict(self._bindings.key_to_bindings)
119
+ self._bindings.key_to_bindings.clear()
120
+ for char, binding in prefix_entries:
121
+ self._bindings.key_to_bindings[char] = [binding]
122
+ for key, bindings in existing.items():
123
+ self._bindings.key_to_bindings[key] = bindings
124
+
125
+ async def _handle_sequence_key(self, event: Key) -> bool:
126
+ if self._seq_pending:
127
+ event.prevent_default()
128
+ event.stop()
129
+
130
+ if event.key == "escape":
131
+ self._reset_sequence()
132
+ return True
133
+
134
+ self._seq_buffer += event.key
135
+
136
+ has_longer = any(
137
+ k.startswith(self._seq_buffer) and k != self._seq_buffer
138
+ for k in self._seq_lookup
139
+ )
140
+ if has_longer:
141
+ self._show_continuations(self._seq_buffer)
142
+ return True
143
+
144
+ if self._seq_buffer in self._seq_lookup:
145
+ seq = self._seq_lookup[self._seq_buffer]
146
+ self._reset_sequence()
147
+ await self.run_action(seq.action)
148
+ return True
149
+
150
+ self._reset_sequence()
151
+ return True
152
+
153
+ if event.key in self._seq_prefixes:
154
+ self._seq_pending = True
155
+ self._seq_buffer = event.key
156
+ event.prevent_default()
157
+ event.stop()
158
+ self._show_continuations(event.key)
159
+ return True
160
+
161
+ return False
162
+
163
+ def _show_continuations(self, prefix: str) -> None:
164
+ if self._seq_saved_bindings is None:
165
+ self._seq_saved_bindings = self._bindings
166
+ self._hide_ancestor_bindings()
167
+
168
+ continuations: list[Binding] = []
169
+ seen: set[str] = set()
170
+ for keys, seq in self._seq_lookup.items():
171
+ if keys.startswith(prefix) and len(keys) > len(prefix):
172
+ next_char = keys[len(prefix)]
173
+ if next_char not in seen:
174
+ seen.add(next_char)
175
+ continuations.append(
176
+ Binding(
177
+ next_char,
178
+ f"_seq_cont_{seq.action}",
179
+ seq.description,
180
+ show=seq.show,
181
+ )
182
+ )
183
+ continuations.append(Binding("escape", "_seq_cancel", "Cancel", show=False))
184
+ self._bindings = BindingsMap(continuations)
185
+ self.refresh_bindings()
186
+
187
+ def _reset_sequence(self) -> None:
188
+ self._seq_pending = False
189
+ self._seq_buffer = ""
190
+ if self._seq_saved_bindings is not None:
191
+ self._bindings = self._seq_saved_bindings
192
+ self._seq_saved_bindings = None
193
+ self._restore_ancestor_bindings()
194
+ self.refresh_bindings()
195
+
196
+ def _hide_ancestor_bindings(self) -> None:
197
+ self._seq_saved_ancestor_bindings = []
198
+ for ancestor in self.ancestors_with_self[1:]:
199
+ if hasattr(ancestor, "_bindings"):
200
+ self._seq_saved_ancestor_bindings.append((ancestor, ancestor._bindings))
201
+ ancestor._bindings = BindingsMap([])
202
+
203
+ def _restore_ancestor_bindings(self) -> None:
204
+ for ancestor, bindings in self._seq_saved_ancestor_bindings:
205
+ ancestor._bindings = bindings
206
+ self._seq_saved_ancestor_bindings = []
207
+
208
+ def set_sequence_keymap(self, keymap: dict[str, str]) -> None:
209
+ self._seq_keymap = keymap
210
+ self._rebuild_sequences()
211
+ self.refresh_bindings()
jnav/log_entry_item.py ADDED
@@ -0,0 +1,96 @@
1
+ from datetime import UTC, datetime
2
+
3
+ from rich.style import Style
4
+ from rich.text import Text
5
+
6
+ from .field_mapping import FieldMapping, TimestampFormat
7
+ from .parsing import ParsedEntry
8
+ from .search_engine import SearchEngine
9
+ from .tree_rendering import highlight_text
10
+
11
+ LEVEL_COMPONENTS = {
12
+ "error": "summary--level-error",
13
+ "fatal": "summary--level-fatal",
14
+ "critical": "summary--level-critical",
15
+ "warn": "summary--level-warning",
16
+ "warning": "summary--level-warning",
17
+ "info": "summary--level-info",
18
+ "debug": "summary--level-debug",
19
+ "trace": "summary--level-trace",
20
+ }
21
+
22
+ _EPOCH_DIVISORS: dict[TimestampFormat, int] = {
23
+ "epoch_s": 1,
24
+ "epoch_ms": 1_000,
25
+ "epoch_us": 1_000_000,
26
+ "epoch_ns": 1_000_000_000,
27
+ }
28
+
29
+
30
+ def format_timestamp(value: object, fmt: TimestampFormat) -> str:
31
+ """Format a timestamp value as HH:MM:SS.mmm. Falls back to the raw string
32
+ representation on any parsing failure."""
33
+ try:
34
+ if fmt == "iso8601":
35
+ dt = datetime.fromisoformat(str(value))
36
+ else:
37
+ if not isinstance(value, (int, float)) or isinstance(value, bool):
38
+ return str(value)
39
+ dt = datetime.fromtimestamp(float(value) / _EPOCH_DIVISORS[fmt], tz=UTC)
40
+ return dt.strftime("%H:%M:%S") + f".{dt.microsecond // 1000:03d}"
41
+ except ValueError, TypeError, OSError, OverflowError:
42
+ return str(value)
43
+
44
+
45
+ def _get_level_shorthand(value: str) -> str:
46
+ s = value[0]
47
+ remainder = value[1:].lower()
48
+ vowels = set("aeiouy")
49
+ s += "".join(c for c in remainder if c not in vowels)
50
+ s = s.upper()
51
+ if len(s) > 3:
52
+ s = s[:3]
53
+ return s
54
+
55
+
56
+ def render_summary(
57
+ parsed: ParsedEntry,
58
+ mapping: FieldMapping,
59
+ search: SearchEngine | None = None,
60
+ *,
61
+ text_style: Style | None = None,
62
+ level_styles: dict[str, Style] | None = None,
63
+ highlight_style: Style | None = None,
64
+ ) -> Text:
65
+ _ts = text_style or Style()
66
+ _hl = highlight_style or Style()
67
+ parts: list[str | tuple[str, str | Style]] = [" "]
68
+
69
+ if mapping.timestamp is not None:
70
+ ts_val = parsed.expanded.get(mapping.timestamp.path)
71
+ if ts_val not in (None, ""):
72
+ parts.append((format_timestamp(ts_val, mapping.timestamp.format), _ts))
73
+ parts.append(" ")
74
+
75
+ if mapping.level is not None:
76
+ level_val = parsed.expanded.get(mapping.level)
77
+ level_str = str(level_val) if level_val else ""
78
+ if level_str:
79
+ component = LEVEL_COMPONENTS.get(level_str.strip().lower())
80
+ short = _get_level_shorthand(level_str)
81
+ if component and level_styles and component in level_styles:
82
+ parts.append((short, level_styles[component]))
83
+ else:
84
+ parts.append((short, _ts))
85
+ parts.append(" ")
86
+
87
+ if mapping.message is not None:
88
+ msg_val = parsed.expanded.get(mapping.message)
89
+ msg_str = str(msg_val) if msg_val or msg_val == 0 else ""
90
+ parts.append((msg_str, _ts))
91
+
92
+ text = Text.assemble(*parts)
93
+ text.no_wrap = True
94
+ text.overflow = "ellipsis"
95
+ search_term = search.term if search else None
96
+ return highlight_text(text, search_term, _hl)
@@ -0,0 +1,90 @@
1
+ from dataclasses import dataclass
2
+
3
+ from rich.console import Group, RenderableType
4
+ from rich.padding import Padding
5
+ from rich.style import Style
6
+ from textual.color import Color
7
+
8
+ from .inline_tree import render_inline_tree
9
+ from .log_entry_item import render_summary
10
+ from .role_mapper import RoleMapper
11
+ from .search_engine import SearchEngine
12
+ from .selector_provider import SelectorProvider
13
+ from .store import IndexedEntry
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class EntryStyles:
18
+ text: Style
19
+ levels: dict[str, Style]
20
+ highlight: Style
21
+ cursor_bg: Style
22
+ tree_key: Style
23
+ tree_key_selected: Style
24
+ tree_value: Style
25
+ tree_value_null: Style
26
+ tree_json_string: Style
27
+ tree_search_highlight: Style
28
+ tree_bg: Color
29
+ cursor_color: Color
30
+
31
+
32
+ class LogEntryRenderer:
33
+ def __init__(
34
+ self,
35
+ *,
36
+ search: SearchEngine,
37
+ role_mapper: RoleMapper,
38
+ selectors: SelectorProvider,
39
+ ) -> None:
40
+ self._search = search
41
+ self._role_mapper = role_mapper
42
+ self._selectors = selectors
43
+
44
+ def render(
45
+ self,
46
+ ie: IndexedEntry,
47
+ *,
48
+ styles: EntryStyles,
49
+ is_cursor: bool,
50
+ expanded: bool,
51
+ width: int,
52
+ ) -> RenderableType:
53
+ text = render_summary(
54
+ ie.entry,
55
+ self._role_mapper.mapping,
56
+ self._search,
57
+ text_style=styles.text,
58
+ level_styles=styles.levels,
59
+ highlight_style=styles.highlight,
60
+ )
61
+ text.truncate(width - 2, overflow="ellipsis", pad=False)
62
+
63
+ if is_cursor:
64
+ text.stylize(styles.cursor_bg)
65
+ text.pad_right(width - text.cell_len)
66
+ text.style = styles.cursor_bg
67
+
68
+ if not expanded:
69
+ return text
70
+
71
+ tree = render_inline_tree(
72
+ ie.entry,
73
+ custom_fields=self._selectors.active_selectors,
74
+ search=self._search,
75
+ key_style=styles.tree_key,
76
+ selected_style=styles.tree_key_selected,
77
+ value_style=styles.tree_value,
78
+ value_null_style=styles.tree_value_null,
79
+ json_string_style=styles.tree_json_string,
80
+ search_highlight_style=styles.tree_search_highlight,
81
+ )
82
+ if tree is None:
83
+ return text
84
+
85
+ tree_bg = styles.tree_bg
86
+ if is_cursor:
87
+ tree_bg = tree_bg.blend(styles.cursor_color, styles.cursor_color.a * 0.66)
88
+ tree_style = Style(bgcolor=tree_bg.rich_color)
89
+ padded_tree = Padding(tree, (0, 2, 0, 5), style=tree_style, expand=True)
90
+ return Group(text, padded_tree)