dataframe-textual 1.2.0__py3-none-any.whl → 1.4.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.
@@ -10,7 +10,7 @@ from textual.app import App, ComposeResult
10
10
  from textual.css.query import NoMatches
11
11
  from textual.theme import BUILTIN_THEMES
12
12
  from textual.widgets import TabbedContent, TabPane
13
- from textual.widgets.tabbed_content import ContentTab, ContentTabs
13
+ from textual.widgets.tabbed_content import ContentTabs
14
14
 
15
15
  from .common import get_next_item, load_file
16
16
  from .data_frame_help_panel import DataFrameHelpPanel
@@ -26,7 +26,7 @@ class DataFrameViewer(App):
26
26
 
27
27
  ## 🎯 File & Tab Management
28
28
  - **Ctrl+O** - 📁 Add a new tab
29
- - **Ctrl+Shift+S** - 💾 Save all tabs
29
+ - **Ctrl+A** - 💾 Save all tabs
30
30
  - **Ctrl+W** - ❌ Close current tab
31
31
  - **>** or **b** - ▶️ Next tab
32
32
  - **<** - ◀️ Previous tab
@@ -34,7 +34,7 @@ class DataFrameViewer(App):
34
34
  - **q** - 🚪 Quit application
35
35
 
36
36
  ## 🎨 View & Settings
37
- - **Ctrl+H** - ❓ Toggle this help panel
37
+ - **F1** - ❓ Toggle this help panel
38
38
  - **k** - 🌙 Cycle through themes
39
39
 
40
40
  ## ⭐ Features
@@ -51,30 +51,25 @@ class DataFrameViewer(App):
51
51
 
52
52
  BINDINGS = [
53
53
  ("q", "quit", "Quit"),
54
- ("ctrl+h", "toggle_help_panel", "Help"),
54
+ ("f1", "toggle_help_panel", "Help"),
55
55
  ("B", "toggle_tab_bar", "Toggle Tab Bar"),
56
56
  ("ctrl+o", "add_tab", "Add Tab"),
57
- ("ctrl+shift+s", "save_all_tabs", "Save All Tabs"),
57
+ ("ctrl+a", "save_all_tabs", "Save All Tabs"),
58
58
  ("ctrl+w", "close_tab", "Close Tab"),
59
59
  ("greater_than_sign,b", "next_tab(1)", "Next Tab"),
60
60
  ("less_than_sign", "next_tab(-1)", "Prev Tab"),
61
61
  ]
62
62
 
63
63
  CSS = """
64
- TabbedContent {
65
- height: 100%; /* Or a specific value, e.g., 20; */
66
- }
67
64
  TabbedContent > ContentTabs {
68
65
  dock: bottom;
69
66
  }
70
67
  TabbedContent > ContentSwitcher {
71
68
  overflow: auto;
72
- height: 1fr; /* Takes the remaining space below tabs */
69
+ height: 1fr;
73
70
  }
74
-
75
- TabbedContent ContentTab.active {
76
- background: $primary;
77
- color: $text;
71
+ ContentTab.-active {
72
+ background: $block-cursor-background; /* Same as underline */
78
73
  }
79
74
  """
80
75
 
@@ -85,7 +80,7 @@ class DataFrameViewer(App):
85
80
 
86
81
  Args:
87
82
  sources: sources to load dataframes from, each as a tuple of
88
- (DataFrame | LazyFrame, filename, tabname).
83
+ (DataFrame, filename, tabname).
89
84
 
90
85
  Returns:
91
86
  None
@@ -127,7 +122,11 @@ class DataFrameViewer(App):
127
122
  self.tabs[tab] = table
128
123
  yield tab
129
124
  except Exception as e:
130
- self.notify(f"Error loading {tabname}: {e}", severity="error")
125
+ self.notify(
126
+ f"Error loading [$error]{filename}[/]: Try [$accent]-I[/] to disable schema inference",
127
+ severity="error",
128
+ )
129
+ self.log(f"Error loading `{filename}`: {str(e)}")
131
130
 
132
131
  def on_mount(self) -> None:
