Skip to content

Commit 1f60663

Browse files
Your Nameclaude
andcommitted
fix(required_arg_missing): suppress Click/Typer CLI command FPs
@click.command(), @click.group(), and @app.command() decorated functions have their arguments injected from sys.argv by the framework. Calling main() with no args at the __main__ entry point is correct — not a missing required argument. Extends the existing pytest.fixture suppression pattern to CLI command decorators. Regression: ray-project/ray CI automation scripts (16 FPs). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 0eff6b5 commit 1f60663

3 files changed

Lines changed: 65 additions & 0 deletions

File tree

extractor.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ class FunctionManifest:
5555
module_path: str
5656
args: list[ArgConstraint] = field(default_factory=list)
5757
is_pytest_fixture: bool = False # True when decorated with @pytest.fixture
58+
is_click_command: bool = False # True when decorated with @click.command/group or @app.command
5859

5960
@property
6061
def required_args(self) -> list[ArgConstraint]:
@@ -374,7 +375,20 @@ def _is_fixture_dec(dec: ast.expr) -> bool:
374375
return _is_fixture_dec(dec.func)
375376
return False
376377

378+
# Detect Click/Typer CLI decorators: @click.command(), @click.group(),
379+
# @app.command(), @typer.command() — the framework injects CLI args,
380+
# so calling these functions with no args is correct.
381+
def _is_click_dec(dec: ast.expr) -> bool:
382+
if isinstance(dec, ast.Call):
383+
return _is_click_dec(dec.func)
384+
if isinstance(dec, ast.Attribute):
385+
return dec.attr in ("command", "group")
386+
if isinstance(dec, ast.Name):
387+
return dec.id in ("command", "group")
388+
return False
389+
377390
is_fixture = any(_is_fixture_dec(d) for d in node.decorator_list)
391+
is_click = any(_is_click_dec(d) for d in node.decorator_list)
378392

379393
qual = ".".join(self._class_stack + [node.name])
380394
self.functions.append(
@@ -385,6 +399,7 @@ def _is_fixture_dec(dec: ast.expr) -> bool:
385399
module_path=self.module_path,
386400
args=constraints,
387401
is_pytest_fixture=is_fixture,
402+
is_click_command=is_click,
388403
)
389404
)
390405
self.generic_visit(node)

failure_mode.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,10 @@ def _check_required_arg(
468468
# (often a factory), not the fixture function itself.
469469
if func.is_pytest_fixture:
470470
return []
471+
# Click/Typer CLI commands: the decorator replaces the function so that
472+
# calling main() with no args reads from sys.argv. Not a missing-arg bug.
473+
if func.is_click_command:
474+
return []
471475
# Only non-kwonly required args can be covered by positional args.
472476
# Enumerate positional-only required args separately so a kwonly arg at
473477
# index i is never falsely marked covered because positional_count > i.

test_checker.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1969,3 +1969,49 @@ def aggregate(df, key_cols):
19691969
violations = check_codebase(tmp_path)
19701970
od = [v for v in violations if v.context == "optional_dereference" and "pandas_agg.py" in v.file]
19711971
assert len(od) == 0, f"Expected 0 violations for pandas groupby.first(), got {len(od)}: {[(v.line, v.call) for v in od]}"
1972+
1973+
1974+
def test_click_command_no_args_not_flagged(tmp_path):
1975+
"""@click.command() decorated functions get args from CLI — calling main() with no args is correct."""
1976+
_write_src(
1977+
tmp_path,
1978+
"cli_tool.py",
1979+
"""
1980+
import click
1981+
1982+
@click.command()
1983+
@click.option("--name", required=True)
1984+
@click.option("--count", required=True, type=int)
1985+
def main(name: str, count: int) -> None:
1986+
for _ in range(count):
1987+
print(name)
1988+
1989+
if __name__ == "__main__":
1990+
main()
1991+
""",
1992+
)
1993+
violations = check_codebase(tmp_path)
1994+
ra = [v for v in violations if v.context == "required_arg_missing" and "cli_tool.py" in v.file]
1995+
assert len(ra) == 0, f"Expected 0 required_arg_missing for @click.command(), got {len(ra)}: {[(v.line, v.call) for v in ra]}"
1996+
1997+
1998+
def test_app_command_no_args_not_flagged(tmp_path):
1999+
"""@app.command() (Typer/Click group) decorated functions should not be flagged."""
2000+
_write_src(
2001+
tmp_path,
2002+
"typer_tool.py",
2003+
"""
2004+
import typer
2005+
app = typer.Typer()
2006+
2007+
@app.command()
2008+
def deploy(env: str, force: bool = False) -> None:
2009+
pass
2010+
2011+
if __name__ == "__main__":
2012+
app()
2013+
""",
2014+
)
2015+
violations = check_codebase(tmp_path)
2016+
ra = [v for v in violations if v.context == "required_arg_missing" and "typer_tool.py" in v.file]
2017+
assert len(ra) == 0, f"Expected 0 required_arg_missing for @app.command(), got {len(ra)}: {[(v.line, v.call) for v in ra]}"

0 commit comments

Comments
 (0)