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
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,50 @@ def _check_block(stmts: list[ast.stmt]):
return results


def _is_main_module_guard(node: ast.AST) -> bool:
"""Return whether a statement is exactly ``if __name__ == "__main__"``."""
return (
isinstance(node, ast.If)
and isinstance(node.test, ast.Compare)
and isinstance(node.test.left, ast.Name)
and node.test.left.id == "__name__"
and len(node.test.ops) == 1
and isinstance(node.test.ops[0], ast.Eq)
and len(node.test.comparators) == 1
and isinstance(node.test.comparators[0], ast.Constant)
and node.test.comparators[0].value == "__main__"
)


def _main_guard_invokes_main(guard: ast.If) -> bool:
"""Return whether a module-main guard directly executes ``main()``."""
stack: list[ast.AST] = list(reversed(guard.body))
while stack:
current = stack.pop()
if isinstance(current, ast.Call) and isinstance(current.func, ast.Name):
if current.func.id == "main":
return True
if isinstance(current, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef | ast.Lambda):
continue
stack.extend(reversed(list(ast.iter_child_nodes(current))))
return False


def _find_top_level_cli_main(
tree: ast.Module,
) -> ast.FunctionDef | ast.AsyncFunctionDef | None:
"""Return the top-level ``main`` invoked by a module-main guard, if any."""
if not any(
_is_main_module_guard(statement) and _main_guard_invokes_main(statement)
for statement in tree.body
):
return None
for statement in reversed(tree.body):
if isinstance(statement, ast.FunctionDef | ast.AsyncFunctionDef) and statement.name == "main":
return statement
return None


def _detect_constant_return(
filepath: str,
tree: ast.Module,
Expand All @@ -95,8 +139,11 @@ def _iter_function_scope_nodes(node: ast.FunctionDef | ast.AsyncFunctionDef):
for child in reversed(list(ast.iter_child_nodes(current))):
stack.append(child)

cli_main = _find_top_level_cli_main(tree)
results: list[dict] = []
for node in _iter_nodes(tree, all_nodes, (ast.FunctionDef, ast.AsyncFunctionDef)):
if node is cli_main:
continue
# Skip tiny functions (stubs/pass-only already caught by dead_function)
if not hasattr(node, "end_lineno") or not node.end_lineno:
continue
Expand Down
98 changes: 98 additions & 0 deletions desloppify/languages/python/tests/test_py_smells_ast.py
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,104 @@ def varying(x):
entries, _ = detect_smells(path)
assert "constant_return" not in _smell_ids(entries)

def test_cli_main_guarded_by_module_entrypoint_is_not_flagged(self, tmp_path):
path = _write_py(
tmp_path,
'''\
def main(argv=None):
if argv:
return 0
return 0


if __name__ == "__main__":
raise SystemExit(main())
''',
)

entries, _ = detect_smells(path)

assert "constant_return" not in _smell_ids(entries)

def test_unguarded_main_with_constant_returns_is_flagged(self, tmp_path):
path = _write_py(
tmp_path,
'''\
def main(argv=None):
if argv:
return 0
return 0
''',
)

entries, _ = detect_smells(path)
constant_return = next(
(entry for entry in entries if entry["id"] == "constant_return"),
None,
)

assert constant_return is not None
assert any("main()" in match["content"] for match in constant_return["matches"])

def test_module_main_guard_does_not_exempt_other_constant_function(self, tmp_path):
path = _write_py(
tmp_path,
'''\
def render(argv=None):
if argv:
return 0
return 0


if __name__ == "__main__":
raise SystemExit(render())
''',
)

entries, _ = detect_smells(path)
constant_return = next(
(entry for entry in entries if entry["id"] == "constant_return"),
None,
)

assert constant_return is not None
assert any("render()" in match["content"] for match in constant_return["matches"])

def test_module_main_guard_does_not_exempt_nested_main(self, tmp_path):
path = _write_py(
tmp_path,
'''\
def main(argv=None):
if argv:
return 0
return 0


def wrapper(flag):
def main(value):
if value:
return 0
return 0
return main(flag)


if __name__ == "__main__":
raise SystemExit(main())
''',
)

entries, _ = detect_smells(path)
constant_return = next(
(entry for entry in entries if entry["id"] == "constant_return"),
None,
)

assert constant_return is not None
main_matches = [
match for match in constant_return["matches"] if "main()" in match["content"]
]
assert len(main_matches) == 1

def test_nested_function_returns_are_ignored(self, tmp_path):
path = _write_py(
tmp_path,
Expand Down