dataframe-textual 0.2.0__py3-none-any.whl → 0.3.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.
@@ -0,0 +1,320 @@
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
@@ -0,0 +1,315 @@
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.renderables.bar import Bar
10
+ from textual.screen import ModalScreen
11
+ from textual.widgets import DataTable
12
+
13
+ from .common import BOOLS, DtypeConfig, _format_row
14
+
15
+
16
+ class TableScreen(ModalScreen):
17
+ """Base class for modal screens displaying data in a DataTable.
18
+
19
+ Provides common functionality for screens that show tabular data with
20
+ keyboard shortcuts and styling.
21
+ """
22
+
23
+ DEFAULT_CSS = """
24
+ TableScreen {
25
+ align: center middle;
26
+ }
27
+
28
+ TableScreen > DataTable {
29
+ width: auto;
30
+ min-width: 30;
31
+ height: auto;
32
+ border: solid $primary;
33
+ }
34
+ """
35
+
36
+ def __init__(self, df: pl.DataFrame, id: str | None = None):
37
+ super().__init__()
38
+ self.df = df
39
+ self.id = id
40
+
41
+ def compose(self) -> ComposeResult:
42
+ """Create the table. Must be overridden by subclasses."""
43
+ self.table = DataTable(zebra_stripes=True, id=self.id)
44
+ yield self.table
45
+
46
+ def on_key(self, event):
47
+ if event.key in ("q", "escape"):
48
+ self.app.pop_screen()
49
+ event.stop()
50
+ # Prevent key events from propagating to parent screen,
51
+ # except for the following default key bindings for DataTable
52
+ elif event.key not in (
53
+ "up",
54
+ "down",
55
+ "right",
56
+ "left",
57
+ "pageup",
58
+ "pagedown",
59
+ "ctrl+home",
60
+ "ctrl+end",
61
+ "home",
62
+ "end",
63
+ ):
64
+ event.stop()
65
+
66
+ def _filter_or_highlight_selected_value(
67
+ self, col_name_value: tuple[str, str] | None, action: str = "filter"
68
+ ) -> None:
69
+ """Apply filter or highlight action by the selected value from the frequency table.
70
+
71
+ Args:
72
+ col_name: The name of the column to filter/highlight.
73
+ col_value: The value to filter/highlight by.
74
+ action: Either "filter" to filter visible rows, or "highlight" to select matching rows.
75
+ """
76
+ if col_name_value is None:
77
+ return
78
+ col_name, col_value = col_name_value
79
+
80
+ # Handle NULL values
81
+ if col_value == "-":
82
+ # Create expression for NULL values
83
+ expr = pl.col(col_name).is_null()
84
+ value_display = "[on $primary]NULL[/]"
85
+ else:
86
+ # Create expression for the selected value
87
+ expr = pl.col(col_name) == col_value
88
+ value_display = f"[on $primary]{col_value}[/]"
89
+
90
+ app = self.app
91
+ matched_indices = set(
92
+ app.df.with_row_index("__rid__").filter(expr)["__rid__"].to_list()
93
+ )
94
+
95
+ # Apply the action
96
+ if action == "filter":
97
+ # Update visible_rows to reflect the filter
98
+ for i in range(len(app.visible_rows)):
99
+ app.visible_rows[i] = i in matched_indices
100
+ title = "Filter"
101
+ message = f"Filtered by [on $primary]{col_name}[/] = {value_display}"
102
+ else: # action == "highlight"
103
+ # Update selected_rows to reflect the highlights
104
+ for i in range(len(app.selected_rows)):
105
+ app.selected_rows[i] = i in matched_indices
106
+ title = "Highlight"
107
+ message = f"Highlighted [on $primary]{col_name}[/] = {value_display}"
108
+
109
+ # Recreate the table display with updated data in the main app
110
+ app._setup_table()
111
+
112
+ # Dismiss the frequency screen
113
+ self.app.pop_screen()
114
+
115
+ self.notify(message, title=title)
116
+
117
+
118
+ class RowDetailScreen(TableScreen):
119
+ """Modal screen to display a single row's details."""
120
+
121
+ CSS = TableScreen.DEFAULT_CSS.replace("TableScreen", "RowDetailScreen")
122
+
123
+ def __init__(self, row_idx: int, df: pl.DataFrame):
124
+ super().__init__(df, id="row-detail-table")
125
+ self.row_idx = row_idx
126
+
127
+ def on_mount(self) -> None:
128
+ """Create the detail table."""
129
+ self.table.add_column("Column")
130
+ self.table.add_column("Value")
131
+
132
+ # Get all columns and values from the dataframe row
133
+ for col, val, dtype in zip(
134
+ self.df.columns, self.df.row(self.row_idx), self.df.dtypes
135
+ ):
136
+ self.table.add_row(
137
+ *_format_row([col, val], [None, dtype], apply_justify=False)
138
+ )
139
+
140
+ self.table.cursor_type = "row"
141
+
142
+ def on_key(self, event):
143
+ if event.key == "v":
144
+ # Filter the main table by the selected value
145
+ self._filter_or_highlight_selected_value(
146
+ self._get_col_name_value(), action="filter"
147
+ )
148
+ event.stop()
149
+ elif event.key == "quotation_mark": # '"'
150
+ # Highlight the main table by the selected value
151
+ self._filter_or_highlight_selected_value(
152
+ self._get_col_name_value(), action="highlight"
153
+ )
154
+ event.stop()
155
+
156
+ def _get_col_name_value(self) -> tuple[str, Any] | None:
157
+ row_idx = self.table.cursor_row
158
+ if row_idx >= len(self.df.columns):
159
+ return None # Invalid row
160
+
161
+ col_name = self.df.columns[row_idx]
162
+ col_value = self.df.item(self.row_idx, row_idx)
163
+
164
+ return col_name, col_value
165
+
166
+
167
+ class FrequencyScreen(TableScreen):
168
+ """Modal screen to display frequency of values in a column."""
169
+
170
+ CSS = TableScreen.DEFAULT_CSS.replace("TableScreen", "FrequencyScreen")
171
+
172
+ def __init__(self, col_idx: int, df: pl.DataFrame):
173
+ super().__init__(df, id="frequency-table")
174
+ self.col_idx = col_idx
175
+ self.sorted_columns = {
176
+ 1: True, # Count
177
+ 2: True, # %
178
+ }
179
+
180
+ def on_mount(self) -> None:
181
+ """Create the frequency table."""
182
+ column = self.df.columns[self.col_idx]
183
+ dtype = str(self.df.dtypes[self.col_idx])
184
+ dc = DtypeConfig(dtype)
185
+
186
+ # Calculate frequencies using Polars
187
+ freq_df = self.df[column].value_counts(sort=True).sort("count", descending=True)
188
+ total_count = len(self.df)
189
+
190
+ # Create frequency table
191
+ self.table.add_column(Text(column, justify=dc.justify), key=column)
192
+ self.table.add_column(Text("Count", justify="right"), key="Count")
193
+ self.table.add_column(Text("%", justify="right"), key="%")
194
+ self.table.add_column(Text("Histogram", justify="left"), key="Histogram")
195
+
196
+ # Get style config for Int64 and Float64
197
+ ds_int = DtypeConfig("Int64")
198
+ ds_float = DtypeConfig("Float64")
199
+
200
+ # Add rows to the frequency table
201
+ for row_idx, row in enumerate(freq_df.rows()):
202
+ value, count = row
203
+ percentage = (count / total_count) * 100
204
+
205
+ self.table.add_row(
206
+ Text(
207
+ "-" if value is None else str(value),
208
+ style=dc.style,
209
+ justify=dc.justify,
210
+ ),
211
+ Text(str(count), style=ds_int.style, justify=ds_int.justify),
212
+ Text(
213
+ f"{percentage:.2f}",
214
+ style=ds_float.style,
215
+ justify=ds_float.justify,
216
+ ),
217
+ Bar(
218
+ highlight_range=(0.0, percentage / 100 * 10),
219
+ width=10,
220
+ ),
221
+ key=str(row_idx + 1),
222
+ )
223
+
224
+ # Add a total row
225
+ self.table.add_row(
226
+ Text("Total", style="bold", justify=dc.justify),
227
+ Text(f"{total_count:,}", style="bold", justify="right"),
228
+ Text("100.00", style="bold", justify="right"),
229
+ key="total",
230
+ )
231
+
232
+ def on_key(self, event):
233
+ if event.key == "left_square_bracket": # '['
234
+ # Sort by current column in ascending order
235
+ self._sort_by_column(descending=False)
236
+ event.stop()
237
+ elif event.key == "right_square_bracket": # ']'
238
+ # Sort by current column in descending order
239
+ self._sort_by_column(descending=True)
240
+ event.stop()
241
+ elif event.key == "v":
242
+ # Filter the main table by the selected value
243
+ self._filter_or_highlight_selected_value(
244
+ self._get_col_name_value(), action="filter"
245
+ )
246
+ event.stop()
247
+ elif event.key == "quotation_mark": # '"'
248
+ # Highlight the main table by the selected value
249
+ self._filter_or_highlight_selected_value(
250
+ self._get_col_name_value(), action="highlight"
251
+ )
252
+ event.stop()
253
+
254
+ def _sort_by_column(self, descending: bool) -> None:
255
+ """Sort the dataframe by the selected column and refresh the main table."""
256
+ freq_table = self.query_one(DataTable)
257
+
258
+ col_idx = freq_table.cursor_column
259
+ col_dtype = "String"
260
+
261
+ sort_dir = self.sorted_columns.get(col_idx)
262
+ if sort_dir is not None:
263
+ # If already sorted in the same direction, do nothing
264
+ if sort_dir == descending:
265
+ self.notify(
266
+ "Already sorted in that order", title="Sort", severity="warning"
267
+ )
268
+ return
269
+
270
+ self.sorted_columns.clear()
271
+ self.sorted_columns[col_idx] = descending
272
+
273
+ if col_idx == 0:
274
+ col_name = self.df.columns[self.col_idx]
275
+ col_dtype = str(self.df.dtypes[self.col_idx])
276
+ elif col_idx == 1:
277
+ col_name = "Count"
278
+ col_dtype = "Int64"
279
+ elif col_idx == 2:
280
+ col_name = "%"
281
+ col_dtype = "Float64"
282
+
283
+ def key_fun(freq_col):
284
+ col_value = freq_col.plain
285
+
286
+ if col_dtype == "Int64":
287
+ return int(col_value)
288
+ elif col_dtype == "Float64":
289
+ return float(col_value)
290
+ elif col_dtype == "Boolean":
291
+ return BOOLS[col_value]
292
+ else:
293
+ return col_value
294
+
295
+ # Sort the table
296
+ freq_table.sort(
297
+ col_name, key=lambda freq_col: key_fun(freq_col), reverse=descending
298
+ )
299
+
300
+ # Notify the user
301
+ order = "desc" if descending else "asc"
302
+ self.notify(f"Sorted by [on $primary]{col_name}[/] ({order})", title="Sort")
303
+
304
+ def _get_col_name_value(self) -> tuple[str, str] | None:
305
+ row_idx = self.table.cursor_row
306
+ if row_idx >= len(self.df.columns):
307
+ return None # Skip total row
308
+
309
+ col_name = self.df.columns[self.col_idx]
310
+ col_dtype = self.df.dtypes[self.col_idx]
311
+
312
+ cell_value = self.table.get_cell_at(Coordinate(row_idx, 0))
313
+ col_value = cell_value.plain
314
+
315
+ return col_name, DtypeConfig(col_dtype).convert(col_value)