| """ |
| Line-by-line code explainer using Python's `ast` module — v2. |
| |
| Improvement over v1: instead of just echoing back the code's own syntax |
| (e.g. "If n < 2, then: Returns False" — which is really just the code |
| with English scaffolding), this version translates expressions into |
| actual natural language (e.g. "Checks whether n is less than 2 — if so, |
| returns False, meaning n is not prime."). |
| |
| Still fully deterministic (based on the real parsed structure, not a |
| guess), but reads like an explanation a person would give, not a |
| transliteration of the syntax. |
| """ |
| |
| import ast |
| |
| |
| |
| |
| |
| |
| _CMP_WORDS = { |
| ast.Lt: "is less than", |
| ast.LtE: "is less than or equal to", |
| ast.Gt: "is greater than", |
| ast.GtE: "is greater than or equal to", |
| ast.Eq: "is equal to", |
| ast.NotEq: "is not equal to", |
| ast.In: "is in", |
| ast.NotIn: "is not in", |
| ast.Is: "is", |
| ast.IsNot: "is not", |
| } |
| |
| _BINOP_WORDS = { |
| ast.Add: "plus", |
| ast.Sub: "minus", |
| ast.Mult: "times", |
| ast.Div: "divided by", |
| ast.FloorDiv: "divided by (rounded down)", |
| ast.Mod: "modulo", |
| ast.Pow: "to the power of", |
| } |
| |
| _BOOLOP_WORDS = { |
| ast.And: "and", |
| ast.Or: "or", |
| } |
| |
| |
| def expr_to_text(node) -> str: |
| """Recursively turn an AST expression into a natural-English phrase.""" |
| if node is None: |
| return "nothing" |
| |
| if isinstance(node, ast.Constant): |
| return repr(node.value) |
| |
| if isinstance(node, ast.Name): |
| return node.id |
| |
| if isinstance(node, ast.Attribute): |
| return f"{expr_to_text(node.value)}.{node.attr}" |
| |
| if isinstance(node, ast.Subscript): |
| return f"{expr_to_text(node.value)}[{expr_to_text(node.slice)}]" |
| |
| if isinstance(node, ast.Compare): |
| left = expr_to_text(node.left) |
| parts = [left] |
| for op, comparator in zip(node.ops, node.comparators): |
| word = _CMP_WORDS.get(type(op), "compares to") |
| parts.append(f"{word} {expr_to_text(comparator)}") |
| return " ".join(parts) |
| |
| if isinstance(node, ast.BoolOp): |
| word = _BOOLOP_WORDS.get(type(node.op), "and") |
| return f" {word} ".join(expr_to_text(v) for v in node.values) |
| |
| if isinstance(node, ast.UnaryOp): |
| if isinstance(node.op, ast.Not): |
| return f"not ({expr_to_text(node.operand)})" |
| if isinstance(node.op, ast.USub): |
| return f"negative {expr_to_text(node.operand)}" |
| return expr_to_text(node.operand) |
| |
| if isinstance(node, ast.BinOp): |
| word = _BINOP_WORDS.get(type(node.op), "combined with") |
| return f"{expr_to_text(node.left)} {word} {expr_to_text(node.right)}" |
| |
| if isinstance(node, ast.Call): |
| func_name = expr_to_text(node.func) |
| args_text = [expr_to_text(a) for a in node.args] |
| |
| |
| if func_name == "range": |
| if len(args_text) == 1: |
| return f"every whole number from 0 up to (not including) {args_text[0]}" |
| elif len(args_text) == 2: |
| return f"every whole number from {args_text[0]} up to (not including) {args_text[1]}" |
| elif len(args_text) == 3: |
| return f"every whole number from {args_text[0]} up to (not including) {args_text[1]}, stepping by {args_text[2]}" |
| if func_name == "int": |
| return f"the whole-number part of {args_text[0]}" if args_text else "an integer" |
| if func_name == "len": |
| return f"the length of {args_text[0]}" if args_text else "a length" |
| if func_name == "str": |
| return f"{args_text[0]} converted to text" if args_text else "a text value" |
| if func_name in ("sum", "max", "min", "sorted", "reversed", "list", "set"): |
| verb = { |
| "sum": "the sum of", |
| "max": "the largest value in", |
| "min": "the smallest value in", |
| "sorted": "a sorted version of", |
| "reversed": "a reversed version of", |
| "list": "a list built from", |
| "set": "a set built from", |
| }[func_name] |
| return f"{verb} {args_text[0]}" if args_text else verb |
| |
| |
| joined_args = ", ".join(args_text) |
| return f"the result of calling {func_name}({joined_args})" |
| |
| if isinstance(node, ast.List): |
| return "[" + ", ".join(expr_to_text(e) for e in node.elts) + "]" |
| |
| if isinstance(node, ast.Tuple): |
| return "(" + ", ".join(expr_to_text(e) for e in node.elts) + ")" |
| |
| |
| try: |
| return ast.unparse(node) |
| except Exception: |
| return "<expression>" |
| |
| |
| |
| |
| |
| |
| def _describe(node, indent=0, class_name=None): |
| prefix = " " * indent |
| lines = [] |
| |
| if isinstance(node, ast.FunctionDef): |
| args = [a.arg for a in node.args.args] |
| is_method = bool(args) and args[0] in ("self", "cls") |
| display_args = args[1:] if is_method else args |
| arg_text = " and ".join(display_args) if display_args else "no additional input" |
| |
| if node.name == "__init__": |
| owner = class_name or "this" |
| lines.append(f"{prefix}Line {node.lineno}: This is the constructor — it runs automatically whenever a new `{owner}` object is created, setting up {arg_text}.") |
| elif is_method: |
| lines.append(f"{prefix}Line {node.lineno}: Defines a method called `{node.name}` (called on instances of this class), which takes {arg_text} as input.") |
| else: |
| lines.append(f"{prefix}Line {node.lineno}: Defines a function called `{node.name}`, which takes {arg_text} as input.") |
| for stmt in node.body: |
| lines.extend(_describe(stmt, indent + 1, class_name=class_name)) |
| |
| elif isinstance(node, ast.ClassDef): |
| bases = [expr_to_text(b) for b in node.bases] |
| if bases: |
| lines.append(f"{prefix}Line {node.lineno}: Defines a class called `{node.name}`, which inherits from {', '.join(bases)}.") |
| else: |
| lines.append(f"{prefix}Line {node.lineno}: Defines a class called `{node.name}` — a blueprint for creating objects that bundle related data and behavior together.") |
| for stmt in node.body: |
| lines.extend(_describe(stmt, indent + 1, class_name=node.name)) |
| |
| elif isinstance(node, ast.If): |
| cond = expr_to_text(node.test) |
| lines.append(f"{prefix}Line {node.lineno}: Checks whether {cond}. If true:") |
| for stmt in node.body: |
| lines.extend(_describe(stmt, indent + 1, class_name=class_name)) |
| if node.orelse: |
| lines.append(f"{prefix}Otherwise (if that condition is false):") |
| for stmt in node.orelse: |
| lines.extend(_describe(stmt, indent + 1, class_name=class_name)) |
| |
| elif isinstance(node, ast.For): |
| target = expr_to_text(node.target) |
| iterable = expr_to_text(node.iter) |
| lines.append(f"{prefix}Line {node.lineno}: Repeats the following, setting {target} to {iterable}, one at a time:") |
| for stmt in node.body: |
| lines.extend(_describe(stmt, indent + 1, class_name=class_name)) |
| |
| elif isinstance(node, ast.While): |
| cond = expr_to_text(node.test) |
| lines.append(f"{prefix}Line {node.lineno}: Keeps repeating the following as long as {cond}:") |
| for stmt in node.body: |
| lines.extend(_describe(stmt, indent + 1, class_name=class_name)) |
| |
| elif isinstance(node, ast.Return): |
| val = expr_to_text(node.value) if node.value is not None else "nothing" |
| lines.append(f"{prefix}Line {node.lineno}: Stops the function here and gives back {val}.") |
| |
| elif isinstance(node, ast.Assign): |
| targets = " and ".join(expr_to_text(t) for t in node.targets) |
| val = expr_to_text(node.value) |
| lines.append(f"{prefix}Line {node.lineno}: Stores {val} in {targets}.") |
| |
| elif isinstance(node, ast.AugAssign): |
| target = expr_to_text(node.target) |
| val = expr_to_text(node.value) |
| op_word = _BINOP_WORDS.get(type(node.op), "combined with") |
| lines.append(f"{prefix}Line {node.lineno}: Updates {target} by taking its current value {op_word} {val}.") |
| |
| elif isinstance(node, ast.Expr): |
| if isinstance(node.value, ast.Constant) and isinstance(node.value.value, str): |
| doc_text = node.value.value.strip().splitlines()[0] |
| lines.append(f'{prefix}Line {node.lineno}: Documentation string: "{doc_text}"') |
| else: |
| lines.append(f"{prefix}Line {node.lineno}: Runs {expr_to_text(node.value)}.") |
| |
| elif isinstance(node, ast.Raise): |
| if node.exc is not None: |
| if isinstance(node.exc, ast.Call): |
| exc_name = expr_to_text(node.exc.func) |
| exc_args = [expr_to_text(a) for a in node.exc.args] |
| if exc_args: |
| lines.append(f"{prefix}Line {node.lineno}: Raises a {exc_name} error with the message {exc_args[0]}.") |
| else: |
| lines.append(f"{prefix}Line {node.lineno}: Raises a {exc_name} error.") |
| else: |
| lines.append(f"{prefix}Line {node.lineno}: Raises an error — {expr_to_text(node.exc)}.") |
| else: |
| lines.append(f"{prefix}Line {node.lineno}: Re-raises the current error.") |
| |
| elif isinstance(node, ast.Import): |
| names = ", ".join(a.name for a in node.names) |
| lines.append(f"{prefix}Line {node.lineno}: Brings in the {names} module so its functions can be used.") |
| |
| elif isinstance(node, ast.ImportFrom): |
| names = ", ".join(a.name for a in node.names) |
| lines.append(f"{prefix}Line {node.lineno}: Brings in {names} from the {node.module} module.") |
| |
| else: |
| try: |
| lines.append(f"{prefix}Line {node.lineno}: {ast.unparse(node)}") |
| except Exception: |
| pass |
| |
| return lines |
| |
| |
| def explain_line_by_line(code: str): |
| """ |
| Returns (success: bool, result) |
| - If code parses: (True, list_of_explanation_strings) |
| - If code has a syntax error: (False, error_message_string) |
| """ |
| try: |
| tree = ast.parse(code) |
| except SyntaxError as e: |
| return False, f"Can't generate a line-by-line breakdown: the code has a syntax error ({e.msg} at line {e.lineno})." |
| |
| explanation_lines = [] |
| for stmt in tree.body: |
| explanation_lines.extend(_describe(stmt)) |
| |
| if not explanation_lines: |
| return True, ["(No statements found to explain.)"] |
| return True, explanation_lines |
| |
| |
| if __name__ == "__main__": |
| sample = """ |
| def is_prime(n): |
| if n < 2: |
| return False |
| for i in range(2, int(n ** 0.5) + 1): |
| if n % i == 0: |
| return False |
| return True |
| """ |
| ok, result = explain_line_by_line(sample) |
| print("\n".join(result) if ok else result) |
| |