Skip to content
Draft
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
36 changes: 36 additions & 0 deletions pedal/assertions/feedbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ class RuntimeAssertionFeedback(AssertionFeedback):
message_template = ("Student code failed instructor test.\n"
"{context_message}"
"{assertion_message}"
"{source_context}"
"{explanation}")
# TODO: The explanation field is broken, because it's not a keyword parameter
_expected_verb: str
Expand Down Expand Up @@ -149,6 +150,8 @@ def __init__(self, left, right, *args, **kwargs):
assertion_message = self.format_assertion(left, right, contexts)
# Calculate explanation
explanation = kwargs.get("explanation", "")
# Calculate source context
source_context = self._get_source_context(contexts)
# Add in new fields
fields = kwargs.setdefault('fields', {})
fields['left'] = left.value
Expand All @@ -161,6 +164,7 @@ def __init__(self, left, right, *args, **kwargs):
fields['inverse_operator'] = self._inverse_operator
fields['context_message'] = context_message
fields['assertion_message'] = assertion_message
fields['source_context'] = source_context
fields['explanation'] = explanation

try:
Expand Down Expand Up @@ -274,6 +278,38 @@ def suppress_runtime_error(self, exception):
if hasattr(exception, "feedback") and exception.feedback is not None:
exception.feedback.parent = self

def _get_source_context(self, contexts):
"""
Generate source code context for function calls in assertion failures.

Args:
contexts (list): List of sandbox contexts from assertion

Returns:
str: Formatted source context or empty string
"""
from pedal.utilities.source_context import format_source_context_for_function

if not contexts:
return ""

# Look for function calls in the contexts
for context_list in contexts:
if isinstance(context_list, list):
for context in context_list:
if hasattr(context, 'called') and context.called:
function_context = format_source_context_for_function(context.called, self.report)
if function_context:
return "\n" + function_context + "\n"
else:
# Handle case where context is not a list
if hasattr(context_list, 'called') and context_list.called:
function_context = format_source_context_for_function(context_list.called, self.report)
if function_context:
return "\n" + function_context + "\n"

return ""


class RuntimePrintingAssertionFeedback(RuntimeAssertionFeedback):
""" Variant for handling printing instead of return value"""
Expand Down
104 changes: 104 additions & 0 deletions pedal/utilities/source_context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""
Utilities for extracting source code context from student submissions.
"""

import ast
from pedal.core.report import MAIN_REPORT
from pedal.core.location import Location


def find_function_definition(function_name, report=MAIN_REPORT):
"""
Find the definition location of a function in the student's source code.

Args:
function_name (str): The name of the function to find.
report (Report): The report containing the source AST.

Returns:
Location or None: The location of the function definition, or None if not found.
"""
if 'source' not in report or not report['source']['ast']:
return None

student_ast = report['source']['ast']

for node in ast.walk(student_ast):
if isinstance(node, ast.FunctionDef) and node.name == function_name:
return Location(line=node.lineno, col=node.col_offset,
end_line=getattr(node, 'end_lineno', None),
end_col=getattr(node, 'end_col_offset', None))

return None


def get_source_line_context(location, report=MAIN_REPORT, context_lines=0):
"""
Get source code lines around a given location.

Args:
location (Location): The location to get context for.
report (Report): The report containing the submission.
context_lines (int): Number of lines before/after to include (0 = just the target line).

Returns:
str or None: The source code context, or None if not available.
"""
if not location:
return None

if 'source' not in report:
return None

# Get the main submission
submission = report.submission
if not submission or not submission.main_file or not submission.files:
return None

# Get the source code
main_file_key = submission.main_file
if main_file_key not in submission.files:
return None

source_code = submission.files[main_file_key]
if not source_code:
return None

lines = source_code.splitlines()

# Convert to 0-based indexing
target_line = location.line - 1
start_line = max(0, target_line - context_lines)
end_line = min(len(lines), target_line + context_lines + 1)

if target_line >= len(lines):
return None

context_lines_list = lines[start_line:end_line]
return "\n".join(context_lines_list)


def format_source_context_for_function(function_name, report=MAIN_REPORT):
"""
Get formatted source context for a function definition.

Args:
function_name (str): The name of the function.
report (Report): The report to use.

Returns:
str or None: Formatted context string, or None if not available.
"""
location = find_function_definition(function_name, report)
if not location:
return None

context = get_source_line_context(location, report, context_lines=0)
if not context:
return None

formatter = report.format if hasattr(report, 'format') else None
if formatter and hasattr(formatter, 'line') and hasattr(formatter, 'python_code'):
return f"In your function {formatter.name(function_name)} on line {formatter.line(location.line)}:\n{formatter.python_code(context)}"
else:
return f"In your function '{function_name}' on line {location.line}:\n{context}"