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 +0 -0
- jsonl_cli/__main__.py +4 -0
- jsonl_cli/cli.py +247 -0
- jsonl_cli/colors.py +84 -0
- jsonl_cli/command.py +99 -0
- jsonl_cli/containers.py +14 -0
- jsonl_cli/helpers.py +161 -0
- jsonl_cli/render.py +196 -0
- jsonl_cli/search.py +180 -0
- jsonl_cli/themes/catppuccin-frappe.json +18 -0
- jsonl_cli/themes/catppuccin-latte.json +18 -0
- jsonl_cli/themes/catppuccin-macchiato.json +18 -0
- jsonl_cli/themes/catppuccin-mocha.json +18 -0
- jsonl_cli-0.1.2.dist-info/METADATA +7 -0
- jsonl_cli-0.1.2.dist-info/RECORD +19 -0
- jsonl_cli-0.1.2.dist-info/WHEEL +5 -0
- jsonl_cli-0.1.2.dist-info/entry_points.txt +2 -0
- jsonl_cli-0.1.2.dist-info/licenses/LICENSE +21 -0
- jsonl_cli-0.1.2.dist-info/top_level.txt +1 -0
jsonl_cli/__init__.py
ADDED
|
File without changes
|
jsonl_cli/__main__.py
ADDED
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}"
|
jsonl_cli/containers.py
ADDED
|
@@ -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,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,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
|