diff --git a/src/docproof/parsers.py b/src/docproof/parsers.py index a190c24..40f093b 100644 --- a/src/docproof/parsers.py +++ b/src/docproof/parsers.py @@ -23,6 +23,7 @@ from __future__ import annotations import ast +import re from dataclasses import dataclass, field from pathlib import Path @@ -191,9 +192,69 @@ def argparse_flags(project: Project) -> FlagSet: "no argparse parser was found in this project, so its command line is built " "with something this cannot read — click, typer, cleo or a hand-rolled parser" ) + for command, package in _console_script_packages(project): + elsewhere = _built_with_something_else(project, package) + if elsewhere: + flags.incomplete( + f"`{command}` is `{package}`, which imports {elsewhere}, so its options are " + f"declared where this cannot read them however much argparse the rest of the " + f"repository contains" + ) return flags +# **`seen_a_parser` is repository-wide, and a monorepo is not one program.** The datasette +# rule above catches a project with NO argparse. It does not catch `unslothai/unsloth`, whose +# console script is a typer app in `unsloth_cli/` and whose backend, tests and scripts contain +# enough argparse for 153 flags and a `complete: True`. Its README shows +# `unsloth start claude --as-subagent`, declared at `unsloth_cli/commands/start.py:340` as +# `typer.Option(False, "--as-subagent", ...)`, and the verifier reported it BROKEN with the +# sentence "no parser in this project defines it". +# +# That is the one thing this file's docstring promises cannot happen: *"when it says no, a flag +# it has not seen is unjudged."* It said yes, from the wrong parsers. +# +# So completeness is asked of the COMMAND, not of the repository. The console script names its +# module; if that package reaches for click or typer, the argparse set cannot describe it. +CLI_FRAMEWORKS = ("typer", "click", "cleo", "docopt", "fire") + + +def _console_script_packages(project: Project) -> list[tuple[str, str]]: + """(command, top-level module) for each declared console script, deduplicated.""" + out, seen = [], set() + for command, target in project.console_scripts.items(): + package = target.split(":", 1)[0].split(".", 1)[0].strip() + if package and (command, package) not in seen: + seen.add((command, package)) + out.append((command, package)) + return out + + +def _built_with_something_else(project: Project, package: str) -> str: + """Which CLI framework this package imports, or empty. + + Reads the package's own files only. A framework imported by a sibling tool in the same + repository says nothing about this command, and widening it to the whole tree would + silence the verifier on every repository that vendors an example. + """ + directory = project.root / package + if not directory.is_dir(): + single = project.root / (package + ".py") + files = [single] if single.is_file() else [] + else: + files = [p for p in directory.rglob("*.py") if p.is_file()] + found: set[str] = set() + for path in files[:400]: # a package with more files than this has been found already + try: + text = path.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + for name in CLI_FRAMEWORKS: + if re.search(r"^\s*(?:import|from)\s+" + name + r"\b", text, re.MULTILINE): + found.add(name) + return " and ".join(sorted(found)) + + def wrapper_flags(project: Project) -> set[str]: """Options named in shell or PowerShell wrappers shipped beside the program. diff --git a/tests/test_cli_flags.py b/tests/test_cli_flags.py index 4ee92e0..b83e3be 100644 --- a/tests/test_cli_flags.py +++ b/tests/test_cli_flags.py @@ -206,3 +206,48 @@ def test_a_project_whose_cli_is_not_argparse_is_never_judged(make_repo: Callable verdict, detail = check(make_repo(files))["--serve"] assert verdict is Verdict.SKIPPED assert "no argparse parser" in detail + + +def test_a_typer_command_is_not_judged_by_argparse_found_elsewhere(make_repo: Callable[..., Path]): + """`unslothai/unsloth` declares `unsloth = unsloth_cli:app`, a typer application, and its + README shows `unsloth start claude --as-subagent`. That option is declared at + `unsloth_cli/commands/start.py:340` as `typer.Option(False, "--as-subagent", ...)`. + + docproof reported it BROKEN with "no parser in this project defines it", because the + backend, tests and scripts elsewhere in that monorepo hold enough argparse for 153 flags + and a complete verdict. `parsers.py` promises in its own docstring that this cannot + happen: *"when it says no, a flag it has not seen is unjudged."* It said yes, from the + wrong parsers. + + The datasette rule catches a project with NO argparse. It cannot catch a monorepo that has + plenty, none of it behind the command being documented. + """ + from docproof.parsers import argparse_flags + + repo = make_repo( + { + "pyproject.toml": PYPROJECT, + "README.md": "Run `toolkit --as-subagent` to attach it.\n", + "toolkit/cli.py": ( + "import typer\n\napp = typer.Typer()\n\nOPT = typer.Option(False, '--as-subagent')\n" + ), + # Plenty of argparse, none of it behind the console script. + "scripts/bench.py": PARSER, + } + ) + flags = argparse_flags(Project(root=repo)) + assert not flags.complete + assert any("which imports typer" in reason for reason in flags.reasons), flags.reasons + assert check(repo)["--as-subagent"][0] is Verdict.SKIPPED + + +def test_an_argparse_command_still_judges(make_repo: Callable[..., Path]): + """The recall side of the same rule. A project whose console script really is argparse + keeps its complete verdict, or the fix above would have silenced the verifier everywhere. + docproof itself is this case: 6 flags, complete.""" + from docproof.parsers import argparse_flags + + repo = make_repo(toolkit("Run `toolkit --dryrun` first.\n")) + flags = argparse_flags(Project(root=repo)) + assert flags.complete, flags.reasons + assert check(repo)["--dryrun"][0] is Verdict.BROKEN