Skip to content

Commit 7705ea8

Browse files
committed
Add timestamp conversion
1 parent dd80fec commit 7705ea8

File tree

3 files changed

+67
-4
lines changed

3 files changed

+67
-4
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ classifiers = [
1212
]
1313
dependencies = [
1414
"click",
15+
"tzlocal",
1516
"textual == 0.83.0",
1617
"polars >= 1.0,<2.0",
1718
]

src/dt_browser/browser.py

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import datetime
12
import gc
23
import pathlib
34
import time
45
from typing import ClassVar
56

67
import click
78
import polars as pl
9+
import tzlocal
810
from rich.spinner import Spinner
911
from rich.style import Style
1012
from textual import on, work
@@ -33,6 +35,9 @@
3335

3436
_SHOW_COLUMNS_ID = "showColumns"
3537
_COLOR_COLUMNS_ID = "colorColumns"
38+
_TS_COLUMNS_ID = "tsColumns"
39+
40+
_TIMEZONE = str(tzlocal.get_localzone())
3641

3742

3843
class TableWithBookmarks(CustomTable):
@@ -90,6 +95,24 @@ def _get_row_bg_color_expr(self, cursor_row_idx: int) -> pl.Expr:
9095
return tmp
9196

9297

98+
_ALREADY_DT = "dt"
99+
100+
101+
def _guess_timestamp_cols(df: pl.DataFrame):
102+
date_range = pl.Series(values=[datetime.date(2001, 1, 1), datetime.date(2042, 1, 1)])
103+
converts = [(x,) + tuple(date_range.dt.epoch(x)) for x in ("s", "ms", "us", "ns")]
104+
105+
for col, dtype in df.schema.items():
106+
if dtype.is_integer():
107+
for suffix, min_val, max_val in converts:
108+
all_in_range = df.filter((pl.col(col) < min_val) | (pl.col(col) > max_val)).is_empty()
109+
if all_in_range:
110+
yield (col, suffix)
111+
break
112+
elif dtype.is_temporal():
113+
yield (col, _ALREADY_DT)
114+
115+
93116
class SpinnerWidget(Static):
94117
def __init__(self, style: str):
95118
super().__init__("")
@@ -265,13 +288,16 @@ class DtBrowser(Widget): # pylint: disable=too-many-public-methods,too-many-ins
265288
("b", "toggle_bookmark", "Add/Del Bookmark"),
266289
Binding("B", "show_bookmarks", "Bookmarks", key_display="shift+B"),
267290
("c", "column_selector", "Columns..."),
291+
("t", "timestamp_selector", "Timestamps..."),
268292
("r", "toggle_row_detail", "Toggle Row Detail"),
269293
Binding("C", "show_colors", "Colors...", key_display="shift+C"),
270294
]
271295

272296
color_by: reactive[tuple[str, ...]] = reactive(tuple(), init=False)
273297
visible_columns: reactive[tuple[str, ...]] = reactive(tuple())
274298
all_columns: reactive[tuple[str, ...]] = reactive(tuple())
299+
timestamp_columns: reactive[tuple[str, ...]] = reactive(tuple())
300+
available_timestamp_columns: reactive[tuple[str, ...]] = reactive(tuple())
275301
is_filtered = reactive(False)
276302
cur_row = reactive(0)
277303
cur_total_rows = reactive(0)
@@ -305,10 +331,15 @@ def __init__(self, table_name: str, source_file_or_table: pathlib.Path | pl.Data
305331
allow_reorder=False, id=_COLOR_COLUMNS_ID, title="Select columns to color by"
306332
)
307333

334+
self._ts_cols = dict(_guess_timestamp_cols(self._original_dt))
335+
self._ts_col_selector = ColumnSelector(id=_TS_COLUMNS_ID, title="Select epoch timestamp columns")
336+
self.available_timestamp_columns = tuple(self._ts_cols.keys())
337+
308338
# Necessary to prevent the main table from resizing to 0 when the col selectors are mounted and then immediately resizing
309339
# (apparently that happens when col selector width = auto)
310340
self._color_selector.styles.width = 1
311341
self._column_selector.styles.width = 1
342+
self._ts_col_selector.styles.width = 1
312343

313344
self._row_detail = RowDetail()
314345

@@ -425,13 +456,32 @@ async def action_show_colors(self):
425456

426457
await self.query_one("#main_hori", Horizontal).mount(self._color_selector)
427458

