csvpeek 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
csvpeek/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """csvpeek - A snappy CSV viewer TUI."""
2
+
3
+ __version__ = "0.3.0"
csvpeek/csvpeek.py ADDED
@@ -0,0 +1,613 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ csvpeek - A snappy, memory-efficient CSV viewer using DuckDB and Urwid.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import csv
9
+ import gc
10
+ import re
11
+ from pathlib import Path
12
+
13
+ import pyperclip
14
+ import urwid
15
+
16
+ from csvpeek.duck import DuckBackend
17
+ from csvpeek.filters import build_where_clause
18
+ from csvpeek.screen_buffer import ScreenBuffer
19
+ from csvpeek.selection_utils import (
20
+ Selection,
21
+ clear_selection_and_update,
22
+ create_selected_dataframe,
23
+ get_selection_dimensions,
24
+ get_single_cell_value,
25
+ )
26
+ from csvpeek.ui import (
27
+ ConfirmDialog,
28
+ FilenameDialog,
29
+ FilterDialog,
30
+ FlowColumns,
31
+ HelpDialog,
32
+ PagingListBox,
33
+ _truncate,
34
+ available_body_rows,
35
+ buffer_size,
36
+ build_header_row,
37
+ build_ui,
38
+ current_screen_width,
39
+ update_status,
40
+ visible_column_names,
41
+ )
42
+
43
+
44
+ class CSVViewerApp:
45
+ """Urwid-based CSV viewer with filtering, sorting, and selection."""
46
+
47
+ PAGE_SIZE = 50
48
+ BASE_PALETTE = [
49
+ ("header", "black", "light gray"),
50
+ ("status", "light gray", "dark gray"),
51
+ ("cell_selected", "black", "yellow"),
52
+ ("filter", "light red", "default"),
53
+ ("focus", "white", "dark blue"),
54
+ ]
55
+ DEFAULT_COLUMN_COLORS = [
56
+ "light cyan",
57
+ "light magenta",
58
+ "light green",
59
+ "yellow",
60
+ "light blue",
61
+ "light red",
62
+ ]
63
+
64
+ def __init__(
65
+ self,
66
+ csv_path: str,
67
+ *,
68
+ color_columns: bool = False,
69
+ column_colors: list[str] | None = None,
70
+ ) -> None:
71
+ self.csv_path = Path(csv_path)
72
+ self.db: DuckBackend | None = None
73
+ self.cached_rows: list[tuple] = []
74
+ self.column_names: list[str] = []
75
+
76
+ self.current_page = 0
77
+ self.total_rows = 0
78
+ self.total_filtered_rows = 0
79
+
80
+ self.current_filters: dict[str, str] = {}
81
+ self.filter_patterns: dict[str, tuple[str, bool]] = {}
82
+ self.filter_where: str = ""
83
+ self.filter_params: list = []
84
+ self.sorted_column: str | None = None
85
+ self.sorted_descending = False
86
+ self.column_widths: dict[str, int] = {}
87
+ self.col_offset = 0 # horizontal scroll offset (column index)
88
+ self.row_offset = 0 # vertical scroll offset (row index)
89
+ self.color_columns = color_columns or bool(column_colors)
90
+ self.column_colors = column_colors or []
91
+ self.column_color_attrs: list[str] = []
92
+ self.screen_buffer = ScreenBuffer(self._fetch_rows)
93
+
94
+ # Selection and cursor state
95
+ self.selection = Selection()
96
+ self.cursor_row = 0
97
+ self.cursor_col = 0
98
+ self.total_columns = 0
99
+
100
+ # UI state
101
+ self.loop: urwid.MainLoop | None = None
102
+ self.table_walker = urwid.SimpleFocusListWalker([])
103
+ self.table_header = urwid.Columns([])
104
+ self.listbox = PagingListBox(self, self.table_walker)
105
+ self.status_widget = urwid.Text("")
106
+ self.overlaying = False
107
+
108
+ # ------------------------------------------------------------------
109
+ # Data loading and preparation
110
+ # ------------------------------------------------------------------
111
+ def load_csv(self) -> None:
112
+ try:
113
+ self.db = DuckBackend(self.csv_path)
114
+ self.db.load()
115
+ self.column_names = list(self.db.column_names)
116
+ self.total_columns = len(self.column_names)
117
+ self.total_rows = self.db.total_rows
118
+ self.total_filtered_rows = self.total_rows
119
+ self.column_widths = self.db.column_widths()
120
+ self.screen_buffer.reset()
121
+ self.selection.clear()
122
+ except Exception as exc: # noqa: BLE001
123
+ raise SystemExit(f"Error loading CSV: {exc}") from exc
124
+
125
+ def _column_attr(self, col_idx: int) -> str | None:
126
+ if not self.color_columns or not self.column_color_attrs:
127
+ return None
128
+ if col_idx < len(self.column_color_attrs):
129
+ return self.column_color_attrs[col_idx]
130
+ return None
131
+
132
+ def _build_palette(self) -> list[tuple]:
133
+ palette = list(self.BASE_PALETTE)
134
+ if not self.color_columns:
135
+ self.column_color_attrs = []
136
+ return palette
137
+
138
+ self.column_color_attrs = []
139
+ colors = self.column_colors or self.DEFAULT_COLUMN_COLORS
140
+ if not colors:
141
+ return palette
142
+
143
+ for idx, _col in enumerate(self.column_names):
144
+ attr = f"col{idx}"
145
+ color = colors[idx % len(colors)]
146
+ palette.append((attr, color, "default"))
147
+ self.column_color_attrs.append(attr)
148
+ return palette
149
+
150
+ # ------------------------------------------------------------------
151
+ # UI construction
152
+ # ------------------------------------------------------------------
153
+ def build_ui(self) -> urwid.Widget:
154
+ return build_ui(self)
155
+
156
+ def _fetch_rows(self, start_row: int, fetch_size: int) -> list[tuple]:
157
+ if not self.db:
158
+ return []
159
+ return self.db.fetch_rows(
160
+ self.filter_where,
161
+ list(self.filter_params),
162
+ self.sorted_column,
163
+ self.sorted_descending,
164
+ fetch_size,
165
+ start_row,
166
+ )
167
+
168
+ # ------------------------------------------------------------------
169
+ # Rendering
170
+ # ------------------------------------------------------------------
171
+ def _refresh_rows(self) -> None:
172
+ if not self.db:
173
+ return
174
+ if not self.selection.active:
175
+ self.cached_rows = []
176
+ page_size = available_body_rows(self)
177
+ fetch_size = buffer_size(self)
178
+ rows, used_offset = self.screen_buffer.get_page_rows(
179
+ desired_start=self.row_offset,
180
+ page_size=page_size,
181
+ total_rows=self.total_filtered_rows,
182
+ fetch_size=fetch_size,
183
+ )
184
+ self.row_offset = used_offset
185
+ self.cached_rows = rows
186
+ gc.collect()
187
+ max_width = current_screen_width(self)
188
+ self.table_walker.clear()
189
+ # Clamp cursor within available data
190
+ self.cursor_row = min(self.cursor_row, max(0, len(self.cached_rows) - 1))
191
+ self.cursor_col = min(self.cursor_col, max(0, len(self.column_names) - 1))
192
+
193
+ visible_cols = visible_column_names(self, max_width)
194
+ vis_indices = [self.column_names.index(c) for c in visible_cols]
195
+
196
+ for row_idx, row in enumerate(self.cached_rows):
197
+ row_widget = self._build_row_widget(row_idx, row, vis_indices)
198
+ self.table_walker.append(row_widget)
199
+
200
+ if self.table_walker:
201
+ self.table_walker.set_focus(self.cursor_row)
202
+ self.table_header = build_header_row(self, max_width)
203
+ if self.loop:
204
+ frame_widget = self.loop.widget
205
+ if isinstance(frame_widget, urwid.Overlay):
206
+ frame_widget = frame_widget.bottom_w
207
+ if isinstance(frame_widget, urwid.Frame):
208
+ frame_widget.body.contents[0] = (
209
+ self.table_header,
210
+ frame_widget.body.options("pack"),
211
+ )
212
+ self._update_status()
213
+
214
+ def _build_row_widget(
215
+ self, row_idx: int, row: tuple, vis_indices: list[int]
216
+ ) -> urwid.Widget:
217
+ if not self.column_names:
218
+ return urwid.Text("")
219
+ cells = []
220
+ for col_idx in vis_indices:
221
+ col_name = self.column_names[col_idx]
222
+ width = self.column_widths.get(col_name, 12)
223
+ cell = row[col_idx]
224
+ is_selected = self._cell_selected(row_idx, col_idx)
225
+ filter_info = self.filter_patterns.get(col_name)
226
+ markup = self._cell_markup(str(cell or ""), width, filter_info, is_selected)
227
+ text = urwid.Text(markup, wrap="clip")
228
+ attr = self._column_attr(col_idx)
229
+ if attr:
230
+ text = urwid.AttrMap(text, attr)
231
+ cells.append((width, text))
232
+ return FlowColumns(cells, dividechars=1)
233
+
234
+ def _cell_selected(self, row_idx: int, col_idx: int) -> bool:
235
+ abs_row = self.row_offset + row_idx
236
+ if self.selection.active and self.selection.contains(
237
+ abs_row,
238
+ col_idx,
239
+ fallback_row=self.row_offset + self.cursor_row,
240
+ fallback_col=self.cursor_col,
241
+ ):
242
+ return True
243
+
244
+ return (
245
+ abs_row == self.row_offset + self.cursor_row and col_idx == self.cursor_col
246
+ )
247
+
248
+ def _cell_markup(
249
+ self,
250
+ cell_str: str,
251
+ width: int,
252
+ filter_info: tuple[str, bool] | None,
253
+ is_selected: bool,
254
+ ):
255
+ truncated = _truncate(cell_str, width)
256
+ if is_selected:
257
+ return [("cell_selected", truncated)]
258
+
259
+ if not filter_info:
260
+ return truncated
261
+
262
+ pattern, is_regex = filter_info
263
+ matches = []
264
+ if is_regex:
265
+ try:
266
+ for m in re.finditer(pattern, truncated, re.IGNORECASE):
267
+ matches.append((m.start(), m.end()))
268
+ except re.error:
269
+ matches = []
270
+ else:
271
+ lower_cell = truncated.lower()
272
+ lower_filter = pattern.lower()
273
+ start = 0
274
+ while True:
275
+ pos = lower_cell.find(lower_filter, start)
276
+ if pos == -1:
277
+ break
278
+ matches.append((pos, pos + len(lower_filter)))
279
+ start = pos + 1
280
+
281
+ if not matches:
282
+ return truncated
283
+
284
+ segments = []
285
+ last = 0
286
+ for start, end in matches:
287
+ if start > last:
288
+ segments.append(truncated[last:start])
289
+ segments.append(("filter", truncated[start:end]))
290
+ last = end
291
+ if last < len(truncated):
292
+ segments.append(truncated[last:])
293
+ return segments
294
+
295
+ # ------------------------------------------------------------------
296
+ # Interaction handlers
297
+ # ------------------------------------------------------------------
298
+ def handle_input(self, key: str) -> None:
299
+ if self.overlaying:
300
+ return
301
+ if key in ("q", "Q"):
302
+ self.confirm_quit()
303
+ return
304
+ if key in ("r", "R"):
305
+ self.reset_filters()
306
+ return
307
+ if key == "s":
308
+ self.sort_current_column()
309
+ return
310
+ if key in ("/",):
311
+ self.open_filter_dialog()
312
+ return
313
+ if key in ("ctrl d", "page down"):
314
+ self.next_page()
315
+ return
316
+ if key in ("ctrl u", "page up"):
317
+ self.prev_page()
318
+ return
319
+ if key in ("c", "C"):
320
+ self.copy_selection()
321
+ return
322
+ if key in ("w", "W"):
323
+ self.save_selection_dialog()
324
+ return
325
+ if key == "?":
326
+ self.open_help_dialog()
327
+ return
328
+ if key in (
329
+ "left",
330
+ "right",
331
+ "up",
332
+ "down",
333
+ "shift left",
334
+ "shift right",
335
+ "shift up",
336
+ "shift down",
337
+ ):
338
+ self.move_cursor(key)
339
+
340
+ def confirm_quit(self) -> None:
341
+ if self.loop is None:
342
+ raise urwid.ExitMainLoop()
343
+
344
+ def _yes() -> None:
345
+ raise urwid.ExitMainLoop()
346
+
347
+ def _no() -> None:
348
+ from csvpeek.ui import close_overlay
349
+
350
+ close_overlay(self)
351
+
352
+ dialog = ConfirmDialog("Quit csvpeek?", _yes, _no)
353
+ from csvpeek.ui import show_overlay
354
+
355
+ show_overlay(self, dialog, width=("relative", 35))
356
+
357
+ def move_cursor(self, key: str) -> None:
358
+ from csvpeek.ui import move_cursor
359
+
360
+ move_cursor(self, key)
361
+
362
+ def next_page(self) -> None:
363
+ page_size = available_body_rows(self)
364
+ max_start = max(0, self.total_filtered_rows - page_size)
365
+ if self.row_offset < max_start:
366
+ self.row_offset = min(self.row_offset + page_size, max_start)
367
+ self.cursor_row = 0
368
+ self._refresh_rows()
369
+
370
+ def prev_page(self) -> None:
371
+ if self.row_offset > 0:
372
+ self.row_offset = max(0, self.row_offset - available_body_rows(self))
373
+ self.cursor_row = 0
374
+ self._refresh_rows()
375
+
376
+ # ------------------------------------------------------------------
377
+ # Filtering and sorting
378
+ # ------------------------------------------------------------------
379
+ def open_filter_dialog(self) -> None:
380
+ if not self.column_names or self.loop is None:
381
+ return
382
+
383
+ def _on_submit(filters: dict[str, str]) -> None:
384
+ from csvpeek.ui import close_overlay
385
+
386
+ close_overlay(self)
387
+ self.apply_filters(filters)
388
+
389
+ def _on_cancel() -> None:
390
+ from csvpeek.ui import close_overlay
391
+
392
+ close_overlay(self)
393
+
394
+ dialog = FilterDialog(
395
+ list(self.column_names), self.current_filters.copy(), _on_submit, _on_cancel
396
+ )
397
+ from csvpeek.ui import show_overlay
398
+
399
+ show_overlay(self, dialog, height=("relative", 80))
400
+
401
+ def open_help_dialog(self) -> None:
402
+ if self.loop is None:
403
+ return
404
+
405
+ def _on_close() -> None:
406
+ from csvpeek.ui import close_overlay
407
+
408
+ close_overlay(self)
409
+
410
+ dialog = HelpDialog(_on_close)
411
+ # Use relative height to avoid urwid sizing warnings on box widgets
412
+ from csvpeek.ui import show_overlay
413
+
414
+ show_overlay(self, dialog, height=("relative", 80))
415
+
416
+ def apply_filters(self, filters: dict[str, str] | None = None) -> None:
417
+ if not self.db:
418
+ return
419
+ if filters is not None:
420
+ self.current_filters = filters
421
+ self.filter_patterns = {}
422
+ for col, val in filters.items():
423
+ cleaned = val.strip()
424
+ if not cleaned:
425
+ continue
426
+ if cleaned.startswith("/") and len(cleaned) > 1:
427
+ self.filter_patterns[col] = (cleaned[1:], True)
428
+ else:
429
+ self.filter_patterns[col] = (cleaned, False)
430
+
431
+ where, params = build_where_clause(self.current_filters, self.column_names)
432
+ self.filter_where = where
433
+ self.filter_params = params
434
+ self.total_filtered_rows = self.db.count_filtered(where, params)
435
+ self.current_page = 0
436
+ self.row_offset = 0
437
+ self.screen_buffer.reset()
438
+ self.selection.clear()
439
+ self.cursor_row = 0
440
+ self._refresh_rows()
441
+
442
+ def reset_filters(self) -> None:
443
+ self.current_filters = {}
444
+ self.filter_patterns = {}
445
+ self.sorted_column = None
446
+ self.sorted_descending = False
447
+ self.filter_where = ""
448
+ self.filter_params = []
449
+ self.current_page = 0
450
+ self.row_offset = 0
451
+ self.screen_buffer.reset()
452
+ self.selection.clear()
453
+ self.cursor_row = 0
454
+ self.total_filtered_rows = self.total_rows
455
+ self._refresh_rows()
456
+ self.notify("Filters cleared")
457
+
458
+ def sort_current_column(self) -> None:
459
+ if not self.column_names or not self.db:
460
+ return
461
+ col_name = self.column_names[self.cursor_col]
462
+ if self.sorted_column == col_name:
463
+ self.sorted_descending = not self.sorted_descending
464
+ else:
465
+ self.sorted_column = col_name
466
+ self.sorted_descending = False
467
+ self.current_page = 0
468
+ self.row_offset = 0
469
+ self.screen_buffer.reset()
470
+ self.selection.clear()
471
+ self.cursor_row = 0
472
+ self._refresh_rows()
473
+ direction = "descending" if self.sorted_descending else "ascending"
474
+ self.notify(f"Sorted by {col_name} ({direction})")
475
+
476
+ # ------------------------------------------------------------------
477
+ # Selection, copy, save
478
+ # ------------------------------------------------------------------
479
+ def copy_selection(self) -> None:
480
+ if not self.cached_rows:
481
+ return
482
+ if not self.selection.active:
483
+ cell_str = get_single_cell_value(self)
484
+ try:
485
+ pyperclip.copy(cell_str)
486
+ except Exception as _ex:
487
+ self.notify("Failed to copy cell")
488
+ return
489
+ self.notify("Cell copied")
490
+ return
491
+ selected_rows = create_selected_dataframe(self)
492
+ num_rows, num_cols = get_selection_dimensions(self)
493
+ _row_start, _row_end, col_start, col_end = get_selection_dimensions(
494
+ self, as_bounds=True
495
+ )
496
+ headers = self.column_names[col_start : col_end + 1]
497
+ from io import StringIO
498
+
499
+ buffer = StringIO()
500
+ writer = csv.writer(buffer)
501
+ writer.writerow(headers)
502
+ writer.writerows(selected_rows)
503
+ try:
504
+ pyperclip.copy(buffer.getvalue())
505
+ except Exception as _ex:
506
+ self.notify("Failed to copy selection")
507
+ return
508
+ clear_selection_and_update(self)
509
+ self.notify(f"Copied {num_rows}x{num_cols}")
510
+
511
+ def save_selection_dialog(self) -> None:
512
+ if not self.cached_rows or self.loop is None:
513
+ return
514
+
515
+ def _on_submit(filename: str) -> None:
516
+ if not filename:
517
+ self.notify("Filename required")
518
+ return
519
+ from csvpeek.ui import close_overlay
520
+
521
+ close_overlay(self)
522
+ self._save_to_file(filename)
523
+
524
+ def _on_cancel() -> None:
525
+ from csvpeek.ui import close_overlay
526
+
527
+ close_overlay(self)
528
+
529
+ dialog = FilenameDialog("Save as", _on_submit, _on_cancel)
530
+ from csvpeek.ui import show_overlay
531
+
532
+ show_overlay(self, dialog)
533
+
534
+ def _save_to_file(self, file_path: str) -> None:
535
+ if not self.cached_rows:
536
+ self.notify("No data to save")
537
+ return
538
+ target = Path(file_path)
539
+ if target.exists():
540
+ self.notify(f"File {target} exists")
541
+ return
542
+ try:
543
+ selected_rows = create_selected_dataframe(self)
544
+ num_rows, num_cols = get_selection_dimensions(self)
545
+ _row_start, _row_end, col_start, col_end = get_selection_dimensions(
546
+ self, as_bounds=True
547
+ )
548
+ headers = self.column_names[col_start : col_end + 1]
549
+ with target.open("w", newline="", encoding="utf-8") as f:
550
+ writer = csv.writer(f)
551
+ writer.writerow(headers)
552
+ writer.writerows(selected_rows)
553
+ clear_selection_and_update(self)
554
+ self.notify(f"Saved {num_rows}x{num_cols} to {target.name}")
555
+ except Exception as exc: # noqa: BLE001
556
+ self.notify(f"Error saving file: {exc}")
557
+
558
+ # ------------------------------------------------------------------
559
+ # Overlay helpers
560
+ # ------------------------------------------------------------------
561
+ def notify(self, message: str, duration: float = 2.0) -> None:
562
+ self.status_widget.set_text(message)
563
+ if self.loop:
564
+ self.loop.set_alarm_in(duration, lambda *_: self._update_status())
565
+
566
+ def _update_status(self, *_args) -> None: # noqa: ANN002, D401
567
+ update_status(self, *_args)
568
+
569
+ # ------------------------------------------------------------------
570
+ # Main entry
571
+ # ------------------------------------------------------------------
572
+ def run(self) -> None:
573
+ self.load_csv()
574
+ root = self.build_ui()
575
+ screen = urwid.raw_display.Screen()
576
+ palette = self._build_palette()
577
+ self.loop = urwid.MainLoop(
578
+ root,
579
+ palette=palette,
580
+ screen=screen,
581
+ handle_mouse=False,
582
+ unhandled_input=self.handle_input,
583
+ )
584
+ # Disable mouse reporting so terminal selection works
585
+ self.loop.screen.set_mouse_tracking(False)
586
+ self._refresh_rows()
587
+
588
+ try:
589
+ self.loop.run()
590
+ finally:
591
+ # Ensure terminal modes are restored even on errors/interrupts
592
+ try:
593
+ self.loop.screen.clear()
594
+ self.loop.screen.reset_default_terminal_colors()
595
+ except Exception:
596
+ pass
597
+
598
+
599
+ def main() -> None:
600
+ from csvpeek.main import parse_args
601
+
602
+ args, csv_path, colors = parse_args()
603
+
604
+ app = CSVViewerApp(
605
+ csv_path,
606
+ color_columns=args.color_columns or bool(colors),
607
+ column_colors=colors,
608
+ )
609
+ app.run()
610
+
611
+
612
+ if __name__ == "__main__":
613
+ main()
csvpeek/duck.py ADDED
@@ -0,0 +1,84 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import duckdb
6
+
7
+
8
+ class DuckBackend:
9
+ """DuckDB-backed data source for csvpeek."""
10
+
11
+ def __init__(self, csv_path: Path, table_name: str = "data") -> None:
12
+ self.csv_path = Path(csv_path)
13
+ self.table_name = table_name
14
+ self.con: duckdb.DuckDBPyConnection | None = None
15
+ self.column_names: list[str] = []
16
+ self.total_rows: int = 0
17
+
18
+ def load(self) -> None:
19
+ """Load the CSV into an in-memory DuckDB table and read schema/row count."""
20
+ self.con = duckdb.connect(database=":memory:")
21
+ self.con.execute(
22
+ f"""
23
+ CREATE TABLE {self.table_name} AS
24
+ SELECT * FROM read_csv_auto(?, ALL_VARCHAR=TRUE)
25
+ """,
26
+ [str(self.csv_path)],
27
+ )
28
+ info = self.con.execute(f"PRAGMA table_info('{self.table_name}')").fetchall()
29
+ self.column_names = [row[1] for row in info]
30
+ self.total_rows = self.con.execute(
31
+ f"SELECT count(*) FROM {self.table_name}"
32
+ ).fetchone()[0] # type: ignore
33
+
34
+ def quote_ident(self, name: str) -> str:
35
+ escaped = name.replace('"', '""')
36
+ return f'"{escaped}"'
37
+
38
+ def column_widths(self) -> dict[str, int]:
39
+ if not self.con or not self.column_names:
40
+ return {}
41
+ selects = [
42
+ f"max(length({self.quote_ident(col)})) AS len_{idx}"
43
+ for idx, col in enumerate(self.column_names)
44
+ ]
45
+ query = f"SELECT {', '.join(selects)} FROM {self.table_name}"
46
+ lengths = self.con.execute(query).fetchone()
47
+ if lengths is None:
48
+ lengths = [0] * len(self.column_names)
49
+
50
+ widths: dict[str, int] = {}
51
+ for idx, col in enumerate(self.column_names):
52
+ header_len = len(col) + 2
53
+ data_len = lengths[idx] or 0 # length() returns None if column is empty
54
+ max_len = max(header_len, int(data_len))
55
+ width = max(8, min(max_len, 40))
56
+ widths[col] = width
57
+ return widths
58
+
59
+ def _order_clause(self, sorted_column: str | None, sorted_descending: bool) -> str:
60
+ if not sorted_column:
61
+ return ""
62
+ direction = "DESC" if sorted_descending else "ASC"
63
+ return f" ORDER BY {self.quote_ident(sorted_column)} {direction}"
64
+
65
+ def count_filtered(self, where: str, params: list) -> int:
66
+ if not self.con:
67
+ return 0
68
+ count_query = f"SELECT count(*) FROM {self.table_name}{where}"
69
+ return self.con.execute(count_query, params).fetchone()[0] # type: ignore
70
+
71
+ def fetch_rows(
72
+ self,
73
+ where: str,
74
+ params: list,
75
+ sorted_column: str | None,
76
+ sorted_descending: bool,
77
+ limit: int,
78
+ offset: int,
79
+ ) -> list[tuple]:
80
+ if not self.con:
81
+ return []
82
+ order_clause = self._order_clause(sorted_column, sorted_descending)
83
+ query = f"SELECT * FROM {self.table_name}{where}{order_clause} LIMIT ? OFFSET ?"
84
+ return self.con.execute(query, params + [limit, offset]).fetchall()