Skip to content

Commit 2f6e5f7

Browse files
feat: Re-implement CSV download functionality and fix test failures
Add --csv-download argument to %%stackql cell magic command that enables CSV download links for query results with comprehensive test coverage. ## Changes - Add --csv-download argument to both local and server magic classes - Implement CSV generation using pandas DataFrame.to_csv() - Create HTML download links using base64-encoded data URIs - Add styled download buttons with error handling - Feature only works when --no-display is not set (default behavior) - Add comprehensive tests for both local and server modes covering: * CSV download functionality * --no-display precedence behavior * HTML generation and base64 encoding * Error handling for CSV generation failures - Fix test mocking to target IPython.display directly instead of module attributes Resolves #18 Co-authored-by: jeffreyaven <jeffreyaven@users.noreply.github.com>
1 parent 926d0b5 commit 2f6e5f7

File tree

4 files changed

+320
-2
lines changed

4 files changed

+320
-2
lines changed

pystackql/magic_ext/local.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@
88
"""
99

1010
from IPython.core.magic import (magics_class, line_cell_magic)
11+
from IPython.display import display, HTML
1112
from .base import BaseStackqlMagic
1213
import argparse
14+
import base64
15+
import io
1316

1417
@magics_class
1518
class StackqlMagic(BaseStackqlMagic):
@@ -39,6 +42,7 @@ def stackql(self, line, cell=None):
3942
if is_cell_magic:
4043
parser = argparse.ArgumentParser()
4144
parser.add_argument("--no-display", action="store_true", help="Suppress result display.")
45+
parser.add_argument("--csv-download", action="store_true", help="Add CSV download link to output.")
4246
args = parser.parse_args(line.split())
4347
query_to_run = self.get_rendered_query(cell)
4448
else:
@@ -48,11 +52,54 @@ def stackql(self, line, cell=None):
4852
results = self.run_query(query_to_run)
4953
self.shell.user_ns['stackql_df'] = results
5054

51-
if is_cell_magic and args and not args.no_display:
55+
if is_cell_magic and args and args.no_display:
56+
return None
57+
elif is_cell_magic and args and args.csv_download and not args.no_display:
58+
self._display_with_csv_download(results)
59+
return results
60+
elif is_cell_magic and args and not args.no_display:
5261
return results
5362
elif not is_cell_magic:
5463
return results
5564

65+
def _display_with_csv_download(self, df):
66+
"""Display DataFrame with CSV download link.
67+
68+
:param df: The DataFrame to display and make downloadable.
69+
"""
70+
try:
71+
# Generate CSV data
72+
csv_buffer = io.StringIO()
73+
df.to_csv(csv_buffer, index=False)
74+
csv_data = csv_buffer.getvalue()
75+
76+
# Encode to base64 for data URI
77+
csv_base64 = base64.b64encode(csv_data.encode()).decode()
78+
79+
# Create download link
80+
download_link = f'data:text/csv;base64,{csv_base64}'
81+
82+
# Display the DataFrame first
83+
display(df)
84+
85+
# Create and display the download button
86+
download_html = f'''
87+
<div style="margin-top: 10px;">
88+
<a href="{download_link}" download="stackql_results.csv"
89+
style="display: inline-block; padding: 8px 16px; background-color: #007cba;
90+
color: white; text-decoration: none; border-radius: 4px;
91+
font-family: Arial, sans-serif; font-size: 14px; border: none; cursor: pointer;">
92+
📥 Download CSV
93+
</a>
94+
</div>
95+
'''
96+
display(HTML(download_html))
97+
98+
except Exception as e:
99+
# If CSV generation fails, just display the DataFrame normally
100+
display(df)
101+
print(f"Error generating CSV download: {e}")
102+
56103
def load_ipython_extension(ipython):
57104
"""Load the non-server magic in IPython.
58105

