dataframe-textual 0.2.1__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.
- dataframe_textual/__init__.py +35 -0
- dataframe_textual/__main__.py +48 -0
- dataframe_textual/common.py +204 -0
- dataframe_textual/data_frame_help_panel.py +98 -0
- dataframe_textual/data_frame_table.py +1395 -0
- dataframe_textual/data_frame_viewer.py +320 -0
- dataframe_textual/table_screen.py +311 -0
- dataframe_textual/yes_no_screen.py +409 -0
- dataframe_textual-0.2.1.dist-info/METADATA +549 -0
- dataframe_textual-0.2.1.dist-info/RECORD +13 -0
- dataframe_textual-0.2.1.dist-info/WHEEL +4 -0
- dataframe_textual-0.2.1.dist-info/entry_points.txt +2 -0
- dataframe_textual-0.2.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""DataFrame Viewer - Interactive CSV/Excel viewer for the terminal."""
|
|
2
|
+
|
|
3
|
+
from .data_frame_help_panel import DataFrameHelpPanel
|
|
4
|
+
from .data_frame_table import DataFrameTable, History
|
|
5
|
+
from .data_frame_viewer import DataFrameViewer, _load_dataframe
|
|
6
|
+
from .table_screen import FrequencyScreen, RowDetailScreen, TableScreen
|
|
7
|
+
from .yes_no_screen import (
|
|
8
|
+
ConfirmScreen,
|
|
9
|
+
EditCellScreen,
|
|
10
|
+
FilterScreen,
|
|
11
|
+
FreezeScreen,
|
|
12
|
+
OpenFileScreen,
|
|
13
|
+
SaveFileScreen,
|
|
14
|
+
SearchScreen,
|
|
15
|
+
YesNoScreen,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"DataFrameViewer",
|
|
20
|
+
"DataFrameHelpPanel",
|
|
21
|
+
"DataFrameTable",
|
|
22
|
+
"History",
|
|
23
|
+
"TableScreen",
|
|
24
|
+
"RowDetailScreen",
|
|
25
|
+
"FrequencyScreen",
|
|
26
|
+
"YesNoScreen",
|
|
27
|
+
"SaveFileScreen",
|
|
28
|
+
"ConfirmScreen",
|
|
29
|
+
"EditCellScreen",
|
|
30
|
+
"SearchScreen",
|
|
31
|
+
"FilterScreen",
|
|
32
|
+
"FreezeScreen",
|
|
33
|
+
"OpenFileScreen",
|
|
34
|
+
"_load_dataframe",
|
|
35
|
+
]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Entry point for running DataFrameViewer as a module."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .data_frame_viewer import DataFrameViewer
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main():
|
|
11
|
+
"""Run the DataFrame Viewer application."""
|
|
12
|
+
parser = argparse.ArgumentParser(
|
|
13
|
+
description="Interactive CSV/Excel viewer for the terminal (Textual version)",
|
|
14
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
15
|
+
epilog="Examples:\n"
|
|
16
|
+
" dataframe-viewer data.csv\n"
|
|
17
|
+
" dataframe-viewer file1.csv file2.csv file3.csv\n"
|
|
18
|
+
" dataframe-viewer data.xlsx (opens all sheets in tabs)\n"
|
|
19
|
+
" cat data.csv | dataframe-viewer\n",
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument(
|
|
22
|
+
"files", nargs="*", help="CSV or Excel files to view (or read from stdin)"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
args = parser.parse_args()
|
|
26
|
+
filenames = []
|
|
27
|
+
|
|
28
|
+
# Check if reading from stdin (pipe or redirect)
|
|
29
|
+
if not sys.stdin.isatty():
|
|
30
|
+
filenames = ["-"]
|
|
31
|
+
elif args.files:
|
|
32
|
+
# Validate all files exist
|
|
33
|
+
for filename in args.files:
|
|
34
|
+
if not Path(filename).exists():
|
|
35
|
+
print(f"File not found: {filename}")
|
|
36
|
+
sys.exit(1)
|
|
37
|
+
filenames = args.files
|
|
38
|
+
|
|
39
|
+
if not filenames:
|
|
40
|
+
parser.print_help()
|
|
41
|
+
sys.exit(1)
|
|
42
|
+
|
|
43
|
+
app = DataFrameViewer(*filenames)
|
|
44
|
+
app.run()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
if __name__ == "__main__":
|
|
48
|
+
main()
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Common utilities and constants for dataframe_viewer."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import polars as pl
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
|
|
10
|
+
# Boolean string mappings
|
|
11
|
+
BOOLS = {
|
|
12
|
+
"true": True,
|
|
13
|
+
"t": True,
|
|
14
|
+
"yes": True,
|
|
15
|
+
"y": True,
|
|
16
|
+
"1": True,
|
|
17
|
+
"false": False,
|
|
18
|
+
"f": False,
|
|
19
|
+
"no": False,
|
|
20
|
+
"n": False,
|
|
21
|
+
"0": False,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
# itype is used by Input widget for input validation
|
|
25
|
+
# fmt: off
|
|
26
|
+
STYLES = {
|
|
27
|
+
"Int64": {"style": "cyan", "justify": "right", "itype": "integer", "convert": int},
|
|
28
|
+
"Float64": {"style": "magenta", "justify": "right", "itype": "number", "convert": float},
|
|
29
|
+
"String": {"style": "green", "justify": "left", "itype": "text", "convert": str},
|
|
30
|
+
"Boolean": {"style": "blue", "justify": "center", "itype": "text", "convert": lambda x: BOOLS[x.lower()]},
|
|
31
|
+
"Date": {"style": "blue", "justify": "center", "itype": "text", "convert": str},
|
|
32
|
+
"Datetime": {"style": "blue", "justify": "center", "itype": "text", "convert": str},
|
|
33
|
+
}
|
|
34
|
+
# fmt: on
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class DtypeConfig:
|
|
39
|
+
style: str
|
|
40
|
+
justify: str
|
|
41
|
+
itype: str
|
|
42
|
+
convert: Any
|
|
43
|
+
|
|
44
|
+
def __init__(self, dtype: pl.DataType):
|
|
45
|
+
dc = STYLES.get(
|
|
46
|
+
str(dtype), {"style": "", "justify": "", "itype": "text", "convert": str}
|
|
47
|
+
)
|
|
48
|
+
self.style = dc["style"]
|
|
49
|
+
self.justify = dc["justify"]
|
|
50
|
+
self.itype = dc["itype"]
|
|
51
|
+
self.convert = dc["convert"]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# Subscript digits mapping for sort indicators
|
|
55
|
+
SUBSCRIPT_DIGITS = {
|
|
56
|
+
0: "₀",
|
|
57
|
+
1: "₁",
|
|
58
|
+
2: "₂",
|
|
59
|
+
3: "₃",
|
|
60
|
+
4: "₄",
|
|
61
|
+
5: "₅",
|
|
62
|
+
6: "₆",
|
|
63
|
+
7: "₇",
|
|
64
|
+
8: "₈",
|
|
65
|
+
9: "₉",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
# Cursor types ("none" removed)
|
|
69
|
+
CURSOR_TYPES = ["row", "column", "cell"]
|
|
70
|
+
|
|
71
|
+
# Pagination settings
|
|
72
|
+
INITIAL_BATCH_SIZE = 100 # Load this many rows initially
|
|
73
|
+
BATCH_SIZE = 50 # Load this many rows when scrolling
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _format_row(vals, dtypes, apply_justify=True) -> list[Text]:
|
|
77
|
+
"""Format a single row with proper styling and justification.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
vals: The list of values in the row.
|
|
81
|
+
dtypes: The list of data types corresponding to each value.
|
|
82
|
+
apply_justify: Whether to apply justification styling. Defaults to True.
|
|
83
|
+
"""
|
|
84
|
+
formatted_row = []
|
|
85
|
+
|
|
86
|
+
for val, dtype in zip(vals, dtypes, strict=True):
|
|
87
|
+
dc = DtypeConfig(dtype)
|
|
88
|
+
|
|
89
|
+
# Format the value
|
|
90
|
+
if val is None:
|
|
91
|
+
text_val = "-"
|
|
92
|
+
elif str(dtype).startswith("Float"):
|
|
93
|
+
text_val = f"{val:.4g}"
|
|
94
|
+
else:
|
|
95
|
+
text_val = str(val)
|
|
96
|
+
|
|
97
|
+
formatted_row.append(
|
|
98
|
+
Text(
|
|
99
|
+
text_val,
|
|
100
|
+
style=dc.style,
|
|
101
|
+
justify=dc.justify if apply_justify else "",
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
return formatted_row
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _rindex(lst: list, value) -> int:
|
|
109
|
+
"""Return the last index of value in a list. Return -1 if not found."""
|
|
110
|
+
for i, item in enumerate(reversed(lst)):
|
|
111
|
+
if item == value:
|
|
112
|
+
return len(lst) - 1 - i
|
|
113
|
+
return -1
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _next(lst: list[Any], current, offset=1) -> Any:
|
|
117
|
+
"""Return the next item in the list after the current item, cycling if needed."""
|
|
118
|
+
if current not in lst:
|
|
119
|
+
raise ValueError("Current item not in list")
|
|
120
|
+
current_index = lst.index(current)
|
|
121
|
+
next_index = (current_index + offset) % len(lst)
|
|
122
|
+
return lst[next_index]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def parse_filter_expression(
|
|
126
|
+
expression: str, df: pl.DataFrame, current_col_idx: int
|
|
127
|
+
) -> str:
|
|
128
|
+
"""Parse and convert a filter expression to Polars syntax.
|
|
129
|
+
|
|
130
|
+
Supports:
|
|
131
|
+
- $_ - Current selected column
|
|
132
|
+
- $1, $2, etc. - Column by 1-based index
|
|
133
|
+
- $col_name - Column by name
|
|
134
|
+
- Comparison operators: ==, !=, <, >, <=, >=
|
|
135
|
+
- Logical operators: &&, ||
|
|
136
|
+
- String literals: 'text', "text"
|
|
137
|
+
- Numeric literals: integers and floats
|
|
138
|
+
|
|
139
|
+
Examples:
|
|
140
|
+
- "$_ > 50" -> "pl.col('current_col') > 50"
|
|
141
|
+
- "$1 > 50" -> "pl.col('col0') > 50"
|
|
142
|
+
- "$name == 'Alex'" -> "pl.col('name') == 'Alex'"
|
|
143
|
+
- "$1 > 3 && $name == 'Alex'" -> "(pl.col('col0') > 3) & (pl.col('name') == 'Alex')"
|
|
144
|
+
- "$age < $salary" -> "pl.col('age') < pl.col('salary')"
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
expression: The filter expression as a string.
|
|
148
|
+
df: The DataFrame to validate column references.
|
|
149
|
+
current_col_idx: The index of the currently selected column (0-based). Used for $_ reference.
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
A Python expression string that can be eval'd with Polars symbols.
|
|
153
|
+
|
|
154
|
+
Raises:
|
|
155
|
+
ValueError: If the expression contains invalid column references.
|
|
156
|
+
SyntaxError: If the expression has invalid syntax.
|
|
157
|
+
"""
|
|
158
|
+
# Tokenize the expression
|
|
159
|
+
# Pattern matches: $_, $index, $identifier, strings, operators, numbers, etc.
|
|
160
|
+
token_pattern = r'\$_|\$\d+|\$\w+|\'[^\']*\'|"[^"]*"|&&|\|\||<=|>=|!=|==|[+\-*/%<>=()]|\d+\.?\d*|\w+|.'
|
|
161
|
+
|
|
162
|
+
tokens = re.findall(token_pattern, expression)
|
|
163
|
+
|
|
164
|
+
if not tokens:
|
|
165
|
+
raise ValueError("Expression is empty")
|
|
166
|
+
|
|
167
|
+
# Convert tokens to Polars expression syntax
|
|
168
|
+
converted_tokens = []
|
|
169
|
+
for token in tokens:
|
|
170
|
+
if token.startswith("$"):
|
|
171
|
+
# Column reference
|
|
172
|
+
col_ref = token[1:]
|
|
173
|
+
|
|
174
|
+
# Special case: $_ refers to the current selected column
|
|
175
|
+
if col_ref == "_":
|
|
176
|
+
col_name = df.columns[current_col_idx]
|
|
177
|
+
# Check if it's a numeric index
|
|
178
|
+
elif col_ref.isdigit():
|
|
179
|
+
col_idx = int(col_ref) - 1 # Convert to 0-based index
|
|
180
|
+
if col_idx < 0 or col_idx >= len(df.columns):
|
|
181
|
+
raise ValueError(f"Column index out of range: ${col_ref}")
|
|
182
|
+
col_name = df.columns[col_idx]
|
|
183
|
+
else:
|
|
184
|
+
# It's a column name
|
|
185
|
+
if col_ref not in df.columns:
|
|
186
|
+
raise ValueError(f"Column not found: ${col_ref}")
|
|
187
|
+
col_name = col_ref
|
|
188
|
+
|
|
189
|
+
converted_tokens.append(f"pl.col('{col_name}')")
|
|
190
|
+
|
|
191
|
+
elif token in ("&&", "||"):
|
|
192
|
+
# Convert logical operators and wrap surrounding expressions in parentheses
|
|
193
|
+
if token == "&&":
|
|
194
|
+
converted_tokens.append(") & (")
|
|
195
|
+
else:
|
|
196
|
+
converted_tokens.append(") | (")
|
|
197
|
+
|
|
198
|
+
else:
|
|
199
|
+
# Keep as-is (operators, numbers, strings, parentheses)
|
|
200
|
+
converted_tokens.append(token)
|
|
201
|
+
|
|
202
|
+
# Join tokens with space to ensure proper separation
|
|
203
|
+
result = "(" + " ".join(converted_tokens) + ")"
|
|
204
|
+
return result
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Help panel widget for displaying context-sensitive help."""
|
|
2
|
+
|
|
3
|
+
from textwrap import dedent
|
|
4
|
+
|
|
5
|
+
from textual.containers import VerticalScroll
|
|
6
|
+
from textual.css.query import NoMatches
|
|
7
|
+
from textual.widget import Widget
|
|
8
|
+
from textual.widgets import Markdown
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DataFrameHelpPanel(Widget):
|
|
12
|
+
"""
|
|
13
|
+
Shows context sensitive help for the currently focused widget.
|
|
14
|
+
|
|
15
|
+
Modified from Textual's built-in HelpPanel with KeyPanel removed.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
DEFAULT_CSS = """
|
|
19
|
+
DataFrameHelpPanel {
|
|
20
|
+
split: right;
|
|
21
|
+
width: 33%;
|
|
22
|
+
min-width: 30;
|
|
23
|
+
max-width: 60;
|
|
24
|
+
border-left: vkey $foreground 30%;
|
|
25
|
+
padding: 0 1;
|
|
26
|
+
height: 1fr;
|
|
27
|
+
padding-right: 1;
|
|
28
|
+
layout: vertical;
|
|
29
|
+
height: 100%;
|
|
30
|
+
|
|
31
|
+
&:ansi {
|
|
32
|
+
background: ansi_default;
|
|
33
|
+
border-left: vkey ansi_black;
|
|
34
|
+
|
|
35
|
+
Markdown {
|
|
36
|
+
background: ansi_default;
|
|
37
|
+
}
|
|
38
|
+
.bindings-table--divide {
|
|
39
|
+
color: transparent;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
#widget-help {
|
|
44
|
+
height: auto;
|
|
45
|
+
width: 1fr;
|
|
46
|
+
padding: 0;
|
|
47
|
+
margin: 0;
|
|
48
|
+
padding: 1 0;
|
|
49
|
+
margin-top: 1;
|
|
50
|
+
display: none;
|
|
51
|
+
background: $panel;
|
|
52
|
+
|
|
53
|
+
&:ansi {
|
|
54
|
+
background: ansi_default;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
MarkdownBlock {
|
|
58
|
+
padding-left: 2;
|
|
59
|
+
padding-right: 2;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
&.-show-help #widget-help {
|
|
64
|
+
display: block;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
DEFAULT_CLASSES = "-textual-system"
|
|
70
|
+
|
|
71
|
+
def on_mount(self):
|
|
72
|
+
def update_help(focused_widget: Widget | None):
|
|
73
|
+
self.update_help(focused_widget)
|
|
74
|
+
|
|
75
|
+
self.watch(self.screen, "focused", update_help)
|
|
76
|
+
|
|
77
|
+
def update_help(self, focused_widget: Widget | None) -> None:
|
|
78
|
+
"""Update the help for the focused widget.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
focused_widget: The currently focused widget, or `None` if no widget was focused.
|
|
82
|
+
"""
|
|
83
|
+
if not self.app.app_focus:
|
|
84
|
+
return
|
|
85
|
+
if not self.screen.is_active:
|
|
86
|
+
return
|
|
87
|
+
self.set_class(focused_widget is not None, "-show-help")
|
|
88
|
+
if focused_widget is not None:
|
|
89
|
+
help = self.app.HELP + "\n" + focused_widget.HELP or ""
|
|
90
|
+
if not help:
|
|
91
|
+
self.remove_class("-show-help")
|
|
92
|
+
try:
|
|
93
|
+
self.query_one(Markdown).update(dedent(help))
|
|
94
|
+
except NoMatches:
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
def compose(self):
|
|
98
|
+
yield VerticalScroll(Markdown(id="widget-help"))
|