133
132
  """Set up the application when it starts.
@@ -178,12 +177,6 @@ class DataFrameViewer(App):
178
177
  if table.loaded_rows == 0:
179
178
  table._setup_table()
180
179
 
181
- # Apply background color to active tab
182
- event.tab.add_class("active")
183
- for tab in self.tabbed.query(ContentTab):
184
- if tab != event.tab:
185
- tab.remove_class("active")
186
-
187
180
  def action_toggle_help_panel(self) -> None:
188
181
  """Toggle the help panel on or off.
189
182
 
@@ -305,11 +298,11 @@ class DataFrameViewer(App):
305
298
  try:
306
299
  n_tab = 0
307
300
  for lf, filename, tabname in load_file(filename, prefix_sheet=True):
308
- self._add_tab(lf.collect(), filename, tabname)
301
+ self._add_tab(lf, filename, tabname)
309
302
  n_tab += 1
310
- self.notify(f"Added [$accent]{n_tab}[/] tab(s) for [$success]{filename}[/]", title="Open")
303
+ # self.notify(f"Added [$accent]{n_tab}[/] tab(s) for [$success]{filename}[/]", title="Open")
311
304
  except Exception as e:
312
- self.notify(f"Error: {e}", title="Open", severity="error")
305
+ self.notify(f"Error loading [$error]{filename}[/]: {str(e)}", title="Open", severity="error")
313
306
  else:
314
307
  self.notify(f"File does not exist: [$warning]{filename}[/]", title="Open", severity="warning")
315
308
 
@@ -321,15 +314,18 @@ class DataFrameViewer(App):
321
314
  if this is no longer the only tab.
322
315
 
323
316
  Args:
324
- df: The Polars DataFrame to display in the new tab.
317
+ lf: The Polars DataFrame to display in the new tab.
325
318
  filename: The source filename for this data (used in table metadata).
326
319
  tabname: The display name for the tab.
327
320
 
328
321
  Returns:
329
322
  None
