dataframe-textual 0.1.0__py3-none-any.whl → 1.1.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.

Potentially problematic release.


This version of dataframe-textual might be problematic. Click here for more details.

@@ -1,320 +0,0 @@
1
- """DataFrame Viewer application and utilities."""
2
-
3
- import os
4
- import sys
5
- from functools import partial
6
- from pathlib import Path
7
- from textwrap import dedent
8
-
9
- import polars as pl
10
- from textual.app import App, ComposeResult
11
- from textual.css.query import NoMatches
12
- from textual.theme import BUILTIN_THEMES
13
- from textual.widgets import TabbedContent, TabPane
14
- from textual.widgets.tabbed_content import ContentTab, ContentTabs
15
-
16
- from .common import _next
17
- from .data_frame_help_panel import DataFrameHelpPanel
18
- from .data_frame_table import DataFrameTable
19
- from .yes_no_screen import OpenFileScreen, SaveFileScreen
20
-
21
-
22
- class DataFrameViewer(App):
23
- """A Textual app to interact with multiple Polars DataFrames via tabbed interface."""
24
-
25
- HELP = dedent("""
26
- # 📊 DataFrame Viewer - App Controls
27
-
28
- ## 🎯 File & Tab Management
29
- - **Ctrl+O** - 📁 Add a new tab
30
- - **Ctrl+Shift+S** - 💾 Save all tabs
31
- - **Ctrl+W** - ❌ Close current tab
32
- - **>** or **b** - ▶️ Next tab
33
- - **<** - ◀️ Previous tab
34
- - **B** - 👁️ Toggle tab bar visibility
35
- - **q** - 🚪 Quit application
36
-
37
- ## 🎨 View & Settings
38
- - **?** or **h** - ❓ Toggle this help panel
39
- - **k** - 🌙 Cycle through themes
40
-
41
- ## ⭐ Features
42
- - **Multi-file support** - 📂 Open multiple CSV/Excel files as tabs
43
- - **Excel sheets** - 📊 Excel files auto-expand sheets into tabs
44
- - **Lazy loading** - ⚡ Large files load on demand
45
- - **Sticky tabs** - 📌 Tab bar stays visible when scrolling
46
- - **Rich formatting** - 🎨 Color-coded data types
47
- - **Search & filter** - 🔍 Find and filter data quickly
48
- - **Sort & reorder** - ⬆️ Multi-column sort, drag rows/columns
49
- - **Undo/Redo** - 🔄 Full history of operations
50
- - **Freeze rows/cols** - 🔒 Pin header rows and columns
51
- """).strip()
52
-
53
- BINDINGS = [
54
- ("q", "quit", "Quit"),
55
- ("h,?", "toggle_help_panel", "Help"),
56
- ("B", "toggle_tab_bar", "Toggle Tab Bar"),
57
- ("ctrl+o", "add_tab", "Add Tab"),
58
- ("ctrl+shift+s", "save_all_tabs", "Save All Tabs"),
59
- ("ctrl+w", "close_tab", "Close Tab"),
60
- ("greater_than_sign,b", "next_tab(1)", "Next Tab"),
61
- ("less_than_sign", "next_tab(-1)", "Prev Tab"),
62
- ]
63
-
64
- CSS = """
65
- TabbedContent {
66
- height: 100%; /* Or a specific value, e.g., 20; */
67
- }
68
- TabbedContent > ContentTabs {
69
- dock: bottom;
70
- }
71
- TabbedContent > ContentSwitcher {
72
- overflow: auto;
73
- height: 1fr; /* Takes the remaining space below tabs */
74
- }
75
-
76
- TabbedContent ContentTab.active {
77
- background: $primary;
78
- color: $text;
79
- }
80
- """
81
-
82
- def __init__(self, *filenames):
83
- super().__init__()
84
- self.sources = _load_dataframe(filenames)
85
- self.tabs: dict[TabPane, DataFrameTable] = {}
86
- self.help_panel = None
87
-
88
- def compose(self) -> ComposeResult:
89
- """Create tabbed interface for multiple files or direct table for single file."""
90
- # Tabbed interface
91
- self.tabbed = TabbedContent(id="main_tabs")
92
- with self.tabbed:
93
- seen_names = set()
94
- for idx, (df, filename, tabname) in enumerate(self.sources, start=1):
95
- # Ensure unique tab names
96
- if tabname in seen_names:
97
- tabname = f"{tabname}_{idx}"
98
- seen_names.add(tabname)
99
-
100
- tab_id = f"tab_{idx}"
101
- try:
102
- table = DataFrameTable(
103
- df, filename, name=tabname, id=tab_id, zebra_stripes=True
104
- )
105
- tab = TabPane(tabname, table, name=tabname, id=tab_id)
106
- self.tabs[tab] = table
107
- yield tab
108
- except Exception as e:
109
- self.notify(f"Error loading {tabname}: {e}", severity="error")
110
-
111
- def on_mount(self) -> None:
112
- """Set up the app when it starts."""
113
- if len(self.tabs) == 1:
114
- self.query_one(ContentTabs).display = False
115
- self._get_active_table().focus()
116
-
117
- def on_key(self, event):
118
- if event.key == "k":
119
- self.theme = _next(list(BUILTIN_THEMES.keys()), self.theme)
120
- self.notify(f"Switched to theme: [$primary]{self.theme}[/]", title="Theme")
121
-
122
- def on_tabbed_content_tab_activated(
123
- self, event: TabbedContent.TabActivated
124
- ) -> None:
125
- """Handle tab changes (only for multiple tabs)."""
126
- # Only process if we have multiple files
127
- if len(self.tabs) <= 1:
128
- return
129
-
130
- # Apply background color to active tab
131
- event.tab.add_class("active")
132
- for tab in self.tabbed.query(ContentTab):
133
- if tab != event.tab:
134
- tab.remove_class("active")
135
-
136
- try:
137
- # Focus the table in the newly activated tab
138
- if table := self._get_active_table():
139
- table.focus()
140
- except NoMatches:
141
- pass
142
-
143
- def _get_active_table(self) -> DataFrameTable | None:
144
- """Get the currently active table."""
145
- try:
146
- tabbed: TabbedContent = self.query_one(TabbedContent)
147
- if active_pane := tabbed.active_pane:
148
- return active_pane.query_one(DataFrameTable)
149
- except (NoMatches, AttributeError):
150
- pass
151
- return None
152
-
153
- def action_toggle_help_panel(self) -> None:
154
- """Toggle the HelpPanel on/off."""
155
- if self.help_panel:
156
- self.help_panel.display = not self.help_panel.display
157
- else:
158
- self.help_panel = DataFrameHelpPanel()
159
- self.mount(self.help_panel)
160
-
161
- def action_add_tab(self) -> None:
162
- """Open file dialog to load file to new tab."""
163
- self.push_screen(OpenFileScreen(), self._handle_file_open)
164
-
165
- def _handle_file_open(self, filename: str) -> None:
166
- """Handle file selection from dialog."""
167
- if filename and os.path.exists(filename):
168
- try:
169
- df = pl.read_csv(filename)
170
- self._add_tab(df, filename)
171
- self.notify(
172
- f"Opened: [on $primary]{Path(filename).name}[/]", title="Open"
173
- )
174
- except Exception as e:
175
- self.notify(f"Error: {e}", severity="error")
176
-
177
- def action_save_all_tabs(self) -> None:
178
- """Save all tabs to a Excel file."""
179
- callback = partial(self._get_active_table()._on_save_file_screen, all_tabs=True)
180
- self.push_screen(
181
- SaveFileScreen("all-tabs.xlsx", title="Save All Tabs"),
182
- callback=callback,
183
- )
184
-
185
- def action_close_tab(self) -> None:
186
- """Close current tab (only for multiple files)."""
187
- if len(self.tabs) <= 1:
188
- self.app.exit()
189
- return
190
- self._close_tab()
191
-
192
- def action_next_tab(self, offset: int = 1) -> str:
193
- """Switch to next tab (only for multiple files)."""
194
- if len(self.tabs) <= 1:
195
- return
196
- try:
197
- tabs: list[TabPane] = list(self.tabs.keys())
198
- next_tab = _next(tabs, self.tabbed.active_pane, offset)
199
- self.tabbed.active = next_tab.id
200
- except (NoMatches, ValueError):
201
- pass
202
-
203
- def _add_tab(self, df: pl.DataFrame, filename: str) -> None:
204
- """Add new table tab. If single file, replace table; if multiple, add tab."""
205
- table = DataFrameTable(df, filename, zebra_stripes=True)
206
- tabname = Path(filename).stem
207
- if any(tab.name == tabname for tab in self.tabs):
208
- tabname = f"{tabname}_{len(self.tabs) + 1}"
209
-
210
- tab = TabPane(tabname, table, name=tabname, id=f"tab_{len(self.tabs) + 1}")
211
- self.tabbed.add_pane(tab)
212
- self.tabs[tab] = table
213
-
214
- if len(self.tabs) > 1:
215
- self.query_one(ContentTabs).display = True
216
-
217
- # Activate the new tab
218
- self.tabbed.active = tab.id
219
- table.focus()
220
-
221
- def _close_tab(self) -> None:
222
- """Close current tab."""
223
- try:
224
- if len(self.tabs) == 1:
225
- self.app.exit()
226
- else:
227
- if active_pane := self.tabbed.active_pane:
228
- self.tabbed.remove_pane(active_pane.id)
229
- self.notify(
230
- f"Closed tab [on $primary]{active_pane.name}[/]", title="Close"
231
- )
232
- except NoMatches:
233
- pass
234
-
235
- def action_toggle_tab_bar(self) -> None:
236
- """Toggle tab bar visibility."""
237
- tabs = self.query_one(ContentTabs)
238
- tabs.display = not tabs.display
239
- status = "shown" if tabs.display else "hidden"
240
- self.notify(f"Tab bar [on $primary]{status}[/]", title="Toggle")
241
-
242
-
243
- def _load_dataframe(filenames: list[str]) -> list[tuple[pl.DataFrame, str, str]]:
244
- """Load a DataFrame from a file spec.
245
-
246
- Args:
247
- filenames: List of filenames to load. If single filename is "-", read from stdin.
248
-
249
- Returns:
250
- List of tuples of (DataFrame, filename, tabname)
251
- """
252
- sources = []
253
-
254
- # Single file
255
- if len(filenames) == 1:
256
- filename = filenames[0]
257
- filepath = Path(filename)
258
- ext = filepath.suffix.lower()
259
-
260
- # Handle stdin
261
- if filename == "-" or not sys.stdin.isatty():
262
- from io import StringIO
263
-
264
- # Read CSV from stdin into memory first (stdin is not seekable)
265
- stdin_data = sys.stdin.read()
266
- df = pl.read_csv(StringIO(stdin_data))
267
-
268
- # Reopen stdin to /dev/tty for proper terminal interaction
269
- try:
270
- tty = open("/dev/tty")
271
- os.dup2(tty.fileno(), sys.stdin.fileno())
272
- except (OSError, FileNotFoundError):
273
- pass
274
-
275
- sources.append((df, "stdin.csv", "stdin"))
276
- # Handle Excel files with multiple sheets
277
- elif ext in (".xlsx", ".xls"):
278
- sheets = pl.read_excel(filename, sheet_id=0)
279
- for sheet_name, df in sheets.items():
280
- sources.append((df, filename, sheet_name))
281
- # Handle TSV files
282
- elif ext in (".tsv", ".tab"):
283
- df = pl.read_csv(filename, separator="\t")
284
- sources.append((df, filename, filepath.stem))
285
- # Handle JSON files
286
- elif ext == ".json":
287
- df = pl.read_json(filename)
288
- sources.append((df, filename, filepath.stem))
289
- # Handle Parquet files
290
- elif ext == ".parquet":
291
- df = pl.read_parquet(filename)
292
- sources.append((df, filename, filepath.stem))
293
- # Handle regular CSV files
294
- else:
295
- df = pl.read_csv(filename)
296
- sources.append((df, filename, filepath.stem))
297
- # Multiple files
298
- else:
299
- for filename in filenames:
300
- filepath = Path(filename)
301
- ext = filepath.suffix.lower()
302
-
303
- if ext in (".xlsx", ".xls"):
304
- # Read only the first sheet for multiple files
305
- df = pl.read_excel(filename)
306
- sources.append((df, filename, filepath.stem))
307
- elif ext in (".tsv", ".tab"):
308
- df = pl.read_csv(filename, separator="\t")
309
- sources.append((df, filename, filepath.stem))
310
- elif ext == ".json":
311
- df = pl.read_json(filename)
312
- sources.append((df, filename, filepath.stem))
313
- elif ext == ".parquet":
314
- df = pl.read_parquet(filename)
315
- sources.append((df, filename, filepath.stem))
316
- else:
317
- df = pl.read_csv(filename)
318
- sources.append((df, filename, filepath.stem))
319
-
320
- return sources
@@ -1,311 +0,0 @@
1
- """Modal screens for displaying data in tables (row details and frequency)."""
2
-
3
- from typing import Any
4
-
5
- import polars as pl
6
- from rich.text import Text
7
- from textual.app import ComposeResult
8
- from textual.coordinate import Coordinate
9
- from textual.screen import ModalScreen
10
- from textual.widgets import DataTable
11
-
12
- from .common import BOOLS, DtypeConfig, _format_row
13
-
14
-
15
- class TableScreen(ModalScreen):
16
- """Base class for modal screens displaying data in a DataTable.
17
-
18
- Provides common functionality for screens that show tabular data with
19
- keyboard shortcuts and styling.
20
- """
21
-
22
- DEFAULT_CSS = """
23
- TableScreen {
24
- align: center middle;
25
- }
26
-
27
- TableScreen > DataTable {
28
- width: auto;
29
- min-width: 30;
30
- height: auto;
31
- border: solid $primary;
32
- }
33
- """
34
-
35
- def __init__(self, df: pl.DataFrame, id: str | None = None):
36
- super().__init__()
37
- self.df = df
38
- self.id = id
39
-
40
- def compose(self) -> ComposeResult:
41
- """Create the table. Must be overridden by subclasses."""
42
- self.table = DataTable(zebra_stripes=True, id=self.id)
43
- yield self.table
44
-
45
- def on_key(self, event):
46
- if event.key in ("q", "escape"):
47
- self.app.pop_screen()
48
- event.stop()
49
- # Prevent key events from propagating to parent screen,
50
- # except for the following default key bindings for DataTable
51
- elif event.key not in (
52
- "up",
53
- "down",
54
- "right",
55
- "left",
56
- "pageup",
57
- "pagedown",
58
- "ctrl+home",
59
- "ctrl+end",
60
- "home",
61
- "end",
62
- ):
63
- event.stop()
64
-
65
- def _filter_or_highlight_selected_value(
66
- self, col_name_value: tuple[str, str] | None, action: str = "filter"
67
- ) -> None:
68
- """Apply filter or highlight action by the selected value from the frequency table.
69
-
70
- Args:
71
- col_name: The name of the column to filter/highlight.
72
- col_value: The value to filter/highlight by.
73
- action: Either "filter" to filter visible rows, or "highlight" to select matching rows.
74
- """
75
- if col_name_value is None:
76
- return
77
- col_name, col_value = col_name_value
78
-
79
- # Handle NULL values
80
- if col_value == "-":
81
- # Create expression for NULL values
82
- expr = pl.col(col_name).is_null()
83
- value_display = "[on $primary]NULL[/]"
84
- else:
85
- # Create expression for the selected value
86
- expr = pl.col(col_name) == col_value
87
- value_display = f"[on $primary]{col_value}[/]"
88
-
89
- app = self.app
90
- matched_indices = set(
91
- app.df.with_row_index("__rid__").filter(expr)["__rid__"].to_list()
92
- )
93
-
94
- # Apply the action
95
- if action == "filter":
96
- # Update visible_rows to reflect the filter
97
- for i in range(len(app.visible_rows)):
98
- app.visible_rows[i] = i in matched_indices
99
- title = "Filter"
100
- message = f"Filtered by [on $primary]{col_name}[/] = {value_display}"
101
- else: # action == "highlight"
102
- # Update selected_rows to reflect the highlights
103
- for i in range(len(app.selected_rows)):
104
- app.selected_rows[i] = i in matched_indices
105
- title = "Highlight"
106
- message = f"Highlighted [on $primary]{col_name}[/] = {value_display}"
107
-
108
- # Recreate the table display with updated data in the main app
109
- app._setup_table()
110
-
111
- # Dismiss the frequency screen
112
- self.app.pop_screen()
113
-
114
- self.notify(message, title=title)
115
-
116
-
117
- class RowDetailScreen(TableScreen):
118
- """Modal screen to display a single row's details."""
119
-
120
- CSS = TableScreen.DEFAULT_CSS.replace("TableScreen", "RowDetailScreen")
121
-
122
- def __init__(self, row_idx: int, df: pl.DataFrame):
123
- super().__init__(df, id="row-detail-table")
124
- self.row_idx = row_idx
125
-
126
- def on_mount(self) -> None:
127
- """Create the detail table."""
128
- self.table.add_column("Column")
129
- self.table.add_column("Value")
130
-
131
- # Get all columns and values from the dataframe row
132
- for col, val, dtype in zip(
133
- self.df.columns, self.df.row(self.row_idx), self.df.dtypes
134
- ):
135
- self.table.add_row(
136
- *_format_row([col, val], [None, dtype], apply_justify=False)
137
- )
138
-
139
- def on_key(self, event):
140
- if event.key == "v":
141
- # Filter the main table by the selected value
142
- self._filter_or_highlight_selected_value(
143
- self._get_col_name_value(), action="filter"
144
- )
145
- event.stop()
146
- elif event.key == "quotation_mark": # '"'
147
- # Highlight the main table by the selected value
148
- self._filter_or_highlight_selected_value(
149
- self._get_col_name_value(), action="highlight"
150
- )
151
- event.stop()
152
-
153
- def _get_col_name_value(self) -> tuple[str, Any] | None:
154
- row_idx = self.table.cursor_row
155
- if row_idx >= len(self.df.columns):
156
- return None # Invalid row
157
-
158
- col_name = self.df.columns[row_idx]
159
- col_value = self.df.item(self.row_idx, row_idx)
160
-
161
- return col_name, col_value
162
-
163
-
164
- class FrequencyScreen(TableScreen):
165
- """Modal screen to display frequency of values in a column."""
166
-
167
- CSS = TableScreen.DEFAULT_CSS.replace("TableScreen", "FrequencyScreen")
168
-
169
- def __init__(self, col_idx: int, df: pl.DataFrame):
170
- super().__init__(df, id="frequency-table")
171
- self.col_idx = col_idx
172
- self.sorted_columns = {
173
- 1: True, # Count
174
- 2: True, # %
175
- }
176
-
177
- def on_mount(self) -> None:
178
- """Create the frequency table."""
179
- column = self.df.columns[self.col_idx]
180
- dtype = str(self.df.dtypes[self.col_idx])
181
- dc = DtypeConfig(dtype)
182
-
183
- # Calculate frequencies using Polars
184
- freq_df = self.df[column].value_counts(sort=True).sort("count", descending=True)
185
- total_count = len(self.df)
186
-
187
- # Create frequency table
188
- self.table.add_column(Text(column, justify=dc.justify), key=column)
189
- self.table.add_column(Text("Count", justify="right"), key="Count")
190
- self.table.add_column(Text("%", justify="right"), key="%")
191
-
192
- # Get style config for Int64 and Float64
193
- ds_int = DtypeConfig("Int64")
194
- ds_float = DtypeConfig("Float64")
195
-
196
- # Add rows to the frequency table
197
- for row_idx, row in enumerate(freq_df.rows()):
198
- value, count = row
199
- percentage = (count / total_count) * 100
200
-
201
- self.table.add_row(
202
- Text(
203
- "-" if value is None else str(value),
204
- style=dc.style,
205
- justify=dc.justify,
206
- ),
207
- Text(
208
- str(count),
209
- style=ds_int.style,
210
- justify=ds_int.justify,
211
- ),
212
- Text(
213
- f"{percentage:.2f}",
214
- style=ds_float.style,
215
- justify=ds_float.justify,
216
- ),
217
- key=str(row_idx + 1),
218
- )
219
-
220
- # Add a total row
221
- self.table.add_row(
222
- Text("Total", style="bold", justify=dc.justify),
223
- Text(f"{total_count:,}", style="bold", justify="right"),
224
- Text("100.00", style="bold", justify="right"),
225
- key="total",
226
- )
227
-
228
- def on_key(self, event):
229
- if event.key == "left_square_bracket": # '['
230
- # Sort by current column in ascending order
231
- self._sort_by_column(descending=False)
232
- event.stop()
233
- elif event.key == "right_square_bracket": # ']'
234
- # Sort by current column in descending order
235
- self._sort_by_column(descending=True)
236
- event.stop()
237
- elif event.key == "v":
238
- # Filter the main table by the selected value
239
- self._filter_or_highlight_selected_value(
240
- self._get_col_name_value(), action="filter"
241
- )
242
- event.stop()
243
- elif event.key == "quotation_mark": # '"'
244
- # Highlight the main table by the selected value
245
- self._filter_or_highlight_selected_value(
246
- self._get_col_name_value(), action="highlight"
247
- )
248
- event.stop()
249
-
250
- def _sort_by_column(self, descending: bool) -> None:
251
- """Sort the dataframe by the selected column and refresh the main table."""
252
- freq_table = self.query_one(DataTable)
253
-
254
- col_idx = freq_table.cursor_column
255
- col_dtype = "String"
256
-
257
- sort_dir = self.sorted_columns.get(col_idx)
258
- if sort_dir is not None:
259
- # If already sorted in the same direction, do nothing
260
- if sort_dir == descending:
261
- self.notify(
262
- "Already sorted in that order", title="Sort", severity="warning"
263
- )
264
- return
265
-
266
- self.sorted_columns.clear()
267
- self.sorted_columns[col_idx] = descending
268
-
269
- if col_idx == 0:
270
- col_name = self.df.columns[self.col_idx]
271
- col_dtype = str(self.df.dtypes[self.col_idx])
272
- elif col_idx == 1:
273
- col_name = "Count"
274
- col_dtype = "Int64"
275
- elif col_idx == 2:
276
- col_name = "%"
277
- col_dtype = "Float64"
278
-
279
- def key_fun(freq_col):
280
- col_value = freq_col.plain
281
-
282
- if col_dtype == "Int64":
283
- return int(col_value)
284
- elif col_dtype == "Float64":
285
- return float(col_value)
286
- elif col_dtype == "Boolean":
287
- return BOOLS[col_value]
288
- else:
289
- return col_value
290
-
291
- # Sort the table
292
- freq_table.sort(
293
- col_name, key=lambda freq_col: key_fun(freq_col), reverse=descending
294
- )
295
-
296
- # Notify the user
297
- order = "desc" if descending else "asc"
298
- self.notify(f"Sorted by [on $primary]{col_name}[/] ({order})", title="Sort")
299
-
300
- def _get_col_name_value(self) -> tuple[str, str] | None:
301
- row_idx = self.table.cursor_row
302
- if row_idx >= len(self.df.columns):
303
- return None # Skip total row
304
-
305
- col_name = self.df.columns[self.col_idx]
306
- col_dtype = self.df.dtypes[self.col_idx]
307
-
308
- cell_value = self.table.get_cell_at(Coordinate(row_idx, 0))
309
- col_value = cell_value.plain
310
-
311
- return col_name, DtypeConfig(col_dtype).convert(col_value)