Skip to content

Commit 7b35017

Browse files
committed
fix prompt
1 parent 6624518 commit 7b35017

7 files changed

Lines changed: 246 additions & 10 deletions

File tree

avito/cli/accounts.py

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import click
1010

11+
from avito.cli.click_params import RequiredPromptArgument, RequiredPromptOption
1112
from avito.cli.config import (
1213
AccountsDocument,
1314
AccountStore,
@@ -33,9 +34,32 @@ def account_group() -> None:
3334
"""Управлять локальными учетными записями."""
3435

3536

37+
class _AuthRequiredPromptOption(RequiredPromptOption):
38+
"""Обязательный option с учетом глобального запрета интерактивного ввода."""
39+
40+
def _missing_value_error(self) -> click.ClickException:
41+
"""Создать auth-ошибку отсутствующего значения для non-interactive режима."""
42+
43+
return CliAuthRequiredError(
44+
f"{self.human_readable_name} не передан, а интерактивный ввод отключен.",
45+
)
46+
47+
3648
@account_group.command("add")
37-
@click.argument("account_name", metavar="ACCOUNT-NAME")
38-
@click.option("--client-id", required=True, metavar="CLIENT-ID", help="Client ID учетной записи.")
49+
@click.argument(
50+
"account_name",
51+
cls=RequiredPromptArgument,
52+
prompt="Имя учетной записи",
53+
metavar="ACCOUNT-NAME",
54+
)
55+
@click.option(
56+
"--client-id",
57+
cls=_AuthRequiredPromptOption,
58+
required=True,
59+
prompt="Client ID",
60+
metavar="CLIENT-ID",
61+
help="Client ID учетной записи.",
62+
)
3963
@click.option(
4064
"--client-secret",
4165
metavar="CLIENT-SECRET",
@@ -144,7 +168,12 @@ def list_accounts(ctx: CliContext) -> None:
144168

145169

146170
@account_group.command("use")
147-
@click.argument("account_name", metavar="ACCOUNT-NAME")
171+
@click.argument(
172+
"account_name",
173+
cls=RequiredPromptArgument,
174+
prompt="Имя учетной записи",
175+
metavar="ACCOUNT-NAME",
176+
)
148177
@click.pass_obj
149178
def use_account(ctx: CliContext, account_name: str) -> None:
150179
"""Сделать учетную запись активной."""
@@ -203,7 +232,12 @@ def current_account(ctx: CliContext) -> None:
203232

204233

205234
@account_group.command("delete")
206-
@click.argument("account_name", metavar="ACCOUNT-NAME")
235+
@click.argument(
236+
"account_name",
237+
cls=RequiredPromptArgument,
238+
prompt="Имя учетной записи",
239+
metavar="ACCOUNT-NAME",
240+
)
207241
@click.option("--yes", is_flag=True, help="Удалить без интерактивного подтверждения.")
208242
@click.option("--confirm", metavar="ACCOUNT-NAME", help="Подтвердить имя удаляемой учетной записи.")
209243
@click.pass_obj

avito/cli/app.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from avito.cli import commands as api_commands
1212
from avito.cli.accounts import account_group
13+
from avito.cli.click_params import RequiredPromptOption
1314
from avito.cli.context import CliContext
1415
from avito.cli.errors import CliUsageError, InvalidFlagCombinationError
1516
from avito.cli.help import render_registry_help
@@ -274,10 +275,11 @@ def _parameter_click_options(
274275
"""Преобразовать registry parameter records в Click options."""
275276

276277
return [
277-
click.Option(
278+
RequiredPromptOption(
278279
param_decls=(parameter.flag,),
279280
multiple=parameter.multiple,
280-
required=False,
281+
required=parameter.required,
282+
prompt=_prompt_label(parameter.name) if parameter.required else False,
281283
metavar="VALUE",
282284
help=f"Параметр SDK `{parameter.name}`.",
283285
)
@@ -336,6 +338,12 @@ def _optional_string(value: object) -> str | None:
336338
return str(value)
337339

338340

341+
def _prompt_label(name: str) -> str:
342+
"""Вернуть человекочитаемую подпись prompt для CLI-параметра."""
343+
344+
return name.replace("_", " ")
345+
346+
339347
def _raw_values_from_click(raw_options: dict[str, object]) -> dict[str, tuple[str, ...]]:
340348
"""Преобразовать Click kwargs в raw CLI values для coercion."""
341349

avito/cli/click_params.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""Click-параметры с единым интерактивным поведением."""
2+
3+
from __future__ import annotations
4+
5+
import collections.abc as cabc
6+
from typing import Any
7+
8+
import click
9+
10+
from avito.cli.context import CliContext
11+
from avito.cli.errors import CliUsageError, CliValidationError
12+
13+
14+
class RequiredPromptOption(click.Option):
15+
"""Обязательный option, который спрашивает значение до callback."""
16+
17+
def prompt_for_value(self, ctx: click.Context) -> object:
18+
"""Запросить значение через Click prompt, если интерактивный ввод разрешен."""
19+
20+
if _no_input(ctx):
21+
raise self._missing_value_error()
22+
if self.multiple:
23+
prompt = self.prompt or self.name or "value"
24+
return (click.prompt(prompt, type=str),)
25+
return super().prompt_for_value(ctx)
26+
27+
def _missing_value_error(self) -> click.ClickException:
28+
"""Создать ошибку отсутствующего значения для non-interactive режима."""
29+
30+
flag = self.opts[0] if self.opts else self.human_readable_name
31+
return CliValidationError(
32+
f"Не указан обязательный параметр {flag}. Интерактивный ввод отключен.",
33+
details={"parameter": self.name},
34+
)
35+
36+
37+
class RequiredPromptArgument(click.Argument):
38+
"""Обязательный argument, который спрашивает значение до callback."""
39+
40+
def __init__(self, param_decls: cabc.Sequence[str], **attrs: Any) -> None:
41+
"""Сохранить prompt label и инициализировать Click argument."""
42+
43+
self.prompt = str(attrs.pop("prompt"))
44+
super().__init__(param_decls, **attrs)
45+
46+
def process_value(self, ctx: click.Context, value: object) -> object:
47+
"""Подставить интерактивно введенное значение, если argument не передан."""
48+
49+
if self.required and (value == () or self.value_is_missing(value)):
50+
if _no_input(ctx):
51+
raise CliUsageError(
52+
f"Не указан обязательный аргумент {self.human_readable_name}. "
53+
"Интерактивный ввод отключен.",
54+
details={"parameter": self.name},
55+
)
56+
value = click.prompt(self.prompt, type=str)
57+
return super().process_value(ctx, value)
58+
59+
60+
def _no_input(ctx: click.Context) -> bool:
61+
"""Вернуть глобальный запрет интерактивного ввода из Click context."""
62+
63+
cli_context = ctx.find_object(CliContext)
64+
return cli_context is not None and cli_context.no_input

avito/cli/local.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import click
1212

13+
from avito.cli.click_params import RequiredPromptArgument
1314
from avito.cli.config import (
1415
AccountStore,
1516
CliConfigDocument,
@@ -34,7 +35,7 @@ def config_group() -> None:
3435

3536

3637
@config_group.command("get")
37-
@click.argument("key", metavar="KEY")
38+
@click.argument("key", cls=RequiredPromptArgument, prompt="Ключ", metavar="KEY")
3839
@click.option("--show-source", is_flag=True, help="Показать источник значения.")
3940
@click.pass_obj
4041
def config_get(ctx: CliContext, key: str, show_source: bool) -> None:
@@ -52,8 +53,8 @@ def config_get(ctx: CliContext, key: str, show_source: bool) -> None:
5253

5354

5455
@config_group.command("set")
55-
@click.argument("key", metavar="KEY")
56-
@click.argument("value", metavar="VALUE")
56+
@click.argument("key", cls=RequiredPromptArgument, prompt="Ключ", metavar="KEY")
57+
@click.argument("value", cls=RequiredPromptArgument, prompt="Значение", metavar="VALUE")
5758
@click.pass_obj
5859
def config_set(ctx: CliContext, key: str, value: str) -> None:
5960
"""Сохранить значение локальной конфигурации."""
@@ -73,7 +74,7 @@ def config_set(ctx: CliContext, key: str, value: str) -> None:
7374

7475

7576
@config_group.command("unset")
76-
@click.argument("key", metavar="KEY")
77+
@click.argument("key", cls=RequiredPromptArgument, prompt="Ключ", metavar="KEY")
7778
@click.pass_obj
7879
def config_unset(ctx: CliContext, key: str) -> None:
7980
"""Удалить значение локальной конфигурации."""

tests/cli/test_accounts.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,52 @@ def test_account_add_no_input_without_secret_fails_without_prompt(tmp_path: Path
114114
assert "AUTH_REQUIRED" in result.stderr
115115

116116

117+
def test_account_add_prompts_for_missing_client_id(tmp_path: Path) -> None:
118+
result = _runner(tmp_path).invoke(
119+
app,
120+
["account", "add", "main", "--client-secret", "client-secret"],
121+
input="prompt-client-id\n",
122+
)
123+
124+
assert result.exit_code == 0
125+
assert "Client ID" in result.output
126+
assert "prompt-client-id" in result.output
127+
128+
129+
def test_account_add_prompts_for_missing_account_name(tmp_path: Path) -> None:
130+
result = _runner(tmp_path).invoke(
131+
app,
132+
["account", "add", "--client-id", "client-id", "--client-secret", "client-secret"],
133+
input="main\n",
134+
)
135+
136+
assert result.exit_code == 0
137+
assert "Имя учетной записи" in result.output
138+
assert "Учетная запись добавлена: main" in result.output
139+
140+
141+
def test_account_add_no_input_without_client_id_fails_without_prompt(tmp_path: Path) -> None:
142+
result = _runner(tmp_path).invoke(
143+
app,
144+
["--no-input", "account", "add", "main", "--client-secret", "client-secret"],
145+
)
146+
147+
assert result.exit_code == 4
148+
assert "AUTH_REQUIRED" in result.stderr
149+
assert "интерактивный ввод отключен" in result.stderr
150+
151+
152+
def test_account_add_no_input_without_account_name_fails_without_prompt(tmp_path: Path) -> None:
153+
result = _runner(tmp_path).invoke(
154+
app,
155+
["--no-input", "account", "add", "--client-id", "client-id", "--client-secret", "client-secret"],
156+
)
157+
158+
assert result.exit_code == 2
159+
assert "CLI_USAGE_ERROR" in result.stderr
160+
assert "Интерактивный ввод отключен" in result.stderr
161+
162+
117163
def test_account_add_accepts_ticket_aliases_api_key_and_endpoint(tmp_path: Path) -> None:
118164
runner = _runner(tmp_path)
119165

tests/cli/test_config_commands.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,5 +52,25 @@ def test_config_rejects_unknown_key(tmp_path: Path) -> None:
5252
assert "не поддерживается" in result.stderr
5353

5454

55+
def test_config_set_prompts_for_missing_required_arguments(tmp_path: Path) -> None:
56+
runner = _runner(tmp_path)
57+
58+
set_result = runner.invoke(app, ["config", "set"], input="active-profile\nmain\n")
59+
get_result = runner.invoke(app, ["config", "get", "active-profile"])
60+
61+
assert set_result.exit_code == 0
62+
assert "Ключ" in set_result.output
63+
assert "Значение" in set_result.output
64+
assert get_result.stdout.strip() == "main"
65+
66+
67+
def test_config_get_no_input_without_key_fails_without_prompt(tmp_path: Path) -> None:
68+
result = _runner(tmp_path).invoke(app, ["--no-input", "config", "get"])
69+
70+
assert result.exit_code == 2
71+
assert "CLI_USAGE_ERROR" in result.stderr
72+
assert "Интерактивный ввод отключен" in result.stderr
73+
74+
5575
def _runner(tmp_path: Path) -> CliRunner:
5676
return CliRunner(env={"AVITO_PY_HOME": str(tmp_path / "home")})

tests/cli/test_domain_smoke_commands.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,53 @@ def test_every_read_factory_has_at_least_one_smoke_command() -> None:
8383
assert smoked_factories == read_factories
8484

8585

86+
def test_api_command_prompts_for_missing_required_option(
87+
monkeypatch: pytest.MonkeyPatch,
88+
tmp_path: Path,
89+
) -> None:
90+
command = _api_command("account.get-operations-history")
91+
fake = SwaggerFakeTransport(registry=load_swagger_registry())
92+
fake.add_success_operation(command.operation_key)
93+
_install_fake_client(monkeypatch, fake)
94+
_write_account(tmp_path)
95+
args = [
96+
"--profile",
97+
"main",
98+
command.resource,
99+
command.action,
100+
*_cli_args_except(command, "date_from"),
101+
]
102+
103+
result = CliRunner(env={"AVITO_PY_HOME": str(tmp_path)}).invoke(
104+
app,
105+
args,
106+
input="2026-05-01T00:00:00+00:00\n",
107+
)
108+
109+
assert result.exit_code == 0, result.output
110+
assert "date from" in result.output
111+
assert fake.count() >= 1
112+
113+
114+
def test_api_command_no_input_without_required_option_fails_without_prompt(tmp_path: Path) -> None:
115+
command = _api_command("account.get-operations-history")
116+
_write_account(tmp_path)
117+
args = [
118+
"--profile",
119+
"main",
120+
"--no-input",
121+
command.resource,
122+
command.action,
123+
*_cli_args_except(command, "date_from"),
124+
]
125+
126+
result = CliRunner(env={"AVITO_PY_HOME": str(tmp_path)}).invoke(app, args)
127+
128+
assert result.exit_code == 7
129+
assert "VALIDATION_FAILED" in result.stderr
130+
assert "Интерактивный ввод отключен" in result.stderr
131+
132+
86133
@pytest.mark.parametrize("command", _WRITE_COMMANDS, ids=lambda command: command.command_id)
87134
def test_write_api_command_is_registered_and_renders_help(
88135
command: ApiCommandRecord,
@@ -172,6 +219,22 @@ def _cli_args(command: ApiCommandRecord) -> tuple[str, ...]:
172219
return tuple(args)
173220

174221

222+
def _cli_args_except(command: ApiCommandRecord, skipped_parameter: str) -> tuple[str, ...]:
223+
args: list[str] = []
224+
for parameter in command.parameters:
225+
if parameter.name != skipped_parameter:
226+
args.extend((parameter.flag, _value_for_parameter(parameter)))
227+
return tuple(args)
228+
229+
230+
def _api_command(command_id: str) -> ApiCommandRecord:
231+
matches = tuple(
232+
command for command in build_cli_registry().api_commands if command.command_id == command_id
233+
)
234+
assert len(matches) == 1
235+
return matches[0]
236+
237+
175238
def _value_for_parameter(parameter: CliParameterSchema) -> str:
176239
if parameter.value_kind == "list":
177240
item_kind = parameter.item_value_kind or "string"

0 commit comments

Comments
 (0)