330
323
  """
331
- if any(tab.name == tabname for tab in self.tabs):
332
- tabname = f"{tabname}_{len(self.tabs) + 1}"
324
+ # Ensure unique tab names
325
+ counter = 1
326
+ while any(tab.name == tabname for tab in self.tabs):
327
+ tabname = f"{tabname}_{counter}"
328
+ counter += 1
333
329
 
334
330
  # Find an available tab index
335
331
  tab_idx = f"tab_{len(self.tabs) + 1}"
@@ -369,6 +365,6 @@ class DataFrameViewer(App):
369
365
  if active_pane := self.tabbed.active_pane:
370
366
  self.tabbed.remove_pane(active_pane.id)
371
367
  self.tabs.pop(active_pane)
372
- self.notify(f"Closed tab [$success]{active_pane.name}[/]", title="Close")
368
+ # self.notify(f"Closed tab [$success]{active_pane.name}[/]", title="Close")
373
369
  except NoMatches:
374
370
  pass
@@ -0,0 +1,202 @@
1
+ """Modal screens for Polars sql manipulation"""
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ if TYPE_CHECKING:
6
+ from .data_frame_table import DataFrameTable
7
+
8
+ import polars as pl
9
+ from textual.app import ComposeResult
10
+ from textual.containers import Container, Horizontal
11
+ from textual.screen import ModalScreen
12
+ from textual.widgets import Button, Input, Label, SelectionList, TextArea
13
+ from textual.widgets.selection_list import Selection
14
+
15
+
16
+ class SqlScreen(ModalScreen):
17
+ """Base class for modal screens handling SQL query."""
18
+
19
+ DEFAULT_CSS = """
20
+ SqlScreen {
21
+ align: center middle;
22
+ }
23
+
24
+ SqlScreen > Container {
25
+ width: auto;
26
+ height: auto;
27
+ border: heavy $accent;
28
+ border-title-color: $accent;
29
+ border-title-background: $panel;
30
+ border-title-style: bold;
31
+ background: $background;
32
+ padding: 1 2;
33
+ overflow: auto;
34
+ }
35
+
36
+ #button-container {
37
+ width: auto;
38
+ margin: 1 0 0 0;
39
+ height: 3;
40
+ align: center middle;
41
+ }
42
+
43
+ Button {
44
+ margin: 0 2;
45
+ }
46
+
47
+ """
48
+
49
+ def __init__(self, dftable: "DataFrameTable", on_yes_callback=None) -> None:
50
+ """Initialize the SQL screen."""
51
+ super().__init__()
52
+ self.dftable = dftable # DataFrameTable
53
+ self.df: pl.DataFrame = dftable.df # Polars DataFrame
54
+ self.on_yes_callback = on_yes_callback
55
+
56
+ def compose(self) -> ComposeResult:
57
+ """Compose the SQL screen widget structure."""
58
+ # Shared by subclasses
59
+ with Horizontal(id="button-container"):
60
+ yield Button("Apply", id="yes", variant="success")
61
+ yield Button("Cancel", id="no", variant="error")
62
+
63
+ def on_key(self, event) -> None:
64
+ """Handle key press events in the SQL screen"""
65
+ if event.key in ("q", "escape"):
66
+ self.app.pop_screen()
67
+ event.stop()
68
+ elif event.key == "enter":
69
+ self._handle_yes()
70
+ event.stop()
71
+ elif event.key == "escape":
72
+ self.dismiss(None)
73
+ event.stop()
74
+
75
+ def on_button_pressed(self, event: Button.Pressed) -> None:
76
+ """Handle button press events in the SQL screen."""
77
+ if event.button.id == "yes":
78
+ self._handle_yes()
79
+ elif event.button.id == "no":
80
+ self.dismiss(None)
81
+
82
+ def _handle_yes(self) -> None:
83
+ """Handle Yes button/Enter key press."""
84
+ if self.on_yes_callback:
85
+ result = self.on_yes_callback()
86
+ self.dismiss(result)
87
+ else:
88
+ self.dismiss(True)
89
+
90
+
91
+ class SimpleSqlScreen(SqlScreen):
92
+ """Simple SQL query screen."""
93
+
94
+ DEFAULT_CSS = SqlScreen.DEFAULT_CSS.replace("SqlScreen", "SimpleSqlScreen")
95
+
96
+ CSS = """
97
+ SimpleSqlScreen SelectionList {
98
+ width: auto;
99
+ min-width: 40;
100
+ margin: 1 0;
101
+ }
102
+
103
+ SimpleSqlScreen SelectionList:blur {
104
+ border: solid $secondary;
105
+ }
106
+
107
+ SimpleSqlScreen Label {
108
+ width: auto;
109
+ }
110
+
111
+ SimpleSqlScreen Input {
112
+ width: auto;
113
+ }
114
+
115
+ SimpleSqlScreen Input:blur {
116
+ border: solid $secondary;
117
+ }
118
+
119
+ #button-container {
120
+ min-width: 40;
121
+ }
122
+ """
123
+
124
+ def __init__(self, dftable: "DataFrameTable") -> None:
125
+ """Initialize the simple SQL screen.
126
+
127
+ Sets up the modal screen with reference to the main DataFrameTable widget
128
+ and stores the DataFrame for display.
129
+
130
+ Args:
131
+ dftable: Reference to the parent DataFrameTable widget.
132
+
133
+ Returns:
134
+ None
135
+ """
136
+ super().__init__(dftable, on_yes_callback=self._handle_simple)
137
+
138
+ def compose(self) -> ComposeResult:
139
+ """Compose the simple SQL screen widget structure."""
140
+ with Container(id="sql-container") as container:
141
+ container.border_title = "SQL Query"
142
+ yield Label("Select columns (default to all):", id="select-label")
143
+ yield SelectionList(*[Selection(col, col) for col in self.df.columns], id="column-selection")
144
+ yield Label("Where condition (optional)", id="where-label")
145
+ yield Input(placeholder="e.g., age > 30 and height < 180", id="where-input")
146
+ yield from super().compose()
147
+
148
+ def _handle_simple(self) -> None:
149
+ """Handle Yes button/Enter key press."""
150
+ selections = self.query_one(SelectionList).selected
151
+ columns = ", ".join(f"`{s}`" for s in selections) if selections else "*"
152
+ where = self.query_one(Input).value.strip()
153
+
154
+ return columns, where
155
+
156
+
157
+ class AdvancedSqlScreen(SqlScreen):
158
+ """Advanced SQL query screen."""
159
+
160
+ DEFAULT_CSS = SqlScreen.DEFAULT_CSS.replace("SqlScreen", "AdvancedSqlScreen")
161
+
162
+ CSS = """
163
+ AdvancedSqlScreen TextArea {
164
+ width: auto;
165
+ min-width: 60;
166
+ height: auto;
167
+ min-height: 10;
168
+ }
169
+
170
+ #button-container {
171
+ min-width: 60;
172
+ }
173
+ """
174
+
175
+ def __init__(self, dftable: "DataFrameTable") -> None:
176
+ """Initialize the simple SQL screen.
177
+
178
+ Sets up the modal screen with reference to the main DataFrameTable widget
179
+ and stores the DataFrame for display.
180
+
181
+ Args:
182
+ dftable: Reference to the parent DataFrameTable widget.
183
+
184
+ Returns:
185
+ None
186
+ """
187
+ super().__init__(dftable, on_yes_callback=self._handle_advanced)
188
+
189
+ def compose(self) -> ComposeResult:
190
+ """Compose the advanced SQL screen widget structure."""
191
+ with Container(id="sql-container") as container:
192
+ container.border_title = "Advanced SQL Query"
193
+ yield TextArea.code_editor(
194
+ placeholder="Enter SQL query (use `self` as the table name), e.g., \n\nSELECT * \nFROM self \nWHERE age > 30",
195
+ id="sql-textarea",
196
+ language="sql",
197
+ )
198
+ yield from super().compose()
199
+
200
+ def _handle_advanced(self) -> None:
201
+ """Handle Yes button/Enter key press."""
202
+ return self.query_one(TextArea).text.strip()
@@ -37,7 +37,7 @@ class TableScreen(ModalScreen):
37
37
  }
38
38
  """
