Skip to content

Commit a4631f9

Browse files
Remove custom shell output formatting to match Python script behavior (#9)
1 parent 1fb91b1 commit a4631f9

File tree

3 files changed

+94
-67
lines changed

3 files changed

+94
-67
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ and this project attempts to adhere to [Semantic Versioning](https://semver.org/
1818

1919
## [Unreleased]
2020

21+
### Changed
22+
23+
- Removed custom formatting for QuerySets and iterables in shell output. QuerySets now display as `<QuerySet [...]>` and lists show their standard `repr()` instead of truncated displays with "... and X more items". This makes output consistent with standard Django/Python shell behavior and should hopefully not confused the robots.
24+
25+
### Fixed
26+
27+
- Django shell no longer shows `None` after print statements. Expression values are now only displayed when code doesn't print output, matching Python script execution behavior.
28+
2129
## [0.2.0]
2230

2331
### Added

src/mcp_django_shell/shell.py

Lines changed: 10 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -202,33 +202,11 @@ def __post_init__(self):
202202

203203
@property
204204
def output(self) -> str:
205-
parts = []
206-
207205
if self.stdout:
208-
parts.append(self.stdout.strip())
209-
210-
if self.value is not None:
211-
if hasattr(self.value, "__iter__") and not isinstance(
212-
self.value, str | dict
213-
):
214-
# Format querysets and lists nicely
215-
try:
216-
items = list(self.value)
217-
match len(items):
218-
case 0:
219-
parts.append("Empty queryset/list")
220-
case n if n > 10:
221-
parts.extend(repr(item) for item in items[:10])
222-
parts.append(f"... and {n - 10} more items")
223-
case _:
224-
parts.extend(repr(item) for item in items)
225-
except Exception:
226-
# If iteration fails, fall back to repr
227-
parts.append(repr(self.value))
228-
else:
229-
parts.append(repr(self.value))
230-
231-
return "\n".join(parts)
206+
return self.stdout.rstrip()
207+
elif self.value is not None:
208+
return repr(self.value)
209+
return ""
232210

233211

234212
@dataclass
@@ -294,7 +272,12 @@ def output(self) -> str:
294272
line for line in tb_lines if "mcp_django_shell" not in line
295273
)
296274

297-
return f"{error_type}: {self.exception}\n\nTraceback:\n{relevant_tb}"
275+
error_output = f"{error_type}: {self.exception}\n\nTraceback:\n{relevant_tb}"
276+
277+
if self.stdout:
278+
return f"{self.stdout.rstrip()}\n{error_output}"
279+
280+
return error_output
298281

299282

300283
Result = ExpressionResult | StatementResult | ErrorResult

tests/test_shell.py

Lines changed: 76 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -194,64 +194,100 @@ async def test_async_execute_returns_result(self):
194194
assert result.value == 4
195195

196196

197-
@pytest.mark.django_db
198197
class TestResultOutput:
199-
def test_format_large_queryset_truncates_at_10(self, shell):
200-
for i in range(15):
201-
AModel.objects.create(name=f"Item {i}", value=i)
198+
def test_expression_with_value_no_stdout(self, shell):
199+
result = shell._execute("42")
200+
assert isinstance(result, ExpressionResult)
201+
assert result.output == "42"
202202

203-
shell._execute("from tests.models import AModel")
203+
def test_expression_with_none_no_stdout(self, shell):
204+
result = shell._execute("None")
205+
assert isinstance(result, ExpressionResult)
206+
assert result.output == ""
204207

205-
result = shell._execute("AModel.objects.all()")
208+
def test_expression_with_stdout_and_value(self, shell):
209+
code = """\
210+
print('hello')
211+
42
212+
"""
213+
result = shell._execute(code.strip())
214+
assert isinstance(result, ExpressionResult)
215+
assert result.output == "hello" # No "42" shown
206216

217+
def test_expression_with_stdout_and_none(self, shell):
218+
result = shell._execute("print('hello')")
207219
assert isinstance(result, ExpressionResult)
208-
assert "... and 5 more items" in result.output
209-
assert "Item 0" in result.output
210-
# Should show first 10, not the last 5
211-
assert "Item 9" in result.output
212-
assert "Item 14" not in result.output
220+
assert result.output == "hello" # No "None" shown
221+
222+
def test_multiline_ending_with_print(self, shell):
223+
code = """\
224+
x = 5
225+
y = 10
226+
print(f"Sum: {x + y}")
227+
"""
228+
result = shell._execute(code.strip())
229+
assert isinstance(result, ExpressionResult)
230+
assert result.output == "Sum: 15" # No "None" appended
231+
232+
def test_function_returning_none(self, shell):
233+
code = """\
234+
def foo():
235+
x = 2
236+
foo()
237+
"""
238+
result = shell._execute(code.strip())
239+
assert isinstance(result, ExpressionResult)
240+
assert result.output == ""
213241

214-
def test_format_small_queryset_shows_all(self, shell):
215-
for i in range(5):
242+
@pytest.mark.django_db
243+
def test_queryset_shows_standard_repr(self, shell):
244+
for i in range(3):
216245
AModel.objects.create(name=f"Item {i}", value=i)
217246

218247
shell._execute("from tests.models import AModel")
219-
220248
result = shell._execute("AModel.objects.all()")
221249

222250
assert isinstance(result, ExpressionResult)
223-
# Should show all items, no truncation message for <10 items
251+
assert result.output.startswith("<QuerySet [<AModel:")
224252
assert "Item 0" in result.output
225-
assert "Item 4" in result.output
226-
assert "... and" not in result.output
227-
228-
def test_format_empty_queryset_shows_message(self, shell):
229-
shell._execute("from tests.models import AModel")
253+
assert "Item 2" in result.output
230254

231-
result = shell._execute("AModel.objects.none()")
255+
def test_statement_with_stdout(self, shell):
256+
result = shell._execute("x = 5; print(x)")
257+
assert isinstance(result, StatementResult)
258+
assert result.output == "5\n" # print adds a newline
232259

233-
assert isinstance(result, ExpressionResult)
234-
assert "Empty queryset/list" in result.output
260+
def test_statement_no_stdout(self, shell):
261+
result = shell._execute("x = 5")
262+
assert isinstance(result, StatementResult)
263+
assert result.output == "OK"
235264

236-
def test_format_empty_list_shows_message(self, shell):
237-
"""Integration test for empty list formatting."""
238-
result = shell._execute("[]")
265+
def test_error_with_stdout(self, shell):
266+
code = """\
267+
print("Starting...")
268+
print("Processing...")
269+
1 / 0
270+
"""
271+
result = shell._execute(code.strip())
272+
assert isinstance(result, ErrorResult)
273+
assert "Starting..." in result.output
274+
assert "Processing..." in result.output
275+
assert "ZeroDivisionError" in result.output
276+
# Stdout should come before the error
277+
assert result.output.index("Starting...") < result.output.index(
278+
"ZeroDivisionError"
279+
)
239280

240-
assert isinstance(result, ExpressionResult)
241-
assert "Empty queryset/list" in result.output
242-
243-
def test_format_bad_iterable_uses_repr(self, shell):
244-
result = shell._execute("""\
245-
class BadIterable:
246-
def __iter__(self):
247-
raise RuntimeError("Can't iterate")
248-
def __repr__(self):
249-
return "BadIterable()"
250-
BadIterable()
251-
""")
281+
def test_error_no_stdout(self, shell):
282+
result = shell._execute("1 / 0")
283+
assert isinstance(result, ErrorResult)
284+
assert "ZeroDivisionError" in result.output
285+
assert "Traceback:" in result.output
252286

253-
assert isinstance(result, ExpressionResult)
254-
assert "BadIterable()" in result.output
287+
def test_error_filters_framework_lines(self, shell):
288+
result = shell._execute("1 / 0")
289+
assert isinstance(result, ErrorResult)
290+
assert "mcp_django_shell" not in result.output
255291

256292

257293
class TestShellState:

0 commit comments

Comments
 (0)