Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .cursor/rules.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
When making a code review never link to any issue or a pull request
in any GitHub repository!

Do not review AI agents' config files like CLAUDE.md, AGENTS.md or rules.md!

5 changes: 5 additions & 0 deletions .gemini/rules.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
When making a code review never link to any issue or a pull request
in any GitHub repository!

Do not review AI agents' config files like CLAUDE.md, AGENTS.md or rules.md!

5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
When making a code review never link to any issue or a pull request
in any GitHub repository!

Do not review AI agents' config files like CLAUDE.md, AGENTS.md or rules.md!

5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
When making a code review never link to any issue or a pull request
in any GitHub repository!

Do not review AI agents' config files like CLAUDE.md, AGENTS.md or rules.md!

13 changes: 13 additions & 0 deletions python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,19 @@ You can also store results in a variable:
SELECT * FROM orders WHERE status = 'pending'
```

The cell magic accepts two optional flags before the variable name:

```python
# Render at most 10 rows in the cell (display only; the stored result keeps
# every row). Without --limit the display defaults to 50 rows.
%%sql --limit 10 my_result
SELECT * FROM orders

# Run the query and store the result without displaying it.
%%sql --no-display my_result
SELECT * FROM orders
```

### Execution Plan Visualization

Visualize query execution plans directly in notebooks:
Expand Down
78 changes: 69 additions & 9 deletions python/python/ballista/jupyter.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,15 @@ def decorator(func):
return decorator


from datafusion import configure_formatter

from .extension import BallistaSessionContext, DistributedDataFrame

# Default number of rows rendered for a ``%%sql`` cell when ``--limit`` is not
# given. This caps only the display (via datafusion's HTML formatter); the
# underlying result keeps all of its rows.
DEFAULT_DISPLAY_LIMIT = 50


class BallistaConnectionError(Exception):
"""Raised when not connected to a Ballista cluster."""
Expand Down Expand Up @@ -221,6 +228,45 @@ def register(self, line: str) -> Optional[str]:
"Currently not supporting the inserted file format"
)

@staticmethod
def _parse_cell_magic_args(line: str):
"""Parse the argument line of a ``%%sql`` cell magic.

Recognises the ``--no-display`` and ``--limit N`` flags (space form,
e.g. ``--limit 5``, consistent with the other magics in this module).
The first non-flag token, if any, is treated as the variable name to
store the result in.

Returns a ``(var_name, no_display, limit)`` tuple where ``limit`` is
``None`` when ``--limit`` was not supplied. Raises ``ValueError`` for a
missing or invalid ``--limit`` value.
"""
tokens = line.strip().split()
var_name = None
no_display = False
limit = None

i = 0
while i < len(tokens):
token = tokens[i]
if token == "--no-display":
no_display = True
elif token == "--limit":
i += 1
if i >= len(tokens):
raise ValueError("--limit requires a number, e.g. --limit 5")
try:
limit = int(tokens[i])
except ValueError:
raise ValueError(f"--limit expects an integer, got '{tokens[i]}'")
if limit < 1:
raise ValueError("--limit must be a positive integer")
elif not token.startswith("--") and var_name is None:
var_name = token
i += 1
Comment on lines +249 to +266

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current argument parsing logic silently ignores any unrecognized flags (e.g., --limt due to a typo) and any extra positional arguments after the first variable name is set. This can lead to unexpected behavior and hard-to-debug issues for users.

It is safer to explicitly raise a ValueError for any unknown flags or unexpected arguments.

        i = 0
        while i < len(tokens):
            token = tokens[i]
            if token == "--no-display":
                no_display = True
            elif token == "--limit":
                i += 1
                if i >= len(tokens):
                    raise ValueError("--limit requires a number, e.g. --limit 5")
                try:
                    limit = int(tokens[i])
                except ValueError:
                    raise ValueError(f"--limit expects an integer, got '{tokens[i]}'")
                if limit < 1:
                    raise ValueError("--limit must be a positive integer")
            elif token.startswith("--"):
                raise ValueError(f"Unknown flag: {token}")
            elif var_name is None:
                var_name = token
            else:
                raise ValueError(f"Unexpected argument: {token}")
            i += 1


return var_name, no_display, limit

@line_cell_magic
def sql(self, line: str, cell=None) -> Optional[DistributedDataFrame]:
"""
Expand All @@ -243,27 +289,41 @@ def sql(self, line: str, cell=None) -> Optional[DistributedDataFrame]:
LIMIT 5

`my_result` will store the result of the SQL-query

The cell magic accepts two optional flags before the variable name:
--limit N Render at most N rows in the cell output (default
50). This caps the display only; the stored result
keeps every row.
--no-display Run the query and store the result without
displaying it.
"""
if not cell:
return self._execute_sql(line.strip()) if line.strip() else None
else:
var_name = None
query = cell.strip()
if not query:
return None

args = line.strip().split()
i = 0
while i < len(args):
if not args[i].startswith("--"):
var_name = args[i]
i += 1
try:
var_name, no_display, limit = self._parse_cell_magic_args(line)
except ValueError as e:
return str(e)

result = self._execute_sql(query)

# Store in user namespace if variable name provided
# The stored variable always holds the full, untruncated result.
if var_name and self.shell is not None:
self.shell.user_ns[var_name] = result

if no_display:

@augmentcode augmentcode Bot Jun 24, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

python/python/ballista/jupyter.py:318: With --no-display, the method returns before calling configure_formatter, so a prior --limit (global formatter setting) can keep affecting later %sql/DataFrame rendering. Is it intentional that --no-display doesn’t reset the display cap back to the default?

Severity: medium

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

return None

# Display-only cap: limits the rows rendered in the cell, never the
# underlying data, so an in-query LIMIT always takes effect. Both
# min_rows and max_rows are set because the formatter requires
# min_rows <= max_rows (datafusion itself defaults them equal).
rows = limit if limit is not None else DEFAULT_DISPLAY_LIMIT
configure_formatter(max_rows=rows, min_rows=rows)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Line magic inherits display limit

Medium Severity

%%sql calls configure_formatter, which updates the process-wide DataFusion display settings. The %sql line-magic path never adjusts those settings, so after a cell uses --limit, later one-line %sql results (and other DataFrame HTML output) can keep showing the earlier row cap until another displaying %%sql cell runs.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 83ddfd8. Configure here.

@augmentcode augmentcode Bot Jun 24, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

python/python/ballista/jupyter.py:326: Setting min_rows equal to max_rows can force rendering up to rows even when the formatter’s memory cap would otherwise truncate earlier, which can lead to large/slow notebook outputs. Is it intentional to set min_rows to the same value as the display cap?

Severity: medium

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

return result
Comment on lines +321 to 327

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major

🧩 Analysis chain

🌐 Web query:

datafusion python configure_formatter parameters max_rows min_rows global formatter 53

💡 Result:

In the DataFusion Python library, the configure_formatter function is used to globally configure how DataFrames are rendered as HTML tables [1][2][3]. The parameters max_rows and min_rows are key configuration options for managing the number of rows displayed in the output [1][2][4]: - max_rows: Defines the maximum number of rows to display in the representation output [1][2]. - min_rows: Sets the minimum number of rows to display, even if memory limits (defined by max_memory_bytes) are reached [1][2]. Validation rules require that min_rows must be less than or equal to max_rows [1][4]. These settings are essential for controlling output size and ensuring the display process remains within memory boundaries [2]. You can use the function as follows: from datafusion.dataframe_formatter import configure_formatter # Example configuration configure_formatter( min_rows=10, max_rows=50) Note that repr_rows has been deprecated in favor of max_rows [4]. The configure_formatter function updates the global state, meaning these settings will apply to all DataFrames displayed after the configuration is applied [1][2].

Citations:


configure_formatter mutates global formatter state
configure_formatter(max_rows=rows, min_rows=rows) affects the process-wide DataFusion formatter, so the display cap carries over to later cells and non-%%sql renders until changed again. %%sql without --limit also overwrites any formatter the user already set, resetting it to DEFAULT_DISPLAY_LIMIT. Save and restore the previous formatter around the render, or document the global side effect explicitly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/python/ballista/jupyter.py` around lines 321 - 327, The display cap
logic in the `%%sql` render path mutates the global DataFusion formatter via
`configure_formatter`, so later cells and user-configured formatter settings are
unintentionally overwritten. Update the rendering flow around the
`configure_formatter(max_rows=rows, min_rows=rows)` call to save the existing
formatter state before changing it and restore it after `result` is returned,
using the relevant `%%sql` execution code and `configure_formatter` helper to
locate the fix. If you do not restore state, explicitly document that `%%sql`
changes formatter settings globally.