39
39
 
40
- def __init__(self, dftable: DataFrameTable) -> None:
40
+ def __init__(self, dftable: "DataFrameTable") -> None:
41
41
  """Initialize the table screen.
42
42
 
43
43
  Sets up the base modal screen with reference to the main DataFrameTable widget
@@ -50,8 +50,8 @@ class TableScreen(ModalScreen):
50
50
  None
51
51
  """
52
52
  super().__init__()
53
- self.df: pl.DataFrame = dftable.df # Polars DataFrame
54
53
  self.dftable = dftable # DataFrameTable
54
+ self.df: pl.DataFrame = dftable.df # Polars DataFrame
55
55
  self.thousand_separator = False # Whether to use thousand separators in numbers
56
56
 
57
57
  def compose(self) -> ComposeResult:
@@ -181,7 +181,12 @@ class RowDetailScreen(TableScreen):
181
181
  # Get all columns and values from the dataframe row
182
182
  for col, val, dtype in zip(self.df.columns, self.df.row(self.ridx), self.df.dtypes):
183
183
  self.table.add_row(
184
- *format_row([col, val], [None, dtype], apply_justify=False, thousand_separator=self.thousand_separator)
184
+ *format_row(
185
+ [col, val],
186
+ [None, dtype],
187
+ apply_justify=False,
188
+ thousand_separator=self.thousand_separator,
189
+ )
185
190
  )
186
191
 
187
192
  self.table.cursor_type = "row"
@@ -225,7 +230,7 @@ class StatisticsScreen(TableScreen):
225
230
 
226
231
  CSS = TableScreen.DEFAULT_CSS.replace("TableScreen", "StatisticsScreen")
227
232
 
228
- def __init__(self, dftable: DataFrameTable, col_idx: int | None = None):
233
+ def __init__(self, dftable: "DataFrameTable", col_idx: int | None = None):
229
234
  super().__init__(dftable)