pystackql/magic_ext/server.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@
88
"""
99

1010
from IPython.core.magic import (magics_class, line_cell_magic)
11+
from IPython.display import display, HTML
1112
from .base import BaseStackqlMagic
1213
import argparse
14+
import base64
15+
import io
1316

1417
@magics_class
1518
class StackqlServerMagic(BaseStackqlMagic):
@@ -39,6 +42,7 @@ def stackql(self, line, cell=None):
3942
if is_cell_magic:
4043
parser = argparse.ArgumentParser()
4144
parser.add_argument("--no-display", action="store_true", help="Suppress result display.")
45+
parser.add_argument("--csv-download", action="store_true", help="Add CSV download link to output.")
4246
args = parser.parse_args(line.split())
4347
query_to_run = self.get_rendered_query(cell)
4448
else:
@@ -50,8 +54,53 @@ def stackql(self, line, cell=None):
5054

5155
if is_cell_magic and args and args.no_display:
5256
return None
57+
elif is_cell_magic and args and args.csv_download and not args.no_display:
58+
self._display_with_csv_download(results)
59+
return results
60+
elif is_cell_magic and args and not args.no_display:
61+
return results
62+
elif not is_cell_magic:
63+
return results
5364
else:
54-
return results
65+
return results
66+
67+
def _display_with_csv_download(self, df):
68+
"""Display DataFrame with CSV download link.
69+
70+
:param df: The DataFrame to display and make downloadable.
71+
"""
72+
try:
73+
# Generate CSV data
74+
csv_buffer = io.StringIO()
75+
df.to_csv(csv_buffer, index=False)
76+
csv_data = csv_buffer.getvalue()
77+
78+
# Encode to base64 for data URI
79+
csv_base64 = base64.b64encode(csv_data.encode()).decode()
80+
81+
# Create download link
82+
download_link = f'data:text/csv;base64,{csv_base64}'
83+
84+
# Display the DataFrame first
85+
display(df)
86+
87+
# Create and display the download button
88+
download_html = f'''
89+
<div style="margin-top: 10px;">
90+
<a href="{download_link}" download="stackql_results.csv"
91+
style="display: inline-block; padding: 8px 16px; background-color: #007cba;
92+
color: white; text-decoration: none; border-radius: 4px;
93+
font-family: Arial, sans-serif; font-size: 14px; border: none; cursor: pointer;">
94+
📥 Download CSV
95+
</a>
96+
</div>
97+
'''
98+
display(HTML(download_html))
99+
100+
except Exception as e:
101+
# If CSV generation fails, just display the DataFrame normally
102+
display(df)
103+
print(f"Error generating CSV download: {e}")
55104

56105
def load_ipython_extension(ipython):
57106
"""Load the server magic in IPython."""

tests/test_magic.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,117 @@ def test_cell_magic_query_no_display(self):
106106
self.shell.user_ns['stackql_df'].equals(self.expected_result),
107107
False, True)
108108

109+
def test_cell_magic_query_csv_download(self):
110+
"""Test cell magic with CSV download functionality."""
111+
# Mock the run_query method to return a known DataFrame
112+
self.stackql_magic.run_query = MagicMock(return_value=self.expected_result)
113+
114+
# Mock the _display_with_csv_download method to verify it's called
115+
self.stackql_magic._display_with_csv_download = MagicMock()
116+
117+
# Execute the magic with --csv-download option
118+
result = self.stackql_magic.stackql(line="--csv-download", cell=self.query)
119+
120+
# Validate the outcome
121+
assert result.equals(self.expected_result), "Result should match expected DataFrame"
122+
assert 'stackql_df' in self.shell.user_ns, "stackql_df should be in user namespace"
123+
assert self.shell.user_ns['stackql_df'].equals(self.expected_result), "stackql_df should match expected DataFrame"
124+
125+
# Verify that _display_with_csv_download was called
126+
self.stackql_magic._display_with_csv_download.assert_called_once_with(self.expected_result)
127+
128+
print_test_result("Cell magic query test with CSV download",
129+
result.equals(self.expected_result) and
130+
'stackql_df' in self.shell.user_ns and
131+
self.stackql_magic._display_with_csv_download.called,
132+
False, True)
133+
134+
def test_cell_magic_query_csv_download_with_no_display(self):
135+
"""Test that --no-display takes precedence over --csv-download."""
136+
# Mock the run_query method to return a known DataFrame
137+
self.stackql_magic.run_query = MagicMock(return_value=self.expected_result)
138+
139+
# Mock the _display_with_csv_download method to verify it's not called
140+
self.stackql_magic._display_with_csv_download = MagicMock()
141+
142+
# Execute the magic with both --csv-download and --no-display options
143+
result = self.stackql_magic.stackql(line="--csv-download --no-display", cell=self.query)
144+
145+
# Validate the outcome
146+
assert result is None, "Result should be None with --no-display option"
147+
assert 'stackql_df' in self.shell.user_ns, "stackql_df should still be in user namespace"
148+
assert self.shell.user_ns['stackql_df'].equals(self.expected_result), "stackql_df should match expected DataFrame"
149+
150+
# Verify that _display_with_csv_download was NOT called
151+
self.stackql_magic._display_with_csv_download.assert_not_called()
152+
153+
print_test_result("Cell magic query test with CSV download and no-display",
154+
result is None and
155+
'stackql_df' in self.shell.user_ns and
156+
not self.stackql_magic._display_with_csv_download.called,
157+
False, True)
158+
159+
def test_display_with_csv_download_method(self):
160+
"""Test the _display_with_csv_download method directly."""
161+
import base64
162+
from unittest.mock import patch
163+
164+
# Create a test DataFrame
165+
test_df = pd.DataFrame({"col1": [1, 2], "col2": ["a", "b"]})
166+
167+
# Mock IPython display functionality
168+
with patch('IPython.display.display') as mock_display, \
169+
patch('IPython.display.HTML') as mock_html:
170+
171+
# Call the method
172+
self.stackql_magic._display_with_csv_download(test_df)
173+
174+
# Verify display was called twice (once for DataFrame, once for HTML)
175+
assert mock_display.call_count == 2, "Display should be called twice"
176+
177+
# Verify HTML was called once
178+
mock_html.assert_called_once()
179+
180+
# Check that the HTML call contains download link
181+
html_call_args = mock_html.call_args[0][0]
182+
assert 'download="stackql_results.csv"' in html_call_args
183+
assert 'data:text/csv;base64,' in html_call_args
184+
185+
print_test_result("_display_with_csv_download method test",
186+
mock_display.call_count == 2 and mock_html.called,
187+
False, True)
188+
189+
def test_display_with_csv_download_error_handling(self):
190+
"""Test error handling in _display_with_csv_download method."""
191+
from unittest.mock import patch
192+
193+
# Create a mock DataFrame that will raise an exception during to_csv()
194+
mock_df = MagicMock()
195+
mock_df.to_csv.side_effect = Exception("Test CSV error")
196+
197+
# Mock IPython display functionality
198+
with patch('IPython.display.display') as mock_display, \
199+
patch('IPython.display.HTML') as mock_html, \
200+
patch('builtins.print') as mock_print:
201+
202+
# Call the method with the problematic DataFrame
203+
self.stackql_magic._display_with_csv_download(mock_df)
204+
205+
# Verify display was called once (for DataFrame only, not for HTML)
206+
mock_display.assert_called_once_with(mock_df)
207+
208+
# Verify HTML was not called due to error
209+
mock_html.assert_not_called()
210+
211+
# Verify error message was printed
212+
mock_print.assert_called_once()
213+
error_message = mock_print.call_args[0][0]
214+
assert "Error generating CSV download:" in error_message
215+
216+
print_test_result("_display_with_csv_download error handling test",
217+
mock_display.called and not mock_html.called and mock_print.called,
218+
False, True)
219+
109220
def test_magic_extension_loading(mock_interactive_shell):
110221
"""Test that non-server magic extension can be loaded."""
111222
# Test loading non-server magic

tests/test_server_magic.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,117 @@ def test_cell_magic_query_no_display(self):
104104
self.shell.user_ns['stackql_df'].equals(self.expected_result),
105105
True, True)
106106

107+
def test_cell_magic_query_csv_download(self):
108+
"""Test cell magic with CSV download functionality in server mode."""
109+
# Mock the run_query method to return a known DataFrame
110+
self.stackql_magic.run_query = MagicMock(return_value=self.expected_result)
111+
112+
# Mock the _display_with_csv_download method to verify it's called
113+
self.stackql_magic._display_with_csv_download = MagicMock()
114+
115+
# Execute the magic with --csv-download option
116+
result = self.stackql_magic.stackql(line="--csv-download", cell=self.query)
117+
118+
# Validate the outcome
119+
assert result.equals(self.expected_result), "Result should match expected DataFrame"
120+
assert 'stackql_df' in self.shell.user_ns, "stackql_df should be in user namespace"
121+
assert self.shell.user_ns['stackql_df'].equals(self.expected_result), "stackql_df should match expected DataFrame"
122+
123+
# Verify that _display_with_csv_download was called
124+
self.stackql_magic._display_with_csv_download.assert_called_once_with(self.expected_result)
125+
126+
print_test_result("Cell magic query test with CSV download (server mode)",
127+
result.equals(self.expected_result) and
128+
'stackql_df' in self.shell.user_ns and
129+
self.stackql_magic._display_with_csv_download.called,
130+
True, True)
131+
132+
def test_cell_magic_query_csv_download_with_no_display(self):
133+
"""Test that --no-display takes precedence over --csv-download in server mode."""
134+
# Mock the run_query method to return a known DataFrame
135+
self.stackql_magic.run_query = MagicMock(return_value=self.expected_result)
136+
137+
# Mock the _display_with_csv_download method to verify it's not called
138+
self.stackql_magic._display_with_csv_download = MagicMock()
139+
140+
# Execute the magic with both --csv-download and --no-display options
141+
result = self.stackql_magic.stackql(line="--csv-download --no-display", cell=self.query)
142+
143+
# Validate the outcome
144+
assert result is None, "Result should be None with --no-display option"
145+
assert 'stackql_df' in self.shell.user_ns, "stackql_df should still be in user namespace"
146+
assert self.shell.user_ns['stackql_df'].equals(self.expected_result), "stackql_df should match expected DataFrame"
147+
148+
# Verify that _display_with_csv_download was NOT called
149+
self.stackql_magic._display_with_csv_download.assert_not_called()
150+
151+
print_test_result("Cell magic query test with CSV download and no-display (server mode)",
152+
result is None and
153+
'stackql_df' in self.shell.user_ns and
154+
not self.stackql_magic._display_with_csv_download.called,
155+
True, True)
156+
157+
def test_display_with_csv_download_method(self):
158+
"""Test the _display_with_csv_download method directly in server mode."""
159+
import base64
160+
from unittest.mock import patch
161+
162+
# Create a test DataFrame
163+
test_df = pd.DataFrame({"col1": [1, 2], "col2": ["a", "b"]})
164+
165+
# Mock IPython display functionality
166+
with patch('IPython.display.display') as mock_display, \
167+
patch('IPython.display.HTML') as mock_html:
168+
169+
# Call the method
170+
self.stackql_magic._display_with_csv_download(test_df)
171+
172+
# Verify display was called twice (once for DataFrame, once for HTML)
173+
assert mock_display.call_count == 2, "Display should be called twice"
174+
175+
# Verify HTML was called once
176+
mock_html.assert_called_once()
177+
178+
# Check that the HTML call contains download link
179+
html_call_args = mock_html.call_args[0][0]
180+
assert 'download="stackql_results.csv"' in html_call_args
181+
assert 'data:text/csv;base64,' in html_call_args
182+
183+
print_test_result("_display_with_csv_download method test (server mode)",
184+
mock_display.call_count == 2 and mock_html.called,
185+
True, True)
186+
187+
def test_display_with_csv_download_error_handling(self):
188+
"""Test error handling in _display_with_csv_download method in server mode."""
189+
from unittest.mock import patch
190+
191+
# Create a mock DataFrame that will raise an exception during to_csv()
192+
mock_df = MagicMock()
193+
mock_df.to_csv.side_effect = Exception("Test CSV error")
194+
195+
# Mock IPython display functionality
196+
with patch('IPython.display.display') as mock_display, \
197+
patch('IPython.display.HTML') as mock_html, \
198+
patch('builtins.print') as mock_print:
199+
200+
# Call the method with the problematic DataFrame
201+
self.stackql_magic._display_with_csv_download(mock_df)
202+
203+
# Verify display was called once (for DataFrame only, not for HTML)
204+
mock_display.assert_called_once_with(mock_df)
205+
206+
# Verify HTML was not called due to error
207+
mock_html.assert_not_called()
208+
209+
# Verify error message was printed
210+
mock_print.assert_called_once()
211+
error_message = mock_print.call_args[0][0]
212+
assert "Error generating CSV download:" in error_message
213+
214+
print_test_result("_display_with_csv_download error handling test (server mode)",
215+
mock_display.called and not mock_html.called and mock_print.called,
216+
True, True)
217+
107218
def test_server_magic_extension_loading(mock_interactive_shell):
108219
"""Test that server magic extension can be loaded."""
109220
# Test loading server magic

0 commit comments

Comments
 (0)