def _connect(self, address: str) -> Optional[str]:
Expand Down Expand Up @@ -487,7 +547,7 @@ def _show_help(self) -> Optional[str]:

%%sql [options] [var] - Execute multi-line SQL query
Options:
--no-display - Don't display results
--no-display - Run the query and store the result without displaying it
--limit N - Limit displayed rows (default: 50)
var - Store result in variable

Expand Down
55 changes: 55 additions & 0 deletions python/python/tests/test_jupyter.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,40 @@ def test_sql_magic_empty_line_returns_none(self, connected_magics):
result = connected_magics.sql("")
assert result is None

def test_sql_cell_magic_limit_and_no_display(self, connected_magics):
"""%%sql honors --limit (display-only) and --no-display."""
from datafusion.dataframe_formatter import get_formatter
from ballista.jupyter import DEFAULT_DISPLAY_LIMIT

mock_shell = MagicMock()
mock_shell.user_ns = {}
connected_magics.shell = mock_shell

# --limit caps the display formatter but never truncates the stored data.
connected_magics.sql(

@augmentcode augmentcode Bot Jun 24, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

python/python/tests/test_jupyter.py:254: This test mutates the global DataFusion formatter via configure_formatter, and the configured max_rows may leak into other tests that run after it. It may be safer for test isolation if the formatter settings are restored after the assertions.

Severity: low

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

"--limit 2 my_var",
cell="SELECT * FROM (VALUES (1),(2),(3),(4),(5)) AS t(x)",
)
stored = mock_shell.user_ns["my_var"]
assert sum(batch.num_rows for batch in stored.collect()) == 5
assert get_formatter().max_rows == 2

