Files
cleveragents-core/robot/indentation_library.py
freemo e0336379f5
CI / lint (pull_request) Successful in 29s
CI / typecheck (pull_request) Successful in 48s
CI / quality (pull_request) Successful in 33s
CI / security (pull_request) Successful in 1m2s
CI / build (pull_request) Successful in 23s
CI / helm (pull_request) Successful in 23s
CI / unit_tests (pull_request) Successful in 6m48s
CI / e2e_tests (pull_request) Successful in 19m4s
CI / integration_tests (pull_request) Successful in 22m6s
CI / coverage (pull_request) Successful in 10m59s
CI / docker (pull_request) Successful in 22s
CI / status-check (pull_request) Successful in 2s
CI / benchmark-publish (pull_request) Has been skipped
CI / benchmark-regression (pull_request) Successful in 57m5s
fix(robot): correct dedent logic for try...except...finally in indentation_library
Rewrites the block_stack management in fix_python_indentation to correctly
handle try...except...finally blocks in all configurations:

- Single except clause
- Multiple except clauses for the same try
- try...except...finally
- Nested try blocks with outer except
- Nested try with inner finally followed by outer except (critical regression)

Root cause: The original implementation stored only keyword strings in
block_stack (e.g. 'try', 'class', 'other'). Two bugs existed:

1. When 'finally:' was encountered, the code popped the 'try' from the
   stack. This meant subsequent 'except:' clauses for an outer try could
   not find their matching try, producing syntactically invalid Python.

2. The dedent calculation for 'except'/'finally' counted all stack entries
   up to the first 'try', but did not account for nested try blocks where
   the inner try's except/finally had already been processed.

Fix: Replace the flat string stack with a tuple stack of
(keyword, base_indent, has_seen_except_finally). The new logic:

- 'finally:' always belongs to the innermost try. Pop non-try entries,
  set indent to that try's base_indent, then pop the try itself (finally
  closes the block). The outer try remains on the stack.

- 'except:' belongs to the innermost try. If the innermost try has already
  seen an except/finally AND there is an outer try, the inner try is done:
  pop it and use the outer try instead. Otherwise use the innermost try
  and mark it as having seen an except/finally clause.

Adds a Behave BDD feature (tdd_indentation_library_try_except.feature)
with 6 scenarios covering all acceptance criteria from issue #2845.

ISSUES CLOSED: #2845
2026-04-05 07:39:57 +00:00

129 lines
5.3 KiB
Python

"""Robot library providing helpers for inline Python scripts."""
from __future__ import annotations
def fix_python_indentation(script: str) -> str:
"""Reconstruct indentation lost when Robot strips leading spaces.
Args:
script: Source code with indentation removed from the start of each line.
Returns:
Script with indentation restored so Python can execute it.
"""
lines = script.split("\n")
result: list[str] = []
indent_level = 0
# Each entry is a tuple (keyword, base_indent, has_seen_except_finally) where:
# keyword — "try", "class", or "other"
# base_indent — the indent level at which the block keyword was written
# has_seen_except_finally — True once an except/finally clause has been seen
# for this try block (used to detect when the inner
# try is done and an outer try's except/finally follows)
block_stack: list[tuple[str, int, bool]] = []
for line in lines:
stripped = line.strip()
if not stripped:
result.append("")
continue
is_dedenting = stripped.startswith(
("else:", "elif ", "except:", "except ", "finally:")
)
# Check if we should dedent from a class/function body back to module level
# This happens when we see import, class, or a top-level statement after a class
is_module_level_statement = False
# If we're inside a class and see an import, we need to dedent
if (
indent_level > 0
and block_stack
and (
stripped.startswith(("import ", "from "))
or (stripped.startswith("class ") and block_stack[-1][0] == "class")
)
):
is_module_level_statement = True
if is_dedenting and block_stack:
if stripped.startswith("finally:"):
# ``finally`` always belongs to the innermost ``try`` on the stack.
# Pop non-try entries above it, set indent to the try's level,
# then pop the try itself (``finally`` closes the try block).
while block_stack and block_stack[-1][0] != "try":
block_stack.pop()
if block_stack and block_stack[-1][0] == "try":
indent_level = block_stack[-1][1]
block_stack.pop()
else:
indent_level = max(0, indent_level - 1)
elif stripped.startswith(("except:", "except ")):
# ``except`` belongs to the innermost ``try``. However, if the
# innermost ``try`` has already seen an ``except``/``finally``
# clause AND there is an outer ``try`` on the stack, this
# ``except`` belongs to the outer ``try`` (the inner try block
# is complete).
while block_stack and block_stack[-1][0] != "try":
block_stack.pop()
if block_stack and block_stack[-1][0] == "try":
innermost_try = block_stack[-1]
has_outer_try = any(b[0] == "try" for b in block_stack[:-1])
if innermost_try[2] and has_outer_try:
# Inner try is done — pop it and find the outer try.
block_stack.pop()
while block_stack and block_stack[-1][0] != "try":
block_stack.pop()
if block_stack and block_stack[-1][0] == "try":
indent_level = block_stack[-1][1]
# Mark this try as having seen an except/finally clause.
block_stack[-1] = (
block_stack[-1][0],
block_stack[-1][1],
True,
)
else:
indent_level = max(0, indent_level - 1)
else:
indent_level = max(0, indent_level - 1)
else:
# ``else`` / ``elif`` — dedent one level from the preceding body.
indent_level = max(0, indent_level - 1)
elif is_module_level_statement:
# Dedent back to module level
indent_level = 0
block_stack.clear()
current_indent = indent_level
result.append(" " * current_indent + stripped)
# Update the stack and indent level for the *next* line.
if stripped.startswith("try:"):
block_stack.append(("try", current_indent, False))
indent_level += 1
elif stripped.startswith("finally:"):
# The matching ``try`` was already popped during the dedent phase above.
indent_level += 1
elif stripped.startswith(("except:", "except ")):
# The try remains on the stack (more except/finally may follow).
indent_level += 1
elif stripped.endswith(":") and not is_dedenting:
if stripped.startswith("class "):
block_stack.append(("class", current_indent, False))
else:
block_stack.append(("other", current_indent, False))
indent_level += 1
elif is_dedenting and stripped.endswith(":"):
indent_level += 1
return "\n".join(result)