230
235
  self.col_idx = col_idx # None for dataframe statistics, otherwise column index
231
236
 
@@ -240,9 +245,11 @@ class StatisticsScreen(TableScreen):
240
245
  if self.col_idx is None:
241
246
  # Dataframe statistics
242
247
  self._build_dataframe_stats()
248
+ self.table.cursor_type = "column"
243
249
  else:
244
250
  # Column statistics
245
251
  self._build_column_stats()
252
+ self.table.cursor_type = "row"
246
253
 
247
254
  def _build_column_stats(self) -> None:
248
255
  """Build statistics for a single column."""
@@ -344,15 +351,16 @@ class FrequencyScreen(TableScreen):
344
351
 
345
352
  CSS = TableScreen.DEFAULT_CSS.replace("TableScreen", "FrequencyScreen")
346
353
 
347
- def __init__(self, col_idx: int, dftable: DataFrameTable):
354
+ def __init__(self, col_idx: int, dftable: "DataFrameTable") -> None:
348
355
  super().__init__(dftable)
349
356
  self.col_idx = col_idx
350
357
  self.sorted_columns = {
351
358
  1: True, # Count
352
359
  }
353
- self.df: pl.DataFrame = (
354
- dftable.df[dftable.df.columns[self.col_idx]].value_counts(sort=True).sort("count", descending=True)
355
- )
360
+
361
+ df = dftable.df.filter(dftable.visible_rows) if False in dftable.visible_rows else dftable.df
362
+ self.total_count = len(df)
363
+ self.df: pl.DataFrame = df[df.columns[self.col_idx]].value_counts(sort=True).sort("count", descending=True)
356
364
 
357
365
  def on_mount(self) -> None:
358
366
  """Create the frequency table."""
@@ -385,9 +393,6 @@ class FrequencyScreen(TableScreen):
385
393
  dtype = self.dftable.df.dtypes[self.col_idx]
386
394
  dc = DtypeConfig(dtype)
387
395
 
388
- # Calculate frequencies using Polars
389
- total_count = len(self.dftable.df)
390
-
391
396
  # Add column headers with sort indicators
392
397
  columns = [
393
398
  (column, "Value", 0),
@@ -415,7 +420,7 @@ class FrequencyScreen(TableScreen):
415
420
  # Add rows to the frequency table
416
421
  for row_idx, row in enumerate(self.df.rows()):
417
422
  column, count = row
418
- percentage = (count / total_count) * 100
423
+ percentage = (count / self.total_count) * 100
419
424
 
420
425
  if column is None:
421
426
  value = NULL_DISPLAY
@@ -432,7 +437,7 @@ class FrequencyScreen(TableScreen):
432
437
  f"{count:,}" if self.thousand_separator else str(count), style=ds_int.style, justify=ds_int.justify
433
438
  ),
434
439
  Text(
435
- f"{percentage:,.3f}" if self.thousand_separator else f"{percentage:.3f}",
440
+ format_float(percentage, self.thousand_separator),
436
441
  style=ds_float.style,
437
442
  justify=ds_float.justify,
438
443
  ),
@@ -446,8 +451,16 @@ class FrequencyScreen(TableScreen):
446
451
  # Add a total row
447
452
  self.table.add_row(
448
453
  Text("Total", style="bold", justify=dc.justify),
449
- Text(f"{total_count:,}", style="bold", justify="right"),
450
- Text("100.00", style="bold", justify="right"),
454
+ Text(
455
+ f"{self.total_count:,}" if self.thousand_separator else str(self.total_count),
456
+ style="bold",
457
+ justify="right",
458
+ ),
459
+ Text(
460
+ format_float(100.0, self.thousand_separator),
461
+ style="bold",
462
+ justify="right",
463
+ ),
451
464
  Bar(
452
465
  highlight_range=(0.0, 10),
453
466
  width=10,
@@ -460,12 +473,10 @@ class FrequencyScreen(TableScreen):
460
473
  row_idx, col_idx = self.table.cursor_coordinate
461
474
  col_sort = col_idx if col_idx == 0 else 1
462
475
 
463
- sort_dir = self.sorted_columns.get(col_sort)
464
- if sort_dir is not None:
476
+ if self.sorted_columns.get(col_sort) == descending:
465
477
  # If already sorted in the same direction, do nothing
466
- if sort_dir == descending:
467
- self.notify("Already sorted in that order", title="Sort", severity="warning")
468
- return
478
+ # self.notify("Already sorted in that order", title="Sort", severity="warning")
479
+ return
469
480
 
470
481
  self.sorted_columns.clear()
471
482
  self.sorted_columns[col_sort] = descending
@@ -33,9 +33,11 @@ class YesNoScreen(ModalScreen):
33
33
  min-width: 40;
34
34
  max-width: 60;
35
35
  height: auto;
36
- border: heavy $primary;
37
- border-title-color: $primary-lighten-3;
38
- background: $surface;
36
+ border: heavy $accent;
37
+ border-title-color: $accent;
38
+ border-title-background: $panel;
39
+ border-title-style: bold;
40
+ background: $background;
39
41
  padding: 1 2;
40
42
  }
41
43
 
@@ -241,6 +243,7 @@ class YesNoScreen(ModalScreen):
241
243
  yield self.no
242
244
 
243
245
  def on_button_pressed(self, event: Button.Pressed) -> None:
246
+ """Handle button press events in the Yes/No screen."""
244
247
  if event.button.id == "yes":
245
248
  self._handle_yes()
246
249
  elif event.button.id == "maybe":
@@ -249,6 +252,7 @@ class YesNoScreen(ModalScreen):
249
252
  self.dismiss(None)
250
253
 
251
254
  def on_key(self, event) -> None:
255
+ """Handle key press events in the table screen."""
252
256
  if event.key == "enter":
253
257
  self._handle_yes()
254
258
  event.stop()
@@ -298,7 +302,7 @@ class SaveFileScreen(YesNoScreen):
298
302
 
299
303
 
300
304
  class ConfirmScreen(YesNoScreen):
301
- """Modal screen to confirm file overwrite."""
305
+ """Modal screen to ask for confirmation."""
302
306
 
303
307
  CSS = YesNoScreen.DEFAULT_CSS.replace("YesNoScreen", "ConfirmScreen")
304
308
 
@@ -582,7 +586,7 @@ class AddColumnScreen(YesNoScreen):
582
586
  title="Add Column",
583
587
  label="Enter column name",
584
588
  input="column name",
585
- label2="Enter value or Polars expression, e.g., 123, NULL, $_ * 2",
589
+ label2="Enter value or Polars expression, e.g., abc, pl.lit(123), NULL, $_ * 2, $1 + $2, $_.str.to_uppercase(), pl.concat_str($_, pl.lit('-suffix'))",
586
590
  input2="column value or expression",
587
591
  on_yes_callback=self._get_input,
588
592
  )
@@ -609,7 +613,7 @@ class AddColumnScreen(YesNoScreen):
609
613
  return self.cidx, col_name, pl.lit(None)
610
614
  elif tentative_expr(term):
611
615
  try:
612
- expr = validate_expr(term, self.df, self.cidx)
616
+ expr = validate_expr(term, self.df.columns, self.cidx)
613
617
  return self.cidx, col_name, expr
614
618
  except ValueError as e:
615
619
  self.notify(f"Invalid expression [$error]{term}[/]: {str(e)}", title="Add Column", severity="error")
@@ -634,10 +638,10 @@ class FindReplaceScreen(YesNoScreen):
634
638
 
635
639
  CSS = YesNoScreen.DEFAULT_CSS.replace("YesNoScreen", "ReplaceScreen")
636
640
 
637
- def __init__(self, dftable: DataFrameTable):
641
+ def __init__(self, dftable: "DataFrameTable", title: str = "Find and Replace"):
638
642
  term_find = str(dftable.cursor_value)
639
643
  super().__init__(
640
- title="Find and Replace",
644
+ title=title,
641
645
  label="Find",
642
646
  input=term_find,
643
647
  label2="Replace with",