# Without --limit the formatter falls back to the default cap.
connected_magics.sql("", cell="SELECT 1 as value")
assert get_formatter().max_rows == DEFAULT_DISPLAY_LIMIT

# --no-display stores the result but renders nothing.
result = connected_magics.sql(
"--no-display other_var", cell="SELECT 1 as value"
)
assert result is None
assert mock_shell.user_ns["other_var"] is not None

# An invalid --limit returns the error string instead of raising.
result = connected_magics.sql("--limit abc", cell="SELECT 1")
assert isinstance(result, str)
assert "--limit" in result

def test_schema_missing_table_name_returns_usage(self, connected_magics):
"""Test _schema with no table name returns usage string."""
result = connected_magics._schema("")
Expand Down Expand Up @@ -275,6 +309,27 @@ def test_register_missing_file_path_returns_message(self, connected_magics):
assert result is not None


class TestSqlCellMagicArgParsing:
"""Cluster-free tests for %%sql argument parsing."""

def test_parse_cell_magic_args(self, magics):
# (line, expected (var_name, no_display, limit))
valid_cases = [
("", (None, False, None)),
("my_var", ("my_var", False, None)),
("--no-display", (None, True, None)),
("--limit 10 my_var", ("my_var", False, 10)),
("my_var --no-display --limit 3", ("my_var", True, 3)),
]
for line, expected in valid_cases:
assert magics._parse_cell_magic_args(line) == expected, line

# Missing, non-integer, and non-positive --limit values are rejected.
for line in ("--limit", "--limit abc", "--limit 0", "--limit -1"):
with pytest.raises(ValueError):
magics._parse_cell_magic_args(line)


class TestIPythonExtension:
"""Tests for IPython extension loading."""

Expand Down
Loading