Skip to content

Commit d499918

Browse files
Your Nameclaude
andcommitted
fix(required_arg_missing): suppress calls inside if __name__ == "__main__" blocks
Entry-point calls like `if __name__ == "__main__": main()` are called with no args because argparse/click reads from sys.argv inside the function body. These 16 violations in ray-project/ray CI scripts were all this pattern. Also suppresses cross-file name collisions where a local main() with no required args is matched to an unrelated main(required_arg) elsewhere. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent bd79325 commit d499918

3 files changed

Lines changed: 71 additions & 0 deletions

File tree

extractor.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ class CallSite:
7676
is_method_call: bool = False # True when call is obj.method(...) — receiver is implicit
7777
has_var_args: bool = False # True when call uses *args spread — positional coverage unknown
7878
has_var_kwargs: bool = False # True when call uses **kwargs spread — kwarg coverage unknown
79+
is_in_main_block: bool = False # True when inside `if __name__ == "__main__":` body
7980
model_name: Optional[str] = None
8081
caller_name: Optional[str] = (
8182
None # qualified name of the enclosing function, if any
@@ -414,6 +415,7 @@ def __init__(self, file_path: str) -> None:
414415
self.call_sites: list[CallSite] = []
415416
self._class_stack: list[str] = [] # class names (mirrors _FunctionVisitor)
416417
self._func_stack: list[str] = [] # qualified function names within classes
418+
self._in_main_block: bool = False # inside `if __name__ == "__main__":` body
417419

418420
def visit_ClassDef(self, node: ast.ClassDef) -> None:
419421
self._class_stack.append(node.name)
@@ -430,6 +432,32 @@ def _enter_func(self, node):
430432
visit_FunctionDef = _enter_func
431433
visit_AsyncFunctionDef = _enter_func
432434

435+
def visit_If(self, node: ast.If) -> None:
436+
# Detect `if __name__ == "__main__":` (both orderings of the comparison)
437+
def _is_main_guard(test):
438+
if not isinstance(test, ast.Compare):
439+
return False
440+
if len(test.ops) != 1 or not isinstance(test.ops[0], ast.Eq):
441+
return False
442+
lhs, rhs = test.left, test.comparators[0]
443+
def _is_dunder_name(n): return isinstance(n, ast.Name) and n.id == "__name__"
444+
def _is_main_str(n): return isinstance(n, ast.Constant) and n.value == "__main__"
445+
return (_is_dunder_name(lhs) and _is_main_str(rhs)) or (
446+
_is_main_str(lhs) and _is_dunder_name(rhs)
447+
)
448+
449+
if _is_main_guard(node.test):
450+
old = self._in_main_block
451+
self._in_main_block = True
452+
for stmt in node.body:
453+
self.visit(stmt)
454+
self._in_main_block = old
455+
# else branch is not a __main__ guard
456+
for stmt in node.orelse:
457+
self.visit(stmt)
458+
else:
459+
self.generic_visit(node)
460+
433461
def visit_Call(self, node: ast.Call) -> None:
434462
site = self._make_site(node)
435463
if site:
@@ -490,6 +518,7 @@ def _make_site(self, node: ast.Call) -> Optional[CallSite]:
490518
caller_name=caller,
491519
has_var_args=has_var_args,
492520
has_var_kwargs=has_var_kwargs,
521+
is_in_main_block=self._in_main_block,
493522
)
494523
return None
495524

failure_mode.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,10 @@ def _check_required_arg(
472472
# calling main() with no args reads from sys.argv. Not a missing-arg bug.
473473
if func.is_click_command:
474474
return []
475+
# `if __name__ == "__main__": main()` — entry point call, args come from
476+
# the runtime / argparse inside the function body, not the call site.
477+
if call.is_in_main_block:
478+
return []
475479
# Only non-kwonly required args can be covered by positional args.
476480
# Enumerate positional-only required args separately so a kwonly arg at
477481
# index i is never falsely marked covered because positional_count > i.

test_checker.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2051,3 +2051,41 @@ async def _prepare_request(self, method: str) -> dict:
20512051
violations = check_codebase(tmp_path)
20522052
ma = [v for v in violations if v.context == "missing_await" and "clients.py" in v.file]
20532053
assert len(ma) == 0, f"Expected 0 missing_await for dual sync/async client, got {len(ma)}: {[(v.line, v.call) for v in ma]}"
2054+
2055+
2056+
def test_main_in_dunder_main_block_not_flagged(tmp_path):
2057+
"""`if __name__ == "__main__": main()` must not be flagged as required_arg_missing."""
2058+
_write_src(
2059+
tmp_path,
2060+
"cli_tool.py",
2061+
"""
2062+
import argparse
2063+
2064+
def main(config: str, verbose: bool = False) -> None:
2065+
print(config, verbose)
2066+
2067+
if __name__ == "__main__":
2068+
main()
2069+
""",
2070+
)
2071+
violations = check_codebase(tmp_path)
2072+
ra = [v for v in violations if v.context == "required_arg_missing" and "cli_tool.py" in v.file]
2073+
assert len(ra) == 0, f"Expected 0 required_arg_missing in __main__ block, got {len(ra)}: {[(v.line, v.call) for v in ra]}"
2074+
2075+
2076+
def test_main_called_outside_dunder_block_still_flagged(tmp_path):
2077+
"""The same missing-arg call outside __main__ block IS a real bug."""
2078+
_write_src(
2079+
tmp_path,
2080+
"caller.py",
2081+
"""
2082+
def main(config: str) -> None:
2083+
print(config)
2084+
2085+
def run_pipeline():
2086+
main() # missing required arg — real bug
2087+
""",
2088+
)
2089+
violations = check_codebase(tmp_path)
2090+
ra = [v for v in violations if v.context == "required_arg_missing" and "caller.py" in v.file]
2091+
assert len(ra) == 1, f"Expected 1 required_arg_missing outside __main__ block, got {len(ra)}"

0 commit comments

Comments
 (0)