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/filter_tree.py ADDED
@@ -0,0 +1,344 @@
1
+ from dataclasses import dataclass
2
+ from typing import TYPE_CHECKING, ClassVar, override
3
+
4
+ from rich.text import Text
5
+ from textual.binding import Binding, BindingType
6
+ from textual.message import Message
7
+ from textual.widgets import Tree
8
+ from textual.widgets.tree import TreeNode
9
+
10
+ from jnav.filter_provider import FilterProvider
11
+ from jnav.filtering import (
12
+ Filter,
13
+ FilterGroup,
14
+ FilterNode,
15
+ build_expression,
16
+ check_filter_warning,
17
+ )
18
+ from jnav.text_input_screen import TextInputScreen
19
+
20
+ if TYPE_CHECKING:
21
+ from textual import getters
22
+ from textual.app import App
23
+
24
+
25
+ @dataclass
26
+ class FilterTreeData:
27
+ node: FilterNode
28
+ parent: FilterGroup
29
+
30
+
31
+ class FilterTree(Tree[FilterTreeData]):
32
+ if TYPE_CHECKING:
33
+ app = getters.app(App[None])
34
+
35
+ class Changed(Message):
36
+ pass
37
+
38
+ DEFAULT_CSS = """
39
+ FilterTree {
40
+ height: auto;
41
+ max-height: 14;
42
+ border: none;
43
+ background: transparent;
44
+ }
45
+ """
46
+
47
+ BINDINGS: ClassVar[list[BindingType]] = [
48
+ Binding("j", "cursor_down", "Down", show=False),
49
+ Binding("k", "cursor_up", "Up", show=False),
50
+ Binding("a", "add_filter", "Add", show=True),
51
+ Binding("e", "edit_filter", "Edit", show=True),
52
+ Binding("d", "delete", "Delete", show=True),
53
+ Binding("t", "toggle_item", "Toggle", show=True),
54
+ Binding("o", "toggle_combine", "AND/OR", show=True),
55
+ Binding("n", "toggle_negated", "Negate", show=True),
56
+ Binding("g", "add_group", "Add group", show=True),
57
+ Binding("p", "paste", "Paste", show=True),
58
+ Binding("c", "flatten", "Flatten", show=True),
59
+ Binding("y", "yank", "Yank", show=True),
60
+ Binding("r", "rename", "Rename", show=True),
61
+ ]
62
+
63
+ def __init__(
64
+ self,
65
+ filter_provider: FilterProvider,
66
+ *,
67
+ id: str | None = None,
68
+ ) -> None:
69
+ super().__init__("Filters", id=id)
70
+ self._fp = filter_provider
71
+ self._clipboard: FilterNode | None = None
72
+
73
+ @override
74
+ def on_mount(self) -> None:
75
+ self.rebuild()
76
+
77
+ def on_tree_node_collapsed(self, event: Tree.NodeCollapsed[FilterTreeData]) -> None:
78
+ data = event.node.data
79
+ if data is None:
80
+ return
81
+ node = data.node
82
+ if isinstance(node, FilterGroup):
83
+ node.collapsed = True
84
+ event.node.set_label(self._node_label(event.node))
85
+
86
+ def on_tree_node_expanded(self, event: Tree.NodeExpanded[FilterTreeData]) -> None:
87
+ data = event.node.data
88
+ if data is None:
89
+ return
90
+ node = data.node
91
+ if isinstance(node, FilterGroup):
92
+ node.collapsed = False
93
+ event.node.set_label(self._render_label(node))
94
+
95
+ def rebuild(self) -> None:
96
+ saved_line = self.cursor_line
97
+
98
+ self.clear()
99
+ self.root.set_label(self._render_label(self._fp.root))
100
+ self.root.data = FilterTreeData(node=self._fp.root, parent=self._fp.root)
101
+ self._populate(self.root, self._fp.root)
102
+ self.root.expand()
103
+
104
+ if self.cursor_line != saved_line:
105
+ self.cursor_line = min(saved_line, self.last_line)
106
+ self.post_message(self.Changed())
107
+
108
+ def refresh_cursor_node(self) -> None:
109
+ tree_node = self.cursor_node
110
+ if tree_node is not None and tree_node.data is not None:
111
+ tree_node.set_label(self._node_label(tree_node))
112
+ self.post_message(self.Changed())
113
+
114
+ def _node_label(self, tree_node: TreeNode[FilterTreeData]) -> Text:
115
+ data = tree_node.data
116
+ assert data is not None
117
+ node = data.node
118
+ if isinstance(node, FilterGroup) and not tree_node.is_expanded:
119
+ style = "" if node.enabled else "dim strike"
120
+ if node.label:
121
+ label = Text(node.label, style=f"italic {style}")
122
+ else:
123
+ preview = build_expression(
124
+ FilterGroup(operator=node.operator, children=node.children)
125
+ )
126
+ label = Text(preview, style=style) if preview else None
127
+ if label is not None:
128
+ if node.negated:
129
+ return Text.assemble(("NOT ", f"bold {style}"), label)
130
+ return label
131
+ return self._render_label(node)
132
+
133
+ def _populate(
134
+ self,
135
+ tree_parent: TreeNode[FilterTreeData],
136
+ group: FilterGroup,
137
+ ) -> None:
138
+ for child in group.children:
139
+ data = FilterTreeData(node=child, parent=group)
140
+ label = self._render_label(child)
141
+ if isinstance(child, FilterGroup):
142
+ branch = tree_parent.add(label, data=data)
143
+ self._populate(branch, child)
144
+ if child.collapsed:
145
+ branch.collapse()
146
+ branch.set_label(self._node_label(branch))
147
+ else:
148
+ branch.expand()
149
+ else:
150
+ tree_parent.add_leaf(label, data=data)
151
+
152
+ def _render_label(self, node: FilterNode) -> Text:
153
+ style = "" if node.enabled else "dim strike"
154
+ if isinstance(node, FilterGroup):
155
+ op_hint = (
156
+ ("", "") if not node.label else (f" ({node.operator.upper()})", "dim")
157
+ )
158
+ if node.label:
159
+ label_style = f"italic {style}"
160
+ if node.negated:
161
+ return Text.assemble(
162
+ ("NOT ", f"bold {style}"),
163
+ (node.label, label_style),
164
+ op_hint,
165
+ )
166
+ return Text.assemble((node.label, label_style), op_hint)
167
+ if node.negated:
168
+ op = "NAND" if node.operator == "and" else "NOR"
169
+ else:
170
+ op = node.operator.upper()
171
+ return Text(op, style=style)
172
+
173
+ if node.label:
174
+ display_style = f"italic {style}"
175
+ display = node.label
176
+ else:
177
+ display_style = style
178
+ display = node.expr
179
+ if node.negated:
180
+ return Text.assemble(("NOT ", f"bold {style}"), (display, display_style))
181
+ return Text(display, style=display_style)
182
+
183
+ def _cursor_data(self) -> FilterTreeData | None:
184
+ node = self.cursor_node
185
+ if node is None or node.data is None:
186
+ return None
187
+ return node.data
188
+
189
+ def _insert_at_cursor(self) -> tuple[FilterGroup, int | None]:
190
+ data = self._cursor_data()
191
+ if data is not None:
192
+ cursor_node = data.node
193
+ parent = data.parent
194
+ if isinstance(cursor_node, FilterGroup):
195
+ return (cursor_node, None)
196
+ return (parent, parent.children.index(cursor_node))
197
+ return (self._fp.root, None)
198
+
199
+ async def action_toggle_item(self) -> None:
200
+ data = self._cursor_data()
201
+ if data is None:
202
+ return
203
+ await self._fp.toggle_node(data.node)
204
+ self.refresh_cursor_node()
205
+
206
+ async def action_toggle_negated(self) -> None:
207
+ data = self._cursor_data()
208
+ if data is None:
209
+ return
210
+ await self._fp.toggle_negated(data.node)
211
+ self.refresh_cursor_node()
212
+
213
+ async def action_add_group(self) -> None:
214
+ data = self._cursor_data()
215
+ if data is None:
216
+ return
217
+ node = data.node
218
+ parent = data.parent
219
+ if isinstance(node, FilterGroup):
220
+ await self._fp.add_group(node)
221
+ else:
222
+ idx = parent.children.index(node)
223
+ await self._fp.add_group(parent, idx)
224
+ self.rebuild()
225
+
226
+ async def action_toggle_combine(self) -> None:
227
+ data = self._cursor_data()
228
+ if data is None:
229
+ return
230
+ if not isinstance(data.node, FilterGroup):
231
+ return
232
+ await self._fp.set_node_operator(data.node, data.parent)
233
+ self.rebuild()
234
+
235
+ def action_yank(self) -> None:
236
+ data = self._cursor_data()
237
+ if data is None:
238
+ return
239
+ node = data.node
240
+ if node is self._fp.root:
241
+ return
242
+ self._clipboard = node.model_copy(deep=True)
243
+
244
+ async def action_flatten(self) -> None:
245
+ data = self._cursor_data()
246
+ if data is None:
247
+ return
248
+ node = data.node
249
+ if not isinstance(node, FilterGroup) or node is self._fp.root:
250
+ return
251
+ await self._fp.flatten_group(node, data.parent)
252
+ self.rebuild()
253
+
254
+ async def action_delete(self) -> None:
255
+ data = self._cursor_data()
256
+ if data is None:
257
+ return
258
+ node = data.node
259
+ if node is self._fp.root:
260
+ return
261
+ self._clipboard = node
262
+ await self._fp.remove_node(node, data.parent)
263
+ self.rebuild()
264
+
265
+ async def action_paste(self) -> None:
266
+ if self._clipboard is None:
267
+ return
268
+ parent, idx = self._insert_at_cursor()
269
+ if idx is not None:
270
+ parent.children.insert(idx + 1, self._clipboard)
271
+ else:
272
+ parent.children.append(self._clipboard)
273
+ self._clipboard = None
274
+ await self._fp.on_change.asend(None)
275
+ self.rebuild()
276
+
277
+ def action_add_filter(self) -> None:
278
+ target = self._insert_at_cursor()
279
+
280
+ async def on_dismiss(expr: str | None) -> None:
281
+ if not expr:
282
+ return
283
+ warning = check_filter_warning(expr)
284
+ parent, idx = target
285
+ leaf = Filter(expr=expr)
286
+ if idx is not None:
287
+ parent.children.insert(idx + 1, leaf)
288
+ else:
289
+ parent.children.append(leaf)
290
+ await self._fp.on_change.asend(None)
291
+ self.rebuild()
292
+ if warning:
293
+ self.app.notify(warning, severity="warning", timeout=3)
294
+
295
+ self.app.push_screen(
296
+ TextInputScreen("Add filter", placeholder="jq expression..."),
297
+ on_dismiss,
298
+ )
299
+
300
+ def action_edit_filter(self) -> None:
301
+ data = self._cursor_data()
302
+ if data is None:
303
+ return
304
+ node = data.node
305
+ if not isinstance(node, Filter):
306
+ return
307
+
308
+ async def on_dismiss(expr: str | None) -> None:
309
+ if not expr:
310
+ return
311
+ warning = check_filter_warning(expr)
312
+ await self._fp.edit_leaf(node, expr)
313
+ self.rebuild()
314
+ if warning:
315
+ self.app.notify(warning, severity="warning", timeout=3)
316
+
317
+ self.app.push_screen(
318
+ TextInputScreen(
319
+ "Edit filter",
320
+ placeholder="jq expression...",
321
+ initial_value=node.expr,
322
+ ),
323
+ on_dismiss,
324
+ )
325
+
326
+ def action_rename(self) -> None:
327
+ data = self._cursor_data()
328
+ if data is None:
329
+ return
330
+ node = data.node
331
+
332
+ async def on_dismiss(label: str | None) -> None:
333
+ node.label = label
334
+ await self._fp.on_change.asend(None)
335
+ self.rebuild()
336
+
337
+ self.app.push_screen(
338
+ TextInputScreen(
339
+ "Rename",
340
+ placeholder="label...",
341
+ initial_value=node.label or "",
342
+ ),
343
+ on_dismiss,
344
+ )
jnav/filtering.py ADDED
@@ -0,0 +1,191 @@
1
+ import functools
2
+ import json
3
+ import math
4
+ import re
5
+ from collections.abc import Sequence
6
+ from typing import Annotated, Any, Literal, cast
7
+
8
+ import jq
9
+ from pydantic import BaseModel, Discriminator, Field
10
+
11
+
12
+ class Filter(BaseModel):
13
+ type: Literal["leaf"] = "leaf"
14
+ expr: str
15
+ enabled: bool = True
16
+ negated: bool = False
17
+ label: str | None = None
18
+
19
+
20
+ class FilterGroup(BaseModel):
21
+ type: Literal["group"] = "group"
22
+ operator: Literal["and", "or"] = "and"
23
+ enabled: bool = True
24
+ negated: bool = False
25
+ label: str | None = None
26
+ collapsed: bool = False
27
+ children: list[FilterNode] = Field(default_factory=list)
28
+
29
+
30
+ FilterNode = Annotated[Filter | FilterGroup, Discriminator("type")]
31
+
32
+ FilterGroup.model_rebuild()
33
+
34
+
35
+ @functools.lru_cache(maxsize=32)
36
+ def _compile_jq(expression: str):
37
+ return jq.compile(expression)
38
+
39
+
40
+ def apply_jq_filter(
41
+ expression: str,
42
+ entries: list[dict[str, Any]],
43
+ ) -> tuple[list[int], str | None]:
44
+ try:
45
+ prog = _compile_jq(expression)
46
+ except ValueError as e:
47
+ return [], str(e)
48
+ matched: list[int] = []
49
+ for i, entry in enumerate(entries):
50
+ try:
51
+ results = prog.input_value(entry).all()
52
+ if any(r is not None and r is not False for r in results):
53
+ matched.append(i)
54
+ except ValueError:
55
+ continue
56
+ return matched, None
57
+
58
+
59
+ def build_expression(node: FilterNode) -> str | None:
60
+ """Recursively build a jq expression from a filter tree.
61
+
62
+ Returns None if the node is disabled or has no effective children.
63
+ """
64
+ if not node.enabled:
65
+ return None
66
+
67
+ if isinstance(node, Filter):
68
+ expr = node.expr
69
+ if node.negated:
70
+ expr = f"{expr} | not"
71
+ return expr
72
+
73
+ child_exprs: list[tuple[str, FilterNode]] = []
74
+ for child in node.children:
75
+ child_expr = build_expression(child)
76
+ if child_expr is not None:
77
+ child_exprs.append((child_expr, child))
78
+
79
+ if not child_exprs:
80
+ return None
81
+
82
+ if len(child_exprs) == 1:
83
+ result = child_exprs[0][0]
84
+ else:
85
+ joiner = f" {node.operator} "
86
+ wrapped: list[str] = []
87
+ for expr, child in child_exprs:
88
+ needs_parens = "|" in expr or (
89
+ isinstance(child, FilterGroup)
90
+ and len(child.children) > 1
91
+ and child.operator != node.operator
92
+ )
93
+ wrapped.append(f"({expr})" if needs_parens else expr)
94
+ result = joiner.join(wrapped)
95
+
96
+ if node.negated:
97
+ result = f"({result}) | not"
98
+
99
+ return result
100
+
101
+
102
+ def apply_filter_tree(
103
+ root: FilterGroup,
104
+ entries: list[dict[str, Any]],
105
+ ) -> tuple[list[int], str | None]:
106
+ """Apply a filter tree to a list of entries."""
107
+ combined = build_expression(root)
108
+ if combined is None:
109
+ return list(range(len(entries))), None
110
+ return apply_jq_filter(combined, entries)
111
+
112
+
113
+ def text_search_expr(term: str) -> str:
114
+ escaped = term.lower().replace("\\", "\\\\").replace('"', '\\"')
115
+ return f'[.. | strings] | any(ascii_downcase | contains("{escaped}"))'
116
+
117
+
118
+ _ASSIGNMENT_RE = re.compile(r"(?<![=!<>])=(?!=)")
119
+ _STRING_LITERAL_RE = re.compile(r'"[^"\\]*(?:\\.[^"\\]*)*"')
120
+
121
+
122
+ def check_filter_warning(expression: str) -> str | None:
123
+ without_strings = _STRING_LITERAL_RE.sub('""', expression)
124
+ if _ASSIGNMENT_RE.search(without_strings):
125
+ return "jq update operators don't filter. Did you mean a comparison like '=='?"
126
+ return None
127
+
128
+
129
+ def get_nested(entry: dict[str, Any], path: str) -> object:
130
+ try:
131
+ results = _compile_jq(path).input_value(entry).all()
132
+ except ValueError:
133
+ return None
134
+ if len(results) == 1:
135
+ return results[0]
136
+ return results
137
+
138
+
139
+ def jq_value_literal(value: object) -> str:
140
+ if isinstance(value, str):
141
+ return json.dumps(value)
142
+ if isinstance(value, bool):
143
+ return "true" if value else "false"
144
+ if value is None:
145
+ return "null"
146
+ if isinstance(value, (int, float)):
147
+ if not math.isfinite(value):
148
+ raise ValueError(f"jq does not support {value!r}")
149
+ return str(value)
150
+ return json.dumps(value)
151
+
152
+
153
+ def _jq_path_to_str(parts: Sequence[str | int]) -> str:
154
+ result = ""
155
+ for p in parts:
156
+ if isinstance(p, int):
157
+ result += f"[{p}]"
158
+ else:
159
+ result = f"{result}.{p}" if result else p
160
+ return result
161
+
162
+
163
+ def resolve_selected_paths(columns: set[str], entry: dict[str, Any]) -> set[str]:
164
+ """Expand jq column expressions into concrete paths for a specific entry."""
165
+ result: set[str] = set()
166
+ for col in columns:
167
+ if "[]" not in col and "|" not in col:
168
+ result.add(col.lstrip("."))
169
+ continue
170
+ jq_path = col
171
+ try:
172
+ raw = jq.compile(f"[path({jq_path})]").input_value(entry).first()
173
+ for parts in raw:
174
+ result.add(_jq_path_to_str(parts))
175
+ continue
176
+ except ValueError:
177
+ pass
178
+ try:
179
+ results = jq.compile(jq_path).input_value(entry).all()
180
+ base_expr = jq_path.split("|")[0].strip()
181
+ raw_bases = jq.compile(f"[path({base_expr})]").input_value(entry).first()
182
+ for base_parts in raw_bases:
183
+ base = _jq_path_to_str(base_parts)
184
+ for r in results:
185
+ if isinstance(r, dict):
186
+ r = cast(dict[str, object], r)
187
+ for key in r:
188
+ result.add(f"{base}.{key}")
189
+ except ValueError:
190
+ result.add(col)
191
+ return result
jnav/header.py ADDED
@@ -0,0 +1,48 @@
1
+ from typing import ClassVar, override
2
+
3
+ from rich.text import Text
4
+ from textual.widgets import Static
5
+
6
+
7
+ class Header(Static):
8
+ COMPONENT_CLASSES: ClassVar[set[str]] = {
9
+ "header--bracket",
10
+ "header--text",
11
+ "header--file",
12
+ }
13
+
14
+ DEFAULT_CSS = """
15
+ Header {
16
+ height: 1;
17
+ background: $surface;
18
+ }
19
+ Header > .header--bracket {
20
+ color: $accent;
21
+ text-style: bold;
22
+ }
23
+ Header > .header--text {
24
+ color: $foreground;
25
+ text-style: bold;
26
+ }
27
+ Header > .header--file {
28
+ color: $text-muted;
29
+ text-style: italic;
30
+ }
31
+ """
32
+
33
+ def __init__(self, file_name: str) -> None:
34
+ super().__init__()
35
+ self._file_name = file_name
36
+
37
+ @override
38
+ def render(self) -> Text:
39
+ bracket = self.get_component_rich_style("header--bracket")
40
+ text = self.get_component_rich_style("header--text")
41
+ file_name = self.get_component_rich_style("header--file")
42
+ return Text.assemble(
43
+ ("[", bracket),
44
+ ("J", text),
45
+ ("]", bracket),
46
+ ("NAV", text),
47
+ (f" {self._file_name}", file_name),
48
+ )
jnav/help_screen.py ADDED
@@ -0,0 +1,79 @@
1
+ from typing import ClassVar, override
2
+
3
+ from textual.app import ComposeResult
4
+ from textual.binding import Binding, BindingType
5
+ from textual.containers import Vertical
6
+ from textual.screen import ModalScreen
7
+ from textual.widgets import Static
8
+
9
+ HELP_TEXT = """\
10
+ [b]Navigation[/b]
11
+ [b]j/k[/b] Cursor down/up
12
+ [b]gg/G[/b] Jump to top/bottom
13
+ [b]Ctrl+D/U[/b] Half-page down/up
14
+ [b]h/l[/b] Focus list/detail
15
+ [b]Enter[/b] Inspect entry
16
+ [b]Escape[/b] Back
17
+
18
+ [b]Actions[/b]
19
+ [b]/[/b] Search
20
+ [b]n/N[/b] Next/prev match
21
+ [b]F[/b] Filter manager
22
+ [b]ft[/b] Text filter
23
+ [b]fp[/b] Pause/resume filters
24
+ [b]S[/b] Selected fields manager
25
+ [b]vi[/b] Toggle inline expanded tree
26
+ [b]d[/b] Toggle detail panel
27
+ [b]r[/b] Reset filters, fields, search
28
+ [b]?[/b] This help
29
+ [b]q[/b] Quit
30
+
31
+ [b]Detail Panel[/b]
32
+ [b]ff[/b] Filter by value
33
+ [b]fn[/b] Has field
34
+ [b]s[/b] (Un)select field
35
+ [b]ve[/b] View value in $EDITOR
36
+ [b]vo[/b] Show selected fields only
37
+ """
38
+
39
+
40
+ class HelpScreen(ModalScreen[bool]):
41
+ DEFAULT_CSS = """
42
+ HelpScreen {
43
+ align: center middle;
44
+ }
45
+ #help-modal {
46
+ width: 55;
47
+ max-width: 90%;
48
+ height: auto;
49
+ max-height: 80%;
50
+ border: round $surface-lighten-2;
51
+ background: $surface;
52
+ padding: 1 2;
53
+ }
54
+ #help-hints {
55
+ color: $text-muted;
56
+ padding: 1 0 0 0;
57
+ }
58
+ """
59
+
60
+ BINDINGS: ClassVar[list[BindingType]] = [
61
+ Binding("escape", "close", "Close", priority=True),
62
+ Binding("q", "close", show=False),
63
+ Binding("ctrl+c", "close", show=False),
64
+ Binding("question_mark", "close", show=False),
65
+ ]
66
+
67
+ @override
68
+ def compose(self) -> ComposeResult:
69
+ yield Vertical(
70
+ Static(HELP_TEXT),
71
+ Static("[b]esc[/b] or [b]?[/b] to close", id="help-hints"),
72
+ id="help-modal",
73
+ )
74
+
75
+ def on_mount(self) -> None:
76
+ self.query_one("#help-modal").border_title = "Keybindings"
77
+
78
+ def action_close(self) -> None:
79
+ self.dismiss(True)