jsonl-cli 0.1.2__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.
jsonl_cli/__init__.py ADDED
File without changes
jsonl_cli/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
jsonl_cli/cli.py ADDED
@@ -0,0 +1,247 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import curses
5
+ import os
6
+ from typing import List, Optional
7
+
8
+ from .colors import _hex_to_rgb, _key_to_pair_id, _rgb_to_xterm256, _load_theme
9
+ from .render import _render_json_styled, _wrap_styled_lines
10
+ from .helpers import _validate_path, _build_offsets, _human_bytes, \
11
+ _read_line_at, _scan_brief, _parse_row, _die
12
+ from .command import _apply_command, _prompt_command
13
+ from .search import SearchSpec, _find_next, _parse_search_spec
14
+
15
+
16
+ def _viewer(stdscr: "curses._CursesWindow", path: str, theme: str) -> None:
17
+ """
18
+ Main driver code for the curses window.
19
+
20
+ Args:
21
+ stdscr: The curses window to display to.
22
+ path: Path to the JSONL file.
23
+ """
24
+ curses.curs_set(0)
25
+
26
+ status_msg: str | None = None
27
+
28
+ curses.start_color()
29
+ try:
30
+ curses.use_default_colors()
31
+ except curses.error:
32
+ pass
33
+
34
+ color_theme = _load_theme(theme)
35
+
36
+ num_pairs = len(color_theme)
37
+ for i, hx in enumerate(color_theme, start=1):
38
+ r, g, b = _hex_to_rgb(hx)
39
+ fg = _rgb_to_xterm256(r, g, b)
40
+ try:
41
+ curses.init_pair(i, fg, -1)
42
+ except curses.error:
43
+ pass
44
+
45
+ def key_attr_fn(key: str) -> int:
46
+ pid = _key_to_pair_id(key, num_pairs)
47
+ return curses.A_BOLD | curses.color_pair(pid)
48
+
49
+ normal_attr = curses.A_NORMAL
50
+
51
+ stdscr.keypad(True)
52
+
53
+ offsets = _build_offsets(path)
54
+ total_lines = max(0, len(offsets) - 1)
55
+
56
+ idx = 0
57
+ scroll = 0
58
+ indent_delta = 4
59
+ last_search: SearchSpec | None = None
60
+
61
+ def apply_search(raw: str, include_current: bool = False, direction: int = 1) -> None:
62
+ nonlocal idx, scroll, status_msg, last_search
63
+ spec, err = _parse_search_spec(raw)
64
+ if err:
65
+ status_msg = err
66
+ return
67
+
68
+ hit = _find_next(path, offsets, total_lines, idx, spec, direction, include_current)
69
+ last_search = spec
70
+ if hit is None:
71
+ status_msg = f"no match: {spec.label()}"
72
+ return
73
+
74
+ idx = hit.idx
75
+ scroll = 0
76
+ prefix = "wrapped to " if hit.wrapped else ""
77
+ status_msg = f"{prefix}row {idx + 1}: {spec.label()}"
78
+
79
+ def repeat_search(direction: int) -> None:
80
+ nonlocal idx, scroll, status_msg
81
+ if last_search is None:
82
+ status_msg = "no previous search"
83
+ return
84
+
85
+ hit = _find_next(path, offsets, total_lines, idx, last_search, direction, False)
86
+ if hit is None:
87
+ status_msg = f"no match: {last_search.label()}"
88
+ return
89
+
90
+ idx = hit.idx
91
+ scroll = 0
92
+ prefix = "wrapped to " if hit.wrapped else ""
93
+ status_msg = f"{prefix}row {idx + 1}: {last_search.label()}"
94
+
95
+ while True:
96
+ height, width = stdscr.getmaxyx()
97
+ stdscr.erase()
98
+
99
+ if total_lines == 0:
100
+ stdscr.addnstr(0, 0, "Empty file. Press q to quit.", width - 1)
101
+ stdscr.refresh()
102
+ ch = stdscr.getch()
103
+ if ch in (ord("q"), ord("Q")):
104
+ return
105
+ continue
106
+
107
+ if idx < 0:
108
+ idx = 0
109
+ if idx >= total_lines:
110
+ idx = total_lines - 1
111
+
112
+ start = offsets[idx]
113
+ end = offsets[idx + 1]
114
+ raw = _read_line_at(path, start, end)
115
+ row = _parse_row(raw, idx)
116
+
117
+ header = f"{os.path.basename(path)} | {idx + 1}/{total_lines} | ↑/↓ scroll ←/→ row / find n/N next/prev q quit"
118
+ stdscr.addnstr(0, 0, header, max(0, width - 1), curses.A_REVERSE)
119
+
120
+ title = row.title
121
+ stdscr.addnstr(1, 0, title, max(0, width - 1), curses.A_BOLD)
122
+
123
+ content_height = max(0, height - 3)
124
+ content_width = max(1, width - 1)
125
+
126
+ if row.ok:
127
+ styled_lines = _render_json_styled(row.obj, 0, key_attr_fn, normal_attr, indent_delta)
128
+ else:
129
+ raw = row.raw_fallback or ""
130
+ styled_lines = [[(raw, curses.A_DIM)]]
131
+
132
+ styled_lines = _wrap_styled_lines(styled_lines, content_width)
133
+
134
+ max_scroll = max(0, len(styled_lines) - content_height)
135
+ if scroll > max_scroll:
136
+ scroll = max_scroll
137
+ if scroll < 0:
138
+ scroll = 0
139
+
140
+ view = styled_lines[scroll : scroll + content_height]
141
+
142
+ for i, line in enumerate(view):
143
+ y = 2 + i
144
+ x = 0
145
+ remaining = content_width
146
+ for text, attr in line:
147
+ if remaining <= 0:
148
+ break
149
+ if not text:
150
+ continue
151
+ chunk = text[:remaining]
152
+ try:
153
+ stdscr.addstr(y, x, chunk, attr)
154
+ except curses.error:
155
+ pass
156
+ x += len(chunk)
157
+ remaining -= len(chunk)
158
+
159
+ footer = status_msg
160
+ if footer is None and max_scroll > 0:
161
+ footer = f"Lines {scroll} - {min(scroll + content_height, len(styled_lines))}"
162
+ if footer and height > 0:
163
+ stdscr.addnstr(height - 1, 0, footer, max(0, width - 1), curses.A_DIM)
164
+
165
+ stdscr.refresh()
166
+ ch = stdscr.getch()
167
+
168
+ if ch in (ord("q"), ord("Q")):
169
+ return
170
+ elif ch == ord(":"):
171
+ cmd = _prompt_command(stdscr, prompt=":")
172
+ status_msg = None
173
+ if cmd is not None:
174
+ parts = cmd.split(maxsplit=1)
175
+ name = parts[0].lower() if parts else ""
176
+ if name in ("find", "f", "search", "s"):
177
+ apply_search(parts[1] if len(parts) == 2 else "", include_current=True)
178
+ elif name in ("next", "n"):
179
+ repeat_search(1)
180
+ elif name in ("prev", "previous", "p"):
181
+ repeat_search(-1)
182
+ else:
183
+ new_idx, msg = _apply_command(cmd, total_lines, idx)
184
+ if new_idx == -1:
185
+ return
186
+ idx = new_idx
187
+ scroll = 0
188
+ status_msg = msg
189
+ elif ch == ord("/"):
190
+ cmd = _prompt_command(stdscr, prompt="/")
191
+ status_msg = None
192
+ if cmd is not None:
193
+ apply_search(cmd, include_current=True)
194
+ elif ch == ord("n"):
195
+ repeat_search(1)
196
+ elif ch == ord("N"):
197
+ repeat_search(-1)
198
+ elif ch == curses.KEY_DOWN:
199
+ scroll += 1
200
+ elif ch == curses.KEY_UP:
201
+ scroll -= 1
202
+ elif ch == curses.KEY_LEFT:
203
+ idx -= 1
204
+ scroll = 0
205
+ elif ch == curses.KEY_RIGHT:
206
+ idx += 1
207
+ scroll = 0
208
+ elif ch in (curses.KEY_NPAGE,):
209
+ indent_delta = max(indent_delta - 1, 1)
210
+ elif ch in (curses.KEY_PPAGE,):
211
+ indent_delta = min(indent_delta + 1, 8)
212
+ elif ch == curses.KEY_RESIZE:
213
+ pass
214
+ else:
215
+ pass
216
+
217
+
218
+ def main(argv: Optional[List[str]] = None) -> None:
219
+ parser = argparse.ArgumentParser(prog="jsonl", add_help=True)
220
+ parser.add_argument("file", metavar="FILE", help="Path to a .jsonl file")
221
+ parser.add_argument("-b", "--brief", action="store_true", help="Show file characteristics and exit")
222
+ parser.add_argument("-t", "--theme", metavar="STR", type=str, default="catppuccin-mocha", help="Color theme")
223
+ args = parser.parse_args(argv)
224
+
225
+ path = _validate_path(args.file)
226
+
227
+ if args.brief:
228
+ line_count, size, cols, invalid = _scan_brief(path)
229
+ print(f"File: {path}")
230
+ print(f"Size: {_human_bytes(size)} ({size} bytes)")
231
+ print(f"Lines: {line_count}")
232
+ if invalid:
233
+ print(f"Invalid JSON lines: {invalid}")
234
+ print("Columns:")
235
+ if cols:
236
+ for c in cols:
237
+ print(f" - {c}")
238
+ else:
239
+ print(" (no object keys found)")
240
+ return
241
+
242
+ try:
243
+ curses.wrapper(_viewer, path, args.theme)
244
+ except KeyboardInterrupt:
245
+ return
246
+ except curses.error as e:
247
+ _die(f"curses error: {e}", code=1)
jsonl_cli/colors.py ADDED
@@ -0,0 +1,84 @@
1
+ import zlib
2
+ import json
3
+ from pathlib import Path
4
+ from typing import List
5
+
6
+
7
+ def _hex_to_rgb(h: str) -> tuple[int, int, int]:
8
+ """
9
+ Converts a hex color string into RGB values.
10
+
11
+ Args:
12
+ h: The hex string.
13
+
14
+ Returns:
15
+ A 3-tuple of the RGB values in base 16, each between 00 and FF inclusive.
16
+ """
17
+ h = h.lstrip("#")
18
+ return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
19
+
20
+
21
+ def _rgb_to_xterm256(r: int, g: int, b: int) -> int:
22
+ """
23
+ Maps 24-bit RGB values to xterm-256 color indices.
24
+ Will always map the closest possible color.
25
+
26
+ Args:
27
+ r: The base 16 value for red.
28
+ g: The base 16 value for green.
29
+ b: The base 16 value for blue.
30
+
31
+ Returns:
32
+ The index of the corresponding xterm-256 color.
33
+ """
34
+ if r == g == b:
35
+ if r < 8:
36
+ return 16
37
+ if r > 248:
38
+ return 231
39
+ return 232 + (r - 8) // 10
40
+
41
+ def to_6(x: int) -> int:
42
+ return int(round(x / 255 * 5))
43
+
44
+ rr, gg, bb = to_6(r), to_6(g), to_6(b)
45
+ return 16 + 36 * rr + 6 * gg + bb
46
+
47
+
48
+ def _key_to_pair_id(key: str, num_pairs: int) -> int:
49
+ """
50
+ Maps a string to a color via hashing.
51
+ Uses zlib.crc32() as the hash function.
52
+
53
+ Args:
54
+ key: The string to assign a color to.
55
+ num_pairs: The number of colors that can be assigned.
56
+
57
+ Returns:
58
+ The index in curses corresponding to that color.
59
+ Note that index 0 in curses corresponds to the default text color, so the return value is 1-indexed.
60
+ """
61
+ h = zlib.crc32(key.encode("utf-8")) & 0xFFFFFFFF
62
+ return 1 + (h % num_pairs)
63
+
64
+
65
+ def _load_theme(theme: str) -> List[str]:
66
+ """
67
+ Loads a theme's hex colors.
68
+
69
+ Args:
70
+ theme: Name of the theme.
71
+
72
+ Returns:
73
+ A list of strings that represent hex colors for that theme.
74
+ """
75
+ try:
76
+ path = Path(__file__).parent / "themes" / f"{theme}.json"
77
+ with path.open("r", encoding="utf-8") as f:
78
+ data = json.load(f)
79
+ return data["key-colors"]
80
+ except:
81
+ path = Path(__file__).parent / "themes" / "catppuccin-mocha.json" # Default
82
+ with path.open("r", encoding="utf-8") as f:
83
+ data = json.load(f)
84
+ return data["key-colors"]
jsonl_cli/command.py ADDED
@@ -0,0 +1,99 @@
1
+ from __future__ import annotations
2
+
3
+ import curses
4
+
5
+
6
+ def _prompt_command(stdscr: "curses._CursesWindow", prompt: str = ":") -> str | None:
7
+ """
8
+ Read a command from the bottom line.
9
+
10
+ Args:
11
+ stdscr: The curses window to display to.
12
+ prompt: The prompt / command to read.
13
+
14
+ Returns:
15
+ The command string (without leading ':') or None if cancelled (ESC).
16
+ """
17
+ height, width = stdscr.getmaxyx()
18
+ y = height - 1
19
+
20
+ buf: list[str] = []
21
+ pos = 0
22
+
23
+ stdscr.move(y, 0)
24
+ stdscr.clrtoeol()
25
+ stdscr.addnstr(y, 0, prompt, width - 1, curses.A_REVERSE)
26
+ stdscr.refresh()
27
+
28
+ while True:
29
+ ch = stdscr.get_wch()
30
+
31
+ if ch == "\x1b":
32
+ return None
33
+
34
+ if ch in ("\n", "\r"):
35
+ return "".join(buf).strip()
36
+
37
+ if ch in (curses.KEY_BACKSPACE, "\b", "\x7f"):
38
+ if pos > 0:
39
+ buf.pop(pos - 1)
40
+ pos -= 1
41
+
42
+ elif ch == curses.KEY_LEFT:
43
+ pos = max(0, pos - 1)
44
+ elif ch == curses.KEY_RIGHT:
45
+ pos = min(len(buf), pos + 1)
46
+ elif ch == curses.KEY_HOME:
47
+ pos = 0
48
+ elif ch == curses.KEY_END:
49
+ pos = len(buf)
50
+ elif isinstance(ch, str) and ch.isprintable():
51
+ buf.insert(pos, ch)
52
+ pos += 1
53
+
54
+ cmd_text = prompt + "".join(buf)
55
+ if len(cmd_text) >= width:
56
+ cmd_text = cmd_text[-(width - 1):]
57
+ stdscr.move(y, 0)
58
+ stdscr.clrtoeol()
59
+ stdscr.addnstr(y, 0, cmd_text, width - 1, curses.A_REVERSE)
60
+
61
+ cursor_x = min(width - 1, len(prompt) + pos)
62
+ stdscr.move(y, cursor_x)
63
+ stdscr.refresh()
64
+
65
+
66
+ def _apply_command(cmd: str, total_lines: int, idx: int) -> tuple[int, str | None]:
67
+ """
68
+ Applies a command string.
69
+
70
+ Args:
71
+ cmd: The command inputted.
72
+ total_lines: The number of rows in the JSONL file.
73
+ idx: The current index being viewed.
74
+
75
+ Returns:
76
+ A tuple (new_idx, status_message_or_None).
77
+ """
78
+ if not cmd:
79
+ return idx, None
80
+
81
+ parts = cmd.split()
82
+ name = parts[0].lower()
83
+
84
+ if name in ("goto", "g") and len(parts) == 2:
85
+ try:
86
+ n = int(parts[1])
87
+ except ValueError:
88
+ return idx, "goto expects an integer row number"
89
+
90
+ if total_lines <= 0:
91
+ return idx, "file has no rows"
92
+
93
+ n = max(1, min(total_lines, n))
94
+ return n - 1, None
95
+
96
+ if name in ("q", "quit", "exit"):
97
+ return -1, None
98
+
99
+ return idx, f"unknown command: {cmd}"
@@ -0,0 +1,14 @@
1
+ from dataclasses import dataclass
2
+ from typing import Any, Optional
3
+
4
+ # We use this datastructure since different portions of 1 line need to have different curses bitmasks (due to colors and bolding).
5
+ Segment = tuple[str, int] # (raw_text, curses_attribute_bitmask)
6
+ StyledLine = list[Segment] # 1 Line in the output
7
+
8
+ # A container for 1 JSONL row.
9
+ @dataclass
10
+ class RowData:
11
+ ok: bool # Whether the line has been successfully parsed to JSON.
12
+ title: str # The status for the row displayed at the top of the window.
13
+ obj: Any = None # The parsed Python object for this JSONL row.
14
+ raw_fallback: Optional[str] = None # Fallback raw JSONL text for failed formatting.
jsonl_cli/helpers.py ADDED
@@ -0,0 +1,161 @@
1
+ import sys
2
+ import json
3
+ import os
4
+ from typing import List, Tuple
5
+
6
+ from .containers import RowData
7
+
8
+ def _die(msg: str, code: int = 2) -> None:
9
+ """
10
+ Raises SystemExit.
11
+
12
+ Args:
13
+ msg: The message to display to the user.
14
+ code: The system exit code.
15
+
16
+ Raises:
17
+ SystemExit unconditionally.
18
+ """
19
+ print(f"jsonl: {msg}", file=sys.stderr)
20
+ raise SystemExit(code)
21
+
22
+
23
+ def _human_bytes(n: int) -> str:
24
+ """
25
+ Converts number of bytes into a human-readable form.
26
+
27
+ Args:
28
+ n: The number of bytes to convert.
29
+
30
+ Returns:
31
+ The human-readable conversion of n bytes.
32
+ """
33
+ units = ["B", "KiB", "MiB", "GiB", "TiB"]
34
+ x = float(n)
35
+ for u in units:
36
+ if x < 1024.0 or u == units[-1]:
37
+ if u == "B":
38
+ return f"{int(x)} {u}"
39
+ return f"{x:.2f} {u}"
40
+ x /= 1024.0
41
+ return f"{n} B"
42
+
43
+
44
+ def _validate_path(path: str) -> str:
45
+ """
46
+ Validates whether a path is a valid JSONL file.
47
+
48
+ Args:
49
+ path: The path to the JSONL file.
50
+
51
+ Returns:
52
+ The given path without modification.
53
+
54
+ Raises:
55
+ SystemExit if path is abnormal.
56
+ """
57
+ if not path.endswith(".jsonl"):
58
+ _die("FILE must end with .jsonl")
59
+ if not os.path.exists(path):
60
+ _die(f"FILE not found: {path}")
61
+ if not os.path.isfile(path):
62
+ _die(f"FILE is not a regular file: {path}")
63
+ return path
64
+
65
+
66
+ def _scan_brief(path: str) -> Tuple[int, int, List[str], int]:
67
+ """
68
+ Returns a summary of the given JSONL file.
69
+ Columns are the union of keys across all JSON objects that are dicts.
70
+
71
+ Args:
72
+ path: The path to the JSONL file.
73
+
74
+ Returns:
75
+ A 4-tuple of (line_count, file_size_bytes, columns_sorted, invalid_json_lines).
76
+ """
77
+ st = os.stat(path)
78
+ size = st.st_size
79
+ line_count = 0
80
+ invalid = 0
81
+ cols = set()
82
+
83
+ with open(path, "rb") as f:
84
+ for raw in f:
85
+ line_count += 1
86
+ line = raw.strip()
87
+ if not line:
88
+ continue
89
+ try:
90
+ obj = json.loads(line)
91
+ if isinstance(obj, dict):
92
+ cols.update(obj.keys())
93
+ except json.JSONDecodeError:
94
+ invalid += 1
95
+
96
+ return line_count, size, sorted(cols), invalid
97
+
98
+
99
+ def _build_offsets(path: str) -> List[int]:
100
+ """
101
+ Builds file 0-indexed offsets for each line start.
102
+
103
+ Args:
104
+ path: The path to the JSONL file.
105
+
106
+ Returns:
107
+ A list of 0-indexed offsets of length equal to the number of lines in the JSONL file.
108
+ """
109
+ offsets: List[int] = []
110
+ pos = 0
111
+
112
+ offsets.append(pos)
113
+ with open(path, "rb") as f:
114
+ for raw in f:
115
+ pos += len(raw)
116
+ offsets.append(pos)
117
+
118
+ return offsets
119
+
120
+
121
+ def _read_line_at(path: str, start: int, end: int) -> bytes:
122
+ """
123
+ Wrapper for reading lines of a file, i.e. start <= && < end
124
+
125
+ Args:
126
+ path: The path to the JSONL file.
127
+ start: The index of the byte to start reading.
128
+ end: The index of the byte right after the byte to end reading.
129
+
130
+ Returns:
131
+ A segment of bytes of the given JSONL file.
132
+ """
133
+ with open(path, "rb") as f:
134
+ f.seek(start)
135
+ return f.read(end - start)
136
+
137
+
138
+ def _parse_row(raw_line: bytes, row_idx: int) -> RowData:
139
+ """
140
+ Parses raw bytes of a JSONL row into a RowData structure.
141
+
142
+ Args:
143
+ raw_line: Bytes of the JSONL row.
144
+ row_idx: 0-indexed row number.
145
+
146
+ Returns:
147
+ A RowData structure corresponding to the given row bytes.
148
+
149
+ Raises:
150
+ JSONDecodeError if the row cannot be parsed.
151
+ """
152
+ s = raw_line.decode("utf-8", errors="replace").rstrip("\r\n")
153
+ if not s.strip():
154
+ return RowData(ok=True, title=f"Row {row_idx+1}: (empty line)", obj="")
155
+
156
+ try:
157
+ obj = json.loads(s)
158
+ return RowData(ok=True, title=f"Row {row_idx+1}: OK", obj=obj)
159
+ except json.JSONDecodeError as e:
160
+ msg = f"Row {row_idx+1}: INVALID JSON ({e.msg} at col {e.colno})"
161
+ return RowData(ok=False, title=msg, obj=None, raw_fallback=s)
jsonl_cli/render.py ADDED
@@ -0,0 +1,196 @@
1
+ import json
2
+ import curses
3
+ from typing import Any
4
+
5
+ from .containers import StyledLine
6
+
7
+ def _json_atom(v: Any) -> str:
8
+ """
9
+ Convert a terminal endpoint in a JSONL file to a string.
10
+
11
+ Args:
12
+ v: A terminal endpoint in the JSONL file, i.e. a value or an item in a list.
13
+
14
+ Returns:
15
+ The string corresponding to the given terminal endpoint.
16
+ """
17
+ return json.dumps(v, ensure_ascii=False)
18
+
19
+
20
+ def _render_json_styled(
21
+ v: Any,
22
+ indent: int,
23
+ key_attr_fn,
24
+ normal_attr: int,
25
+ indent_delta: int,
26
+ ) -> list[StyledLine]:
27
+ """
28
+ Recursively renders a Python object corresponding to 1 line in a JSONL file.
29
+
30
+ Args:
31
+ v: The Python object to render.
32
+ indent: The base indent to use.
33
+ key_attr_fn: Function that maps strings to curses bitmasks. Applies to keys.
34
+ normal_attr: Function that maps strings to curses bitmasks. Applies to everything else.
35
+ indent_delta: Amount to indent each line.
36
+
37
+ Returns:
38
+ The rendered Python object in a list of StyledLines.
39
+ """
40
+ lines: list[StyledLine] = []
41
+
42
+ is_root = (indent == 0)
43
+
44
+ sp = " " * indent
45
+ sp_child = " " * (indent + indent_delta)
46
+
47
+ if isinstance(v, dict):
48
+ if is_root:
49
+ lines.append([(sp + "{", normal_attr)])
50
+ items = list(v.items())
51
+ for i, (k, val) in enumerate(items):
52
+ last = (i == len(items) - 1)
53
+
54
+ k_str = json.dumps(k, ensure_ascii=False)
55
+ k_attr = key_attr_fn(str(k))
56
+
57
+ if isinstance(val, (dict, list)):
58
+ opener = "{" if isinstance(val, dict) else "["
59
+ line: StyledLine = [
60
+ (sp_child, normal_attr),
61
+ (k_str, k_attr),
62
+ (": " + opener, normal_attr),
63
+ ]
64
+ lines.append(line)
65
+ lines.extend(_render_json_styled(val, indent + indent_delta, key_attr_fn, normal_attr, indent_delta))
66
+ closer = "}" if isinstance(val, dict) else "]"
67
+ tail = closer + ("" if last else ",")
68
+ lines.append([(sp_child + tail, normal_attr)]) if lines[-1] else lines.append([(sp_child + tail, normal_attr)])
69
+ else:
70
+ atom = _json_atom(val)
71
+ comma = "" if last else ","
72
+ lines.append([
73
+ (sp_child, normal_attr),
74
+ (k_str, k_attr),
75
+ (": " + atom + comma, normal_attr),
76
+ ])
77
+ if is_root:
78
+ lines.append([(sp + "}", normal_attr)])
79
+
80
+ return lines
81
+
82
+ if isinstance(v, list):
83
+ if is_root:
84
+ lines.append([(sp + "[", normal_attr)])
85
+
86
+ for i, item in enumerate(v):
87
+ last = (i == len(v) - 1)
88
+ if isinstance(item, (dict, list)):
89
+ opener = "{" if isinstance(item, dict) else "["
90
+ lines.append([(sp_child + opener, normal_attr)])
91
+ lines.extend(_render_json_styled(item, indent + indent_delta, key_attr_fn, normal_attr, indent_delta))
92
+ closer = "}" if isinstance(item, dict) else "]"
93
+ tail = closer + ("" if last else ",")
94
+ lines.append([(sp_child + tail, normal_attr)]) if lines[-1] else lines.append([(sp_child + tail, normal_attr)])
95
+ else:
96
+ atom = _json_atom(item)
97
+ comma = "" if last else ","
98
+ lines.append([(sp_child + atom + comma, normal_attr)])
99
+
100
+ if is_root:
101
+ lines.append([(sp + "]", normal_attr)])
102
+
103
+ return lines
104
+
105
+ lines.append([(sp + _json_atom(v), normal_attr)])
106
+
107
+ return lines
108
+
109
+
110
+ def _wrap_styled_lines(lines: list[StyledLine], width: int) -> list[StyledLine]:
111
+ """
112
+ Wrap styled lines to fit window width.
113
+ Preserves per-segment attributes and breaks only at character boundaries.
114
+
115
+ Args:
116
+ lines: List of StyledLines to wrap.
117
+ width: Width to wrap to.
118
+
119
+ Returns:
120
+ The resulting list of StyledLines that have been wrapped.
121
+ """
122
+ if width <= 1:
123
+ return lines
124
+
125
+ out: list[StyledLine] = []
126
+
127
+ for line in lines:
128
+ out_start = len(out)
129
+ rendered = "".join(t for t, _ in line)
130
+
131
+ sep = rendered.find(": ")
132
+ if sep != -1:
133
+ align = sep + 2
134
+ else:
135
+ align = 0
136
+ while align < len(rendered) and rendered[align] == " ":
137
+ align += 1
138
+
139
+ align = min(align, max(0, width - 1))
140
+
141
+ cur: StyledLine = []
142
+ cur_len = 0
143
+ needs_indent = False
144
+
145
+ def _append_indent() -> None:
146
+ nonlocal cur, cur_len, needs_indent
147
+ if needs_indent and align > 0:
148
+ cur.append((" " * align, curses.A_NORMAL))
149
+ cur_len += align
150
+ needs_indent = False
151
+
152
+ def _append(text: str, attr: int) -> None:
153
+ nonlocal cur, cur_len
154
+ if not text:
155
+ return
156
+ _append_indent()
157
+ if cur and cur[-1][1] == attr:
158
+ cur[-1] = (cur[-1][0] + text, attr)
159
+ else:
160
+ cur.append((text, attr))
161
+ cur_len += len(text)
162
+
163
+ def flush() -> None:
164
+ nonlocal cur, cur_len, needs_indent
165
+ out.append(cur if cur else [("", curses.A_NORMAL)])
166
+ cur = []
167
+ cur_len = 0
168
+ needs_indent = True
169
+
170
+ for text, attr in line:
171
+ if not text:
172
+ continue
173
+
174
+ i = 0
175
+ while i < len(text):
176
+ _append_indent()
177
+ space = width - cur_len
178
+ if space <= 0:
179
+ flush()
180
+ _append_indent()
181
+ space = width - cur_len
182
+
183
+ chunk = text[i:i + space]
184
+ i += len(chunk)
185
+
186
+ _append(chunk, attr)
187
+
188
+ if cur_len >= width:
189
+ flush()
190
+
191
+ if cur:
192
+ out.append(cur)
193
+ elif len(out) == out_start:
194
+ out.append([("", curses.A_NORMAL)])
195
+
196
+ return out
jsonl_cli/search.py ADDED
@@ -0,0 +1,180 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from dataclasses import dataclass
6
+ from typing import Any, Iterable
7
+
8
+ from .helpers import _parse_row, _read_line_at
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class SearchSpec:
13
+ query: str
14
+ paths: tuple[str, ...] = ()
15
+ case_sensitive: bool = False
16
+ regex: bool = False
17
+
18
+ def label(self) -> str:
19
+ scope = ",".join(self.paths) if self.paths else "all fields"
20
+ return f"{scope}: {self.query}"
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class SearchHit:
25
+ idx: int
26
+ wrapped: bool
27
+
28
+
29
+ _PATHS_RE = re.compile(r"^([A-Za-z0-9_.*\[\]-]+(?:,[A-Za-z0-9_.*\[\]-]+)*):(.*)$")
30
+
31
+
32
+ def _parse_search_spec(raw: str) -> tuple[SearchSpec | None, str | None]:
33
+ text = raw.strip()
34
+ if not text:
35
+ return None, "find expects a search string"
36
+
37
+ case_sensitive = False
38
+ regex = False
39
+
40
+ while True:
41
+ if text.startswith("-c "):
42
+ case_sensitive = True
43
+ text = text[3:].lstrip()
44
+ elif text.startswith("-r "):
45
+ regex = True
46
+ text = text[3:].lstrip()
47
+ else:
48
+ break
49
+
50
+ paths: tuple[str, ...] = ()
51
+ match = _PATHS_RE.match(text)
52
+ if match:
53
+ paths = tuple(p for p in match.group(1).split(",") if p)
54
+ text = match.group(2).strip()
55
+
56
+ query = _unquote(text)
57
+ if not query:
58
+ return None, "find expects a search string"
59
+
60
+ if regex:
61
+ try:
62
+ re.compile(query)
63
+ except re.error as e:
64
+ return None, f"invalid regex: {e.msg}"
65
+
66
+ return SearchSpec(query, paths, case_sensitive, regex), None
67
+
68
+
69
+ def _find_next(
70
+ path: str,
71
+ offsets: list[int],
72
+ total_lines: int,
73
+ idx: int,
74
+ spec: SearchSpec,
75
+ direction: int = 1,
76
+ include_current: bool = False,
77
+ ) -> SearchHit | None:
78
+ if total_lines <= 0:
79
+ return None
80
+
81
+ direction = -1 if direction < 0 else 1
82
+ start = idx if include_current else idx + direction
83
+
84
+ for step in range(total_lines):
85
+ row_idx = (start + step * direction) % total_lines
86
+ start_byte = offsets[row_idx]
87
+ end_byte = offsets[row_idx + 1]
88
+ row = _parse_row(_read_line_at(path, start_byte, end_byte), row_idx)
89
+ if row.ok and _matches(row.obj, spec):
90
+ wrapped = row_idx < idx if direction > 0 else row_idx > idx
91
+ return SearchHit(row_idx, wrapped)
92
+
93
+ return None
94
+
95
+
96
+ def _matches(obj: Any, spec: SearchSpec) -> bool:
97
+ values: Iterable[Any]
98
+ if spec.paths:
99
+ values = (value for path in spec.paths for value in _path_values(obj, path))
100
+ else:
101
+ values = (obj,)
102
+
103
+ return any(_text_matches(text, spec) for value in values for text in _search_texts(value))
104
+
105
+
106
+ def _text_matches(text: str, spec: SearchSpec) -> bool:
107
+ if spec.regex:
108
+ flags = 0 if spec.case_sensitive else re.IGNORECASE
109
+ return re.search(spec.query, text, flags) is not None
110
+
111
+ needle = spec.query if spec.case_sensitive else spec.query.casefold()
112
+ haystack = text if spec.case_sensitive else text.casefold()
113
+ return needle in haystack
114
+
115
+
116
+ def _search_texts(value: Any) -> Iterable[str]:
117
+ if isinstance(value, dict):
118
+ yield json.dumps(value, ensure_ascii=False, separators=(",", ":"))
119
+ for k, v in value.items():
120
+ yield str(k)
121
+ yield from _search_texts(v)
122
+ elif isinstance(value, list):
123
+ yield json.dumps(value, ensure_ascii=False, separators=(",", ":"))
124
+ for item in value:
125
+ yield from _search_texts(item)
126
+ else:
127
+ yield str(value)
128
+
129
+
130
+ def _path_values(obj: Any, path: str) -> Iterable[Any]:
131
+ values = [obj]
132
+ for token in _path_tokens(path):
133
+ next_values: list[Any] = []
134
+ for value in values:
135
+ next_values.extend(_descend(value, token))
136
+ values = next_values
137
+ if not values:
138
+ break
139
+
140
+ return values
141
+
142
+
143
+ def _path_tokens(path: str) -> list[str]:
144
+ tokens: list[str] = []
145
+ for part in path.split("."):
146
+ if not part:
147
+ continue
148
+ while part.endswith("[]"):
149
+ part = part[:-2]
150
+ if part:
151
+ tokens.append(part)
152
+ part = ""
153
+ tokens.append("*")
154
+ if part:
155
+ tokens.append(part)
156
+ return tokens
157
+
158
+
159
+ def _descend(value: Any, token: str) -> list[Any]:
160
+ if token == "*":
161
+ if isinstance(value, dict):
162
+ return list(value.values())
163
+ if isinstance(value, list):
164
+ return list(value)
165
+ return []
166
+
167
+ if isinstance(value, dict):
168
+ return [value[token]] if token in value else []
169
+
170
+ if isinstance(value, list) and token.isdigit():
171
+ i = int(token)
172
+ return [value[i]] if i < len(value) else []
173
+
174
+ return []
175
+
176
+
177
+ def _unquote(text: str) -> str:
178
+ if len(text) >= 2 and text[0] == text[-1] and text[0] in ("'", '"'):
179
+ return text[1:-1]
180
+ return text
@@ -0,0 +1,18 @@
1
+ {
2
+ "key-colors": [
3
+ "#f2d5cf",
4
+ "#eebebe",
5
+ "#f4b8e4",
6
+ "#ca9ee6",
7
+ "#e78284",
8
+ "#ea999c",
9
+ "#ef9f76",
10
+ "#e5c890",
11
+ "#a6d189",
12
+ "#81c8be",
13
+ "#99d1db",
14
+ "#85c1dc",
15
+ "#8caaee",
16
+ "#babbf1"
17
+ ]
18
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "key-colors": [
3
+ "#dc8a78",
4
+ "#dd7878",
5
+ "#ea76cb",
6
+ "#8839ef",
7
+ "#d20f39",
8
+ "#e64553",
9
+ "#fe640b",
10
+ "#df8e1d",
11
+ "#40a02b",
12
+ "#179299",
13
+ "#04a5e5",
14
+ "#209fb5",
15
+ "#1e66f5",
16
+ "#7287fd"
17
+ ]
18
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "key-colors": [
3
+ "#f4dbd6",
4
+ "#f0c6c6",
5
+ "#f5bde6",
6
+ "#c6a0f6",
7
+ "#ed8796",
8
+ "#ee99a0",
9
+ "#f5a97f",
10
+ "#eed49f",
11
+ "#a6da95",
12
+ "#8bd5ca",
13
+ "#91d7e3",
14
+ "#7dc4e4",
15
+ "#8aadf4",
16
+ "#b7bdf8"
17
+ ]
18
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "key-colors" : [
3
+ "#f5e0dc",
4
+ "#f2cdcd",
5
+ "#f5c2e7",
6
+ "#cba6f7",
7
+ "#f38ba8",
8
+ "#eba0ac",
9
+ "#fab387",
10
+ "#f9e2af",
11
+ "#a6e3a1",
12
+ "#94e2d5",
13
+ "#89dceb",
14
+ "#74c7ec",
15
+ "#89b4fa",
16
+ "#b4befe"
17
+ ]
18
+ }
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: jsonl-cli
3
+ Version: 0.1.2
4
+ Summary: CLI JSONL viewer
5
+ Requires-Python: >=3.9
6
+ License-File: LICENSE
7
+ Dynamic: license-file
@@ -0,0 +1,19 @@
1
+ jsonl_cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ jsonl_cli/__main__.py,sha256=0g3iknXOS9gZUcpL_trgAcuCJnZZKjdsT_xt61WOVb4,60
3
+ jsonl_cli/cli.py,sha256=Z6xG9m3WgYDl2GY5EgIiVQ5OttkijSI131Vpz1MU74c,8028
4
+ jsonl_cli/colors.py,sha256=i2MueCV4DeiN5YrHMrbE4DOmooEj-cJcU_2t5YmU8HI,2277
5
+ jsonl_cli/command.py,sha256=C2bCtP9cZUg60uTGyYwBCKSbysEUOqJ95haNhlOTFaU,2550
6
+ jsonl_cli/containers.py,sha256=pL4lvl3xL1NqROkSRJMAzyJCOOrV4FV1j4GbLO_dZF4,746
7
+ jsonl_cli/helpers.py,sha256=VEKxh67Jz7JdnEEiQho2i3EdbL9HHBUz_XJbisyv-Lk,4157
8
+ jsonl_cli/render.py,sha256=rLZ2gnPIuhuZ-gD8zawa6tFLMA0UC03ypw_LUvw0xx0,6115
9
+ jsonl_cli/search.py,sha256=5Nkg5MneYQ-Kg2WCjykNN6ulMkzZtVDjS-mj0qtTHhA,4830
10
+ jsonl_cli/themes/catppuccin-frappe.json,sha256=ku8kYf0HHvHvV3J2IDTzGei_QRmOu5tzRTS5iDeoNnU,295
11
+ jsonl_cli/themes/catppuccin-latte.json,sha256=04iVoT64y_w_EKgQGxKXOroXH2qWlIWnH5FiEtcGuuY,295
12
+ jsonl_cli/themes/catppuccin-macchiato.json,sha256=DPmG4EGNGguZs2AcQcIhm_8qhBljFdhhltG5XzV9Je0,295
13
+ jsonl_cli/themes/catppuccin-mocha.json,sha256=0CDmV9ij9fm7cfHgOEukH6cgUCo4bayPOybXjtQFBCU,295
14
+ jsonl_cli-0.1.2.dist-info/licenses/LICENSE,sha256=Y30pdXmLKUPz4ojQ9BJp9xeobyARUbVPXmzeESeRKM8,1070
15
+ jsonl_cli-0.1.2.dist-info/METADATA,sha256=c5RAzpLajzLkS4njdQMevQUReXVsTF-NvbN1lV94cyE,146
16
+ jsonl_cli-0.1.2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
17
+ jsonl_cli-0.1.2.dist-info/entry_points.txt,sha256=jIy5LWmaZEP1HHkZEzbwKYNGx9uyp8bCSPeUVCoEly4,45
18
+ jsonl_cli-0.1.2.dist-info/top_level.txt,sha256=bggxTPfSYVdpvhkOjw_PCnQGBFCdpDYbXA_xy4I37wo,10
19
+ jsonl_cli-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ jsonl = jsonl_cli.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Taehoon Hwang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ jsonl_cli