jsonl-viewer 0.0.1__tar.gz

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.
@@ -0,0 +1,90 @@
1
+ Metadata-Version: 2.3
2
+ Name: jsonl-viewer
3
+ Version: 0.0.1
4
+ Summary: A terminal viewer for JSONL files
5
+ Author: Adam Fourney
6
+ Author-email: Adam Fourney <adam.fourney@gmail.com>
7
+ Requires-Dist: textual>=8.0.0
8
+ Requires-Python: >=3.12
9
+ Project-URL: Homepage, https://github.com/afourney/jlv
10
+ Project-URL: Repository, https://github.com/afourney/jlv
11
+ Project-URL: Issues, https://github.com/afourney/jlv/issues
12
+ Description-Content-Type: text/markdown
13
+
14
+ # jlv
15
+
16
+ A terminal viewer for JSONL files (one JSON value per line).
17
+
18
+ Install the `jsonl-viewer` package; the command is still `jlv`:
19
+
20
+ ```bash
21
+ uv tool install jsonl-viewer
22
+ jlv your-file.jsonl
23
+ ```
24
+
25
+ Alternatively, use `pip install jsonl-viewer`. From a source checkout:
26
+
27
+ ```bash
28
+ uv run jlv your-file.jsonl
29
+ ```
30
+
31
+ Requires Python 3.12+ and Textual 8+. The left panel lists numbered records;
32
+ the right panel shows the selected record as indented JSON. Select a string
33
+ to read its decoded contents in a scrollable, word-wrapped inspector.
34
+
35
+ ## Screenshots
36
+
37
+ Browse records alongside their formatted JSON:
38
+
39
+ ![JSONL records in the left panel and the selected record's formatted JSON in the right panel](https://raw.githubusercontent.com/afourney/jlv/main/docs/imgs/jlv_main_ui.png)
40
+
41
+ Open a string to read its decoded, word-wrapped contents:
42
+
43
+ ![String inspector showing decoded multi-paragraph text in a scrollable window](https://raw.githubusercontent.com/afourney/jlv/main/docs/imgs/jlv_string_window.png)
44
+
45
+ ## Controls
46
+
47
+ | Key / action | Effect |
48
+ | --- | --- |
49
+ | Up / Down | Select the previous / next record or JSON line |
50
+ | Page Up / Page Down | Move by a page |
51
+ | Home / End | Move to the first / last row |
52
+ | Tab / Shift+Tab | Switch panels |
53
+ | Left / Right | Scroll horizontally |
54
+ | Enter or click | Inspect a selected string |
55
+ | Mouse wheel / scrollbars | Scroll the panel under the pointer |
56
+ | Escape | Close the string inspector |
57
+ | q | Quit, including from the inspector |
58
+
59
+ The inspector is read-only and supports text selection and Ctrl+C to copy.
60
+ The main panels also support text selection. The left-hand preview is capped
61
+ at 1,000 characters, but the right-hand document and decoded strings are not
62
+ truncated.
63
+
64
+ ## Large files
65
+
66
+ Both main panels render only visible rows, rather than creating a widget for
67
+ every record or JSON field. The loader validates the entire file in one buffered
68
+ pass and builds a compact byte-offset index. Records are read and parsed on
69
+ demand; small bounded caches retain recent previews and formatted documents.
70
+ The string inspector also renders a viewport, and defers wrapping until its
71
+ actual display width is known.
72
+
73
+ Validation still happens before the viewer opens: malformed JSON, invalid UTF-8,
74
+ and interior blank lines are reported with a line number. Trailing blank lines
75
+ are permitted. This is a read-only viewer, not a live file follower; do not edit
76
+ the input file while viewing it.
77
+
78
+ ## Sample file
79
+
80
+ The repository includes `stress-test.jsonl.gz`, a compressed synthetic dataset
81
+ with 30,000 records. Extract it before opening it:
82
+
83
+ ```bash
84
+ gzip -dk stress-test.jsonl.gz
85
+ uv run jlv stress-test.jsonl
86
+ ```
87
+
88
+ The extracted file is 300 MB and is Git-ignored. Records range from approximately
89
+ 1-100 KB and contain nested structures and strings of varying lengths. Some
90
+ strings intentionally end mid-sentence to fit the generated record sizes.
@@ -0,0 +1,77 @@
1
+ # jlv
2
+
3
+ A terminal viewer for JSONL files (one JSON value per line).
4
+
5
+ Install the `jsonl-viewer` package; the command is still `jlv`:
6
+
7
+ ```bash
8
+ uv tool install jsonl-viewer
9
+ jlv your-file.jsonl
10
+ ```
11
+
12
+ Alternatively, use `pip install jsonl-viewer`. From a source checkout:
13
+
14
+ ```bash
15
+ uv run jlv your-file.jsonl
16
+ ```
17
+
18
+ Requires Python 3.12+ and Textual 8+. The left panel lists numbered records;
19
+ the right panel shows the selected record as indented JSON. Select a string
20
+ to read its decoded contents in a scrollable, word-wrapped inspector.
21
+
22
+ ## Screenshots
23
+
24
+ Browse records alongside their formatted JSON:
25
+
26
+ ![JSONL records in the left panel and the selected record's formatted JSON in the right panel](https://raw.githubusercontent.com/afourney/jlv/main/docs/imgs/jlv_main_ui.png)
27
+
28
+ Open a string to read its decoded, word-wrapped contents:
29
+
30
+ ![String inspector showing decoded multi-paragraph text in a scrollable window](https://raw.githubusercontent.com/afourney/jlv/main/docs/imgs/jlv_string_window.png)
31
+
32
+ ## Controls
33
+
34
+ | Key / action | Effect |
35
+ | --- | --- |
36
+ | Up / Down | Select the previous / next record or JSON line |
37
+ | Page Up / Page Down | Move by a page |
38
+ | Home / End | Move to the first / last row |
39
+ | Tab / Shift+Tab | Switch panels |
40
+ | Left / Right | Scroll horizontally |
41
+ | Enter or click | Inspect a selected string |
42
+ | Mouse wheel / scrollbars | Scroll the panel under the pointer |
43
+ | Escape | Close the string inspector |
44
+ | q | Quit, including from the inspector |
45
+
46
+ The inspector is read-only and supports text selection and Ctrl+C to copy.
47
+ The main panels also support text selection. The left-hand preview is capped
48
+ at 1,000 characters, but the right-hand document and decoded strings are not
49
+ truncated.
50
+
51
+ ## Large files
52
+
53
+ Both main panels render only visible rows, rather than creating a widget for
54
+ every record or JSON field. The loader validates the entire file in one buffered
55
+ pass and builds a compact byte-offset index. Records are read and parsed on
56
+ demand; small bounded caches retain recent previews and formatted documents.
57
+ The string inspector also renders a viewport, and defers wrapping until its
58
+ actual display width is known.
59
+
60
+ Validation still happens before the viewer opens: malformed JSON, invalid UTF-8,
61
+ and interior blank lines are reported with a line number. Trailing blank lines
62
+ are permitted. This is a read-only viewer, not a live file follower; do not edit
63
+ the input file while viewing it.
64
+
65
+ ## Sample file
66
+
67
+ The repository includes `stress-test.jsonl.gz`, a compressed synthetic dataset
68
+ with 30,000 records. Extract it before opening it:
69
+
70
+ ```bash
71
+ gzip -dk stress-test.jsonl.gz
72
+ uv run jlv stress-test.jsonl
73
+ ```
74
+
75
+ The extracted file is 300 MB and is Git-ignored. Records range from approximately
76
+ 1-100 KB and contain nested structures and strings of varying lengths. Some
77
+ strings intentionally end mid-sentence to fit the generated record sizes.
@@ -0,0 +1,26 @@
1
+ [project]
2
+ name = "jsonl-viewer"
3
+ version = "0.0.1"
4
+ description = "A terminal viewer for JSONL files"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ dependencies = ["textual>=8.0.0"]
8
+
9
+ [[project.authors]]
10
+ name = "Adam Fourney"
11
+ email = "adam.fourney@gmail.com"
12
+
13
+ [project.scripts]
14
+ jlv = "jlv:main"
15
+
16
+ [project.urls]
17
+ Homepage = "https://github.com/afourney/jlv"
18
+ Repository = "https://github.com/afourney/jlv"
19
+ Issues = "https://github.com/afourney/jlv/issues"
20
+
21
+ [build-system]
22
+ requires = ["uv_build>=0.10.2,<0.11.0"]
23
+ build-backend = "uv_build"
24
+
25
+ [tool.uv.build-backend]
26
+ module-name = "jlv"
@@ -0,0 +1,25 @@
1
+ [project]
2
+ name = "jsonl-viewer"
3
+ version = "0.0.1"
4
+ description = "A terminal viewer for JSONL files"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Adam Fourney", email = "adam.fourney@gmail.com" }
8
+ ]
9
+ requires-python = ">=3.12"
10
+ dependencies = ["textual>=8.0.0"]
11
+
12
+ [project.scripts]
13
+ jlv = "jlv:main"
14
+
15
+ [project.urls]
16
+ Homepage = "https://github.com/afourney/jlv"
17
+ Repository = "https://github.com/afourney/jlv"
18
+ Issues = "https://github.com/afourney/jlv/issues"
19
+
20
+ [build-system]
21
+ requires = ["uv_build>=0.10.2,<0.11.0"]
22
+ build-backend = "uv_build"
23
+
24
+ [tool.uv.build-backend]
25
+ module-name = "jlv"
@@ -0,0 +1,200 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ from collections import OrderedDict
6
+ from dataclasses import dataclass
7
+
8
+ from rich.cells import cell_len
9
+ from textual.app import App, ComposeResult
10
+ from textual import events
11
+ from textual.binding import Binding
12
+ from textual.containers import Horizontal
13
+ from textual.screen import ModalScreen
14
+ from textual.widgets import TextArea
15
+
16
+ from jlv.rows import RowView
17
+ from jlv.source import JsonlFile
18
+
19
+
20
+ @dataclass
21
+ class PrettyDocument:
22
+ lines: list[tuple[str, str | None]]
23
+ strings: dict[int, str]
24
+ width: int
25
+
26
+ @classmethod
27
+ def from_value(cls, value: object) -> PrettyDocument:
28
+ lines: list[tuple[str, str | None]] = []
29
+ strings: dict[int, str] = {}
30
+
31
+ def append(value: object, depth: int, prefix: str = "", suffix: str = "") -> None:
32
+ indent = " " * depth
33
+ if isinstance(value, (dict, list)) and value:
34
+ opening, closing = ("{", "}") if isinstance(value, dict) else ("[", "]")
35
+ lines.append((indent + prefix + opening, None))
36
+ entries = value.items() if isinstance(value, dict) else enumerate(value)
37
+ for i, (key, child) in enumerate(entries):
38
+ child_prefix = json.dumps(key, ensure_ascii=False) + ": " if isinstance(value, dict) else ""
39
+ append(child, depth + 1, child_prefix, "," if i < len(value) - 1 else "")
40
+ lines.append((indent + closing + suffix, None))
41
+ else:
42
+ kind = None
43
+ if isinstance(value, str):
44
+ kind = "string"
45
+ strings[len(lines)] = value
46
+ elif value is None:
47
+ kind = "null"
48
+ elif isinstance(value, bool):
49
+ kind = "bool"
50
+ elif isinstance(value, (int, float)):
51
+ kind = "number"
52
+ lines.append((indent + prefix + json.dumps(value, ensure_ascii=False) + suffix, kind))
53
+
54
+ append(value, 0)
55
+ return cls(
56
+ lines, strings,
57
+ max(
58
+ len(line) if line.isascii() and "\x7f" not in line else cell_len(line)
59
+ for line, _ in lines
60
+ ),
61
+ )
62
+
63
+
64
+ class StringView(TextArea):
65
+ def __init__(self, value: str) -> None:
66
+ self._pending_value: str | None = value
67
+ super().__init__(
68
+ read_only=True,
69
+ show_cursor=False,
70
+ highlight_cursor_line=False,
71
+ soft_wrap=True,
72
+ id="modal-container",
73
+ )
74
+
75
+ def on_resize(self, event: events.Resize) -> None:
76
+ if self._pending_value is not None and self.content_size.width:
77
+ # Wrap once at the actual viewport width, not at each startup size.
78
+ value, self._pending_value = self._pending_value, None
79
+ self.load_text(value)
80
+ event.prevent_default()
81
+
82
+
83
+ class StringModal(ModalScreen[None]):
84
+ """A read-only, wrapped document with viewport-based rendering."""
85
+
86
+ BINDINGS = [
87
+ Binding("escape", "dismiss", "Close"),
88
+ Binding("q", "app.quit", "Quit"),
89
+ ]
90
+ DEFAULT_CSS = """
91
+ StringModal {
92
+ align: center middle;
93
+ }
94
+ StringModal #modal-container {
95
+ width: 90%;
96
+ height: 90%;
97
+ border: thick $accent;
98
+ background: $surface;
99
+ padding: 1 2;
100
+ overflow-y: scroll;
101
+ scrollbar-size: 1 1;
102
+ }
103
+ """
104
+
105
+ def __init__(self, value: str) -> None:
106
+ super().__init__()
107
+ self.value = value
108
+
109
+ def compose(self) -> ComposeResult:
110
+ yield StringView(self.value)
111
+
112
+ def action_dismiss(self) -> None:
113
+ self.app.pop_screen()
114
+
115
+
116
+ class JlvApp(App):
117
+ """JSONL Log Viewer."""
118
+
119
+ TITLE = "jlv"
120
+ BINDINGS = [
121
+ Binding("q", "quit", "Quit"),
122
+ Binding("tab", "focus_next", "Switch Panel"),
123
+ Binding("shift+tab", "focus_previous", "Switch Panel"),
124
+ ]
125
+ DEFAULT_CSS = """
126
+ Horizontal {
127
+ height: 1fr;
128
+ }
129
+ #lines-list {
130
+ width: 2fr;
131
+ }
132
+ #detail-list {
133
+ width: 3fr;
134
+ border-left: solid $accent;
135
+ }
136
+ """
137
+
138
+ def __init__(self, source: JsonlFile) -> None:
139
+ super().__init__()
140
+ self.source = source
141
+ self._documents: OrderedDict[int, PrettyDocument] = OrderedDict()
142
+ self._document: PrettyDocument | None = None
143
+ self._detail_idx: int | None = None
144
+ self._pretty_lines: list[tuple[str, str | None]] = []
145
+
146
+ def compose(self) -> ComposeResult:
147
+ with Horizontal():
148
+ yield RowView(id="lines-list")
149
+ yield RowView(id="detail-list")
150
+
151
+ def on_mount(self) -> None:
152
+ left = self.query_one("#lines-list", RowView)
153
+ left.set_rows(len(self.source), self.source.preview_width, self.source.preview)
154
+ left.focus()
155
+ self._update_detail(0)
156
+
157
+ def on_row_view_highlighted(self, event: RowView.Highlighted) -> None:
158
+ if event.view.id == "lines-list":
159
+ # Ignore queued highlights superseded by newer input in the same frame.
160
+ if event.index == event.view.index:
161
+ self._update_detail(event.index)
162
+
163
+ def on_row_view_selected(self, event: RowView.Selected) -> None:
164
+ if event.view.id == "detail-list" and self._document is not None:
165
+ value = self._document.strings.get(event.index)
166
+ if value is not None:
167
+ self.push_screen(StringModal(value))
168
+ return
169
+ self.bell()
170
+
171
+ def _update_detail(self, index: int) -> None:
172
+ if index == self._detail_idx:
173
+ return
174
+ if index in self._documents:
175
+ document = self._documents[index]
176
+ self._documents.move_to_end(index)
177
+ else:
178
+ document = PrettyDocument.from_value(self.source.value(index))
179
+ self._documents[index] = document
180
+ if len(self._documents) > 8:
181
+ self._documents.popitem(last=False)
182
+ self._document = document
183
+ self._detail_idx = index
184
+ self._pretty_lines = document.lines
185
+ self.query_one("#detail-list", RowView).set_rows(
186
+ len(document.lines), document.width, lambda row: document.lines[row][0]
187
+ )
188
+
189
+
190
+ def main() -> None:
191
+ if len(sys.argv) < 2:
192
+ print("Usage: jlv <file.jsonl>", file=sys.stderr)
193
+ sys.exit(1)
194
+ try:
195
+ source = JsonlFile(sys.argv[1])
196
+ except (OSError, ValueError) as error:
197
+ print(f"Error: {error}", file=sys.stderr)
198
+ sys.exit(1)
199
+ with source:
200
+ JlvApp(source).run()
@@ -0,0 +1,192 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import OrderedDict
4
+ from collections.abc import Callable
5
+
6
+ from rich.cells import cell_len
7
+ from rich.segment import Segment
8
+ from textual import events
9
+ from textual.binding import Binding
10
+ from textual.geometry import Offset, Region, Size
11
+ from textual.message import Message
12
+ from textual.reactive import reactive
13
+ from textual.scroll_view import ScrollView
14
+ from textual.selection import Selection
15
+ from textual.strip import Strip
16
+
17
+
18
+ class RowView(ScrollView, can_focus=True):
19
+ """Fixed-height selectable rows, rendered only for the visible viewport."""
20
+
21
+ BINDINGS = [
22
+ Binding("up", "cursor_up", show=False),
23
+ Binding("down", "cursor_down", show=False),
24
+ Binding("pageup", "cursor_page_up", show=False),
25
+ Binding("pagedown", "cursor_page_down", show=False),
26
+ Binding("home", "cursor_home", show=False),
27
+ Binding("end", "cursor_end", show=False),
28
+ Binding("enter", "select", show=False),
29
+ ]
30
+
31
+ DEFAULT_CSS = """
32
+ RowView {
33
+ height: 1fr;
34
+ scrollbar-size: 1 1;
35
+ }
36
+ RowView > .rowview--highlight {
37
+ background: $boost;
38
+ }
39
+ RowView:focus > .rowview--highlight {
40
+ background: $accent;
41
+ color: $text;
42
+ }
43
+ """
44
+ COMPONENT_CLASSES = {"rowview--highlight"}
45
+ index: reactive[int] = reactive(0, init=False)
46
+
47
+ class Highlighted(Message):
48
+ def __init__(self, view: RowView, index: int) -> None:
49
+ self.view = view
50
+ self.index = index
51
+ super().__init__()
52
+
53
+ class Selected(Message):
54
+ def __init__(self, view: RowView, index: int) -> None:
55
+ self.view = view
56
+ self.index = index
57
+ super().__init__()
58
+
59
+ def __init__(self, *, id: str) -> None:
60
+ super().__init__(id=id)
61
+ self.row_count = 0
62
+ self._line: Callable[[int], str] = lambda index: ""
63
+ self._unicode_cache: OrderedDict[tuple[int, int, int], tuple[Strip, int]] = OrderedDict()
64
+
65
+ def set_rows(self, count: int, width: int, line: Callable[[int], str]) -> None:
66
+ if self.is_mounted and self.text_selection is not None:
67
+ self.screen.selections = {
68
+ widget: selection for widget, selection in self.screen.selections.items()
69
+ if widget is not self
70
+ }
71
+ self.row_count = count
72
+ self._line = line
73
+ self._unicode_cache.clear()
74
+ self.virtual_size = Size(width, count)
75
+ self.index = 0
76
+ self.scroll_to(x=0, y=0, animate=False, force=True)
77
+ self.refresh()
78
+
79
+ def validate_index(self, index: int) -> int:
80
+ return max(0, min(index, self.row_count - 1))
81
+
82
+ def watch_index(self, old: int, new: int) -> None:
83
+ self.refresh_line(old)
84
+ self.refresh_line(new)
85
+ self.scroll_to_region(
86
+ Region(0, new, 1, 1), animate=False, x_axis=False, force=True
87
+ )
88
+ self.post_message(self.Highlighted(self, new))
89
+
90
+ def render_line(self, y: int) -> Strip:
91
+ x, offset = self.scroll_offset
92
+ row = y + offset
93
+ width = self.size.width
94
+ style = self.rich_style
95
+ if row >= self.row_count:
96
+ return Strip.blank(width, style)
97
+ if row == self.index:
98
+ style += self.get_component_rich_style("rowview--highlight")
99
+ text = self._line(row)
100
+ single_cell = text.isascii() and "\x7f" not in text
101
+ # Avoid constructing/cropping a giant Rich segment for long ASCII strings.
102
+ if single_cell:
103
+ visible = text[x:x + width].ljust(width)
104
+ strip = Strip([Segment(visible)], width)
105
+ character_offset = x
106
+ else:
107
+ key = (row, x, width)
108
+ if key not in self._unicode_cache:
109
+ strip = Strip([Segment(text)], cell_len(text))
110
+ _, remainder = Segment(text).split_cells(x)
111
+ character_offset = len(text) - len(remainder.text)
112
+ self._unicode_cache[key] = (
113
+ strip.crop_extend(x, x + width, None), character_offset
114
+ )
115
+ if len(self._unicode_cache) > 256:
116
+ self._unicode_cache.popitem(last=False)
117
+ self._unicode_cache.move_to_end(key)
118
+ strip, character_offset = self._unicode_cache[key]
119
+ strip = strip.apply_style(style)
120
+ if self.text_selection is not None:
121
+ span = self.text_selection.get_span(row)
122
+ if span is not None:
123
+ start, end = span
124
+ start = min(start, len(text)) if single_cell else cell_len(text[:start])
125
+ end = len(text) if end == -1 else end
126
+ end = min(end, len(text)) if single_cell else cell_len(text[:end])
127
+ start, end = max(0, start - x), min(width, end - x)
128
+ if start < end:
129
+ before, selected, after = strip.divide([start, end, width])
130
+ strip = Strip.join([
131
+ before,
132
+ selected.apply_style(self.screen.get_component_rich_style("screen--selection")),
133
+ after,
134
+ ])
135
+ return strip.apply_offsets(character_offset, row)
136
+
137
+ def get_selection(self, selection: Selection) -> tuple[str, str]:
138
+ start = selection.start or Offset(0, 0)
139
+ last = min(
140
+ self.row_count - 1,
141
+ selection.end.y if selection.end is not None else self.row_count - 1,
142
+ )
143
+ if last < start.y:
144
+ return "", "\n"
145
+ lines = [self._line(row) for row in range(start.y, last + 1)]
146
+ end_x = selection.end.x if selection.end is not None else len(lines[-1])
147
+ relative = Selection(Offset(start.x, 0), Offset(end_x, len(lines) - 1))
148
+ return relative.extract("\n".join(lines)), "\n"
149
+
150
+ def selection_updated(self, selection: Selection | None) -> None:
151
+ self.refresh()
152
+
153
+ def on_focus(self) -> None:
154
+ self.refresh_line(self.index)
155
+
156
+ def on_blur(self) -> None:
157
+ self.refresh_line(self.index)
158
+
159
+ def on_click(self, event: events.Click) -> None:
160
+ if event.widget is not self:
161
+ return
162
+ offset = event.get_content_offset(self)
163
+ if offset is None:
164
+ return
165
+ row = offset.y + self.scroll_offset.y
166
+ if 0 <= row < self.row_count:
167
+ self.focus()
168
+ self.index = row
169
+ self.post_message(self.Selected(self, row))
170
+ event.stop()
171
+
172
+ def action_cursor_up(self) -> None:
173
+ self.index -= 1
174
+
175
+ def action_cursor_down(self) -> None:
176
+ self.index += 1
177
+
178
+ def action_cursor_page_up(self) -> None:
179
+ self.index -= max(1, self.scrollable_content_region.height)
180
+
181
+ def action_cursor_page_down(self) -> None:
182
+ self.index += max(1, self.scrollable_content_region.height)
183
+
184
+ def action_cursor_home(self) -> None:
185
+ self.index = 0
186
+
187
+ def action_cursor_end(self) -> None:
188
+ self.index = self.row_count - 1
189
+
190
+ def action_select(self) -> None:
191
+ if self.row_count:
192
+ self.post_message(self.Selected(self, self.index))
@@ -0,0 +1,110 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import shutil
6
+ import stat
7
+ import tempfile
8
+ from array import array
9
+ from collections import OrderedDict
10
+ from collections.abc import Iterator
11
+ from pathlib import Path
12
+ from typing import BinaryIO
13
+
14
+ from rich.cells import cell_len
15
+
16
+
17
+ class JsonlFile:
18
+ """Validated, indexed JSONL without retaining every decoded record in memory."""
19
+
20
+ def __init__(self, path: str | Path) -> None:
21
+ self._file: BinaryIO = open(path, "rb", buffering=1024 * 1024)
22
+ self._previews: OrderedDict[int, str] = OrderedDict()
23
+ self.offsets = array("Q", [0])
24
+ self.preview_width = 0
25
+ try:
26
+ if not stat.S_ISREG(os.fstat(self._file.fileno()).st_mode):
27
+ with self._file as stream:
28
+ self._file = tempfile.TemporaryFile("w+b")
29
+ shutil.copyfileobj(stream, self._file, length=1024 * 1024)
30
+ self._file.seek(0)
31
+ first_blank: int | None = None
32
+ offset = 0
33
+ decoder = json.JSONDecoder()
34
+ for line_number, raw in enumerate(self._lines(), 1):
35
+ offset += len(raw)
36
+ try:
37
+ text = raw.decode("utf-8").strip()
38
+ except UnicodeDecodeError as error:
39
+ raise ValueError(f"line {line_number} is not valid UTF-8: {error}") from error
40
+ if not text:
41
+ if first_blank is None:
42
+ first_blank = line_number
43
+ continue
44
+ if first_blank is not None:
45
+ raise ValueError(f"line {first_blank} is empty — not valid JSONL")
46
+ try:
47
+ decoder.decode(text)
48
+ except (ValueError, RecursionError) as error:
49
+ raise ValueError(f"line {line_number} is not valid JSON: {error}") from error
50
+ self.offsets.append(offset)
51
+ preview = self._preview_text(text, line_number)
52
+ width = len(preview) if preview.isascii() and "\x7f" not in preview else cell_len(preview)
53
+ self.preview_width = max(self.preview_width, width)
54
+ if not len(self):
55
+ raise ValueError("file is empty")
56
+ except BaseException:
57
+ self.close()
58
+ raise
59
+
60
+ @staticmethod
61
+ def _preview_text(text: str, line_number: int) -> str:
62
+ if len(text) > 1000:
63
+ text = text[:1000] + "..."
64
+ preview = f"{line_number:>5} {text}"
65
+ return preview.expandtabs(4) if "\t" in preview else preview
66
+
67
+ def _lines(self) -> Iterator[bytes]:
68
+ for raw in self._file:
69
+ if b"\r" in raw:
70
+ yield from raw.splitlines(keepends=True)
71
+ else:
72
+ yield raw
73
+
74
+ def __len__(self) -> int:
75
+ return len(self.offsets) - 1
76
+
77
+ def raw(self, index: int) -> str:
78
+ if not 0 <= index < len(self):
79
+ raise IndexError(index)
80
+ if self._file.closed:
81
+ raise ValueError("file is closed")
82
+ start, end = self.offsets[index:index + 2]
83
+ self._file.seek(start)
84
+ raw = self._file.read(end - start)
85
+ if len(raw) != end - start:
86
+ raise ValueError("file was truncated while viewing; reopen it")
87
+ return raw.decode("utf-8")
88
+
89
+ def value(self, index: int) -> object:
90
+ return json.loads(self.raw(index).strip())
91
+
92
+ def preview(self, index: int) -> str:
93
+ if index in self._previews:
94
+ self._previews.move_to_end(index)
95
+ return self._previews[index]
96
+ text = self._preview_text(self.raw(index).strip(), index + 1)
97
+ self._previews[index] = text
98
+ if len(self._previews) > 256:
99
+ self._previews.popitem(last=False)
100
+ return text
101
+
102
+ def close(self) -> None:
103
+ self._file.close()
104
+ self._previews.clear()
105
+
106
+ def __enter__(self) -> JsonlFile:
107
+ return self
108
+
109
+ def __exit__(self, *args: object) -> None:
110
+ self.close()