Files
falyx/tests/test_parsers/test_completions.py
Roland Thomas 169f228c92 feat(parser): POSIX bundling, multi-value/default validation, smarter completions; help UX & examples
- Mark help parser with `_is_help_command=True` so CLI renders as `program help`.
- Add TLDR examples to `Exit` and `History` commands.
- Normalize help TLDR/tag docs to short forms `-T` (tldr) and `-t [TAG]`.
- Also propagate submenu exit help text TLDRs when set.
- Disallow defaults for `HELP`, `TLDR`, `COUNT`, and boolean store actions.
- Enforce list defaults for `APPEND`/`EXTEND` and any `nargs` in `{int, "*", "+"}`; coerce to list when `nargs == 1`.
- Validate default(s) against `choices` (lists must be subset).
- Strengthen `choices` checking at parse-time for both scalars and lists; track invalid-choice state for UX.
- New `_resolve_posix_bundling()` with context:
  - Won’t split negative numbers or dash-prefixed positional/path values.
  - Uses the *last seen flag’s type/action* to decide if a dash token is a value vs. bundle.
- Add `_is_valid_dash_token_positional_value()` and `_find_last_flag_argument()` helpers.
- Completions overhaul
  - Track `consumed_position` and `has_invalid_choice` per-arg (via new `ArgumentState.set_consumed()` / `reset()`).
  - Add `_is_mid_value()` and `_value_suggestions_for_arg()` to produce value suggestions while typing.
  - Persist value context for multi-value args (`nargs="*"`, `"+"`) for each call to parse_args
  - Suppress suggestions when a choice is currently invalid, then recover as the prefix becomes valid.
  - Respect `cursor_at_end_of_token`; do not mutate the user’s prefix; improve path suggestions (`"."` vs prefix).
  - Better behavior after a space: suggest remaining flags when appropriate.
- Consistent `index` naming (vs `i`) and propagate `base_index` into positional consumption to mark positions accurately.
- Return value tweaks for `find_argument_by_dest()` and minor readability changes.
- Replace the minimal completion test with a comprehensive suite covering:
  - Basics (defaults, option parsing, lists, booleans).
  - Validation edges (default/choices, `nargs` list requirements).
  - POSIX bundling (flags only; negative values; dash-prefixed paths).
  - Completions for flags/values/mid-value/path/`nargs="*"` persistence.
  - `store_bool_optional` (feature / no-feature, last one wins).
  - Invalid choice suppression & recovery.
  - Repeated keywords (last one wins) and completion context follows the last.
  - File-system-backed path suggestions.
- Bumped version to 0.1.83.
2025-08-10 15:55:45 -04:00

312 lines
9.9 KiB
Python

