Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
[FIX] Hoist -qvo clusters and reject a dangling valued option
A short cluster ending in `-o` is a valid root spelling, so it is hoisted
with its value like a bare `-o`. A valued option with nothing after it is
refused up front: hoisted alone it would take the subcommand name as its
value and fail with a misleading message.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MQhP8Rvn5JDXewaAPbn8Z
  • Loading branch information
chandrasekharan-zipstack and claude committed Sep 18, 2026
commit 0f400fbf2c91d220c8087c9ae7c7b3cf4830a48b
17 changes: 10 additions & 7 deletions src/unstract_cli/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,12 @@ def _option_from_argv(argv: list[str], *spellings: str) -> str | None:
return None


#: The root group's options. `-p` is deliberately absent: leaf commands
#: declare their own `-p` with a different meaning.
_GLOBAL_VALUED = frozenset({"-o", "--output", "--agent", "--config"})
#: The root group's options, including `-o` at the end of a `-qv` cluster.
#: `-p` is deliberately absent: leaf commands declare their own `-p` with a
#: different meaning.
_GLOBAL_VALUED = re.compile(r"^(-[qv]*o|--output|--agent|--config)$")
_GLOBAL_FLAG = re.compile(
r"^(-o.+|--output=.*|--agent=.*|--config=.*|-[qv]+|--quiet|--verbose)$"
r"^(-[qv]*o.+|--output=.*|--agent=.*|--config=.*|-[qv]+|--quiet|--verbose)$"
)


Expand All @@ -58,7 +59,9 @@ def _hoist_globals(argv: list[str]) -> list[str]:
if arg == "--":
rest.extend(argv[i:])
break
if arg in _GLOBAL_VALUED:
if _GLOBAL_VALUED.match(arg):
if i + 1 == len(argv):
raise click.BadOptionUsage(arg, f"Option '{arg}' requires an argument.")
hoisted.extend(argv[i : i + 2])
i += 1
elif _GLOBAL_FLAG.match(arg):
Expand All @@ -83,12 +86,12 @@ def _format_from_argv(argv: list[str]) -> OutputFormat:


def main(argv: list[str] | None = None) -> int:
args = _hoist_globals(list(sys.argv[1:] if argv is None else argv))
args = list(sys.argv[1:] if argv is None else argv)
# Guessed from argv, then corrected by the root callback: a failure after
# parsing has to render in the format the run actually resolved.
ctx = Context(output=_format_from_argv(args))
try:
cli.main(args=args, standalone_mode=False, obj=ctx)
cli.main(args=_hoist_globals(args), standalone_mode=False, obj=ctx)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
except CLIError as exc:
return int(emit_error(exc, ctx.output))
except ConfigError as exc:
Expand Down
47 changes: 47 additions & 0 deletions tests/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -3883,3 +3883,50 @@ def test_a_trailing_config_path_reads_the_same_file_as_leading(capsys, tmp_path)
assert leading == trailing == int(ExitCode.SUCCESS)
assert leading_out == trailing_out
assert envelope(trailing_out)["data"]["value"] == "org_A"


@pytest.mark.parametrize(
("leading", "trailing", "quiet", "verbosity"),
[
(["-qojson"], ["-qojson"], True, 0),
(["-vo", "json"], ["-vo", "json"], False, 1),
(["-qvo", "json"], ["-qvo", "json"], True, 1),
],
)
def test_a_cluster_ending_in_o_is_hoisted_whole(
capsys, whisper_client, monkeypatch, leading, trailing, quiet, verbosity
):
whisper_client(whisper_retrieve={"extraction": {"result_text": "hello"}})
seen = _capture_context(monkeypatch)

first = main([*leading, "whisper", "retrieve", "h1"])
first_out = capsys.readouterr().out
second = main(["whisper", "retrieve", "h1", *trailing])
second_out = capsys.readouterr().out

assert first == second == int(ExitCode.SUCCESS)
assert first_out == second_out
assert envelope(second_out)["data"] == {"result_text": "hello"}
assert (seen[1].quiet, seen[1].verbosity) == (quiet, verbosity)


@pytest.mark.parametrize("dangling", ["-o", "--config", "-vo"])
def test_a_dangling_valued_option_is_a_usage_error(capsys, whisper_client, dangling):
"""Hoisted alone it would swallow the subcommand name as its value."""
whisper_client(whisper_retrieve={"extraction": {"result_text": "hello"}})

code = main(["-o", "json", "whisper", "retrieve", "h1", dangling])
out = capsys.readouterr().out

assert code == int(ExitCode.USAGE)
assert "requires an argument" in envelope(out)["error"]["message"]


def test_a_cluster_carrying_p_is_left_where_it_is(capsys, whisper_client):
whisper_client(whisper_retrieve={"extraction": {"result_text": "hello"}})

code = main(["-o", "json", "whisper", "retrieve", "h1", "-qp", "x"])
out = capsys.readouterr().out

assert code == int(ExitCode.USAGE)
assert "No such option" in envelope(out)["error"]["message"]
Loading