459+
async def action_timestamp_selector(self):
460+
await self.query_one("#main_hori", Horizontal).mount(self._ts_col_selector)
461+
428462
def _set_filtered_dt(self, filtered_dt: pl.DataFrame, filtered_meta: pl.DataFrame, **kwargs):
429463
self._filtered_dt = filtered_dt
430464
self._meta_dt = filtered_meta
431465
self._set_active_dt(self._filtered_dt, **kwargs)
432466

433467
def _set_active_dt(self, active_dt: pl.DataFrame, new_row: int | None = None):
434-
self._display_dt = active_dt.select(self.visible_columns)
468+
active_dt = active_dt.select(self.visible_columns)
469+
if self.timestamp_columns:
470+
ordered_cols: list[pl.Expr] = []
471+
for col in self.visible_columns:
472+
ordered_cols.append(pl.col(col))
473+
if col in self.timestamp_columns:
474+
expr = (
475+
pl.col(col).dt.epoch("ns").alias(f"{col} (ns)")
476+
if self._ts_cols[col] == _ALREADY_DT
477+
else pl.from_epoch(pl.col(col), time_unit=self._ts_cols[col])
478+
.dt.convert_time_zone(_TIMEZONE)
479+
.alias(f"{col} (Local)")
480+
)
481+
ordered_cols.append(expr)
482+
active_dt = active_dt.select(ordered_cols)
483+
484+
self._display_dt = active_dt
435485
self.cur_total_rows = len(self._display_dt)
436486
self.watch_active_search(goto=False)
437487
(table := self.query_one("#main_table", CustomTable)).set_dt(self._display_dt, self._meta_dt)
@@ -448,6 +498,11 @@ def reorder_columns(self, event: ColumnSelector.ColumnSelectionChanged):
448498
async def set_color_by(self, event: ColumnSelector.ColumnSelectionChanged):
449499
self.color_by = tuple(event.selected_columns)
450500

501+
@on(ColumnSelector.ColumnSelectionChanged, f"#{_TS_COLUMNS_ID}")
502+
async def set_timestamp_cols(self, event: ColumnSelector.ColumnSelectionChanged):
503+
self.timestamp_columns = tuple(event.selected_columns)
504+
self._set_active_dt(self._filtered_dt)
505+
451506
@on(SelectFromTable)
452507
def enable_select_from_table(self, event: SelectFromTable):
453508
self._select_interest = f"#{event.interested_widget.id}"
@@ -534,6 +589,9 @@ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | No
534589
if action == "show_bookmarks":
535590
return self._bookmarks.has_bookmarks
536591

592+
if action == "timestamp_selector":
593+
return len(self._ts_cols) > 0
594+
537595
return True
538596

539597
@work(exclusive=True)
@@ -584,6 +642,9 @@ def on_mount(self):
584642

585643
def compose(self) -> ComposeResult:
586644
"""Create child widgets for the app."""
645+
self._ts_col_selector.data_bind(
646+
selected_columns=DtBrowser.timestamp_columns, available_columns=DtBrowser.available_timestamp_columns
647+
)
587648
self._color_selector.data_bind(selected_columns=DtBrowser.color_by, available_columns=DtBrowser.all_columns)
588649
self._column_selector.data_bind(
589650
selected_columns=DtBrowser.visible_columns, available_columns=DtBrowser.all_columns

src/dt_browser/custom_table.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -580,8 +580,9 @@ def render_lines(self, crop: Region):
580580
self._lines.clear()
581581
self._lines.append(cur_header)
582582
if not render_df.is_empty():
583+
rend = render_df.lazy()
583584
if self._cursor_type == CustomTable.CursorType.NONE:
584-
rend = render_df.lazy().select(
585+
rend = rend.select(
585586
segments=pl.col("before_selected").map_elements(
586587
lambda x: [Segment(PADDING_STR), Segment(x)],
587588
return_dtype=pl.Object,
@@ -590,7 +591,7 @@ def render_lines(self, crop: Region):
590591
else:
591592
_, scroll_y = self.scroll_offset
592593
cursor_row_idx = self.cursor_coordinate.row - scroll_y
593-
rend = render_df.lazy().select(
594+
rend = rend.select(
594595
segments=pl.struct(
595596
pl.col("*"),
596597
self._get_row_bg_color_expr(cursor_row_idx).alias("bgcolor"),
@@ -678,7 +679,7 @@ def _measure(self, arr: pl.Series) -> int:
678679
return max(measure_width(el, self._console) for el in [col_max, col_min])
679680
if dtype.is_temporal():
680681
try:
681-
value = arr.drop_nulls()[0]
682+
value = arr.drop_nulls().slice(0, 1).cast(pl.Utf8)[0]
682683
except IndexError:
683684
return 0
684685
return measure_width(value, self._console)

0 commit comments

Comments
 (0)