from pathlib import Path
import pytest
from falyx.exceptions import CommandArgumentError
from falyx.parser.command_argument_parser import CommandArgumentParser
def build_default_parser():
p = CommandArgumentParser(
command_key="D", aliases=["deploy"], program="argument_examples.py"
)
p.add_argument("service", type=str, help="Service name.")
p.add_argument("place", type=str, nargs="?", default="New York", help="Place.")
p.add_argument(
"--region",
choices=["us-east-1", "us-west-2", "eu-west-1"],
help="Region.",
default="us-east-1",
)
p.add_argument("-p", "--path", type=Path, help="Path.")
p.add_argument("-v", "--verbose", action="store_true", help="Verbose.")
p.add_argument("-t", "--tag", type=str, suggestions=["latest", "stable", "beta"])
p.add_argument("--numbers", type=int, nargs="*", default=[1, 2, 3], help="Nums.")
p.add_argument("-j", "--just-a-bool", action="store_true", help="Bool.")
p.add_argument("-a", action="store_true")
p.add_argument("-b", action="store_true")
return p
@pytest.mark.asyncio
async def test_parse_minimal_positional_and_defaults():
p = build_default_parser()
got = await p.parse_args(["web"])
assert got["service"] == "web"
assert got["place"] == "New York"
assert got["numbers"] == [1, 2, 3]
assert got["verbose"] is False
assert got["tag"] is None
assert got["path"] is None
@pytest.mark.asyncio
async def test_parse_all_keywords_and_lists_and_bools():
p = build_default_parser()
got = await p.parse_args(
[
"web",
"Paris",
"--region",
"eu-west-1",
"--numbers",
"10",
"20",
"-30",
"-t",
"stable",
"-p",
"pyproject.toml",
"-v",
"-j",
]
)
assert got["service"] == "web"
assert got["place"] == "Paris"
assert got["region"] == "eu-west-1"
assert got["numbers"] == [10, 20, -30]
assert got["tag"] == "stable"
assert isinstance(got["path"], Path)
assert got["verbose"] is True and got["just_a_bool"] is True
@pytest.mark.asyncio
async def test_parse_numbers_negative_values_not_flags():
p = build_default_parser()
got = await p.parse_args(["web", "--numbers", "-1", "-2", "-3"])
assert got["numbers"] == [-1, -2, -3]
def test_default_list_must_match_choices_when_choices_present():
p = CommandArgumentParser()
with pytest.raises(CommandArgumentError):
p.add_argument(
"--color", choices=["red", "blue"], nargs="*", default=["red", "green"]
)
def test_default_type_for_nargs_requires_list():
p = CommandArgumentParser()
with pytest.raises(CommandArgumentError):
p.add_argument("--ints", type=int, nargs=2, default=1)
@pytest.mark.asyncio
async def test_choices_enforced_on_result():
p = CommandArgumentParser()
p.add_argument("--env", choices=["prod", "dev"])
with pytest.raises(CommandArgumentError):
await p.parse_args(["--env", "staging"])
@pytest.mark.asyncio
async def test_posix_bundling_flags_only():
p = CommandArgumentParser()
p.add_argument("-a", "--aa", action="store_true")
p.add_argument("-b", "--bb", action="store_true")
p.add_argument("-c", "--cc", action="store_true")
got = await p.parse_args(["-abc"])
assert got["aa"] and got["bb"] and got["cc"]
@pytest.mark.asyncio
async def test_posix_bundling_not_applied_when_value_like():
p = CommandArgumentParser()
p.add_argument("-n", "--num", type=int)
p.add_argument("-a", action="store_true")
p.add_argument("-b", action="store_true")
got = await p.parse_args(["--num", "-123", "-ab"])
assert got["num"] == -123
assert got["a"] and got["b"]
def mk_tmp_tree(tmp_path: Path):
(tmp_path / "dirA").mkdir()
(tmp_path / "dirB").mkdir()
(tmp_path / "file.txt").write_text("x")
def test_complete_initial_flags_and_suggestions():
p = build_default_parser()
sugg = p.suggest_next([""], cursor_at_end_of_token=False)
assert "--tag" in sugg and "--region" in sugg and "-v" in sugg
def test_complete_flag_by_prefix():
p = build_default_parser()
assert p.suggest_next(["--ta"], False) == ["--tag"]
@pytest.mark.asyncio
async def test_complete_values_for_flag_choices():
p = build_default_parser()
with pytest.raises(CommandArgumentError):
await p.parse_args(["--region"])
sugg = p.suggest_next(["--region"], True)
assert set(sugg) == {"us-east-1", "us-west-2", "eu-west-1"}
with pytest.raises(CommandArgumentError):
await p.parse_args(["--region", "us-"])
sugg2 = p.suggest_next(["--region", "us-"], False)
assert set(sugg2) == {"us-east-1", "us-west-2"}
@pytest.mark.asyncio
async def test_complete_values_for_flag_suggestions():
p = build_default_parser()
with pytest.raises(CommandArgumentError):
await p.parse_args(["--tag"])
assert set(p.suggest_next(["--tag"], True)) == {"latest", "stable", "beta"}
with pytest.raises(CommandArgumentError):
await p.parse_args(["--tag", "st"])
assert set(p.suggest_next(["--tag", "st"], False)) == {"stable"}
def test_complete_mid_flag_hyphen_value_uses_previous_flag_context():
p = build_default_parser()
sugg = p.suggest_next(["--numbers", "-1"], False)
assert "--tag" not in sugg and "--region" not in sugg
def test_complete_multi_value_keeps_suggesting_for_plus_star():
p = build_default_parser()
sugg1 = p.suggest_next(["--numbers"], False)
assert "--tag" not in sugg1 or True
sugg2 = p.suggest_next(["--numbers", "1"], False)
assert "--tag" not in sugg2 or True
@pytest.mark.asyncio
async def test_complete_path_values(tmp_path, monkeypatch):
mk_tmp_tree(tmp_path)
monkeypatch.chdir(tmp_path)
p = build_default_parser()
with pytest.raises(CommandArgumentError):
await p.parse_args(["--path"])
sugg = p.suggest_next(["--path"], True)
assert any(s.endswith("/") for s in sugg) and "file.txt" in sugg
with pytest.raises(CommandArgumentError):
await p.parse_args(["--path", "d"])
sugg2 = p.suggest_next(["--path", "d"], False)
assert "dirA/" in sugg2 or "dirB/" in sugg2
@pytest.mark.asyncio
async def test_complete_positional_path(tmp_path, monkeypatch):
mk_tmp_tree(tmp_path)
monkeypatch.chdir(tmp_path)
p = CommandArgumentParser()
p.add_argument("paths", type=Path, nargs="*")
await p.parse_args([""])
s1 = p.suggest_next([""], False)
assert "file.txt" in s1 or "dirA/" in s1
await p.parse_args(["fi"])
s2 = p.suggest_next(["fi"], False)
assert "file.txt" in s2
@pytest.mark.asyncio
async def test_flag_then_space_yields_flag_suggestions():
p = build_default_parser()
with pytest.raises(CommandArgumentError):
await p.parse_args(["--tag"])
sugg = p.suggest_next(["--tag"], True)
assert "latest" in sugg
def test_complete_multi_value_persists_until_space_or_new_flag():
p = build_default_parser()
s1 = p.suggest_next(["--numbers"], cursor_at_end_of_token=False)
assert "--tag" not in s1 or True
s2 = p.suggest_next(["--numbers", "1"], cursor_at_end_of_token=False)
assert "--tag" not in s2 or True
s3 = p.suggest_next(["--numbers", "1"], cursor_at_end_of_token=True)
assert "--tag" not in s3 or True
@pytest.mark.asyncio
async def test_mid_value_suggestions_then_flags_after_space():
p = build_default_parser()
with pytest.raises(CommandArgumentError):
await p.parse_args(["--tag", "st"])
s_mid = p.suggest_next(["--tag", "st"], cursor_at_end_of_token=False)
assert set(s_mid) == {"stable"}
s_after = p.suggest_next(["--tag"], cursor_at_end_of_token=True)
assert any(opt.startswith("-") for opt in s_after)
@pytest.mark.asyncio
async def test_negative_values_then_posix_bundle():
p = build_default_parser()
out = await p.parse_args(["prod", "--numbers", "-3", "-ab"])
assert out["numbers"] == [-3]
assert out["a"] is True and out["b"] is True
def test_mid_flag_token_after_negative_value_uses_prior_flag_context():
p = build_default_parser()
sugg = p.suggest_next(["--numbers", "-1"], cursor_at_end_of_token=False)
assert "--tag" not in sugg and "--region" not in sugg
@pytest.mark.asyncio
async def test_path_dash_prefix_is_value_not_flags():
p = CommandArgumentParser()
p.add_argument("-a", action="store_true")
p.add_argument("--path", type=Path)
out = await p.parse_args(["--path", "-abc", "-a"])
assert str(out["path"]) == "-abc"
assert out["a"] is True
@pytest.mark.asyncio
async def test_store_bool_optional_pair_last_one_wins():
p = CommandArgumentParser()
p.add_argument("--feature", action="store_bool_optional", help="toggle feature")
out0 = await p.parse_args([])
assert out0["feature"] is None
out1 = await p.parse_args(["--feature"])
assert out1["feature"] is True
out2 = await p.parse_args(["--no-feature"])
assert out2["feature"] is False
out3 = await p.parse_args(["--feature", "--no-feature"])
assert out3["feature"] is False
out4 = await p.parse_args(["--no-feature", "--feature"])
assert out4["feature"] is True
@pytest.mark.asyncio
async def test_invalid_choice_suppresses_then_recovers():
p = build_default_parser()
with pytest.raises(CommandArgumentError):
await p.parse_args(["--region", "us-"])
s_suppressed = p.suggest_next(["--region", "us-"], cursor_at_end_of_token=True)
assert s_suppressed == []
s_recover = p.suggest_next(["--region", "us-"], cursor_at_end_of_token=False)
assert set(s_recover) == {"us-east-1", "us-west-2"}
@pytest.mark.asyncio
async def test_repeated_keyword_last_one_wins_and_guides_completion():
p = build_default_parser()
out = await p.parse_args(["test", "--tag", "alpha", "--tag", "st"])
assert out["tag"] == "st"
s = p.suggest_next(
["test", "--tag", "alpha", "--tag", "st"], cursor_at_end_of_token=False
)
assert set(s) == {"stable"}