Skip to content
Merged
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
61 changes: 61 additions & 0 deletions src/docproof/parsers.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from __future__ import annotations

import ast
import re
from dataclasses import dataclass, field
from pathlib import Path

Expand Down Expand Up @@ -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.

Expand Down
45 changes: 45 additions & 0 deletions tests/test_cli_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading