diff --git a/.ai/cli-guidelines.md b/.ai/cli-guidelines.md new file mode 100644 index 0000000..72bd283 --- /dev/null +++ b/.ai/cli-guidelines.md @@ -0,0 +1,506 @@ +# CLI UX Style Guide + +Design a CLI as both a product interface and a scriptable API. Commands, flags, +output schemas, and exit codes are long-term contracts. + +## Core Rules + +1. Prefer additive changes. Document deprecations before removing commands, + flags, fields, or exit codes. +2. Default output is human-readable. Provide machine-readable output explicitly: + `--json`, `--plain`, `--quiet`, and `--no-input`. +3. Make successful commands answer: what happened, what changed, and what to do + next. +4. Use a consistent command grammar: `tool `. +5. Resources are nouns; actions are verbs: `tool model deploy`, + `tool endpoint delete`, `tool registry sync`. +6. Command names and flags use lowercase kebab-case. +7. Prefer explicit names over unclear abbreviations. Short aliases may exist + only beside clear long flags: `-o, --output`, `-f, --file`, `-h, --help`. + +## Arguments and Flags + +Use positional arguments only for obvious primary inputs: + +```bash +tool model inspect llama-3-8b +tool endpoint delete production-chat +``` + +Use named flags when argument order is ambiguous or the command is complex: + +```bash +tool model copy --from llama-3-8b --to llama-3-8b-prod +tool model deploy --model llama-3-8b --namespace production --gpu a100 --replicas 2 +``` + +Boolean flags should be positive: `--enable-cache`, `--verify-checksum`, +`--wait`. `--no-*` is acceptable only for disabling default behavior: +`--no-color`, `--no-input`, `--no-cache`. + +Use the same flag name for the same concept everywhere: + +```text +--namespace +--output +--format +--quiet +--json +--yes +--no-input +--dry-run +``` + +## Safety + +Every destructive command must support explicit confirmation. + +Interactive confirmation: + +```text +This will delete endpoint "production-chat". +Type "production-chat" to confirm: +``` + +Automation confirmation: + +```bash +tool endpoint delete production-chat --confirm production-chat +tool cache clear --yes +``` + +Complex write operations should support `--dry-run` and build the same planned +change without applying it: + +```text +Dry run: no changes will be applied. + +Would create: + Deployment: llama-3-70b + Replicas: 2 + GPU type: a100-80gb +``` + +Dangerous commands must be hard to run accidentally: delete, reset, destroy, +force deploy, overwrite, production changes, and expensive operations. Use +`--force` only to bypass explicit safety checks. Show cost or resource impact +before infrastructure-heavy operations. Never print secrets by default; mask +them unless a deliberate reveal command is used. + +## Help + +Every command must support: + +```bash +tool --help +tool model --help +tool model deploy --help +tool help model deploy +``` + +Help output should follow this shape: + +```text +Description: + Deploy a model and expose it through an OpenAI-compatible endpoint. + +Usage: + tool model deploy [flags] + +Examples: + tool model deploy llama-3-8b + tool model deploy llama-3-70b --gpu a100 --replicas 2 + tool model deploy llama-3-8b --namespace production --json + +Flags: + --gpu string GPU type to use + --replicas int Number of replicas + --namespace string Target namespace + --json Output result as JSON + --dry-run Preview changes without applying them + -h, --help Show help + +Related commands: + tool model list + tool endpoint test + tool logs +``` + +Put examples before exhaustive reference. Include at least one minimal example, +one realistic production example, and one automation-friendly example. For +recoverable usage errors, show the problem, usage, and examples. + +## Output + +Default output should be concise and useful: + +```text +Endpoint created: production-chat + +URL: + https://api.example.com/v1/chat/completions + +Next: + tool endpoint test production-chat +``` + +Use aligned tables for lists and keep columns stable: + +```text +NAME STATUS MODEL GPU REPLICAS +production-chat ready llama-3-8b a10g 2 +staging-chat pending mistral-7b l4 1 +``` + +Use grouped key-value output for one resource: + +```text +Endpoint: production-chat + +Status: ready +Model: llama-3-8b +GPU: a10g +Replicas: 2 +URL: https://api.example.com/v1/chat/completions +Created: 2026-05-09 12:30:00 +``` + +Machine-readable output must be stable and undecorated. Do not include spinners, +warnings, progress, or human instructions in JSON. + +Use stdout for command results. Use stderr for errors, warnings, progress, +debug logs, spinners, and deprecation notices. This must work: + +```bash +tool model list --json | jq '.models[]' +``` + +Support output formatting with a documented primary convention: + +```text +--json +--plain +--table +--wide +--quiet +--output +``` + +## Errors + +Errors must explain the problem, cause, fix, command to try, and stable error +code when useful: + +```text +Error: model "llama-3-70b" requires more GPU memory. + +Required: + 80GB GPU memory + +Available: + 40GB GPU memory + +Try: + tool model deploy llama-3-8b + tool nodepool add --gpu a100-80gb + +Error code: + MODEL_GPU_MEMORY_INSUFFICIENT +``` + +Use stable machine-readable error codes such as `MODEL_NOT_FOUND`, +`AUTH_REQUIRED`, `PERMISSION_DENIED`, `REGISTRY_UNAVAILABLE`, +`GPU_MEMORY_INSUFFICIENT`, `CHECKSUM_FAILED`, and `CONFIG_INVALID`. + +Suggest close matches for mistyped commands or invalid values. Do not expose +internal stack traces by default; reserve diagnostic detail for `--verbose` or +`--debug`, and never expose secrets in either mode. + +## Progress + +For operations longer than a few seconds, show progress on stderr: + +```text +Deploying model: llama-3-8b + +OK Validated model license +OK Checked GPU compatibility +OK Downloaded model weights +OK Verified checksum +.. Creating runtime artifact + Starting endpoint +``` + +Use a spinner for unknown duration, step counters for workflows, progress bars +for measurable transfers, and live status for deployments. Clean up progress +output after completion so the final result is readable. + +## Interactivity + +Interactive prompts are allowed only when stdin is a TTY. Commands must never +hang in CI, scripts, or piped usage: + +```bash +tool model deploy llama-3-8b --json > result.json +``` + +Every prompt needs a non-interactive equivalent using flags and `--no-input`. +Prompts must be specific, safe, and show defaults: + +```text +Namespace [default: default]: +Replicas [default: 1]: +GPU type [default: auto]: +``` + +## Automation and Exit Codes + +Automation-friendly commands should support `--json`, `--quiet`, `--no-input`, +`--yes`, and `--dry-run` where relevant. + +Baseline exit codes: + +```text +0 Success +1 General error +2 Invalid usage +3 Not found +4 Permission denied +5 Authentication required +6 Conflict +7 Validation failed +8 External dependency unavailable +``` + +Document all public exit codes. `--quiet` should suppress non-essential output +and emit only the final value or nothing on success. Keep `--verbose` +user-facing and `--debug` diagnostic. + +## Configuration + +Use and document this precedence order: + +```text +1. CLI flags +2. Environment variables +3. Project config +4. User config +5. System config +6. Built-in defaults +``` + +Flags must override environment variables. Config commands should be explicit: + +```bash +tool config get +tool config set registry s3://company-models +tool config unset registry +tool config list +tool config list --show-source +``` + +Show config source when debugging: + +```text +KEY VALUE SOURCE +registry s3://company-models project config +namespace production environment +gpu auto default +``` + +## Color and Accessibility + +Color must never be the only source of meaning; pair it with text or symbols. +Respect `--no-color` and `NO_COLOR=1`. Enable color only in TTY output and use +it sparingly: + +```text +Green success +Yellow warning +Red error +Blue links or neutral emphasis +Gray secondary metadata +``` + +## Naming + +Use consistent action verbs: + +```text +create +list +get +inspect +update +delete +deploy +start +stop +restart +sync +test +logs +status +doctor +``` + +Use `list` for collections and choose one of `get` or `inspect` for one item. +Prefer `delete` over `remove`; reserve `remove` for detaching something rather +than destroying it. Use `status` for quick system state and `doctor` for +diagnostics. + +## Resilience + +Commands should be safe to retry and should not create duplicate resources after +partial failure. Long operations should resume where possible: model downloads, +artifact builds, image pushes, deployments, and registry syncs. + +Partial failure must be explicit: + +```text +Deployment partially completed. + +Completed: + OK Model downloaded + OK Checksum verified + +Failed: + ERR Endpoint creation + +Reason: + Namespace "production" does not exist. + +Try: + tool namespace create production + tool model deploy llama-3-8b --namespace production +``` + +## Versioning and Deprecation + +Support `tool version` and `tool --version`. Include CLI version, build commit, +API version, and server compatibility when available. + +Deprecation warnings must include removal timing and replacement guidance: + +```text +Warning: "tool deploy" is deprecated and will be removed in v2.0. + +Use: + tool model deploy +``` + +## Completion and Logs + +Provide shell completion: + +```bash +tool completion bash +tool completion zsh +tool completion fish +``` + +Completion should include commands, flags, models, endpoints, namespaces, +registries, and config keys where possible. + +Logs should be easy to follow but should not replace structured status: + +```bash +tool logs endpoint production-chat --follow --tail 100 +tool logs scheduler --since 1h --level warn +tool status +tool endpoint status production-chat +``` + +## Recommended Global Flags + +```text +-h, --help +--version +--verbose +--debug +--quiet +--json +--no-color +--no-input +--config +--profile +--namespace +--context +--dry-run +--yes +--confirm +--timeout +``` + +## Recommended Command Set + +```bash +tool init +tool status +tool doctor +tool config get +tool config set +tool config list + +tool auth login +tool auth logout +tool auth status + +tool model list +tool model inspect +tool model deploy +tool model delete + +tool registry list +tool registry add +tool registry sync +tool registry inspect + +tool endpoint list +tool endpoint inspect +tool endpoint test +tool endpoint delete + +tool logs +tool version +tool completion +``` + +## Review Checklist + +```text +[ ] Follows tool +[ ] Uses lowercase kebab-case +[ ] Supports --help with examples +[ ] Has actionable errors and stable error codes +[ ] Separates stdout and stderr +[ ] Provides JSON output where useful +[ ] Works in CI without prompts +[ ] Protects destructive actions +[ ] Supports --dry-run for complex writes +[ ] Documents exit codes +[ ] Makes color optional +[ ] Is safe to retry +[ ] Handles deprecated flags gracefully +[ ] Produces useful success output +[ ] Suggests next steps where relevant +``` + +## Opinionated Defaults + +```text +Command style: tool +Case style: lowercase kebab-case +Default output: human-readable +Machine output: --json +Help: --help and help command +Progress: stderr +Result data: stdout +Errors: stderr +Color: enabled only in TTY +No color: --no-color and NO_COLOR +Automation: --quiet, --no-input, --yes +Safety: confirmation for destructive actions +Preview: --dry-run for write operations +Diagnostics: doctor and status commands +``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 02b078d..69562f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,58 @@ and this project adheres to Semantic Versioning. ## [Unreleased] ### Added -- Нет изменений. +- Добавлена базовая CLI-оболочка: `avito --help`, `avito --version`, `avito version`, + `avito help` и единый вход через `python -m avito`. +- Задокументирован первый CLI-контракт: глобальные флаги, режимы вывода, + разделение stdout/stderr, безопасный JSON/human-рендеринг ошибок и стабильные + exit codes. +- Добавлены локальные CLI-команды учетных записей: `avito account add`, + `avito account list`, `avito account use`, `avito account current`, + `avito account delete` и alias `avito account remove`. +- CLI хранит учетные записи в локальных JSON-файлах с правами доступа к файлам и + маскирует секреты во всех режимах вывода; первая версия использует plaintext + storage без OS keychain. +- Для безопасного ввода `client_secret` добавлены скрытый интерактивный prompt и + `--client-secret-stdin`; `--client-secret` и совместимый `--api-key` оставлены + для явной автоматизации с учетом риска попадания значения в историю shell. +- Добавлена registry-backed справка `avito help ` и + `avito help ` для локальных команд, helper workflows, + aliases и API command candidates без создания `AvitoClient`. +- Добавлена основа сериализации результатов CLI: SDK-модели выводятся через + публичный `model_dump()` / `to_dict()`, секреты маскируются после + сериализации, а `PaginatedList` по умолчанию выгружает только ограниченный + набор страниц. +- Подключен первый registry-backed API slice CLI: `avito account get-self` и + `avito account get-balance --user-id ...` вызывают публичный `AvitoClient`, + проходят через общий слой приведения аргументов, сериализации и безопасного + JSON/human-вывода. +- Расширено read-only CLI-покрытие API: все поддержанные sync Swagger-bound + GET/HEAD-команды регистрируются через CLI registry, проходят fake-transport + smoke coverage и проверяются фазой `scripts/lint_cli_coverage.py --phase read`; + read-bindings без достаточных factory/method аргументов оформлены как + временные исключения до Stage 10C. +- Добавлены CLI safety primitives для будущих write/destructive API-команд: + registry хранит проверенную safety policy, destructive/expensive команды + требуют prompt, `--yes` или точный `--confirm`, а `--dry-run` публикуется + только для SDK-методов с публичным `dry_run`. +- Расширено write CLI-покрытие API: generic-safe sync Swagger-bound write-команды + регистрируются через CLI registry, проходят fake-transport smoke coverage и + проверяются фазой `scripts/lint_cli_coverage.py --phase write`; write-bindings, + которым нужен CLI adapter или уточнение binding metadata, оформлены как + временные исключения до Stage 10C. +- Добавлены публичные CLI helper workflows: `account-health show`, + `listing-health show`, `chat-summary show`, `order-summary show`, + `review-summary show`, `promotion-summary show` и `capabilities show`; + команды вызывают только публичные методы `AvitoClient`, поддерживают JSON/human + вывод и не входят в Swagger one-to-one coverage. +- Добавлены локальные CLI-команды конфигурации и диагностики: `avito config get`, + `avito config set`, `avito config unset`, `avito config list --show-source`, + `avito status`, `avito doctor` и `avito completion bash|zsh|fish`; команды + работают без сетевых вызовов и не раскрывают секреты. +- Добавлена публичная CLI-документация: quickstart в README, how-to для + профилей и ежедневных workflows, стабильный reference контракт, объяснение + архитектуры registry/coverage и ссылки из разделов security, auth/config и + API coverage. ## [2.1.0] - 2026-05-08 diff --git a/Makefile b/Makefile index 87b37be..f084051 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ MKDOCS_ENV=DISABLE_MKDOCS_2_WARNING=true NO_MKDOCS_2_WARNING=1 check: test quality -quality: typecheck lint python-guidelines-lint swagger-lint architecture-lint async-parity-lint docstring-lint build +quality: typecheck lint python-guidelines-lint swagger-lint cli-lint architecture-lint async-parity-lint docstring-lint build build: clean poetry build @@ -41,6 +41,9 @@ swagger-update: swagger-lint: poetry run python scripts/lint_swagger_bindings.py --strict +cli-lint: + poetry run python scripts/lint_cli_coverage.py --strict + architecture-lint: poetry run python scripts/lint_architecture.py diff --git a/README.md b/README.md index 9675e09..c763634 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,20 @@ print(ad.title) По умолчанию настройки читаются из переменных окружения с префиксом `AVITO_`. +CLI использует тот же публичный SDK и удобен для smoke-проверок, скриптов и +операционных задач: + +```bash +avito account add main --client-id client-id --user-id 123 +avito --profile main account get-self +avito --json --no-input --profile main account get-balance --user-id 123 +``` + +`account add` спросит `Client Secret` скрытым prompt. Для CI используйте +`--client-secret-stdin`, чтобы не передавать секрет в аргументах shell. +Подробно: [CLI how-to](https://18studio.github.io/avito_python_api/how-to/cli/) +и [CLI reference](https://18studio.github.io/avito_python_api/reference/cli/). + `avito-py` — Python SDK для работы с Avito API через единые sync/async фасады `AvitoClient` и `AsyncAvitoClient`. diff --git a/avito/__main__.py b/avito/__main__.py index 8b5a6dc..8eb2c37 100644 --- a/avito/__main__.py +++ b/avito/__main__.py @@ -1,13 +1,6 @@ -"""Точка входа модуля для локальной smoke-проверки.""" - -from avito.client import AvitoClient - - -def main() -> None: - """Создает фасад, чтобы `python -m avito` работал как smoke-проверка.""" - - AvitoClient() +"""Точка входа `python -m avito`.""" +from avito.cli.app import main if __name__ == "__main__": main() diff --git a/avito/cli/__init__.py b/avito/cli/__init__.py new file mode 100644 index 0000000..2a7699a --- /dev/null +++ b/avito/cli/__init__.py @@ -0,0 +1,5 @@ +"""CLI package for avito-py.""" + +from avito.cli.app import app, main + +__all__ = ("app", "main") diff --git a/avito/cli/accounts.py b/avito/cli/accounts.py new file mode 100644 index 0000000..e7b91ac --- /dev/null +++ b/avito/cli/accounts.py @@ -0,0 +1,387 @@ +"""Локальные команды учетных записей CLI.""" + +from __future__ import annotations + +import json +from collections.abc import Sequence +from dataclasses import replace + +import click + +from avito.cli.config import ( + AccountsDocument, + AccountStore, + CliConfigDocument, + ConfigStore, + JsonValue, + StoredAccount, + resolve_cli_home, +) +from avito.cli.context import CliContext +from avito.cli.errors import ( + CliAuthRequiredError, + CliConfigFileError, + CliUsageError, + InvalidFlagCombinationError, +) +from avito.cli.ui import emit_stdout + + +@click.group(name="account") +@click.help_option("-h", "--help", help="Показать справку и выйти.") +def account_group() -> None: + """Управлять локальными учетными записями.""" + + +@account_group.command("add") +@click.argument("account_name", metavar="ACCOUNT-NAME") +@click.option("--client-id", required=True, metavar="CLIENT-ID", help="Client ID учетной записи.") +@click.option( + "--client-secret", + metavar="CLIENT-SECRET", + help="Client Secret. Значение может попасть в историю shell.", +) +@click.option( + "--api-key", + metavar="API-KEY", + help="Совместимый alias для --client-secret. Значение может попасть в историю shell.", +) +@click.option( + "--client-secret-stdin", + is_flag=True, + help="Прочитать Client Secret одной строкой из stdin.", +) +@click.option("--endpoint", metavar="URL", help="Alias для базового URL Avito API.") +@click.option("--user-id", type=int, metavar="USER-ID", help="ID пользователя Avito.") +@click.option("--scope", metavar="SCOPE", help="OAuth scope для учетной записи.") +@click.pass_obj +def add_account( + ctx: CliContext, + account_name: str, + client_id: str, + client_secret: str | None, + api_key: str | None, + client_secret_stdin: bool, + endpoint: str | None, + user_id: int | None, + scope: str | None, +) -> None: + """Добавить локальную учетную запись без сетевых вызовов.""" + + secret = _resolve_client_secret( + ctx, + client_secret=client_secret, + api_key=api_key, + client_secret_stdin=client_secret_stdin, + ) + account_store = _account_store() + config_store = _config_store(ctx) + document = account_store.load() + if _find_account(document, account_name) is not None: + raise CliConfigFileError( + "Учетная запись с таким именем уже существует.", + details={"account_name": account_name}, + ) + + account = StoredAccount( + name=account_name, + client_id=client_id, + client_secret=secret, + base_url=endpoint or "https://api.avito.ru", + user_id=user_id, + scope=scope, + ) + updated = replace(document, accounts=(*document.accounts, account)) + account_store.save(updated) + + config = config_store.load() + if config.active_profile is None: + config_store.save(replace(config, active_profile=account_name)) + + if ctx.json_output: + emit_stdout(ctx, _json_dump({"account": account.to_json(mask_secrets=True)})) + return + emit_stdout(ctx, f"Учетная запись добавлена: {account_name}") + + +@account_group.command("list") +@click.pass_obj +def list_accounts(ctx: CliContext) -> None: + """Показать локальные учетные записи.""" + + document = _account_store().load() + config = _config_store(ctx).load() + active_profile = _effective_profile(ctx, config) + if ctx.json_output: + emit_stdout( + ctx, + _json_dump( + { + "active_profile": active_profile, + "accounts": [ + _account_summary(account, active=account.name == active_profile) + for account in document.accounts + ], + } + ), + ) + return + + if not document.accounts: + emit_stdout(ctx, "Локальные учетные записи не настроены.") + return + + rows = [ + ( + account.name, + "да" if account.name == active_profile else "", + account.client_id, + account.base_url, + ) + for account in document.accounts + ] + emit_stdout(ctx, _render_table(("ИМЯ", "АКТИВНА", "CLIENT ID", "URL"), rows)) + + +@account_group.command("use") +@click.argument("account_name", metavar="ACCOUNT-NAME") +@click.pass_obj +def use_account(ctx: CliContext, account_name: str) -> None: + """Сделать учетную запись активной.""" + + document = _account_store().load() + if _find_account(document, account_name) is None: + raise CliConfigFileError( + "Учетная запись не найдена.", + details={"account_name": account_name}, + ) + + config_store = _config_store(ctx) + config_store.save(replace(config_store.load(), active_profile=account_name)) + if ctx.json_output: + emit_stdout(ctx, _json_dump({"active_profile": account_name})) + return + emit_stdout(ctx, f"Активная учетная запись: {account_name}") + + +@account_group.command("current") +@click.pass_obj +def current_account(ctx: CliContext) -> None: + """Показать активную учетную запись.""" + + config = _config_store(ctx).load() + active_profile = _effective_profile(ctx, config) + if active_profile is None: + raise CliConfigFileError("Активная учетная запись не выбрана.") + + account = _find_account(_account_store().load(), active_profile) + if account is None: + raise CliConfigFileError( + "Активная учетная запись не найдена в локальном хранилище.", + details={"active_profile": active_profile}, + ) + + if ctx.json_output: + emit_stdout( + ctx, + _json_dump({"active_profile": active_profile, "account": account.to_json(mask_secrets=True)}), + ) + return + + emit_stdout( + ctx, + "\n".join( + ( + f"Активная учетная запись: {account.name}", + "", + f"Client ID: {account.client_id}", + f"URL: {account.base_url}", + f"User ID: {account.user_id if account.user_id is not None else '-'}", + ) + ), + ) + + +@account_group.command("delete") +@click.argument("account_name", metavar="ACCOUNT-NAME") +@click.option("--yes", is_flag=True, help="Удалить без интерактивного подтверждения.") +@click.option("--confirm", metavar="ACCOUNT-NAME", help="Подтвердить имя удаляемой учетной записи.") +@click.pass_obj +def delete_account(ctx: CliContext, account_name: str, yes: bool, confirm: str | None) -> None: + """Удалить локальную учетную запись.""" + + _delete_account(ctx, account_name=account_name, yes=yes, confirm=confirm) + + +account_group.add_command( + click.Command( + name="remove", + params=delete_account.params, + callback=delete_account.callback, + help="Alias для `account delete`.", + ) +) + + +def _delete_account(ctx: CliContext, *, account_name: str, yes: bool, confirm: str | None) -> None: + """Удалить account с интерактивной или явной проверкой имени.""" + + if yes and confirm is not None: + raise InvalidFlagCombinationError("Флаги --yes и --confirm нельзя использовать вместе.") + if not yes and confirm != account_name: + if ctx.no_input: + raise CliUsageError( + "Удаление требует подтверждения.", + details={"account_name": account_name}, + ) + entered = click.prompt( + f"Введите имя учетной записи `{account_name}` для подтверждения", + type=str, + ) + if entered != account_name: + raise CliUsageError("Подтверждение удаления не совпадает с именем учетной записи.") + + account_store = _account_store() + document = account_store.load() + account = _find_account(document, account_name) + if account is None: + raise CliConfigFileError( + "Учетная запись не найдена.", + details={"account_name": account_name}, + ) + + remaining = tuple(item for item in document.accounts if item.name != account.name) + account_store.save(replace(document, accounts=remaining)) + + config_store = _config_store(ctx) + config = config_store.load() + if _effective_profile(ctx, config) == account_name and ctx.profile is None: + config_store.save(replace(config, active_profile=None)) + + if ctx.json_output: + emit_stdout(ctx, _json_dump({"deleted": account_name})) + return + emit_stdout(ctx, f"Учетная запись удалена: {account_name}") + + +def _resolve_client_secret( + ctx: CliContext, + *, + client_secret: str | None, + api_key: str | None, + client_secret_stdin: bool, +) -> str: + """Получить client secret из одного разрешенного источника.""" + + selected = [ + name + for name, enabled in ( + ("--client-secret", client_secret is not None), + ("--api-key", api_key is not None), + ("--client-secret-stdin", client_secret_stdin), + ) + if enabled + ] + if len(selected) > 1: + raise InvalidFlagCombinationError( + "Флаги --client-secret, --api-key и --client-secret-stdin нельзя использовать вместе.", + details={"selected_flags": selected}, + ) + if client_secret is not None: + return _require_non_empty_secret(client_secret) + if api_key is not None: + return _require_non_empty_secret(api_key) + if client_secret_stdin: + return _read_client_secret_stdin() + if ctx.no_input: + raise CliAuthRequiredError( + "Client Secret не передан, а интерактивный ввод отключен.", + ) + return _require_non_empty_secret( + click.prompt("Client Secret", hide_input=True, confirmation_prompt=False, type=str) + ) + + +def _read_client_secret_stdin() -> str: + """Прочитать ровно одну строку client secret из stdin.""" + + stream = click.get_text_stream("stdin") + if stream.isatty(): + raise CliUsageError("--client-secret-stdin требует неинтерактивный stdin.") + value = stream.read() + if value.endswith("\n"): + value = value[:-1] + if value.endswith("\r"): + value = value[:-1] + if "\n" in value or "\r" in value: + raise CliUsageError("--client-secret-stdin принимает ровно одну строку.") + return _require_non_empty_secret(value) + + +def _require_non_empty_secret(value: str) -> str: + """Проверить, что client secret не пустой.""" + + if not value: + raise CliAuthRequiredError("Client Secret не может быть пустым.") + return value + + +def _account_store() -> AccountStore: + """Создать store учетных записей для текущего CLI home.""" + + return AccountStore(resolve_cli_home()) + + +def _config_store(ctx: CliContext) -> ConfigStore: + """Создать store конфигурации с учетом флага --config.""" + + return ConfigStore(resolve_cli_home(), path=ctx.config) + + +def _find_account(document: AccountsDocument, account_name: str) -> StoredAccount | None: + """Найти account по имени в документе хранилища.""" + + for account in document.accounts: + if account.name == account_name: + return account + return None + + +def _effective_profile(ctx: CliContext, config: CliConfigDocument) -> str | None: + """Вернуть профиль из CLI-флага или локальной конфигурации.""" + + if ctx.profile is not None: + return ctx.profile + return config.active_profile + + +def _account_summary(account: StoredAccount, *, active: bool) -> dict[str, JsonValue]: + """Собрать безопасную JSON-сводку account для вывода.""" + + payload = account.to_json(mask_secrets=True) + payload["active"] = active + return payload + + +def _json_dump(payload: dict[str, object]) -> str: + """Сериализовать payload в стабильный JSON.""" + + return json.dumps(payload, ensure_ascii=False, sort_keys=True) + + +def _render_table(headers: tuple[str, ...], rows: Sequence[tuple[str, ...]]) -> str: + """Отрендерить простую выровненную таблицу.""" + + widths = [ + max(len(header), *(len(row[index]) for row in rows)) + for index, header in enumerate(headers) + ] + lines = [" ".join(header.ljust(widths[index]) for index, header in enumerate(headers))] + lines.extend( + " ".join(value.ljust(widths[index]) for index, value in enumerate(row)) + for row in rows + ) + return "\n".join(lines) + + +__all__ = ("account_group",) diff --git a/avito/cli/adapters.py b/avito/cli/adapters.py new file mode 100644 index 0000000..8f87f3b --- /dev/null +++ b/avito/cli/adapters.py @@ -0,0 +1,255 @@ +"""Typed extension point for non-generic CLI command input handling.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from types import TracebackType +from typing import Protocol, cast + +from avito.cli.context import CliContext +from avito.cli.errors import CliError, CliSdkMethodError, CliValidationError +from avito.cli.registry import ApiCommandRecord +from avito.cli.safety import SafetyOptions +from avito.config import AvitoSettings + + +class ClientContext(Protocol): + """Context manager that yields a public SDK client object.""" + + def __enter__(self) -> object: + """Enter SDK client context.""" + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: + """Exit SDK client context.""" + + +class ClientFactory(Protocol): + """Factory used by adapters to build public SDK clients.""" + + def __call__(self, settings: AvitoSettings) -> ClientContext: + """Build a context-managed SDK client.""" + + +class CommandInvocationEngine(Protocol): + """Shared engine that invokes a command through `AvitoClient` public methods.""" + + def __call__( + self, + ctx: CliContext, + command: ApiCommandRecord, + raw_values: Mapping[str, Sequence[str]], + *, + safety_options: SafetyOptions | None = None, + client_factory: ClientFactory | None = None, + ) -> object: + """Invoke a command after adapter-owned CLI input normalization.""" + + +class CommandAdapter(Protocol): + """Adapter for CLI-only concerns before the shared SDK invocation path. + + Implementations may normalize stdin, file paths, multipart-friendly CLI + values, binary rendering options, or public input models. They must delegate + the actual Avito API call to the supplied invocation engine or call + `AvitoClient` factories and public domain methods directly. + """ + + def invoke( + self, + ctx: CliContext, + command: ApiCommandRecord, + raw_values: Mapping[str, Sequence[str]], + *, + engine: CommandInvocationEngine, + client_factory: ClientFactory | None = None, + ) -> object: + """Invoke adapter-backed command.""" + + +@dataclass(frozen=True, slots=True) +class AdapterMetadata: + """Stable serializable metadata for a command adapter.""" + + adapter_id: str + owner: str + reason: str + + def to_dict(self) -> dict[str, object]: + """Return JSON-compatible adapter metadata.""" + + return { + "adapter_id": self.adapter_id, + "owner": self.owner, + "reason": self.reason, + } + + +@dataclass(frozen=True, slots=True) +class RegisteredCommandAdapter: + """A command adapter paired with stable metadata.""" + + metadata: AdapterMetadata + adapter: CommandAdapter + + @property + def adapter_id(self) -> str: + """Return stable adapter id.""" + + return self.metadata.adapter_id + + +@dataclass(frozen=True, slots=True) +class CommandAdapterRegistry: + """Explicit registry of CLI command adapters.""" + + adapters: tuple[RegisteredCommandAdapter, ...] + + def get(self, adapter_id: str) -> RegisteredCommandAdapter | None: + """Return registered adapter by id.""" + + for adapter in self.adapters: + if adapter.adapter_id == adapter_id: + return adapter + return None + + def ids(self) -> frozenset[str]: + """Return registered adapter ids.""" + + return frozenset(adapter.adapter_id for adapter in self.adapters) + + def to_dict(self) -> dict[str, object]: + """Return JSON-compatible adapter registry metadata.""" + + return { + "adapters": [ + adapter.metadata.to_dict() + for adapter in sorted(self.adapters, key=lambda item: item.adapter_id) + ], + } + + +def build_command_adapter_registry( + adapters: Sequence[RegisteredCommandAdapter], +) -> CommandAdapterRegistry: + """Build and validate a deterministic adapter registry.""" + + registry = CommandAdapterRegistry(tuple(sorted(adapters, key=lambda item: item.adapter_id))) + validate_command_adapter_registry(registry) + return registry + + +def validate_command_adapter_registry(registry: CommandAdapterRegistry) -> None: + """Validate adapter ids and required owner/reason metadata.""" + + seen: set[str] = set() + for adapter in registry.adapters: + adapter_id = adapter.adapter_id + if adapter_id in seen: + raise ValueError(f"CLI adapter id повторяется: {adapter_id}") + seen.add(adapter_id) + if not adapter.metadata.owner: + raise ValueError(f"CLI adapter {adapter_id} должен содержать owner.") + if not adapter.metadata.reason: + raise ValueError(f"CLI adapter {adapter_id} должен содержать reason.") + + +def get_command_adapter_registry() -> CommandAdapterRegistry: + """Return production adapter registry. + + Stage 6B intentionally registers no production adapters yet. Later command + waves can add concrete adapters here with stable ids and owner/reason notes. + """ + + return build_command_adapter_registry(()) + + +def invoke_adapter_command( + registry: CommandAdapterRegistry, + ctx: CliContext, + command: ApiCommandRecord, + raw_values: Mapping[str, Sequence[str]], + *, + engine: CommandInvocationEngine, + safety_options: SafetyOptions | None = None, + client_factory: ClientFactory | None = None, +) -> object: + """Invoke an adapter-backed command with sanitized adapter errors.""" + + if command.adapter_id is None: + return engine( + ctx, + command, + raw_values, + client_factory=client_factory, + ) + + registered_adapter = registry.get(command.adapter_id) + if registered_adapter is None: + raise CliSdkMethodError( + "CLI adapter для команды не найден.", + details={"adapter_id": command.adapter_id, "command_id": command.command_id}, + ) + try: + resolved_safety_options = safety_options + + def safety_engine( + engine_ctx: CliContext, + engine_command: ApiCommandRecord, + engine_raw_values: Mapping[str, Sequence[str]], + *, + safety_options: SafetyOptions | None = None, + client_factory: ClientFactory | None = None, + ) -> object: + """Invoke shared engine while preserving command safety options.""" + + if safety_options is not None: + raise CliValidationError( + "CLI adapter не должен переопределять safety-флаги команды.", + details={"command_id": command.command_id}, + ) + return engine( + engine_ctx, + engine_command, + engine_raw_values, + safety_options=resolved_safety_options, + client_factory=client_factory, + ) + + return registered_adapter.adapter.invoke( + ctx, + command, + raw_values, + engine=cast(CommandInvocationEngine, safety_engine), + client_factory=client_factory, + ) + except CliError: + raise + except (OSError, ValueError) as exc: + raise CliValidationError( + "Не удалось обработать входные данные CLI adapter.", + details={ + "adapter_id": command.adapter_id, + "command_id": command.command_id, + "error_type": type(exc).__name__, + }, + ) from exc + + +__all__ = ( + "AdapterMetadata", + "ClientFactory", + "CommandAdapter", + "CommandAdapterRegistry", + "CommandInvocationEngine", + "RegisteredCommandAdapter", + "build_command_adapter_registry", + "get_command_adapter_registry", + "invoke_adapter_command", + "validate_command_adapter_registry", +) diff --git a/avito/cli/app.py b/avito/cli/app.py new file mode 100644 index 0000000..5be380f --- /dev/null +++ b/avito/cli/app.py @@ -0,0 +1,360 @@ +"""Корневая команда CLI для avito-py.""" + +from __future__ import annotations + +import json +from importlib import metadata +from pathlib import Path + +import click + +from avito.cli import commands as api_commands +from avito.cli.accounts import account_group +from avito.cli.context import CliContext +from avito.cli.errors import CliUsageError, InvalidFlagCombinationError +from avito.cli.help import render_registry_help +from avito.cli.local import completion_group, config_group, doctor_command, status_command +from avito.cli.registry import ApiCommandRecord, HelperCommandRecord, build_cli_registry +from avito.cli.safety import SafetyOptions +from avito.cli.serialization import emit_cli_result +from avito.cli.ui import emit_stdout + +PACKAGE_NAME = "avito-py" + + +def package_version() -> str: + """Return installed package version for CLI output.""" + + try: + return metadata.version(PACKAGE_NAME) + except metadata.PackageNotFoundError: + return "0+unknown" + + +def _validate_output_flags(json_output: bool, plain: bool, table: bool, wide: bool) -> None: + """Проверить взаимоисключающие режимы вывода.""" + + output_flags = { + "--json": json_output, + "--plain": plain, + "--table": table, + "--wide": wide, + } + selected = [name for name, enabled in output_flags.items() if enabled] + if len(selected) > 1: + raise InvalidFlagCombinationError( + "Флаги --json, --plain, --table и --wide нельзя использовать вместе.", + details={"selected_flags": selected}, + ) + + +@click.group() +@click.help_option("-h", "--help", help="Показать справку и выйти.") +@click.version_option( + version=package_version(), + prog_name="avito-py", + message="%(prog)s %(version)s", + help="Показать версию и выйти.", +) +@click.option("--profile", metavar="NAME", help="Профиль учетной записи.") +@click.option( + "--config", + type=click.Path(dir_okay=False, path_type=Path), + metavar="PATH", + help="Путь к файлу конфигурации.", +) +@click.option("--json", "json_output", is_flag=True, help="Вывести результат в JSON.") +@click.option("--plain", is_flag=True, help="Вывести результат без оформления.") +@click.option("--table", is_flag=True, help="Вывести результат таблицей.") +@click.option("--wide", is_flag=True, help="Показать расширенный табличный вывод.") +@click.option("--quiet", is_flag=True, help="Скрыть необязательный вывод.") +@click.option("--no-input", is_flag=True, help="Не задавать интерактивные вопросы.") +@click.option("--no-color", is_flag=True, help="Отключить цветной вывод.") +@click.option("--verbose", is_flag=True, help="Показать дополнительные сведения.") +@click.option("--debug", is_flag=True, help="Показать отладочные сведения без секретов.") +@click.option( + "--timeout", + type=click.FloatRange(min=0.001), + metavar="SECONDS", + help="Таймаут SDK-вызовов в секундах.", +) +@click.pass_context +def app( + ctx: click.Context, + profile: str | None, + config: Path | None, + json_output: bool, + plain: bool, + table: bool, + wide: bool, + quiet: bool, + no_input: bool, + no_color: bool, + verbose: bool, + debug: bool, + timeout: float | None, +) -> None: + """Командная строка для Avito API SDK.""" + + cli_context = CliContext( + profile=profile, + config=config, + json_output=json_output, + plain=plain, + table=table, + wide=wide, + quiet=quiet, + no_input=no_input, + no_color=no_color, + verbose=verbose, + debug=debug, + timeout=timeout, + ) + ctx.obj = cli_context + _validate_output_flags(json_output=json_output, plain=plain, table=table, wide=wide) + + +@app.command() +@click.pass_obj +def version(ctx: CliContext) -> None: + """Показать версию avito-py.""" + + version_value = package_version() + if ctx.json_output: + emit_stdout(ctx, json.dumps({"version": version_value}, ensure_ascii=False)) + return + emit_stdout(ctx, f"avito-py {version_value}", essential=False) + + +@app.command("help", context_settings={"ignore_unknown_options": True}) +@click.argument("topic", nargs=-1) +@click.pass_context +def help_command(ctx: click.Context, topic: tuple[str, ...]) -> None: + """Показать справку по командам.""" + + parent = ctx.parent + if parent is None: + click.echo(ctx.get_help()) + return + if not topic: + click.echo(parent.get_help()) + return + + registry_help = render_registry_help(topic) + if registry_help is not None: + click.echo(registry_help) + return + + command_context = _resolve_help_topic(parent, topic) + click.echo(command_context.get_help()) + + +def _resolve_help_topic(parent: click.Context, topic: tuple[str, ...]) -> click.Context: + """Найти Click context для вложенной команды справки.""" + + command: click.Command = parent.command + command_context = parent + for part in topic: + if not isinstance(command, click.Group): + raise CliUsageError( + "Команда не содержит вложенную справку.", + details={"topic": topic}, + ) + nested = command.get_command(command_context, part) + if nested is None: + raise CliUsageError( + "Команда для справки не найдена.", + details={"topic": topic}, + ) + command = nested + command_context = click.Context(command, info_name=part, parent=command_context) + return command_context + + +app.add_command(account_group) +app.add_command(config_group) +app.add_command(status_command) +app.add_command(doctor_command) +app.add_command(completion_group) + + +def _register_api_commands(root: click.Group) -> None: + """Зарегистрировать реализованные API и helper команды из registry.""" + + registry = build_cli_registry() + for api_command in registry.api_commands: + if not api_command.implemented: + continue + group = _resource_group(root, api_command.resource) + group.add_command(_build_api_click_command(api_command)) + for helper_command in registry.helper_commands: + if not helper_command.implemented: + continue + group = _resource_group(root, helper_command.resource) + group.add_command(_build_helper_click_command(helper_command)) + + +def _resource_group(root: click.Group, resource: str) -> click.Group: + """Вернуть существующую или создать новую группу resource.""" + + existing = root.get_command(click.Context(root), resource) + if isinstance(existing, click.Group): + return existing + if existing is not None: + raise CliUsageError( + "Команда API конфликтует с существующей CLI-командой.", + details={"resource": resource}, + ) + group = click.Group(name=resource, help=f"Команды ресурса {resource}.") + root.add_command(group) + return group + + +def _build_api_click_command(command: ApiCommandRecord) -> click.Command: + """Построить Click-команду для registry-backed API command.""" + + params = _parameter_click_options(command) + params.extend(_safety_click_options(command)) + + @click.pass_context + def callback(click_context: click.Context, /, **raw_options: object) -> None: + """Выполнить registry-backed API command через общий invocation engine.""" + + ctx = click_context.find_object(CliContext) + if ctx is None: + raise CliUsageError("Контекст CLI не найден.") + safety_options = _safety_options_from_click(command, raw_options) + raw_values = _raw_values_from_click(raw_options) + result = api_commands.invoke_api_command( + ctx, + command, + raw_values, + safety_options=safety_options, + ) + emit_cli_result(ctx, result) + + return click.Command( + name=command.action, + params=params, + callback=callback, + help=command.description, + ) + + +def _build_helper_click_command(command: HelperCommandRecord) -> click.Command: + """Построить Click-команду для helper workflow.""" + + params = _parameter_click_options(command) + + @click.pass_context + def callback(click_context: click.Context, /, **raw_options: object) -> None: + """Выполнить helper workflow через общий invocation engine.""" + + ctx = click_context.find_object(CliContext) + if ctx is None: + raise CliUsageError("Контекст CLI не найден.") + result = api_commands.invoke_helper_command( + ctx, + command, + _raw_values_from_click(raw_options), + ) + emit_cli_result(ctx, result) + + return click.Command( + name=command.action, + params=params, + callback=callback, + help=command.description, + ) + + +def _parameter_click_options( + command: ApiCommandRecord | HelperCommandRecord, +) -> list[click.Parameter]: + """Преобразовать registry parameter records в Click options.""" + + return [ + click.Option( + param_decls=(parameter.flag,), + multiple=parameter.multiple, + required=False, + metavar="VALUE", + help=f"Параметр SDK `{parameter.name}`.", + ) + for parameter in command.parameters + ] + + +def _safety_click_options(command: ApiCommandRecord) -> list[click.Parameter]: + """Вернуть Click options для safety-флагов команды.""" + + if command.safety in {"read", "local"}: + return [] + options: list[click.Parameter] = [ + click.Option( + param_decls=("--yes",), + is_flag=True, + help="Выполнить команду без интерактивного подтверждения.", + ), + click.Option( + param_decls=("--confirm",), + metavar="VALUE", + help="Точно подтвердить выполнение команды.", + ), + ] + if command.safety_policy.dry_run_supported: + options.append( + click.Option( + param_decls=("--dry-run",), + is_flag=True, + help="Показать план без применения изменений.", + ) + ) + return options + + +def _safety_options_from_click( + command: ApiCommandRecord, + raw_options: dict[str, object], +) -> SafetyOptions: + """Извлечь safety-флаги из Click options.""" + + if command.safety in {"read", "local"}: + return SafetyOptions() + return SafetyOptions( + yes=bool(raw_options.pop("yes", False)), + confirm=_optional_string(raw_options.pop("confirm", None)), + dry_run=bool(raw_options.pop("dry_run", False)), + ) + + +def _optional_string(value: object) -> str | None: + """Преобразовать optional Click value в строку.""" + + if value is None: + return None + return str(value) + + +def _raw_values_from_click(raw_options: dict[str, object]) -> dict[str, tuple[str, ...]]: + """Преобразовать Click kwargs в raw CLI values для coercion.""" + + values: dict[str, tuple[str, ...]] = {} + for name, value in raw_options.items(): + if value is None: + continue + if isinstance(value, tuple): + if value: + values[name] = tuple(str(item) for item in value) + continue + values[name] = (str(value),) + return values + + +_register_api_commands(app) + + +def main() -> None: + """Run the avito CLI application.""" + + app.main(prog_name="avito", standalone_mode=True) diff --git a/avito/cli/commands.py b/avito/cli/commands.py new file mode 100644 index 0000000..93a4a20 --- /dev/null +++ b/avito/cli/commands.py @@ -0,0 +1,319 @@ +"""Generic API command invocation through the public SDK facade.""" + +from __future__ import annotations + +import inspect +from collections.abc import Mapping, Sequence + +from avito.cli.adapters import ( + ClientContext, + ClientFactory, + get_command_adapter_registry, + invoke_adapter_command, +) +from avito.cli.config import AccountStore, ConfigStore, StoredAccount, resolve_cli_home +from avito.cli.context import CliContext +from avito.cli.errors import ( + CliAuthorizationError, + CliAuthRequiredError, + CliConflictError, + CliError, + CliRateLimitError, + CliSdkMethodError, + CliTransportError, + CliUsageError, + CliValidationError, +) +from avito.cli.registry import ApiCommandRecord, HelperCommandRecord +from avito.cli.safety import SafetyOptions, validate_safety_options +from avito.cli.schemas import coerce_cli_values +from avito.client import AvitoClient +from avito.config import AvitoSettings +from avito.core.exceptions import ( + AuthenticationError, + AuthorizationError, + AvitoError, + ConflictError, + RateLimitError, + TransportError, + ValidationError, +) +from avito.core.types import ApiTimeouts + + +def invoke_api_command( + ctx: CliContext, + command: ApiCommandRecord, + raw_values: Mapping[str, Sequence[str]], + *, + safety_options: SafetyOptions | None = None, + client_factory: ClientFactory | None = None, +) -> object: + """Invoke one registry-backed API command through `AvitoClient`.""" + + if command.adapter_id is not None: + return invoke_adapter_command( + get_command_adapter_registry(), + ctx, + command, + raw_values, + engine=_invoke_api_command_generic, + safety_options=safety_options, + client_factory=client_factory, + ) + return _invoke_api_command_generic( + ctx, + command, + raw_values, + safety_options=safety_options, + client_factory=client_factory, + ) + + +def invoke_helper_command( + ctx: CliContext, + command: HelperCommandRecord, + raw_values: Mapping[str, Sequence[str]], + *, + client_factory: ClientFactory | None = None, +) -> object: + """Invoke one public helper workflow through `AvitoClient`.""" + + values = coerce_cli_values(command.parameters, raw_values, no_input=ctx.no_input) + settings = resolve_avito_settings(ctx) + resolved_factory = client_factory or _default_client_factory + + try: + with resolved_factory(settings) as client: + method = getattr(client, command.sdk_method_name, None) + if not callable(method): + raise CliSdkMethodError( + "Публичный helper-метод SDK для команды не найден.", + details={ + "method": command.sdk_method_name, + "command_id": command.command_id, + }, + ) + return method(**values) + except CliError: + raise + except AvitoError as exc: + raise map_sdk_error(exc, command=command) from exc + + +def _invoke_api_command_generic( + ctx: CliContext, + command: ApiCommandRecord, + raw_values: Mapping[str, Sequence[str]], + *, + safety_options: SafetyOptions | None = None, + client_factory: ClientFactory | None = None, +) -> object: + """Invoke one command through the generic public SDK path.""" + + resolved_safety_options = safety_options or SafetyOptions() + validate_safety_options(ctx, command, resolved_safety_options) + values = coerce_cli_values(command.parameters, raw_values, no_input=ctx.no_input) + factory_kwargs = _kwargs_for_source(command, values, source="factory") + method_kwargs = _kwargs_for_source(command, values, source="method") + settings = resolve_avito_settings(ctx) + resolved_factory = client_factory or _default_client_factory + + try: + with resolved_factory(settings) as client: + domain = _call_public_factory(client, command, factory_kwargs) + method_kwargs = _with_timeout_if_supported(ctx, domain, command, method_kwargs) + method_kwargs = _with_dry_run_if_supported( + resolved_safety_options, + domain, + command, + method_kwargs, + ) + return _call_public_method(domain, command, method_kwargs) + except CliError: + raise + except AvitoError as exc: + raise map_sdk_error(exc, command=command) from exc + + +def resolve_avito_settings(ctx: CliContext) -> AvitoSettings: + """Resolve active CLI profile into public SDK settings.""" + + home = resolve_cli_home() + config = ConfigStore(home, path=ctx.config).load() + profile = ctx.profile or config.active_profile + if profile is None: + raise CliAuthRequiredError("Активная учетная запись не выбрана.") + + account = _find_account(AccountStore(home).load().accounts, profile) + if account is None: + raise CliAuthRequiredError( + "Учетная запись не найдена в локальном хранилище.", + details={"profile": profile}, + ) + return account.to_avito_settings() + + +def map_sdk_error( + error: AvitoError, + *, + command: ApiCommandRecord | HelperCommandRecord, +) -> CliError: + """Convert SDK exceptions into documented CLI errors.""" + + details = _sdk_error_details(error, command=command) + if isinstance(error, AuthenticationError): + return CliAuthRequiredError(error.message, details=details) + if isinstance(error, AuthorizationError): + return CliAuthorizationError(error.message, details=details) + if isinstance(error, ValidationError): + return CliValidationError(error.message, details=details) + if isinstance(error, ConflictError): + return CliConflictError(error.message, details=details) + if isinstance(error, RateLimitError): + return CliRateLimitError(error.message, details=details) + if isinstance(error, TransportError): + return CliTransportError(error.message, details=details) + return CliSdkMethodError(error.message, details=details) + + +def _default_client_factory(settings: AvitoSettings) -> ClientContext: + """Создать context-managed AvitoClient для production invocation.""" + + return AvitoClient(settings) + + +def _kwargs_for_source( + command: ApiCommandRecord, + values: Mapping[str, object], + *, + source: str, +) -> dict[str, object]: + """Выбрать kwargs для factory или method из coerced values.""" + + return { + parameter.name: values[parameter.name] + for parameter in command.parameters + if parameter.source == source and parameter.name in values + } + + +def _call_public_factory( + client: object, + command: ApiCommandRecord, + kwargs: Mapping[str, object], +) -> object: + """Вызвать публичную factory на AvitoClient.""" + + factory = getattr(client, command.factory, None) + if not callable(factory): + raise CliSdkMethodError( + "Публичная SDK factory для команды не найдена.", + details={"factory": command.factory, "command_id": command.command_id}, + ) + return factory(**kwargs) + + +def _call_public_method( + domain: object, + command: ApiCommandRecord, + kwargs: Mapping[str, object], +) -> object: + """Вызвать публичный метод domain object.""" + + method = getattr(domain, command.sdk_method_name, None) + if not callable(method): + raise CliSdkMethodError( + "Публичный SDK-метод для команды не найден.", + details={"method": command.sdk_method_name, "command_id": command.command_id}, + ) + return method(**kwargs) + + +def _with_timeout_if_supported( + ctx: CliContext, + domain: object, + command: ApiCommandRecord, + kwargs: Mapping[str, object], +) -> dict[str, object]: + """Добавить SDK timeout, если публичный метод его принимает.""" + + resolved = dict(kwargs) + if ctx.timeout is None: + return resolved + method = getattr(domain, command.sdk_method_name, None) + if not callable(method): + return resolved + if "timeout" in inspect.signature(method).parameters: + resolved["timeout"] = ApiTimeouts( + connect=ctx.timeout, + read=ctx.timeout, + write=ctx.timeout, + pool=ctx.timeout, + ) + return resolved + + +def _with_dry_run_if_supported( + options: SafetyOptions, + domain: object, + command: ApiCommandRecord, + kwargs: Mapping[str, object], +) -> dict[str, object]: + """Добавить dry_run, если команда и SDK-метод его поддерживают.""" + + resolved = dict(kwargs) + if not options.dry_run: + return resolved + method = getattr(domain, command.sdk_method_name, None) + if callable(method) and "dry_run" in inspect.signature(method).parameters: + resolved["dry_run"] = True + return resolved + raise CliUsageError( + "SDK-метод команды не поддерживает dry_run.", + details={"command_id": command.command_id, "method": command.sdk_method}, + ) + + +def _find_account(accounts: Sequence[StoredAccount], profile: str) -> StoredAccount | None: + """Найти сохраненный account по имени профиля.""" + + for account in accounts: + if account.name == profile: + return account + return None + + +def _sdk_error_details( + error: AvitoError, + *, + command: ApiCommandRecord | HelperCommandRecord, +) -> dict[str, object]: + """Собрать безопасные детали SDK-ошибки для CLI diagnostics.""" + + details: dict[str, object] = { + "command_id": command.command_id, + "sdk_method": command.sdk_method, + "status_code": error.status_code, + "error_code": error.error_code, + "operation": error.operation, + "method": error.method, + "endpoint": error.endpoint, + "request_id": error.request_id, + "metadata": dict(error.metadata), + "details": error.details, + } + if isinstance(command, ApiCommandRecord): + details["operation_key"] = command.operation_key + return details + + +__all__ = ( + "ClientContext", + "ClientFactory", + "SafetyOptions", + "invoke_helper_command", + "invoke_api_command", + "map_sdk_error", + "resolve_avito_settings", +) diff --git a/avito/cli/config.py b/avito/cli/config.py new file mode 100644 index 0000000..3f65df9 --- /dev/null +++ b/avito/cli/config.py @@ -0,0 +1,385 @@ +"""Локальное хранилище профилей CLI.""" + +from __future__ import annotations + +import json +import os +import tempfile +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import cast + +from avito.auth.settings import AuthSettings +from avito.cli.errors import CliConfigFileError, CliPermissionError +from avito.config import AvitoSettings + +type JsonValue = None | bool | int | float | str | list[JsonValue] | dict[str, JsonValue] + +CLI_HOME_ENV = "AVITO_PY_HOME" +TICKET_HOME_ENV = "MY_SDK_HOME" +DEFAULT_HOME_NAME = ".avito-py" +CONFIG_FILENAME = "config.json" +ACCOUNTS_FILENAME = "accounts.json" +SCHEMA_VERSION = 1 +SECRET_MASK = "***" + + +@dataclass(frozen=True, slots=True) +class StoredAccount: + """Сохраненная учетная запись CLI.""" + + name: str + client_id: str + client_secret: str + base_url: str = "https://api.avito.ru" + user_id: int | None = None + scope: str | None = None + refresh_token: str | None = None + token_url: str = "/token" + alternate_token_url: str = "/token" + autoteka_token_url: str = "/autoteka/token" + autoteka_client_id: str | None = None + autoteka_client_secret: str | None = None + autoteka_scope: str | None = None + + @classmethod + def from_json(cls, value: object) -> StoredAccount: + """Создает учетную запись из JSON-модели.""" + + payload = _require_mapping(value, label="account") + name = _require_str(payload, "name") + client_id = _require_str(payload, "client_id") + client_secret = _require_str(payload, "client_secret") + base_url = _optional_str(payload, "base_url") or "https://api.avito.ru" + user_id = _optional_int(payload, "user_id") + return cls( + name=name, + client_id=client_id, + client_secret=client_secret, + base_url=base_url, + user_id=user_id, + scope=_optional_str(payload, "scope"), + refresh_token=_optional_str(payload, "refresh_token"), + token_url=_optional_str(payload, "token_url") or "/token", + alternate_token_url=_optional_str(payload, "alternate_token_url") or "/token", + autoteka_token_url=_optional_str(payload, "autoteka_token_url") or "/autoteka/token", + autoteka_client_id=_optional_str(payload, "autoteka_client_id"), + autoteka_client_secret=_optional_str(payload, "autoteka_client_secret"), + autoteka_scope=_optional_str(payload, "autoteka_scope"), + ) + + def to_json(self, *, mask_secrets: bool = False) -> dict[str, JsonValue]: + """Возвращает JSON-модель учетной записи.""" + + return { + "name": self.name, + "client_id": self.client_id, + "client_secret": _mask(self.client_secret, enabled=mask_secrets), + "base_url": self.base_url, + "user_id": self.user_id, + "scope": self.scope, + "refresh_token": _mask(self.refresh_token, enabled=mask_secrets), + "token_url": self.token_url, + "alternate_token_url": self.alternate_token_url, + "autoteka_token_url": self.autoteka_token_url, + "autoteka_client_id": self.autoteka_client_id, + "autoteka_client_secret": _mask( + self.autoteka_client_secret, + enabled=mask_secrets, + ), + "autoteka_scope": self.autoteka_scope, + } + + def to_avito_settings(self) -> AvitoSettings: + """Создает публичные настройки SDK без сетевых вызовов.""" + + auth = AuthSettings( + client_id=self.client_id, + client_secret=self.client_secret, + scope=self.scope, + refresh_token=self.refresh_token, + token_url=self.token_url, + alternate_token_url=self.alternate_token_url, + autoteka_token_url=self.autoteka_token_url, + autoteka_client_id=self.autoteka_client_id, + autoteka_client_secret=self.autoteka_client_secret, + autoteka_scope=self.autoteka_scope, + ) + return AvitoSettings(base_url=self.base_url, user_id=self.user_id, auth=auth) + + +@dataclass(frozen=True, slots=True) +class AccountsDocument: + """Файл сохраненных учетных записей CLI.""" + + schema_version: int = SCHEMA_VERSION + accounts: tuple[StoredAccount, ...] = () + + @classmethod + def from_json(cls, value: object) -> AccountsDocument: + """Создает документ учетных записей из JSON-модели.""" + + payload = _require_mapping(value, label=ACCOUNTS_FILENAME) + schema_version = _optional_int(payload, "schema_version") or SCHEMA_VERSION + _validate_schema_version(schema_version, filename=ACCOUNTS_FILENAME) + accounts_value = payload.get("accounts", []) + if not isinstance(accounts_value, list): + raise CliConfigFileError("Поле `accounts` должно быть списком.") + accounts = tuple(StoredAccount.from_json(item) for item in accounts_value) + names = [account.name for account in accounts] + if len(names) != len(set(names)): + raise CliConfigFileError("Имена учетных записей в `accounts.json` должны быть уникальны.") + return cls(schema_version=schema_version, accounts=accounts) + + def to_json(self, *, mask_secrets: bool = False) -> dict[str, JsonValue]: + """Возвращает JSON-модель файла учетных записей.""" + + return { + "schema_version": self.schema_version, + "accounts": [account.to_json(mask_secrets=mask_secrets) for account in self.accounts], + } + + +@dataclass(frozen=True, slots=True) +class CliConfigDocument: + """Файл локальной конфигурации CLI.""" + + schema_version: int = SCHEMA_VERSION + active_profile: str | None = None + + @classmethod + def from_json(cls, value: object) -> CliConfigDocument: + """Создает документ конфигурации из JSON-модели.""" + + payload = _require_mapping(value, label=CONFIG_FILENAME) + schema_version = _optional_int(payload, "schema_version") or SCHEMA_VERSION + _validate_schema_version(schema_version, filename=CONFIG_FILENAME) + return cls( + schema_version=schema_version, + active_profile=_optional_str(payload, "active_profile"), + ) + + def to_json(self, *, mask_secrets: bool = False) -> dict[str, JsonValue]: + """Возвращает JSON-модель файла конфигурации.""" + + return { + "schema_version": self.schema_version, + "active_profile": self.active_profile, + } + + +class AccountStore: + """Хранилище локальных учетных записей CLI.""" + + def __init__(self, home: Path) -> None: + """Создать store для accounts.json внутри CLI home.""" + + self._home = home + self._path = home / ACCOUNTS_FILENAME + + @property + def path(self) -> Path: + """Возвращает путь к файлу учетных записей.""" + + return self._path + + def load(self) -> AccountsDocument: + """Загружает учетные записи или возвращает пустой документ.""" + + if not self._path.exists(): + return AccountsDocument() + return AccountsDocument.from_json(_read_json_file(self._path)) + + def save(self, document: AccountsDocument) -> None: + """Атомарно сохраняет учетные записи.""" + + _ensure_cli_home(self._home) + _write_json_file(self._path, document.to_json(mask_secrets=False)) + + +class ConfigStore: + """Хранилище локальной конфигурации CLI.""" + + def __init__(self, home: Path, *, path: Path | None = None) -> None: + """Создать store для config.json или пользовательского пути.""" + + self._home = home + self._uses_default_path = path is None + self._path = path if path is not None else home / CONFIG_FILENAME + + @property + def path(self) -> Path: + """Возвращает путь к файлу конфигурации.""" + + return self._path + + def load(self) -> CliConfigDocument: + """Загружает конфигурацию или возвращает пустой документ.""" + + if not self._path.exists(): + return CliConfigDocument() + return CliConfigDocument.from_json(_read_json_file(self._path)) + + def save(self, document: CliConfigDocument) -> None: + """Атомарно сохраняет конфигурацию.""" + + if self._uses_default_path: + _ensure_cli_home(self._home) + else: + _ensure_config_parent(self._path.parent) + _write_json_file(self._path, document.to_json(mask_secrets=False)) + + +def resolve_cli_home(env: Mapping[str, str] | None = None) -> Path: + """Возвращает каталог CLI без создания файлов.""" + + source = os.environ if env is None else env + avito_home = source.get(CLI_HOME_ENV) + if avito_home: + return Path(avito_home).expanduser() + ticket_home = source.get(TICKET_HOME_ENV) + if ticket_home: + return Path(ticket_home).expanduser() + return Path.home() / DEFAULT_HOME_NAME + + +def _ensure_cli_home(path: Path) -> None: + """Создать CLI home с закрытыми правами доступа.""" + + try: + path.mkdir(mode=0o700, parents=True, exist_ok=True) + os.chmod(path, 0o700) + except PermissionError as exc: + raise CliPermissionError( + "Нет прав на создание или изменение каталога CLI.", + details={"path": str(path)}, + ) from exc + + +def _ensure_config_parent(path: Path) -> None: + """Создать родительский каталог пользовательского config path.""" + + try: + path.mkdir(parents=True, exist_ok=True) + except PermissionError as exc: + raise CliPermissionError( + "Нет прав на создание или изменение каталога CLI.", + details={"path": str(path)}, + ) from exc + + +def _read_json_file(path: Path) -> JsonValue: + """Прочитать JSON-файл и преобразовать ошибки в CLI errors.""" + + try: + with path.open("r", encoding="utf-8") as file_obj: + # JSON is the boundary where Python cannot know the concrete shape. + return cast(JsonValue, json.load(file_obj)) + except PermissionError as exc: + raise CliPermissionError( + "Нет прав на чтение локального файла CLI.", + details={"path": str(path)}, + ) from exc + except json.JSONDecodeError as exc: + raise CliConfigFileError( + "Локальный файл CLI содержит некорректный JSON.", + details={"path": str(path), "line": exc.lineno, "column": exc.colno}, + ) from exc + + +def _write_json_file(path: Path, payload: Mapping[str, JsonValue]) -> None: + """Атомарно записать JSON-файл с закрытыми правами доступа.""" + + text = json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n" + temporary_name: str | None = None + try: + fd, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent, text=True) + with os.fdopen(fd, "w", encoding="utf-8") as file_obj: + file_obj.write(text) + os.chmod(temporary_name, 0o600) + os.replace(temporary_name, path) + os.chmod(path, 0o600) + except PermissionError as exc: + raise CliPermissionError( + "Нет прав на запись локального файла CLI.", + details={"path": str(path)}, + ) from exc + finally: + if temporary_name is not None and Path(temporary_name).exists(): + Path(temporary_name).unlink() + + +def _require_mapping(value: object, *, label: str) -> Mapping[str, object]: + """Проверить, что JSON value является объектом.""" + + if not isinstance(value, dict): + raise CliConfigFileError(f"`{label}` должен содержать JSON-объект.") + return value + + +def _require_str(payload: Mapping[str, object], field_name: str) -> str: + """Прочитать обязательную непустую строку из JSON object.""" + + value = payload.get(field_name) + if not isinstance(value, str) or not value: + raise CliConfigFileError(f"Поле `{field_name}` должно быть непустой строкой.") + return value + + +def _optional_str(payload: Mapping[str, object], field_name: str) -> str | None: + """Прочитать optional строку из JSON object.""" + + value = payload.get(field_name) + if value is None: + return None + if not isinstance(value, str): + raise CliConfigFileError(f"Поле `{field_name}` должно быть строкой.") + return value + + +def _optional_int(payload: Mapping[str, object], field_name: str) -> int | None: + """Прочитать optional integer из JSON object.""" + + value = payload.get(field_name) + if value is None: + return None + if not isinstance(value, int) or isinstance(value, bool): + raise CliConfigFileError(f"Поле `{field_name}` должно быть целым числом.") + return value + + +def _validate_schema_version(schema_version: int, *, filename: str) -> None: + """Проверить поддерживаемую версию локального JSON-файла.""" + + if schema_version != SCHEMA_VERSION: + raise CliConfigFileError( + "Версия локального файла CLI не поддерживается.", + details={"filename": filename, "schema_version": schema_version}, + ) + + +def _mask(value: str | None, *, enabled: bool) -> str | None: + """Замаскировать secret value при безопасном выводе.""" + + if value is None: + return None + if enabled: + return SECRET_MASK + return value + + +__all__ = ( + "ACCOUNTS_FILENAME", + "CLI_HOME_ENV", + "CONFIG_FILENAME", + "DEFAULT_HOME_NAME", + "SCHEMA_VERSION", + "TICKET_HOME_ENV", + "AccountStore", + "AccountsDocument", + "CliConfigDocument", + "ConfigStore", + "StoredAccount", + "resolve_cli_home", +) diff --git a/avito/cli/context.py b/avito/cli/context.py new file mode 100644 index 0000000..65fd440 --- /dev/null +++ b/avito/cli/context.py @@ -0,0 +1,27 @@ +"""Типизированный контекст одного запуска CLI.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True, slots=True) +class CliContext: + """Глобальные настройки одного запуска CLI.""" + + profile: str | None + config: Path | None + json_output: bool + plain: bool + table: bool + wide: bool + quiet: bool + no_input: bool + no_color: bool + verbose: bool + debug: bool + timeout: float | None + + +__all__ = ("CliContext",) diff --git a/avito/cli/errors.py b/avito/cli/errors.py new file mode 100644 index 0000000..a2379ed --- /dev/null +++ b/avito/cli/errors.py @@ -0,0 +1,268 @@ +"""Иерархия ошибок CLI и безопасный рендеринг.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import IO, Any, ClassVar + +import click + +from avito.cli.context import CliContext +from avito.cli.ui import emit_json_stderr, emit_stderr, sanitize_cli_output + +EXIT_USAGE = 2 +EXIT_CONFIGURATION = 3 +EXIT_AUTHENTICATION = 4 +EXIT_AUTHORIZATION = 5 +EXIT_RATE_LIMIT = 6 +EXIT_UPSTREAM = 7 +EXIT_TRANSPORT = 8 +EXIT_INTERNAL = 70 + + +def _current_cli_context() -> CliContext | None: + """Вернуть текущий CliContext из Click context stack.""" + + click_context = click.get_current_context(silent=True) + if click_context is None: + return None + if isinstance(click_context.obj, CliContext): + return click_context.obj + parent = click_context.parent + if parent is not None and isinstance(parent.obj, CliContext): + return parent.obj + return None + + +@dataclass(slots=True) +class CliError(click.ClickException): + """Базовая ошибка CLI со стабильным кодом и exit code.""" + + message: str + code: str + exit_code: int + details: object | None = None + _raw_message: str = field(init=False, repr=False) + _ctx: CliContext | None = field(init=False, repr=False) + + def __post_init__(self) -> None: + """Инициализировать ClickException и сохранить CLI context.""" + + self._raw_message = self.message + self._ctx = _current_cli_context() + click.ClickException.__init__(self, self.message) + + def to_payload(self, *, debug: bool = False) -> dict[str, object]: + """Возвращает безопасную JSON-совместимую модель ошибки.""" + + payload: dict[str, object] = { + "code": self.code, + "exit_code": self.exit_code, + "message": self.message, + } + if debug and self.details is not None: + payload["details"] = sanitize_cli_output(self.details) + return payload + + def show(self, file: IO[Any] | None = None) -> None: + """Печатает ошибку в stderr в human или JSON-формате.""" + + ctx = self._ctx + payload = self.to_payload(debug=ctx.debug if ctx is not None else False) + if ctx is not None and ctx.json_output: + emit_json_stderr(payload) + return + + emit_stderr(ctx, f"{self.code}: {payload['message']}", fg="red") + if ctx is not None and ctx.debug and "details" in payload: + emit_stderr(ctx, f"details={payload['details']}", fg="yellow") + + +class CliUsageError(CliError): + """Ошибка использования команды.""" + + DEFAULT_CODE: ClassVar[str] = "CLI_USAGE_ERROR" + + def __init__(self, message: str, *, details: object | None = None) -> None: + """Создать ошибку некорректного использования CLI.""" + + super().__init__( + message=message, + code=self.DEFAULT_CODE, + exit_code=EXIT_USAGE, + details=details, + ) + + +class InvalidFlagCombinationError(CliUsageError): + """Несовместимые глобальные флаги.""" + + DEFAULT_CODE: ClassVar[str] = "INVALID_FLAG_COMBINATION" + + +class CliPermissionError(CliError): + """Ошибка доступа к локальным файлам CLI.""" + + DEFAULT_CODE: ClassVar[str] = "PERMISSION_DENIED" + + def __init__(self, message: str, *, details: object | None = None) -> None: + """Создать ошибку доступа к локальному ресурсу.""" + + super().__init__( + message=message, + code=self.DEFAULT_CODE, + exit_code=EXIT_AUTHENTICATION, + details=details, + ) + + +class CliConfigFileError(CliError): + """Ошибка чтения или валидации локальной конфигурации CLI.""" + + DEFAULT_CODE: ClassVar[str] = "CONFIG_INVALID" + + def __init__(self, message: str, *, details: object | None = None) -> None: + """Создать ошибку локальной конфигурации.""" + + super().__init__( + message=message, + code=self.DEFAULT_CODE, + exit_code=EXIT_UPSTREAM, + details=details, + ) + + +class CliAuthRequiredError(CliError): + """Ошибка отсутствующих учетных данных CLI.""" + + DEFAULT_CODE: ClassVar[str] = "AUTH_REQUIRED" + + def __init__(self, message: str, *, details: object | None = None) -> None: + """Создать ошибку отсутствующей авторизации.""" + + super().__init__( + message=message, + code=self.DEFAULT_CODE, + exit_code=EXIT_AUTHENTICATION, + details=details, + ) + + +class CliAuthorizationError(CliError): + """Ошибка прав доступа в Avito API.""" + + DEFAULT_CODE: ClassVar[str] = "PERMISSION_DENIED" + + def __init__(self, message: str, *, details: object | None = None) -> None: + """Создать ошибку недостаточных прав upstream API.""" + + super().__init__( + message=message, + code=self.DEFAULT_CODE, + exit_code=EXIT_AUTHORIZATION, + details=details, + ) + + +class CliValidationError(CliError): + """Ошибка валидации входных значений CLI.""" + + DEFAULT_CODE: ClassVar[str] = "VALIDATION_FAILED" + + def __init__(self, message: str, *, details: object | None = None) -> None: + """Создать ошибку валидации CLI input.""" + + super().__init__( + message=message, + code=self.DEFAULT_CODE, + exit_code=EXIT_UPSTREAM, + details=details, + ) + + +class CliConflictError(CliError): + """Конфликт состояния upstream-ресурса.""" + + DEFAULT_CODE: ClassVar[str] = "CONFLICT" + + def __init__(self, message: str, *, details: object | None = None) -> None: + """Создать ошибку конфликта состояния.""" + + super().__init__( + message=message, + code=self.DEFAULT_CODE, + exit_code=EXIT_UPSTREAM, + details=details, + ) + + +class CliRateLimitError(CliError): + """Upstream API вернул ограничение частоты запросов.""" + + DEFAULT_CODE: ClassVar[str] = "RATE_LIMITED" + + def __init__(self, message: str, *, details: object | None = None) -> None: + """Создать ошибку rate limit.""" + + super().__init__( + message=message, + code=self.DEFAULT_CODE, + exit_code=EXIT_RATE_LIMIT, + details=details, + ) + + +class CliTransportError(CliError): + """Транспортный сбой до корректного ответа upstream API.""" + + DEFAULT_CODE: ClassVar[str] = "TRANSPORT_FAILED" + + def __init__(self, message: str, *, details: object | None = None) -> None: + """Создать ошибку транспорта.""" + + super().__init__( + message=message, + code=self.DEFAULT_CODE, + exit_code=EXIT_TRANSPORT, + details=details, + ) + + +class CliSdkMethodError(CliError): + """Ошибка выполнения публичного SDK-метода.""" + + DEFAULT_CODE: ClassVar[str] = "SDK_METHOD_FAILED" + + def __init__(self, message: str, *, details: object | None = None) -> None: + """Создать ошибку публичного SDK method.""" + + super().__init__( + message=message, + code=self.DEFAULT_CODE, + exit_code=EXIT_UPSTREAM, + details=details, + ) + + +__all__ = ( + "EXIT_AUTHENTICATION", + "EXIT_AUTHORIZATION", + "EXIT_CONFIGURATION", + "EXIT_INTERNAL", + "EXIT_RATE_LIMIT", + "EXIT_TRANSPORT", + "EXIT_UPSTREAM", + "EXIT_USAGE", + "CliAuthRequiredError", + "CliAuthorizationError", + "CliConflictError", + "CliConfigFileError", + "CliError", + "CliPermissionError", + "CliRateLimitError", + "CliSdkMethodError", + "CliTransportError", + "CliUsageError", + "CliValidationError", + "InvalidFlagCombinationError", +) diff --git a/avito/cli/help.py b/avito/cli/help.py new file mode 100644 index 0000000..680c94c --- /dev/null +++ b/avito/cli/help.py @@ -0,0 +1,230 @@ +"""Registry-backed справка CLI.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +from avito.cli.registry import ( + AliasRecord, + ApiCommandRecord, + CliRegistry, + HelperCommandRecord, + LocalCommandRecord, + OutputHint, + build_cli_registry, +) + +RegistryCommandRecord = ApiCommandRecord | HelperCommandRecord | LocalCommandRecord + + +@dataclass(frozen=True, slots=True) +class RegistryHelpRenderer: + """Рендерит справку из registry без создания AvitoClient.""" + + registry: CliRegistry + + def render(self, topic: tuple[str, ...]) -> str | None: + """Вернуть registry-backed справку или None, если topic не найден.""" + + if len(topic) == 1: + return self.render_resource(topic[0]) + if len(topic) == 2: + return self.render_action(topic[0], topic[1]) + return None + + def render_resource(self, resource: str) -> str | None: + """Вернуть справку по resource.""" + + commands = self._commands_for_resource(resource) + aliases = self._aliases_for_resource(resource) + if not commands and not aliases: + return None + + lines = [ + f"Справка: avito {resource}", + "", + "Использование:", + f" avito {resource} [flags]", + "", + "Команды:", + ] + lines.extend(_format_command_line(record) for record in commands) + if aliases: + lines.append("") + lines.append("Совместимые команды:") + lines.extend(_format_alias_line(alias) for alias in aliases) + return "\n".join(lines) + + def render_action(self, resource: str, action: str) -> str | None: + """Вернуть справку по конкретному resource/action.""" + + command = self._command_for_action(resource, action) + if command is not None: + return _render_command_help(command) + + alias = self._alias_for_action(resource, action) + if alias is not None: + return _render_alias_help(alias) + return None + + def _commands_for_resource(self, resource: str) -> tuple[RegistryCommandRecord, ...]: + """Вернуть canonical commands для resource.""" + + records: list[RegistryCommandRecord] = [] + records.extend( + record for record in self.registry.api_commands if record.resource == resource + ) + records.extend( + record for record in self.registry.helper_commands if record.resource == resource + ) + records.extend( + record for record in self.registry.local_commands if record.resource == resource + ) + return tuple(sorted(records, key=lambda record: record.action)) + + def _aliases_for_resource(self, resource: str) -> tuple[AliasRecord, ...]: + """Вернуть aliases для resource.""" + + aliases = tuple(alias for alias in self.registry.aliases if alias.resource == resource) + return tuple(sorted(aliases, key=lambda alias: alias.action)) + + def _command_for_action(self, resource: str, action: str) -> RegistryCommandRecord | None: + """Найти canonical command для resource/action.""" + + matches = [ + record + for record in self._commands_for_resource(resource) + if record.action == action + ] + if not matches: + return None + if len(matches) > 1: + raise ValueError(f"Registry содержит несколько команд для avito {resource} {action}.") + return matches[0] + + def _alias_for_action(self, resource: str, action: str) -> AliasRecord | None: + """Найти alias для resource/action.""" + + matches = [ + alias + for alias in self._aliases_for_resource(resource) + if alias.action == action + ] + if not matches: + return None + if len(matches) > 1: + raise ValueError(f"Registry содержит несколько alias для avito {resource} {action}.") + return matches[0] + + +def render_registry_help( + topic: tuple[str, ...], + *, + registry: CliRegistry | None = None, +) -> str | None: + """Вернуть registry-backed справку для `avito help ...`.""" + + resolved_registry = registry or build_cli_registry() + return RegistryHelpRenderer(resolved_registry).render(topic) + + +def _format_command_line(record: RegistryCommandRecord) -> str: + """Отформатировать строку команды в resource help.""" + + status = "готова" if record.implemented else "запланирована" + return f" {record.action:<24} {record.description} ({status})" + + +def _format_alias_line(alias: AliasRecord) -> str: + """Отформатировать строку alias в resource help.""" + + return f" {alias.action:<24} совместимое имя для {alias.target_command_id}: {alias.reason}" + + +def _render_command_help(record: RegistryCommandRecord) -> str: + """Отрендерить help для canonical command.""" + + lines = [ + f"Справка: avito {record.resource} {record.action}", + "", + record.description, + "", + "Использование:", + f" avito {record.resource} {record.action} [flags]", + "", + f"Безопасность: {record.safety_summary}", + f"Вывод: {_format_output_hint(record.output_hint)}", + ] + parameters = _parameter_lines(record) + if parameters: + lines.append("") + lines.append("Флаги:") + lines.extend(parameters) + if not record.implemented: + lines.append("") + lines.append("Статус: команда описана в реестре, исполнение будет подключено позже.") + if record.examples: + lines.append("") + lines.append("Примеры:") + lines.extend(f" {example}" for example in record.examples) + if record.related_commands: + lines.append("") + lines.append("Связанные команды:") + lines.extend(f" {command_id}" for command_id in record.related_commands) + return "\n".join(lines) + + +def _render_alias_help(alias: AliasRecord) -> str: + """Отрендерить help для compatibility alias.""" + + target_resource, target_action = alias.target_command_id.split(".", maxsplit=1) + return "\n".join( + ( + f"Справка: avito {alias.resource} {alias.action}", + "", + f"Совместимое имя для `avito {target_resource} {target_action}`.", + alias.reason, + "", + "Использование:", + f" avito {alias.resource} {alias.action} [flags]", + ) + ) + + +def _parameter_lines(record: RegistryCommandRecord) -> tuple[str, ...]: + """Вернуть help-строки параметров команды.""" + + if isinstance(record, LocalCommandRecord): + return () + return tuple( + f" {parameter.flag:<24} {_format_parameter_source(parameter.source)}: " + f"{parameter.binding_expression}" + for parameter in record.parameters + ) + + +def _format_output_hint(output_hint: OutputHint) -> str: + """Вернуть русскую подпись output hint.""" + + labels: dict[OutputHint, str] = { + "object": "объект", + "collection": "коллекция", + "mutation": "изменение", + "plain": "простое значение", + "unknown": "будет уточнен при подключении исполнения", + } + return labels[output_hint] + + +def _format_parameter_source(source: Literal["factory", "method"]) -> str: + """Вернуть русскую подпись источника параметра.""" + + labels: dict[Literal["factory", "method"], str] = { + "factory": "аргумент ресурса", + "method": "аргумент действия", + } + return labels[source] + + +__all__ = ("RegistryHelpRenderer", "render_registry_help") diff --git a/avito/cli/local.py b/avito/cli/local.py new file mode 100644 index 0000000..6b4fd0b --- /dev/null +++ b/avito/cli/local.py @@ -0,0 +1,362 @@ +"""Локальные команды конфигурации, диагностики и shell completion.""" + +from __future__ import annotations + +import json +from collections.abc import Callable +from dataclasses import replace +from pathlib import Path +from typing import Literal + +import click + +from avito.cli.config import ( + AccountStore, + CliConfigDocument, + ConfigStore, + JsonValue, + resolve_cli_home, +) +from avito.cli.context import CliContext +from avito.cli.errors import CliConfigFileError, CliError, CliUsageError +from avito.cli.ui import emit_stdout + +ConfigKey = Literal["active-profile"] +ShellName = Literal["bash", "zsh", "fish"] + +_CONFIG_KEYS: frozenset[ConfigKey] = frozenset({"active-profile"}) + + +@click.group(name="config") +@click.help_option("-h", "--help", help="Показать справку и выйти.") +def config_group() -> None: + """Управлять локальной конфигурацией CLI.""" + + +@config_group.command("get") +@click.argument("key", metavar="KEY") +@click.option("--show-source", is_flag=True, help="Показать источник значения.") +@click.pass_obj +def config_get(ctx: CliContext, key: str, show_source: bool) -> None: + """Показать значение локальной конфигурации.""" + + config_key = _parse_config_key(key) + entry = _config_entry(ctx, config_key) + if ctx.json_output: + emit_stdout(ctx, _json_dump({"config": {config_key: entry}})) + return + if show_source: + emit_stdout(ctx, f"{config_key}: {entry['value'] or '-'} ({entry['source']})") + return + emit_stdout(ctx, str(entry["value"] or "")) + + +@config_group.command("set") +@click.argument("key", metavar="KEY") +@click.argument("value", metavar="VALUE") +@click.pass_obj +def config_set(ctx: CliContext, key: str, value: str) -> None: + """Сохранить значение локальной конфигурации.""" + + config_key = _parse_config_key(key) + if not value: + raise CliUsageError("Значение конфигурации не может быть пустым.") + store = _config_store(ctx) + document = store.load() + if config_key == "active-profile": + updated = replace(document, active_profile=value) + store.save(updated) + if ctx.json_output: + emit_stdout(ctx, _json_dump({"config": {config_key: value}})) + return + emit_stdout(ctx, f"Конфигурация обновлена: {config_key}") + + +@config_group.command("unset") +@click.argument("key", metavar="KEY") +@click.pass_obj +def config_unset(ctx: CliContext, key: str) -> None: + """Удалить значение локальной конфигурации.""" + + config_key = _parse_config_key(key) + store = _config_store(ctx) + document = store.load() + if config_key == "active-profile": + updated = replace(document, active_profile=None) + store.save(updated) + if ctx.json_output: + emit_stdout(ctx, _json_dump({"config": {config_key: None}})) + return + emit_stdout(ctx, f"Конфигурация очищена: {config_key}") + + +@config_group.command("list") +@click.option("--show-source", is_flag=True, help="Показать источник каждого значения.") +@click.pass_obj +def config_list(ctx: CliContext, show_source: bool) -> None: + """Показать локальную конфигурацию.""" + + entries = {key: _config_entry(ctx, key) for key in sorted(_CONFIG_KEYS)} + if ctx.json_output: + emit_stdout(ctx, _json_dump({"config": entries})) + return + lines: list[str] = [] + for key, entry in entries.items(): + if show_source: + lines.append(f"{key}: {entry['value'] or '-'} ({entry['source']})") + else: + lines.append(f"{key}: {entry['value'] or '-'}") + emit_stdout(ctx, "\n".join(lines)) + + +@click.command(name="status") +@click.help_option("-h", "--help", help="Показать справку и выйти.") +@click.pass_obj +def status_command(ctx: CliContext) -> None: + """Показать локальную готовность CLI без сетевых вызовов.""" + + payload = _status_payload(ctx) + if ctx.json_output: + emit_stdout(ctx, _json_dump({"status": payload})) + return + ready_label = "готов" if payload["ready"] else "не готов" + lines = [ + f"Статус CLI: {ready_label}", + f"Профиль: {payload['selected_profile'] or '-'}", + f"Источник: {payload['profile_source']}", + f"Аккаунт: {'найден' if payload['account_found'] else 'не найден'}", + f"Каталог: {payload['cli_home']}", + "Сеть: не проверялась", + ] + emit_stdout(ctx, "\n".join(lines)) + + +@click.command(name="doctor") +@click.help_option("-h", "--help", help="Показать справку и выйти.") +@click.pass_obj +def doctor_command(ctx: CliContext) -> None: + """Проверить локальные файлы CLI и показать диагностику.""" + + payload = _doctor_payload(ctx) + if ctx.json_output: + emit_stdout(ctx, _json_dump({"doctor": payload})) + else: + lines = [f"Диагностика CLI: {payload['status']}"] + issues = payload["issues"] + if isinstance(issues, list) and issues: + lines.append("") + lines.extend(_format_issue(issue) for issue in issues) + else: + lines.append("Проблемы не найдены.") + emit_stdout(ctx, "\n".join(lines)) + if payload["status"] != "ok": + raise CliConfigFileError( + "Локальная диагностика нашла проблемы.", + details=payload, + ) + + +@click.group(name="completion") +@click.help_option("-h", "--help", help="Показать справку и выйти.") +def completion_group() -> None: + """Показать команды подключения shell completion.""" + + +@completion_group.command("bash") +@click.pass_obj +def completion_bash(ctx: CliContext) -> None: + """Показать подключение completion для bash.""" + + _emit_completion(ctx, "bash") + + +@completion_group.command("zsh") +@click.pass_obj +def completion_zsh(ctx: CliContext) -> None: + """Показать подключение completion для zsh.""" + + _emit_completion(ctx, "zsh") + + +@completion_group.command("fish") +@click.pass_obj +def completion_fish(ctx: CliContext) -> None: + """Показать подключение completion для fish.""" + + _emit_completion(ctx, "fish") + + +def _config_entry(ctx: CliContext, key: ConfigKey) -> dict[str, JsonValue]: + """Вернуть значение config key вместе с источником.""" + + store = _config_store(ctx) + document = store.load() + if key == "active-profile": + if ctx.profile is not None: + return { + "value": ctx.profile, + "source": "cli", + "path": None, + } + if document.active_profile is not None: + return { + "value": document.active_profile, + "source": "config", + "path": str(store.path), + } + return { + "value": None, + "source": "default", + "path": None, + } + + +def _status_payload(ctx: CliContext) -> dict[str, JsonValue]: + """Собрать payload локальной готовности CLI.""" + + home = resolve_cli_home() + config_store = _config_store(ctx) + account_store = AccountStore(home) + config = config_store.load() + accounts = account_store.load() + selected_profile = ctx.profile or config.active_profile + account_found = ( + selected_profile is not None + and any(account.name == selected_profile for account in accounts.accounts) + ) + return { + "ready": selected_profile is not None and account_found, + "cli_home": str(home), + "config_path": str(config_store.path), + "accounts_path": str(account_store.path), + "selected_profile": selected_profile, + "profile_source": _profile_source(ctx, config), + "account_found": account_found, + "configured_accounts": len(accounts.accounts), + "network_checked": False, + } + + +def _doctor_payload(ctx: CliContext) -> dict[str, JsonValue]: + """Собрать payload диагностики локальных CLI files.""" + + home = resolve_cli_home() + config_store = _config_store(ctx) + account_store = AccountStore(home) + issues: list[JsonValue] = [] + _load_for_doctor("config", config_store.path, config_store.load, issues) + _load_for_doctor("accounts", account_store.path, account_store.load, issues) + status = "ok" if not issues else "error" + return { + "status": status, + "cli_home": str(home), + "config_path": str(config_store.path), + "accounts_path": str(account_store.path), + "issues": issues, + "network_checked": False, + } + + +def _load_for_doctor( + name: str, + path: Path, + loader: Callable[[], object], + issues: list[JsonValue], +) -> None: + """Загрузить локальный файл и добавить issue при CLI error.""" + + try: + loader() + except CliError as exc: + issues.append( + { + "name": name, + "path": str(path), + "severity": "error", + "code": exc.code, + "message": exc.message, + } + ) + + +def _format_issue(issue: object) -> str: + """Отформатировать одну diagnostic issue для human output.""" + + if not isinstance(issue, dict): + return "- Некорректная диагностическая запись." + return ( + f"- {issue.get('name', '-')}: {issue.get('message', '-')}" + f" [{issue.get('code', '-')}]" + ) + + +def _emit_completion(ctx: CliContext, shell: ShellName) -> None: + """Напечатать команду подключения shell completion.""" + + command = _completion_command(shell) + if ctx.json_output: + emit_stdout(ctx, _json_dump({"completion": {"shell": shell, "command": command}})) + return + emit_stdout( + ctx, + "\n".join( + ( + f"Shell completion для {shell}:", + "", + command, + "", + "Добавьте эту команду в профиль shell, если completion нужен постоянно.", + ) + ), + ) + + +def _completion_command(shell: ShellName) -> str: + """Вернуть shell-specific команду completion.""" + + if shell == "bash": + return 'eval "$(_AVITO_COMPLETE=bash_source avito)"' + if shell == "zsh": + return 'eval "$(_AVITO_COMPLETE=zsh_source avito)"' + return "_AVITO_COMPLETE=fish_source avito | source" + + +def _profile_source(ctx: CliContext, config: CliConfigDocument) -> str: + """Определить источник выбранного профиля.""" + + if ctx.profile is not None: + return "cli" + if config.active_profile is not None: + return "config" + return "none" + + +def _parse_config_key(value: str) -> ConfigKey: + """Проверить и нормализовать ключ локальной конфигурации.""" + + if value == "active-profile": + return "active-profile" + raise CliUsageError( + "Ключ конфигурации не поддерживается.", + details={"key": value, "supported_keys": sorted(_CONFIG_KEYS)}, + ) + + +def _config_store(ctx: CliContext) -> ConfigStore: + """Создать ConfigStore с учетом --config.""" + + return ConfigStore(resolve_cli_home(), path=ctx.config) + + +def _json_dump(payload: dict[str, object]) -> str: + """Сериализовать payload в стабильный JSON.""" + + return json.dumps(payload, ensure_ascii=False, sort_keys=True) + + +__all__ = ( + "completion_group", + "config_group", + "doctor_command", + "status_command", +) diff --git a/avito/cli/registry.py b/avito/cli/registry.py new file mode 100644 index 0000000..0eaad0e --- /dev/null +++ b/avito/cli/registry.py @@ -0,0 +1,906 @@ +"""Метаданные команд CLI, построенные из публичных SDK bindings.""" + +from __future__ import annotations + +import importlib +import inspect +import re +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Literal + +from avito.cli.safety import CommandSafetyPolicy +from avito.cli.schemas import ( + CliParameterSchema, + CliValueKind, + build_parameter_schemas, +) +from avito.core.swagger_discovery import ( + DiscoveredSwaggerBinding, + SwaggerBindingDiscovery, + discover_swagger_bindings, +) +from avito.core.swagger_registry import SwaggerOperation, SwaggerRegistry, load_swagger_registry + +CommandCategory = Literal["api", "helper", "local"] +ExclusionCategory = Literal["api", "helper", "execution_smoke"] +ExclusionStatus = Literal["intentional", "temporary"] +OutputHint = Literal["object", "collection", "mutation", "plain", "unknown"] +SafetyKind = Literal["read", "write", "destructive", "expensive", "local"] + +_KEBAB_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +_NON_ALNUM_RE = re.compile(r"[^a-z0-9]+") +_IMPLEMENTED_API_COMMAND_IDS = frozenset({"account.get-balance", "account.get-self"}) +_READ_METHODS = frozenset({"GET", "HEAD"}) +_TEMPORARY_WRITE_EXCLUSION_COMMAND_IDS = frozenset( + { + "application.update", + "autostrategy-campaign.get-stat", + "bbip-promotion.create-order", + "bbip-promotion.get-forecasts", + "bbip-promotion.get-suggests", + "call-tracking-call.get", + "chat-media.upload-images", + "cpa-auction.create-item-bids", + "promotion-order.get-order-status", + "realty-analytics-report.get-report-for-classified", + "realty-listing.get-intervals", + "realty-pricing.update-realty-prices", + "sandbox-delivery.add-areas", + "sandbox-delivery.add-sorting-center", + "sandbox-delivery.add-tags-to-sorting-center", + "sandbox-delivery.add-tariff", + "sandbox-delivery.add-terminals", + "sandbox-delivery.cancel-sandbox-announcement", + "sandbox-delivery.create-sandbox-announcement", + "sandbox-delivery.set-order-properties", + "sandbox-delivery.set-order-real-address", + "sandbox-delivery.update-custom-area-schedule", + "sandbox-delivery.update-terms", + "stock.update", + "target-action-pricing.delete", + "target-action-pricing.get-promotions-by-item-ids", + "target-action-pricing.update-auto", + "target-action-pricing.update-manual", + "trx-promotion.apply", + "vacancy.update", + "vacancy.update-auto-renewal", + } +) +_TEMPORARY_READ_EXCLUSION_COMMAND_IDS = frozenset( + { + "autoteka-vehicle.get-preview", + "autoteka-vehicle.get-specification-by-id", + "autoteka-vehicle.get-teaser", + "cpa-chat.get", + "order-label.download", + "realty-analytics-report.get-market-price-correspondence", + "target-action-pricing.get-bids", + } +) + + +@dataclass(frozen=True, slots=True) +class ApiCommandRecord: + """Кандидат канонической API-команды из sync Swagger binding.""" + + command_id: str + resource: str + action: str + operation_key: str + sdk_module: str + sdk_class: str + sdk_method_name: str + sdk_method: str + factory: str + factory_args: Mapping[str, str] + method_args: Mapping[str, str] + parameters: tuple[CliParameterSchema, ...] + spec: str + http_method: str + path: str + operation_id: str | None + domain: str | None + deprecated: bool + legacy: bool + implemented: bool + description: str + examples: tuple[str, ...] + related_commands: tuple[str, ...] + safety: SafetyKind + safety_summary: str + safety_policy: CommandSafetyPolicy + output_hint: OutputHint + adapter_id: str | None = None + + def to_dict(self) -> dict[str, object]: + """Вернуть JSON-совместимые данные API-команды.""" + + return { + "command_id": self.command_id, + "resource": self.resource, + "action": self.action, + "operation_key": self.operation_key, + "sdk_module": self.sdk_module, + "sdk_class": self.sdk_class, + "sdk_method_name": self.sdk_method_name, + "sdk_method": self.sdk_method, + "factory": self.factory, + "factory_args": dict(sorted(self.factory_args.items())), + "method_args": dict(sorted(self.method_args.items())), + "parameters": [parameter.to_dict() for parameter in self.parameters], + "spec": self.spec, + "http_method": self.http_method, + "path": self.path, + "operation_id": self.operation_id, + "domain": self.domain, + "deprecated": self.deprecated, + "legacy": self.legacy, + "implemented": self.implemented, + "description": self.description, + "examples": list(self.examples), + "related_commands": list(self.related_commands), + "safety": self.safety, + "safety_summary": self.safety_summary, + "safety_policy": self.safety_policy.to_dict(), + "output_hint": self.output_hint, + "adapter_id": self.adapter_id, + } + + +@dataclass(frozen=True, slots=True) +class HelperCommandRecord: + """Кандидат команды для публичного non-Swagger helper workflow.""" + + command_id: str + resource: str + action: str + sdk_method_name: str + sdk_method: str + parameters: tuple[CliParameterSchema, ...] + implemented: bool + description: str + examples: tuple[str, ...] + related_commands: tuple[str, ...] + safety: SafetyKind + safety_summary: str + safety_policy: CommandSafetyPolicy + output_hint: OutputHint + adapter_id: str | None = None + + def to_dict(self) -> dict[str, object]: + """Вернуть JSON-совместимые данные helper-команды.""" + + return { + "command_id": self.command_id, + "resource": self.resource, + "action": self.action, + "sdk_method_name": self.sdk_method_name, + "sdk_method": self.sdk_method, + "parameters": [parameter.to_dict() for parameter in self.parameters], + "implemented": self.implemented, + "description": self.description, + "examples": list(self.examples), + "related_commands": list(self.related_commands), + "safety": self.safety, + "safety_summary": self.safety_summary, + "safety_policy": self.safety_policy.to_dict(), + "output_hint": self.output_hint, + "adapter_id": self.adapter_id, + } + + +@dataclass(frozen=True, slots=True) +class LocalCommandRecord: + """Локальная CLI-команда, не привязанная к Swagger operation.""" + + command_id: str + resource: str + action: str + implemented: bool + description: str + examples: tuple[str, ...] + related_commands: tuple[str, ...] + safety: SafetyKind + safety_summary: str + safety_policy: CommandSafetyPolicy + output_hint: OutputHint + + def to_dict(self) -> dict[str, object]: + """Вернуть JSON-совместимые данные локальной команды.""" + + return { + "command_id": self.command_id, + "resource": self.resource, + "action": self.action, + "implemented": self.implemented, + "description": self.description, + "examples": list(self.examples), + "related_commands": list(self.related_commands), + "safety": self.safety, + "safety_summary": self.safety_summary, + "safety_policy": self.safety_policy.to_dict(), + "output_hint": self.output_hint, + } + + +@dataclass(frozen=True, slots=True) +class AliasRecord: + """Совместимый alias, который не считается канонической командой.""" + + alias_id: str + resource: str + action: str + target_command_id: str + implemented: bool + reason: str + + def to_dict(self) -> dict[str, object]: + """Вернуть JSON-совместимые данные alias.""" + + return { + "alias_id": self.alias_id, + "resource": self.resource, + "action": self.action, + "target_command_id": self.target_command_id, + "implemented": self.implemented, + "reason": self.reason, + } + + +@dataclass(frozen=True, slots=True) +class ExclusionRecord: + """Документированное исключение из покрытия CLI.""" + + exclusion_id: str + category: ExclusionCategory + status: ExclusionStatus + reason: str + follow_up: str + owner: str + operation_key: str | None = None + sdk_method: str | None = None + command_id: str | None = None + target_stage: str | None = None + + def to_dict(self) -> dict[str, object]: + """Вернуть JSON-совместимые данные исключения.""" + + return { + "exclusion_id": self.exclusion_id, + "category": self.category, + "status": self.status, + "reason": self.reason, + "follow_up": self.follow_up, + "owner": self.owner, + "operation_key": self.operation_key, + "sdk_method": self.sdk_method, + "command_id": self.command_id, + "target_stage": self.target_stage, + } + + +@dataclass(frozen=True, slots=True) +class CliRegistry: + """Детерминированный registry будущих CLI-команд.""" + + api_commands: tuple[ApiCommandRecord, ...] + helper_commands: tuple[HelperCommandRecord, ...] + local_commands: tuple[LocalCommandRecord, ...] + aliases: tuple[AliasRecord, ...] + exclusions: tuple[ExclusionRecord, ...] + + def to_dict(self) -> dict[str, object]: + """Вернуть JSON-совместимый report registry.""" + + api_exclusions = tuple( + exclusion for exclusion in self.exclusions if exclusion.category == "api" + ) + helper_exclusions = tuple( + exclusion for exclusion in self.exclusions if exclusion.category == "helper" + ) + execution_exclusions = tuple( + exclusion for exclusion in self.exclusions if exclusion.category == "execution_smoke" + ) + return { + "summary": { + "api_command_candidates": len(self.api_commands), + "api_exclusions": len(api_exclusions), + "helper_command_candidates": len(self.helper_commands), + "helper_exclusions": len(helper_exclusions), + "local_commands": len(self.local_commands), + "aliases": len(self.aliases), + "execution_smoke_exclusions": len(execution_exclusions), + }, + "api_commands": [record.to_dict() for record in self.api_commands], + "helper_commands": [record.to_dict() for record in self.helper_commands], + "local_commands": [record.to_dict() for record in self.local_commands], + "aliases": [record.to_dict() for record in self.aliases], + "exclusions": [record.to_dict() for record in self.exclusions], + } + + def command_ids(self) -> frozenset[str]: + """Вернуть множество канонических command id без alias.""" + + command_ids: set[str] = set() + for _category, record in _canonical_records(self): + command_ids.add(record.command_id) + return frozenset(command_ids) + + +def build_cli_registry( + *, + swagger_registry: SwaggerRegistry | None = None, + discovery: SwaggerBindingDiscovery | None = None, +) -> CliRegistry: + """Построить registry без создания `AvitoClient` и без сетевых вызовов.""" + + resolved_swagger_registry = swagger_registry or load_swagger_registry() + resolved_discovery = discovery or discover_swagger_bindings(registry=resolved_swagger_registry) + operations_by_key = { + operation.key: operation for operation in resolved_swagger_registry.operations + } + api_commands: list[ApiCommandRecord] = [] + exclusions: list[ExclusionRecord] = [] + + for binding in _sync_bindings(resolved_discovery): + operation = _operation_for_binding(binding, operations_by_key) + if binding.factory is None: + exclusions.append(_build_auth_token_exclusion(binding)) + continue + command_record = _build_api_command_record(binding, operation) + if command_record.command_id in _TEMPORARY_READ_EXCLUSION_COMMAND_IDS: + exclusions.append(_build_temporary_read_exclusion(command_record)) + continue + if command_record.command_id in _TEMPORARY_WRITE_EXCLUSION_COMMAND_IDS: + exclusions.append(_build_temporary_write_exclusion(command_record)) + continue + api_commands.append(command_record) + + helper_commands, helper_exclusions = _build_helper_records() + exclusions.extend(helper_exclusions) + registry = CliRegistry( + api_commands=tuple(sorted(api_commands, key=lambda record: record.command_id)), + helper_commands=helper_commands, + local_commands=_build_local_command_records(), + aliases=_build_alias_records(), + exclusions=tuple(sorted(exclusions, key=lambda record: record.exclusion_id)), + ) + validate_cli_registry(registry) + return registry + + +def _sync_bindings( + discovery: SwaggerBindingDiscovery, +) -> tuple[DiscoveredSwaggerBinding, ...]: + """Вернуть sync Swagger bindings с operation key.""" + + return tuple( + sorted( + ( + binding + for binding in discovery.bindings + if binding.variant == "sync" and binding.operation_key is not None + ), + key=lambda binding: binding.operation_key or "", + ) + ) + + +def _operation_for_binding( + binding: DiscoveredSwaggerBinding, + operations_by_key: Mapping[str, SwaggerOperation], +) -> SwaggerOperation: + """Найти Swagger operation для discovered binding.""" + + if binding.operation_key is None: + raise ValueError("Swagger binding без operation_key не может стать API-командой.") + operation = operations_by_key.get(binding.operation_key) + if operation is None: + raise ValueError(f"Swagger operation не найдена: {binding.operation_key}") + return operation + + +def _build_api_command_record( + binding: DiscoveredSwaggerBinding, + operation: SwaggerOperation, +) -> ApiCommandRecord: + """Построить canonical API command record из SDK binding.""" + + if binding.operation_key is None or binding.spec is None or binding.factory is None: + raise ValueError("API-команда требует operation_key, spec и factory.") + resource = kebab_case(binding.factory) + action = kebab_case(binding.method_name) + command_id = f"{resource}.{action}" + return ApiCommandRecord( + command_id=command_id, + resource=resource, + action=action, + operation_key=binding.operation_key, + sdk_module=binding.module, + sdk_class=binding.class_name, + sdk_method_name=binding.method_name, + sdk_method=binding.sdk_method, + factory=binding.factory, + factory_args=dict(sorted(binding.factory_args.items())), + method_args=dict(sorted(binding.method_args.items())), + parameters=_build_parameter_records(binding), + spec=binding.spec, + http_method=operation.method, + path=operation.path, + operation_id=operation.operation_id, + domain=binding.domain, + deprecated=binding.deprecated or operation.deprecated, + legacy=binding.legacy, + implemented=True, + description=_api_description(binding, operation), + examples=_api_examples(resource, action, binding), + related_commands=(), + safety=_safety_for_method(operation.method), + safety_summary=_safety_summary_for_method(operation.method), + safety_policy=_api_safety_policy(binding, operation), + output_hint=_output_hint_for_command(command_id, operation.method), + ) + + +def _build_parameter_records( + binding: DiscoveredSwaggerBinding, +) -> tuple[CliParameterSchema, ...]: + """Построить CLI parameter records из binding metadata.""" + + return build_parameter_schemas(binding) + + +def _build_auth_token_exclusion(binding: DiscoveredSwaggerBinding) -> ExclusionRecord: + """Построить intentional exclusion для non-domain token binding.""" + + return ExclusionRecord( + exclusion_id=f"api.{binding.operation_key}", + category="api", + status="intentional", + reason="Token-client binding не имеет публичной AvitoClient factory в первом CLI release.", + follow_up="Проектировать отдельный публичный token facade перед добавлением CLI-команды.", + owner="cli", + operation_key=binding.operation_key, + sdk_method=binding.sdk_method, + ) + + +def _build_temporary_read_exclusion(command: ApiCommandRecord) -> ExclusionRecord: + """Построить intentional exclusion для read command без safe generic input.""" + + return ExclusionRecord( + exclusion_id=f"api.{command.operation_key}", + category="api", + status="intentional", + reason=( + "Read-only binding намеренно исключен из первого CLI release: команда требует " + "дополнительный идентификатор доменного объекта, который не представлен в " + "factory_args/method_args metadata." + ), + follow_up=( + "Уточнить Swagger binding metadata или добавить CLI adapter, чтобы команда " + "могла принять обязательный идентификатор без обхода публичного SDK в " + "следующем coverage increment." + ), + owner="cli", + operation_key=command.operation_key, + sdk_method=command.sdk_method, + command_id=command.command_id, + ) + + +def _build_temporary_write_exclusion(command: ApiCommandRecord) -> ExclusionRecord: + """Построить intentional exclusion для write command без safe generic input.""" + + return ExclusionRecord( + exclusion_id=f"api.{command.operation_key}", + category="api", + status="intentional", + reason=( + "Write binding намеренно исключен из первого CLI release: команда требует " + "CLI adapter или уточнения binding metadata, потому что " + "generic flags не могут безопасно построить обязательный публичный input " + "model, file/stdin payload или отсутствующий идентификатор доменного объекта." + ), + follow_up=( + "Добавить typed CLI adapter или исправить factory_args/method_args metadata, " + "затем включить команду в canonical CLI coverage." + ), + owner="cli", + operation_key=command.operation_key, + sdk_method=command.sdk_method, + command_id=command.command_id, + ) + + +def _build_helper_records() -> tuple[tuple[HelperCommandRecord, ...], tuple[ExclusionRecord, ...]]: + """Построить helper commands и helper exclusions.""" + + helper_commands = ( + _helper( + "account-health", + "show", + "account_health", + "Health-сводка аккаунта.", + parameters=( + _helper_parameter("user_id", "integer"), + _helper_parameter("listing_limit", "integer"), + _helper_parameter("listing_page_size", "integer"), + _helper_parameter("date_from", "date"), + _helper_parameter("date_to", "date"), + ), + ), + _helper( + "listing-health", + "show", + "listing_health", + "Health-сводка объявлений.", + parameters=( + _helper_parameter("user_id", "integer"), + _helper_parameter("limit", "integer"), + _helper_parameter("page_size", "integer"), + _helper_parameter("date_from", "date"), + _helper_parameter("date_to", "date"), + ), + ), + _helper( + "chat-summary", + "show", + "chat_summary", + "Сводка сообщений.", + parameters=(_helper_parameter("user_id", "integer"),), + ), + _helper("order-summary", "show", "order_summary", "Сводка заказов."), + _helper("review-summary", "show", "review_summary", "Сводка отзывов."), + _helper( + "promotion-summary", + "show", + "promotion_summary", + "Сводка продвижения.", + parameters=( + _helper_parameter( + "item_ids", + "list", + multiple=True, + item_value_kind="integer", + ), + ), + ), + _helper("capabilities", "show", "capabilities", "Список возможностей SDK."), + ) + exclusions = ( + ExclusionRecord( + exclusion_id="helper.business-summary", + category="helper", + status="intentional", + reason="business_summary является compatibility wrapper для account_health.", + follow_up="Использовать canonical helper account_health; alias возможен только отдельно.", + owner="cli", + sdk_method="avito.client.AvitoClient.business_summary", + command_id="business-summary.show", + ), + ) + return helper_commands, exclusions + + +def _helper( + resource: str, + action: str, + method_name: str, + description: str, + *, + parameters: tuple[CliParameterSchema, ...] = (), +) -> HelperCommandRecord: + """Создать helper command record.""" + + return HelperCommandRecord( + command_id=f"{resource}.{action}", + resource=resource, + action=action, + sdk_method_name=method_name, + sdk_method=f"avito.client.AvitoClient.{method_name}", + parameters=parameters, + implemented=True, + description=description, + examples=(f"avito {resource} {action}", f"avito --json --no-input {resource} {action}"), + related_commands=(), + safety="read", + safety_summary="Локальная вспомогательная команда читает данные через публичный интерфейс SDK.", + safety_policy=CommandSafetyPolicy( + kind="read", + confirmation_required=False, + dry_run_supported=False, + review_note="Helper-команда только читает данные через публичный интерфейс SDK.", + ), + output_hint="object", + ) + + +def _helper_parameter( + name: str, + value_kind: CliValueKind, + *, + multiple: bool = False, + item_value_kind: CliValueKind | None = None, +) -> CliParameterSchema: + """Создать optional helper parameter schema.""" + + return CliParameterSchema( + name=name, + source="method", + binding_expression=f"helper.{name}", + flag=f"--{kebab_case(name)}", + value_kind=value_kind, + required=False, + multiple=multiple, + item_value_kind=item_value_kind, + annotation=value_kind, + ) + + +def _build_local_command_records() -> tuple[LocalCommandRecord, ...]: + """Построить registry records для local CLI commands.""" + + records = ( + _local("account", "add", "Добавить учетную запись.", "mutation"), + _local("account", "list", "Показать учетные записи.", "collection"), + _local("account", "use", "Выбрать активную учетную запись.", "mutation"), + _local("account", "current", "Показать активную учетную запись.", "object"), + _local("account", "delete", "Удалить учетную запись.", "mutation", safety="destructive"), + _local("completion", "bash", "Показать подключение completion для bash.", "plain"), + _local("completion", "fish", "Показать подключение completion для fish.", "plain"), + _local("completion", "zsh", "Показать подключение completion для zsh.", "plain"), + _local("config", "get", "Показать значение локальной конфигурации.", "object"), + _local("config", "list", "Показать локальную конфигурацию.", "collection"), + _local("config", "set", "Сохранить значение локальной конфигурации.", "mutation"), + _local("config", "unset", "Удалить значение локальной конфигурации.", "mutation"), + _local("doctor", "show", "Проверить локальные файлы CLI.", "object"), + _local("status", "show", "Показать локальную готовность CLI.", "object"), + _local("version", "show", "Показать версию.", "plain"), + _local("help", "show", "Показать справку.", "plain"), + ) + return tuple(sorted(records, key=lambda record: record.command_id)) + + +def _local( + resource: str, + action: str, + description: str, + output_hint: OutputHint, + *, + safety: SafetyKind = "local", +) -> LocalCommandRecord: + """Создать local command record.""" + + return LocalCommandRecord( + command_id=f"{resource}.{action}", + resource=resource, + action=action, + implemented=True, + description=description, + examples=(f"avito {resource} {action}",), + related_commands=(), + safety=safety, + safety_summary="Локальная команда не вызывает Avito API.", + safety_policy=_local_safety_policy(safety), + output_hint=output_hint, + ) + + +def _build_alias_records() -> tuple[AliasRecord, ...]: + """Построить compatibility alias records.""" + + records = ( + AliasRecord( + alias_id="account.remove", + resource="account", + action="remove", + target_command_id="account.delete", + implemented=True, + reason="Совместимое имя для account delete.", + ), + ) + return tuple(sorted(records, key=lambda record: record.alias_id)) + + +def kebab_case(value: str) -> str: + """Преобразовать имя SDK в lowercase kebab-case.""" + + normalized = _NON_ALNUM_RE.sub("-", value.replace("_", "-").lower()).strip("-") + if not normalized or _KEBAB_RE.fullmatch(normalized) is None: + raise ValueError(f"Невозможно построить kebab-case имя: {value}") + return normalized + + +def validate_cli_registry(registry: CliRegistry) -> None: + """Проверить детерминированные collision-инварианты registry.""" + + _validate_canonical_collisions(registry) + _validate_aliases(registry) + + +def _validate_canonical_collisions(registry: CliRegistry) -> None: + """Проверить отсутствие duplicate canonical resource/action.""" + + seen: dict[tuple[str, str], str] = {} + for category, record in _canonical_records(registry): + key = (record.resource, record.action) + existing = seen.get(key) + if existing is not None: + raise ValueError( + "CLI registry содержит конфликт команд " + f"{record.resource} {record.action}: {existing} и {category}:{record.command_id}" + ) + seen[key] = f"{category}:{record.command_id}" + + +def _validate_aliases(registry: CliRegistry) -> None: + """Проверить target и collision policy для aliases.""" + + command_ids = registry.command_ids() + canonical_keys = { + (record.resource, record.action) + for _category, record in _canonical_records(registry) + } + seen_alias_keys: dict[tuple[str, str], str] = {} + for alias in registry.aliases: + key = (alias.resource, alias.action) + if alias.target_command_id not in command_ids: + raise ValueError( + f"CLI alias {alias.alias_id} ссылается на неизвестную команду " + f"{alias.target_command_id}." + ) + if key in canonical_keys: + raise ValueError( + f"CLI alias {alias.alias_id} конфликтует с canonical command " + f"{alias.resource} {alias.action}." + ) + existing = seen_alias_keys.get(key) + if existing is not None: + raise ValueError( + f"CLI alias {alias.alias_id} конфликтует с alias {existing} " + f"для {alias.resource} {alias.action}." + ) + seen_alias_keys[key] = alias.alias_id + + +def _canonical_records( + registry: CliRegistry, +) -> tuple[ + tuple[CommandCategory, ApiCommandRecord | HelperCommandRecord | LocalCommandRecord], + ..., +]: + """Вернуть все canonical records с category labels.""" + + records: list[ + tuple[CommandCategory, ApiCommandRecord | HelperCommandRecord | LocalCommandRecord] + ] = [] + records.extend(("api", record) for record in registry.api_commands) + records.extend(("helper", record) for record in registry.helper_commands) + records.extend(("local", record) for record in registry.local_commands) + return tuple(records) + + +def _api_description(binding: DiscoveredSwaggerBinding, operation: SwaggerOperation) -> str: + """Сформировать русское описание API command.""" + + if binding.deprecated or operation.deprecated: + return ( + "Устаревшая операция Avito API; политика CLI должна сохранять " + "совместимость или явно исключать команду." + ) + if binding.legacy: + return ( + "Совместимая операция Avito API; политика CLI должна сохранять " + "канонический путь или явно исключать команду." + ) + if operation.operation_id is not None: + return f"Вызвать операцию Avito API `{operation.operation_id}` через публичный SDK." + return f"Вызвать SDK-метод {binding.sdk_method}." + + +def _api_examples( + resource: str, + action: str, + binding: DiscoveredSwaggerBinding, +) -> tuple[str, ...]: + """Сформировать базовые examples для API command.""" + + flags = tuple( + f"--{kebab_case(name)} " + for name in (*sorted(binding.factory_args), *sorted(binding.method_args)) + ) + command = " ".join(("avito", resource, action, *flags)) + return (command, f"avito --json --no-input {resource} {action}") + + +def _safety_for_method(method: str) -> SafetyKind: + """Классифицировать safety kind по HTTP method.""" + + if method in _READ_METHODS: + return "read" + if method == "DELETE": + return "destructive" + return "write" + + +def _safety_summary_for_method(method: str) -> str: + """Вернуть русскую safety-сводку для HTTP method.""" + + if method in _READ_METHODS: + return "Команда только читает данные Avito API." + if method == "DELETE": + return "Команда удаляет или отменяет данные в Avito API и требует подтверждения." + return "Команда может изменить состояние или запустить действие в Avito API." + + +def _api_safety_policy( + binding: DiscoveredSwaggerBinding, + operation: SwaggerOperation, +) -> CommandSafetyPolicy: + """Построить reviewed safety policy для API command.""" + + kind = _safety_for_method(operation.method) + if kind == "read": + return CommandSafetyPolicy( + kind="read", + confirmation_required=False, + dry_run_supported=False, + review_note="GET/HEAD operation проверена как read-only команда.", + ) + return CommandSafetyPolicy( + kind=kind, + confirmation_required=kind in {"destructive", "expensive"}, + dry_run_supported=_sdk_method_accepts(binding, "dry_run"), + review_note=( + "Write operation получает явную CLI safety metadata перед публикацией; " + "HTTP method используется только как исходная классификация." + ), + ) + + +def _local_safety_policy(kind: SafetyKind) -> CommandSafetyPolicy: + """Построить safety policy для local CLI command.""" + + return CommandSafetyPolicy( + kind=kind, + confirmation_required=kind in {"destructive", "expensive"}, + dry_run_supported=False, + review_note="Локальная CLI-команда проверена отдельно от Swagger coverage.", + ) + + +def _sdk_method_accepts(binding: DiscoveredSwaggerBinding, parameter_name: str) -> bool: + """Проверить наличие параметра в публичном SDK method.""" + + module = importlib.import_module(binding.module) + domain_class = getattr(module, binding.class_name) + method = getattr(domain_class, binding.method_name) + return parameter_name in inspect.signature(method).parameters + + +def _output_hint_for_command(command_id: str, method: str) -> OutputHint: + """Определить output hint для command record.""" + + if command_id in _IMPLEMENTED_API_COMMAND_IDS: + return "object" + if method not in _READ_METHODS: + return "mutation" + return "unknown" + + +__all__ = ( + "AliasRecord", + "ApiCommandRecord", + "CliParameterSchema", + "CliRegistry", + "ExclusionRecord", + "HelperCommandRecord", + "LocalCommandRecord", + "OutputHint", + "SafetyKind", + "build_cli_registry", + "kebab_case", + "validate_cli_registry", +) diff --git a/avito/cli/safety.py b/avito/cli/safety.py new file mode 100644 index 0000000..adcd275 --- /dev/null +++ b/avito/cli/safety.py @@ -0,0 +1,91 @@ +"""Safety checks for CLI commands that can change upstream state.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import click + +from avito.cli.context import CliContext +from avito.cli.errors import CliUsageError, InvalidFlagCombinationError + +if TYPE_CHECKING: + from avito.cli.registry import ApiCommandRecord, SafetyKind + + +@dataclass(frozen=True, slots=True) +class CommandSafetyPolicy: + """Reviewed safety policy for one CLI command.""" + + kind: SafetyKind + confirmation_required: bool + dry_run_supported: bool + review_note: str + + def to_dict(self) -> dict[str, object]: + """Return JSON-compatible safety metadata.""" + + return { + "kind": self.kind, + "confirmation_required": self.confirmation_required, + "dry_run_supported": self.dry_run_supported, + "review_note": self.review_note, + } + + +@dataclass(frozen=True, slots=True) +class SafetyOptions: + """Safety flags supplied by the user for one command invocation.""" + + yes: bool = False + confirm: str | None = None + dry_run: bool = False + + +def validate_safety_options( + ctx: CliContext, + command: ApiCommandRecord, + options: SafetyOptions, +) -> None: + """Validate write safety flags before constructing the SDK client.""" + + if options.yes and options.confirm is not None: + raise InvalidFlagCombinationError("Флаги --yes и --confirm нельзя использовать вместе.") + if options.dry_run and not command.safety_policy.dry_run_supported: + raise CliUsageError( + "Команда не поддерживает --dry-run.", + details={"command_id": command.command_id}, + ) + if not command.safety_policy.confirmation_required: + return + if options.yes: + return + expected = confirmation_value(command) + if options.confirm == expected: + return + if ctx.no_input: + raise CliUsageError( + "Команда требует подтверждения.", + details={"command_id": command.command_id, "confirm": expected}, + ) + entered = click.prompt( + f"Введите `{expected}` для подтверждения команды {command.resource} {command.action}", + type=str, + ) + if entered != expected: + raise CliUsageError("Подтверждение не совпадает с ожидаемым значением.") + + +def confirmation_value(command: ApiCommandRecord) -> str: + """Return the exact confirmation value for an API command.""" + + return command.command_id + + +__all__ = ( + "CommandSafetyPolicy", + "SafetyOptions", + "confirmation_value", + "validate_safety_options", +) diff --git a/avito/cli/schemas.py b/avito/cli/schemas.py new file mode 100644 index 0000000..9efd6b2 --- /dev/null +++ b/avito/cli/schemas.py @@ -0,0 +1,468 @@ +"""Типизация и приведение входных параметров CLI.""" + +from __future__ import annotations + +import importlib +import inspect +import types +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass +from datetime import date, datetime +from enum import Enum +from typing import Literal, Union, get_args, get_origin, get_type_hints + +from avito.cli.errors import CliValidationError +from avito.client import AvitoClient +from avito.core.swagger_discovery import DiscoveredSwaggerBinding + +CliParameterSource = Literal["factory", "method"] +CliValueKind = Literal["string", "integer", "float", "boolean", "date", "datetime", "enum", "list", "unknown"] + +_CONTROL_PARAMETER_NAMES = frozenset({"timeout", "retry"}) +_NONE_TYPE = type(None) +_BOOLEAN_TRUE = frozenset({"1", "true", "yes", "y", "on", "да", "д"}) +_BOOLEAN_FALSE = frozenset({"0", "false", "no", "n", "off", "нет", "н"}) + + +@dataclass(frozen=True, slots=True) +class CliParameterSchema: + """Аргумент CLI-команды, выбранный из Swagger binding metadata.""" + + name: str + source: CliParameterSource + binding_expression: str + flag: str + value_kind: CliValueKind + required: bool + multiple: bool + item_value_kind: CliValueKind | None + annotation: str + enum_values: tuple[str, ...] = () + + def to_dict(self) -> dict[str, object]: + """Вернуть JSON-совместимое описание параметра.""" + + return { + "name": self.name, + "source": self.source, + "binding_expression": self.binding_expression, + "flag": self.flag, + "value_kind": self.value_kind, + "required": self.required, + "multiple": self.multiple, + "item_value_kind": self.item_value_kind, + "annotation": self.annotation, + "enum_values": list(self.enum_values), + } + + +@dataclass(frozen=True, slots=True) +class CoercedParameter: + """Приведенное значение параметра CLI.""" + + name: str + value: object + + +@dataclass(frozen=True, slots=True) +class _CallableSchemaContext: + """Signature and type hints used to build parameter schemas.""" + + signature: inspect.Signature + type_hints: Mapping[str, object] + + +def build_parameter_schemas(binding: DiscoveredSwaggerBinding) -> tuple[CliParameterSchema, ...]: + """Построить CLI schema только из factory_args и method_args binding.""" + + if binding.factory is None: + return () + factory_context = _context_for_factory(binding.factory) + method_context = _context_for_sdk_method(binding) + schemas: list[CliParameterSchema] = [] + schemas.extend( + _build_schema( + name=name, + source="factory", + expression=expression, + context=factory_context, + ) + for name, expression in sorted(binding.factory_args.items()) + if name not in _CONTROL_PARAMETER_NAMES + ) + schemas.extend( + _build_schema( + name=name, + source="method", + expression=expression, + context=method_context, + ) + for name, expression in sorted(binding.method_args.items()) + if name not in _CONTROL_PARAMETER_NAMES + ) + return tuple(schemas) + + +def coerce_cli_values( + schemas: Sequence[CliParameterSchema], + raw_values: Mapping[str, Sequence[str]], + *, + no_input: bool = False, +) -> dict[str, object]: + """Привести набор строковых CLI-значений по schema.""" + + coerced: dict[str, object] = {} + for schema in schemas: + values = tuple(raw_values.get(schema.name, ())) + if not values: + if schema.required: + message = f"Не указан обязательный параметр {schema.flag}." + if no_input: + message = f"{message} Интерактивный ввод отключен." + raise CliValidationError(message, details={"parameter": schema.name}) + continue + coerced[schema.name] = coerce_cli_value(schema, values) + return coerced + + +def coerce_cli_value(schema: CliParameterSchema, values: Sequence[str]) -> object: + """Привести одно CLI-значение или повторяющийся флаг по schema.""" + + if schema.multiple or schema.value_kind == "list": + item_kind = schema.item_value_kind or "string" + return [ + _coerce_scalar(schema, item, value_kind=item_kind) + for item in _split_list_values(values) + ] + if len(values) != 1: + raise CliValidationError( + f"Параметр {schema.flag} нельзя передавать несколько раз.", + details={"parameter": schema.name, "values": list(values)}, + ) + return _coerce_scalar(schema, values[0], value_kind=schema.value_kind) + + +def _context_for_factory(factory_name: str) -> _CallableSchemaContext: + """Построить schema context для AvitoClient factory.""" + + factory = getattr(AvitoClient, factory_name) + return _context_for_callable(factory) + + +def _context_for_sdk_method(binding: DiscoveredSwaggerBinding) -> _CallableSchemaContext: + """Построить schema context для публичного SDK method.""" + + module = importlib.import_module(binding.module) + domain_class = getattr(module, binding.class_name) + method = getattr(domain_class, binding.method_name) + return _context_for_callable(method) + + +def _context_for_callable(callable_object: Callable[..., object]) -> _CallableSchemaContext: + """Построить schema context для callable object.""" + + return _CallableSchemaContext( + signature=inspect.signature(callable_object), + type_hints=get_type_hints(callable_object), + ) + + +def _build_schema( + *, + name: str, + source: CliParameterSource, + expression: str, + context: _CallableSchemaContext, +) -> CliParameterSchema: + """Построить schema для одного selected binding argument.""" + + parameter = context.signature.parameters.get(name) + annotation: object = object + required = True + if parameter is not None: + annotation = _resolve_annotation(context, parameter) + required = parameter.default is inspect.Parameter.empty + value_kind, item_value_kind, enum_values = _classify_annotation(name, annotation) + return CliParameterSchema( + name=name, + source=source, + binding_expression=expression, + flag=_flag_for_name(name), + value_kind=value_kind, + required=required, + multiple=value_kind == "list", + item_value_kind=item_value_kind, + annotation=_annotation_label(annotation), + enum_values=enum_values, + ) + + +def _resolve_annotation(context: _CallableSchemaContext, parameter: inspect.Parameter) -> object: + """Вернуть resolved type annotation для parameter.""" + + annotation = context.type_hints.get(parameter.name) + if annotation is not None: + return annotation + if parameter.annotation is inspect.Parameter.empty: + return object + return parameter.annotation + + +def _classify_annotation( + name: str, + annotation: object, +) -> tuple[CliValueKind, CliValueKind | None, tuple[str, ...]]: + """Классифицировать type annotation в CLI value kind.""" + + normalized = _strip_optional(annotation) + origin = get_origin(normalized) + if origin in {list, tuple, Sequence}: + item_annotation = _first_type_arg(normalized) + item_kind, _nested_item_kind, enum_values = _classify_annotation(name, item_annotation) + return "list", item_kind, enum_values + if _is_union_origin(origin): + return _classify_union(name, normalized) + if isinstance(normalized, type) and issubclass(normalized, Enum): + return "enum", None, _enum_values(normalized) + if normalized is bool: + return "boolean", None, () + if normalized is int: + return "integer", None, () + if normalized is float: + return "float", None, () + if normalized is datetime: + return "datetime", None, () + if normalized is date: + return "date", None, () + if normalized is str: + return _string_kind_for_name(name), None, () + if normalized is object: + return "unknown", None, () + return "unknown", None, () + + +def _classify_union( + name: str, + annotation: object, +) -> tuple[CliValueKind, CliValueKind | None, tuple[str, ...]]: + """Классифицировать union annotation в CLI value kind.""" + + choices = tuple(argument for argument in get_args(annotation) if argument is not _NONE_TYPE) + enum_choices = tuple(choice for choice in choices if isinstance(choice, type) and issubclass(choice, Enum)) + if enum_choices: + enum_type = enum_choices[0] + return "enum", None, _enum_values(enum_type) + if datetime in choices: + return "datetime", None, () + if date in choices: + return _string_kind_for_name(name), None, () + if int in choices and _integer_name(name): + return "integer", None, () + if str in choices: + return _string_kind_for_name(name), None, () + if bool in choices: + return "boolean", None, () + if int in choices: + return "integer", None, () + if float in choices: + return "float", None, () + return "unknown", None, () + + +def _strip_optional(annotation: object) -> object: + """Убрать None из Optional annotation, если это единственный wrapper.""" + + origin = get_origin(annotation) + if not _is_union_origin(origin): + return annotation + choices = tuple(argument for argument in get_args(annotation) if argument is not _NONE_TYPE) + if len(choices) == 1: + return choices[0] + return annotation + + +def _is_union_origin(origin: object) -> bool: + """Проверить, является ли origin union type.""" + + return origin in {Union, types.UnionType} + + +def _first_type_arg(annotation: object) -> object: + """Вернуть первый generic type argument или str по умолчанию.""" + + arguments = get_args(annotation) + if not arguments: + return str + return arguments[0] + + +def _string_kind_for_name(name: str) -> CliValueKind: + """Уточнить string-like kind по имени параметра.""" + + if "date_time" in name or name.endswith("_at") or name.endswith("_time"): + return "datetime" + if "date" in name or name.endswith("_from") or name.endswith("_to"): + return "date" + return "string" + + +def _integer_name(name: str) -> bool: + """Проверить, выглядит ли имя параметра как integer id.""" + + return name == "id" or name.endswith("_id") or name.endswith("_ids") + + +def _enum_values(enum_type: type[Enum]) -> tuple[str, ...]: + """Вернуть допустимые строковые enum values.""" + + return tuple(str(item.value) for item in enum_type) + + +def _annotation_label(annotation: object) -> str: + """Вернуть стабильную подпись annotation для coverage report.""" + + if annotation is object: + return "object" + if isinstance(annotation, type): + return annotation.__name__ + return str(annotation) + + +def _flag_for_name(name: str) -> str: + """Преобразовать parameter name в CLI flag.""" + + normalized = name.replace("_", "-") + return f"--{normalized}" + + +def _split_list_values(values: Sequence[str]) -> tuple[str, ...]: + """Развернуть repeated и comma-separated list values.""" + + items: list[str] = [] + for value in values: + for item in value.split(","): + normalized = item.strip() + if normalized: + items.append(normalized) + return tuple(items) + + +def _coerce_scalar( + schema: CliParameterSchema, + value: str, + *, + value_kind: CliValueKind, +) -> object: + """Привести scalar CLI value к schema kind.""" + + normalized = value.strip() + if value_kind == "string" or value_kind == "unknown": + return value + if value_kind == "integer": + return _coerce_int(schema, normalized) + if value_kind == "float": + return _coerce_float(schema, normalized) + if value_kind == "boolean": + return _coerce_bool(schema, normalized) + if value_kind == "date": + return _coerce_date(schema, normalized) + if value_kind == "datetime": + return _coerce_datetime(schema, normalized) + if value_kind == "enum": + return _coerce_enum(schema, normalized) + if value_kind == "list": + raise CliValidationError( + f"Параметр {schema.flag} имеет вложенный список, который CLI не поддерживает.", + details={"parameter": schema.name}, + ) + raise CliValidationError( + f"Параметр {schema.flag} имеет неподдерживаемый тип.", + details={"parameter": schema.name, "value_kind": value_kind}, + ) + + +def _coerce_int(schema: CliParameterSchema, value: str) -> int: + """Привести CLI value к int.""" + + try: + return int(value, 10) + except ValueError as exc: + raise CliValidationError( + f"Параметр {schema.flag} должен быть целым числом.", + details={"parameter": schema.name, "value": value}, + ) from exc + + +def _coerce_float(schema: CliParameterSchema, value: str) -> float: + """Привести CLI value к float.""" + + try: + return float(value) + except ValueError as exc: + raise CliValidationError( + f"Параметр {schema.flag} должен быть числом.", + details={"parameter": schema.name, "value": value}, + ) from exc + + +def _coerce_bool(schema: CliParameterSchema, value: str) -> bool: + """Привести CLI value к bool.""" + + normalized = value.lower() + if normalized in _BOOLEAN_TRUE: + return True + if normalized in _BOOLEAN_FALSE: + return False + raise CliValidationError( + f"Параметр {schema.flag} должен быть boolean-значением.", + details={"parameter": schema.name, "value": value}, + ) + + +def _coerce_date(schema: CliParameterSchema, value: str) -> date: + """Привести CLI value к date.""" + + try: + return date.fromisoformat(value) + except ValueError as exc: + raise CliValidationError( + f"Параметр {schema.flag} должен быть датой в формате YYYY-MM-DD.", + details={"parameter": schema.name, "value": value}, + ) from exc + + +def _coerce_datetime(schema: CliParameterSchema, value: str) -> datetime: + """Привести CLI value к datetime.""" + + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as exc: + raise CliValidationError( + f"Параметр {schema.flag} должен быть датой-временем в ISO-формате.", + details={"parameter": schema.name, "value": value}, + ) from exc + + +def _coerce_enum(schema: CliParameterSchema, value: str) -> str: + """Проверить CLI value против enum values.""" + + normalized = value.lower() + for enum_value in schema.enum_values: + if value == enum_value or normalized == enum_value.lower(): + return enum_value + allowed = ", ".join(schema.enum_values) + raise CliValidationError( + f"Параметр {schema.flag} должен быть одним из значений: {allowed}.", + details={"parameter": schema.name, "value": value, "allowed": list(schema.enum_values)}, + ) + + +__all__ = ( + "CliParameterSchema", + "CliParameterSource", + "CliValueKind", + "CoercedParameter", + "build_parameter_schemas", + "coerce_cli_value", + "coerce_cli_values", +) diff --git a/avito/cli/serialization.py b/avito/cli/serialization.py new file mode 100644 index 0000000..34857fd --- /dev/null +++ b/avito/cli/serialization.py @@ -0,0 +1,338 @@ +"""Сериализация и рендеринг результатов CLI.""" + +from __future__ import annotations + +import json +from base64 import b64encode +from collections.abc import Mapping, Sequence +from dataclasses import Field, dataclass, is_dataclass +from datetime import date, datetime +from enum import Enum +from typing import Protocol, cast, runtime_checkable + +from avito.cli.context import CliContext +from avito.cli.ui import emit_stdout, sanitize_cli_output +from avito.core.pagination import PaginatedList + +DEFAULT_PAGE_LIMIT = 1 + + +@runtime_checkable +class _ModelDumpable(Protocol): + """Protocol for SDK models exposing model_dump.""" + + def model_dump(self) -> Mapping[str, object]: + """Вернуть публичное JSON-совместимое представление модели.""" + + +@runtime_checkable +class _DictSerializable(Protocol): + """Protocol for SDK models exposing to_dict.""" + + def to_dict(self) -> Mapping[str, object]: + """Вернуть публичное JSON-совместимое представление модели.""" + + +class _DataclassInstance(Protocol): + """Protocol marker for dataclass instances accepted by serializer.""" + + __dataclass_fields__: Mapping[str, Field[object]] + + +@dataclass(frozen=True, slots=True) +class SerializationOptions: + """Ограничения сериализации результата CLI.""" + + limit: int | None = None + page_limit: int = DEFAULT_PAGE_LIMIT + all_items: bool = False + + +def serialize_cli_result( + value: object, + *, + options: SerializationOptions | None = None, +) -> object: + """Сериализовать результат SDK или локальной CLI-команды без секретов.""" + + serialized = _serialize_value(value, options=options or SerializationOptions()) + return sanitize_cli_output(serialized) + + +def render_cli_result( + ctx: CliContext, + value: object, + *, + options: SerializationOptions | None = None, +) -> str: + """Подготовить результат CLI для stdout в выбранном режиме вывода.""" + + serialized = serialize_cli_result(value, options=options) + if ctx.json_output: + return json.dumps(serialized, ensure_ascii=False, sort_keys=True) + if ctx.plain: + return _render_plain(serialized) + if ctx.table or _is_collection_payload(serialized): + return _render_table(serialized, wide=ctx.wide) + return _render_grouped(serialized) + + +def emit_cli_result( + ctx: CliContext, + value: object, + *, + options: SerializationOptions | None = None, + essential: bool = True, +) -> None: + """Напечатать сериализованный результат CLI в stdout.""" + + emit_stdout(ctx, render_cli_result(ctx, value, options=options), essential=essential) + + +def _serialize_value(value: object, *, options: SerializationOptions) -> object: + """Сериализовать одно значение результата CLI.""" + + if isinstance(value, PaginatedList): + return _serialize_paginated_list(value, options=options) + if isinstance(value, _ModelDumpable): + return _serialize_value(value.model_dump(), options=options) + if isinstance(value, _DictSerializable): + return _serialize_value(value.to_dict(), options=options) + if isinstance(value, Enum): + return value.value + if isinstance(value, datetime | date): + return value.isoformat() + if isinstance(value, bytes | bytearray): + return b64encode(bytes(value)).decode("ascii") + if is_dataclass(value): + return _serialize_dataclass(cast(_DataclassInstance, value), options=options) + if isinstance(value, Mapping): + return { + str(key): _serialize_value(item, options=options) + for key, item in value.items() + } + if isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray): + return [_serialize_value(item, options=options) for item in value] + return value + + +def _serialize_dataclass( + value: _DataclassInstance, + *, + options: SerializationOptions, +) -> dict[str, object]: + """Сериализовать dataclass без private и raw payload fields.""" + + return { + field.name: _serialize_value(getattr(value, field.name), options=options) + for field in value.__dataclass_fields__.values() + if not field.name.startswith("_") and field.name != "raw_payload" + } + + +def _serialize_paginated_list( + value: PaginatedList[object], + *, + options: SerializationOptions, +) -> dict[str, object]: + """Сериализовать PaginatedList с bounded snapshot metadata.""" + + items = _paginated_snapshot(value, options=options) + visible_items = items + if options.limit is not None: + visible_items = items[: options.limit] + serialized_items = [_serialize_value(item, options=options) for item in visible_items] + return { + "items": serialized_items, + "pagination": { + "loaded_count": value.loaded_count, + "known_total": value.known_total, + "source_total": value.source_total, + "is_materialized": value.is_materialized, + "limit": options.limit, + "page_limit": None if options.all_items else max(1, options.page_limit), + "truncated": _pagination_is_truncated( + value, + loaded_items=len(items), + visible_items=len(visible_items), + ), + }, + } + + +def _paginated_snapshot( + value: PaginatedList[object], + *, + options: SerializationOptions, +) -> list[object]: + """Получить bounded snapshot из lazy PaginatedList.""" + + if options.all_items: + return value.materialize() + + page_limit = max(1, options.page_limit) + loaded_count = value.loaded_count + loaded_pages = 1 if loaded_count > 0 else 0 + if loaded_pages == 0: + _load_next_page(value) + loaded_count = value.loaded_count + loaded_pages = 1 + + while loaded_pages < page_limit and not value.is_materialized: + previous_count = loaded_count + _load_next_page(value) + loaded_count = value.loaded_count + if loaded_count == previous_count: + break + loaded_pages += 1 + + return list(list.__iter__(value)) + + +def _load_next_page(value: PaginatedList[object]) -> None: + """Загрузить следующую страницу PaginatedList, если она доступна.""" + + if value.is_materialized: + return + try: + _ = value[value.loaded_count] + except IndexError: + return + + +def _pagination_is_truncated( + value: PaginatedList[object], + *, + loaded_items: int, + visible_items: int, +) -> bool: + """Определить, был ли pagination output усечен.""" + + if visible_items < loaded_items: + return True + return not value.is_materialized + + +def _render_plain(value: object) -> str: + """Отрендерить значение в plain output mode.""" + + if isinstance(value, str): + return value + if isinstance(value, int | float | bool) or value is None: + return json.dumps(value, ensure_ascii=False) + return json.dumps(value, ensure_ascii=False, sort_keys=True) + + +def _render_grouped(value: object) -> str: + """Отрендерить mapping как grouped key-value output.""" + + if isinstance(value, Mapping): + rows = [ + f"{str(key)}: {_render_cell(item)}" + for key, item in sorted(value.items(), key=lambda pair: str(pair[0])) + ] + return "\n".join(rows) + return _render_plain(value) + + +def _render_table(value: object, *, wide: bool) -> str: + """Отрендерить collection payload как таблицу.""" + + rows = _table_rows(value) + if not rows: + return "" + columns = _table_columns(rows, wide=wide) + widths = { + column: max(len(column.upper()), *(len(_render_cell(row.get(column))) for row in rows)) + for column in columns + } + header = " ".join(column.upper().ljust(widths[column]) for column in columns) + body = [ + " ".join(_render_cell(row.get(column)).ljust(widths[column]) for column in columns).rstrip() + for row in rows + ] + return "\n".join((header, *body)) + + +def _table_rows(value: object) -> list[Mapping[str, object]]: + """Преобразовать payload в строки таблицы.""" + + if isinstance(value, Mapping) and isinstance(value.get("items"), Sequence): + return _sequence_table_rows(value["items"]) + if isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray): + return _sequence_table_rows(value) + if isinstance(value, Mapping): + return [value] + return [{"value": value}] + + +def _sequence_table_rows(value: object) -> list[Mapping[str, object]]: + """Преобразовать sequence payload в строки таблицы.""" + + if not isinstance(value, Sequence) or isinstance(value, str | bytes | bytearray): + return [] + rows: list[Mapping[str, object]] = [] + for item in value: + if isinstance(item, Mapping): + rows.append(item) + else: + rows.append({"value": item}) + return rows + + +def _table_columns(rows: Sequence[Mapping[str, object]], *, wide: bool) -> tuple[str, ...]: + """Выбрать columns для table output.""" + + columns: list[str] = [] + for row in rows: + for key in row: + text_key = str(key) + if text_key not in columns: + columns.append(text_key) + if wide: + return tuple(columns) + simple_columns = [ + column + for column in columns + if all(_is_simple_cell(row.get(column)) for row in rows) + ] + return tuple(simple_columns or columns) + + +def _is_simple_cell(value: object) -> bool: + """Проверить, безопасно ли значение показывать в narrow table.""" + + return ( + value is None + or isinstance(value, str | int | float | bool) + or isinstance(value, datetime | date) + ) + + +def _render_cell(value: object) -> str: + """Отрендерить одно значение table cell.""" + + if value is None: + return "" + if isinstance(value, str): + return value + if isinstance(value, int | float | bool): + return str(value) + return json.dumps(value, ensure_ascii=False, sort_keys=True) + + +def _is_collection_payload(value: object) -> bool: + """Проверить, выглядит ли payload как collection.""" + + if isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray): + return True + return isinstance(value, Mapping) and isinstance(value.get("items"), Sequence) + + +__all__ = ( + "DEFAULT_PAGE_LIMIT", + "SerializationOptions", + "emit_cli_result", + "render_cli_result", + "serialize_cli_result", +) diff --git a/avito/cli/ui.py b/avito/cli/ui.py new file mode 100644 index 0000000..1c6f437 --- /dev/null +++ b/avito/cli/ui.py @@ -0,0 +1,63 @@ +"""Безопасный вывод CLI в stdout и stderr.""" + +from __future__ import annotations + +import json +import os +from collections.abc import Mapping + +import click + +from avito.cli.context import CliContext +from avito.core.exceptions import sanitize_metadata + + +def sanitize_cli_output(value: object) -> object: + """Удаляет секреты из CLI-диагностики перед любым выводом.""" + + return sanitize_metadata(value) + + +def color_enabled(ctx: CliContext | None) -> bool: + """Возвращает, можно ли использовать ANSI-цвет для текущего запуска.""" + + if os.environ.get("NO_COLOR") == "1": + return False + return ctx is not None and not ctx.no_color + + +def emit_stdout(ctx: CliContext, message: str, *, essential: bool = True) -> None: + """Печатает результат команды в stdout с учетом quiet-режима.""" + + if ctx.quiet and not essential: + return + click.echo(message) + + +def emit_stderr( + ctx: CliContext | None, + message: str, + *, + fg: str | None = None, + essential: bool = True, +) -> None: + """Печатает диагностическое сообщение в stderr с учетом quiet-режима.""" + + if ctx is not None and ctx.quiet and not essential: + return + click.secho(message, err=True, fg=fg, color=color_enabled(ctx)) + + +def emit_json_stderr(payload: Mapping[str, object]) -> None: + """Печатает JSON-диагностику в stderr.""" + + click.echo(json.dumps(payload, ensure_ascii=False, sort_keys=True), err=True) + + +__all__ = ( + "color_enabled", + "emit_json_stderr", + "emit_stderr", + "emit_stdout", + "sanitize_cli_output", +) diff --git a/docs/site/assets/_gen_reference.py b/docs/site/assets/_gen_reference.py index a6cd342..52ce7ac 100644 --- a/docs/site/assets/_gen_reference.py +++ b/docs/site/assets/_gen_reference.py @@ -314,6 +314,7 @@ def write_summary(domain_pages: list[str]) -> None: file.write("* [Покрытие API](coverage.md)\n") file.write("* [Отчёт покрытия API](api-report.md)\n") file.write("* [AvitoClient](client.md)\n") + file.write("* [CLI](cli.md)\n") file.write("* [Конфигурация](config.md)\n") file.write("* [Операции API](operations.md)\n") file.write("* Домены\n") diff --git a/docs/site/explanations/.pages b/docs/site/explanations/.pages index d6df08d..396636c 100644 --- a/docs/site/explanations/.pages +++ b/docs/site/explanations/.pages @@ -1,5 +1,6 @@ nav: - index.md + - cli-architecture.md - architecture.md - auth-flow.md - transport-and-retries.md diff --git a/docs/site/explanations/api-coverage-and-deprecations.md b/docs/site/explanations/api-coverage-and-deprecations.md index 1ed9c8c..097bb78 100644 --- a/docs/site/explanations/api-coverage-and-deprecations.md +++ b/docs/site/explanations/api-coverage-and-deprecations.md @@ -31,6 +31,22 @@ Operation-level `deprecated: true` в Swagger означает, что публ Публичная поверхность проверяется contract-тестами и сборкой reference-документации. `make swagger-coverage` скачивает свежие Swagger files, запускает strict binding validation и полный contract suite. Deprecated-символы должны сохранять runtime warning, а не только пометку в документации. +## CLI coverage + +CLI coverage считается отдельно от SDK coverage, но строится из той же Swagger +binding discovery. Для первого CLI-релиза sync binding должен иметь ровно одну +canonical CLI-команду или documented intentional exclusion. Compatibility aliases +не считаются покрытием. + +Нормальные API-команды идут через `AvitoClient` factory и публичный доменный +метод. Четыре token-client bindings без публичной factory намеренно исключены: +CLI не вызывает token clients напрямую, а пользовательскую готовность credentials +покрывают `account`, `status` и `doctor`. + +`scripts/lint_cli_coverage.py --strict` проверяет one-to-one mapping, kebab-case +имена, alias policy, safety metadata, execution-smoke coverage и полноту +исключений. `make cli-lint` входит в `make check`. + Страница для пользователя: [покрытие API](../reference/coverage.md). Детальный отчёт: [отчёт покрытия API](../reference/api-report.md). Карта операций: [operations reference](../reference/operations.md). -Подробная механика discovery, strict lint, JSON report и `SwaggerFakeTransport` описана в [Swagger binding subsystem](swagger-binding-subsystem.md). +Подробная механика discovery, strict lint, JSON report и `SwaggerFakeTransport` описана в [Swagger binding subsystem](swagger-binding-subsystem.md). CLI registry и coverage linter описаны в [архитектуре CLI](cli-architecture.md). diff --git a/docs/site/explanations/cli-architecture.md b/docs/site/explanations/cli-architecture.md new file mode 100644 index 0000000..ca2b276 --- /dev/null +++ b/docs/site/explanations/cli-architecture.md @@ -0,0 +1,138 @@ +# Архитектура CLI + +CLI в `avito-py` сделан как продуктовая оболочка над публичным SDK, а не как +второй SDK. Его задача — принять shell-аргументы, выбрать локальный профиль, +вызвать публичный `AvitoClient` и безопасно напечатать результат. + +```mermaid +flowchart LR + shell[avito CLI] --> registry[CLI registry] + shell --> profile[Local profile] + profile --> settings[AvitoSettings] + settings --> client[AvitoClient] + registry --> invoke[Invocation engine] + client --> factory[Public factory] + factory --> method[Public domain method] + method --> model[SDK model] + model --> render[Human/JSON renderer] +``` + +## Тонкая оболочка над SDK + +API-команда не знает, как устроены HTTP-запросы, OAuth, retry, Swagger operation +specs, transport или mapper-слой. Для API-вызова она проходит один путь: + +1. Разобрать root-флаги и аргументы команды. +2. Найти профиль и собрать `AvitoSettings`. +3. Создать `AvitoClient(settings)` в context manager. +4. Вызвать публичную factory, например `account()` или `ad(...)`. +5. Вызвать публичный доменный метод. +6. Сериализовать результат через публичный контракт модели. + +Production-код в `avito/cli/` не импортирует transport implementations, +`OperationSpec`, `OperationExecutor`, auth provider internals, `tests` или fake +transport. Это проверяется architecture lint. + +## Registry и discovery + +Канонические API-команды строятся из Swagger binding discovery: + +- `factory` задаёт CLI resource; +- `method_name` задаёт CLI action; +- `factory_args` и `method_args` задают поддержанные флаги; +- публичные type hints используются только для проверки и приведения значений; +- `timeout` и `retry` не становятся флагами конкретного метода. + +Имя команды получается в lowercase kebab-case: + +```text +account.get-self -> avito account get-self +ad-stats.get-item-stats -> avito ad-stats get-item-stats +``` + +Registry содержит отдельные категории: + +- API-команды, связанные с одним sync Swagger binding; +- helper workflows, которые вызывают публичные non-Swagger методы + `AvitoClient`; +- локальные команды account/config/status/doctor/completion; +- aliases, которые делегируют canonical command и не считаются покрытием; +- исключения с причиной, owner и follow-up. + +Регистрация Click-команд детерминированная: Python source для команд не +генерируется, SDK-классы не патчатся, `setattr` и monkey-patching не +используются. + +## Проверка покрытия + +`scripts/lint_cli_coverage.py` проверяет, что CLI registry остаётся +согласованным с публичной SDK-поверхностью. + +Фазы используются для staged rollout: + +- `--phase registry` проверяет базовые инварианты registry; +- `--phase read` проверяет read-only coverage; +- `--phase write` и `--phase write-safety` проверяют write coverage и safety + metadata; +- `--strict` требует полного покрытия или документированного intentional + exclusion. + +Строгий инвариант: + +```text +каждый sync Swagger binding -> одна canonical CLI-команда или documented exclusion +каждая canonical API CLI-команда -> один sync Swagger binding +каждый поддержанный helper workflow -> команда или documented exclusion +``` + +`make cli-lint` запускает strict mode и входит в `make check`. + +## Исключения первого релиза + +Четыре token-client Swagger bindings не имеют публичной `AvitoClient` factory и +намеренно исключены из первого CLI-релиза. CLI не вызывает `TokenClient` и +`AlternateTokenClient` напрямую; пользовательская готовность credentials +покрывается локальными командами `account`, `status` и `doctor`. + +Часть API bindings также исключена намеренно, если generic flags не могут +безопасно построить обязательный public input model, file/stdin payload, +binary-result flow или отсутствующий идентификатор доменного объекта. Такие +случаи требуют typed CLI adapter или уточнения binding metadata перед +публикацией команды. + +## Политика безопасности + +HTTP method может дать начальную классификацию, но опубликованная команда +использует reviewed safety metadata из registry. + +- Read-команды не требуют подтверждения. +- Write-команды явно помечены как write/destructive/expensive. +- Destructive и expensive команды требуют prompt, `--yes` или точный + `--confirm`. +- `--dry-run` показывается только там, где публичный SDK-метод принимает + `dry_run` и может не выполнять transport-вызов. + +В non-interactive режиме команда, которой нужно подтверждение, завершается +ошибкой вместо prompt. + +## Секреты и redaction + +Один sanitizer применяется к human output, JSON output, ошибкам, debug-details, +diagnostics и coverage/debug reports. Редактируются поля и значения, похожие на +OAuth-секреты: `client_secret`, `api_key`, refresh/access token и authorization +headers. + +Локальное CLI-хранилище первой версии — plaintext JSON с файловыми правами +доступа. Это осознанное ограничение первого релиза; OS keychain не используется. +Публичные команды не печатают сырые секреты. + +## Пагинация + +`PaginatedList` ленивый в SDK. CLI не должен случайно материализовать весь +результат. Поэтому вывод пагинированных результатов ограничен по умолчанию: +первая страница или SDK/default page size. Полная материализация требует явного +пользовательского opt-in через поддержанные флаги команды. + +JSON-вывод пагинации включает `items` и metadata, когда она доступна. Progress и +warnings печатаются только в stderr, чтобы stdout оставался пригодным для +пайпов. diff --git a/docs/site/explanations/index.md b/docs/site/explanations/index.md index 3ea8f97..242ceb5 100644 --- a/docs/site/explanations/index.md +++ b/docs/site/explanations/index.md @@ -4,6 +4,7 @@ Explanations описывают причины архитектурных реш | Статья | Что объясняет | |---|---| +| [Архитектура CLI](cli-architecture.md) | Почему CLI является тонкой оболочкой над `AvitoClient`, как работают registry, coverage linter, exclusions, redaction и пагинация | | [Архитектура SDK](architecture.md) | Как `AvitoClient`, домены, `OperationSpec`, executor, transport, auth и модели разделяют ответственность | | [Целевая структура доменов](domain-architecture-v2.md) | Как API-домены используют dataclass-модели для сериализации, десериализации, нормализации и enum-ов | | [OAuth и токены](auth-flow.md) | Почему token-flow скрыт за `AuthProvider` | diff --git a/docs/site/explanations/security-and-redaction.md b/docs/site/explanations/security-and-redaction.md index 0742d37..1ccb1c6 100644 --- a/docs/site/explanations/security-and-redaction.md +++ b/docs/site/explanations/security-and-redaction.md @@ -12,6 +12,19 @@ SDK не является secret manager, но обязан не ухудшат Модели SDK могут содержать пользовательские данные: телефоны, email, тексты сообщений, адреса, цены, идентификаторы заказов. `to_dict()` и `model_dump()` сериализуют публичную модель, а не применяют бизнес-редакцию персональных данных. Если consumer-код пишет эти данные в логи, он должен применять собственную политику redaction. +## CLI + +CLI применяет тот же принцип к human output, JSON output, ошибкам, debug-details +и локальной диагностике: секретные поля маскируются перед печатью. Локальные +учётные записи первой версии хранятся в plaintext JSON-файле +`~/.avito-py/accounts.json`; каталог создаётся с правами `0700`, файл — с +правами `0600`, но OS keychain не используется. + +Для безопасного ввода секрета используйте скрытый prompt в `avito account add` +или `--client-secret-stdin`. Явные `--client-secret` и `--api-key` нужны только +для осознанной автоматизации, потому что shell может сохранить команду в +истории. + ## Ошибки Ошибки SDK сохраняют поля `operation`, `status`, `request_id`, `attempt`, `method`, `endpoint`. Эти поля достаточны для диагностики большинства интеграционных сбоев и не требуют раскрывать raw request body или OAuth headers. @@ -20,4 +33,4 @@ SDK не является secret manager, но обязан не ухудшат Логируйте typed exception metadata и `debug_info()`. Не логируйте raw payload, binary content и полные `to_dict()` пользовательских моделей без фильтрации на стороне приложения. -Поля `debug_info()` описаны в [reference по клиенту](../reference/client.md), а metadata ошибок — в [reference по исключениям](../reference/exceptions.md). +Поля `debug_info()` описаны в [reference по клиенту](../reference/client.md), metadata ошибок — в [reference по исключениям](../reference/exceptions.md), а CLI-контракт redaction — в [CLI reference](../reference/cli.md). diff --git a/docs/site/how-to/.pages b/docs/site/how-to/.pages index 537691a..2013c62 100644 --- a/docs/site/how-to/.pages +++ b/docs/site/how-to/.pages @@ -1,5 +1,6 @@ nav: - index.md + - cli.md - auth-and-config.md - async.md - account-profile.md diff --git a/docs/site/how-to/auth-and-config.md b/docs/site/how-to/auth-and-config.md index 9f54519..6a9c64a 100644 --- a/docs/site/how-to/auth-and-config.md +++ b/docs/site/how-to/auth-and-config.md @@ -92,4 +92,9 @@ print(info.user_id) Управление retry: `AVITO_RETRY_MAX_ATTEMPTS` (3), `AVITO_RETRY_BACKOFF_FACTOR` (0.5), `AVITO_RETRY_MAX_DELAY` (30 с), `AVITO_RETRY_RETRY_ON_RATE_LIMIT` (true), `AVITO_RETRY_RETRY_ON_SERVER_ERROR` (true). +CLI использует отдельное локальное хранилище профилей под `~/.avito-py/` и +превращает выбранный профиль в публичный `AvitoSettings` без сетевого вызова. +Практический сценарий настройки описан в [CLI how-to](cli.md), а стабильный +контракт файлов и precedence — в [CLI reference](../reference/cli.md). + Полная таблица с типами и дефолтами — в [справочнике по конфигурации](../reference/config.md). diff --git a/docs/site/how-to/cli.md b/docs/site/how-to/cli.md new file mode 100644 index 0000000..5e3c534 --- /dev/null +++ b/docs/site/how-to/cli.md @@ -0,0 +1,174 @@ +# CLI + +Этот рецепт показывает ежедневную работу через команду `avito`: настройку +локального профиля, первый API-вызов, JSON-вывод для автоматизации, +диагностику и shell completion. + +CLI является тонкой оболочкой над SDK. API-команды создают `AvitoClient`, +вызывают публичную factory и публичный доменный метод, а затем сериализуют +публичные SDK-модели. + +## Установка и проверка + +```bash +pip install avito-py +avito --version +python -m avito --help +``` + +`avito` и `python -m avito` используют одно и то же CLI-приложение. + +## Добавить профиль + +Интерактивный ввод не кладёт секрет в историю shell: + +```bash +avito account add main --client-id client-id --user-id 123 +``` + +CLI спросит `Client Secret` скрытым prompt. Для CI и shell-скриптов используйте +stdin: + +```bash +printf '%s\n' 'client-secret' | avito --no-input account add main \ + --client-id client-id \ + --client-secret-stdin \ + --user-id 123 +``` + +Явные `--client-secret` и совместимый alias `--api-key` тоже поддержаны, но их +значения могут попасть в историю shell. Используйте их только там, где это +осознанно контролируется. + +По умолчанию локальные файлы лежат в `~/.avito-py/`: + +```text +~/.avito-py/ + config.json + accounts.json +``` + +Это plaintext JSON-хранилище. Каталог создаётся с правами `0700`, файлы +записываются атомарно с правами `0600`. Первая CLI-версия не использует OS +keychain, поэтому защищайте домашний каталог и не добавляйте эти файлы в +репозиторий. + +Каталог можно переопределить: + +```bash +AVITO_PY_HOME=.avito-local avito account list +``` + +`AVITO_PY_HOME` имеет приоритет над совместимой переменной `MY_SDK_HOME`. + +## Управлять профилями + +```bash +avito account list +avito account use main +avito account current +avito account delete old-profile --confirm old-profile +``` + +`avito account remove` является совместимым alias для `account delete` и не +считается отдельной канонической командой. + +Активный профиль можно задать явно на один вызов: + +```bash +avito --profile main account current +``` + +Или сохранить в локальной конфигурации: + +```bash +avito config set active-profile main +avito config get active-profile --show-source +avito config list --show-source +``` + +Приоритет профиля: root-флаг `--profile`, затем `config.json`, затем пустое +значение. + +## Первый API-вызов + +```bash +avito --profile main account get-self +avito --profile main account get-balance --user-id 123 +``` + +API-команды используют только публичный `AvitoClient`. CLI не обращается к +transport, operation specs или auth internals напрямую. + +Справка строится из той же registry metadata, что и команды: + +```bash +avito help account +avito help account get-balance +``` + +## JSON для автоматизации + +Для скриптов используйте `--json --no-input`, чтобы stdout содержал только +машиночитаемый результат, а ошибки уходили в stderr: + +```bash +avito --json --no-input --profile main account get-self +avito --json --no-input config list --show-source +avito --json --no-input status +``` + +`--plain`, `--table`, `--wide` и `--json` взаимоисключающие. Если указать больше +одного режима вывода, CLI завершится с кодом `2`. + +## Вспомогательные workflows + +Публичные helper-команды не входят в Swagger one-to-one coverage, но вызывают +только публичные методы `AvitoClient`: + +```bash +avito --profile main account-health show --user-id 123 +avito --profile main listing-health show --user-id 123 --limit 20 +avito --profile main chat-summary show --user-id 123 +avito --profile main order-summary show +avito --profile main review-summary show +avito --profile main promotion-summary show --item-ids 456 +avito capabilities show +``` + +Для автоматизации добавьте `--json --no-input`. + +## Диагностика + +`status` проверяет локальную готовность профиля без сетевого вызова: + +```bash +avito status +avito --json status +``` + +`doctor` проверяет локальные JSON-файлы и права доступа: + +```bash +avito doctor +``` + +Если найдены проблемы, команда печатает диагностический отчёт и завершает работу +с ошибкой конфигурации. Секреты в диагностике маскируются. + +## Completion + +```bash +avito completion bash +avito completion zsh +avito completion fish +``` + +Команды печатают инструкцию для выбранного shell. Добавьте её в профиль shell, +если completion нужен постоянно. + +## Где точный контракт + +Полный стабильный контракт CLI: [CLI reference](../reference/cli.md). Архитектура +registry, coverage linter, исключения и политика пагинации описаны в +[CLI architecture](../explanations/cli-architecture.md). diff --git a/docs/site/how-to/index.md b/docs/site/how-to/index.md index 5fbe0ab..4b4c4ad 100644 --- a/docs/site/how-to/index.md +++ b/docs/site/how-to/index.md @@ -4,6 +4,7 @@ How-to раздел собирает рецепты для конкретных | Рецепт | Задача | |---|---| +| [CLI](cli.md) | Настроить локальный профиль, выполнить API-команду, включить JSON-вывод, status, doctor и completion | | [Авторизация и конфигурация](auth-and-config.md) | Создать клиент через env, явные ключи или `AvitoSettings` | | [Асинхронный режим](async.md) | Использовать `AsyncAvitoClient`, ASGI lifespan и async fake transport | | [Профиль, баланс и иерархия аккаунта](account-profile.md) | Получить профиль, баланс, историю операций и данные сотрудников | diff --git a/docs/site/index.md b/docs/site/index.md index 5dc1b75..7b218b5 100644 --- a/docs/site/index.md +++ b/docs/site/index.md @@ -43,6 +43,14 @@ pip install avito-py [:octicons-arrow-right-24: How-to рецепты](how-to/index.md) +- :material-console:{ .lg .middle } **Хочу работать из терминала** + + --- + + `avito` CLI: профили, JSON для автоматизации, status, doctor, completion и API-команды через публичный SDK. + + [:octicons-arrow-right-24: CLI how-to](how-to/cli.md) + - :material-sync:{ .lg .middle } **Асинхронный режим** --- @@ -79,4 +87,4 @@ pip install avito-py | **Цель** | Обучение через действие | Решить конкретную задачу | Точная информация | Понять «почему» | | **Раздел** | [Tutorials](tutorials/index.md) | [How-to](how-to/index.md) | [Reference](reference/index.md) | [Explanations](explanations/index.md) | -Для async-кода начните с рецепта [Асинхронный режим](how-to/async.md), а точный контракт смотрите в [AvitoClient и AsyncAvitoClient](reference/client.md). +Для терминала начните с рецепта [CLI](how-to/cli.md). Для async-кода начните с рецепта [Асинхронный режим](how-to/async.md), а точный контракт смотрите в [AvitoClient и AsyncAvitoClient](reference/client.md). diff --git a/docs/site/reference/.pages b/docs/site/reference/.pages index 29edad8..69a76d4 100644 --- a/docs/site/reference/.pages +++ b/docs/site/reference/.pages @@ -3,6 +3,7 @@ nav: - coverage.md - api-report.md - client.md + - cli.md - config.md - operations.md - domains diff --git a/docs/site/reference/cli.md b/docs/site/reference/cli.md new file mode 100644 index 0000000..af41403 --- /dev/null +++ b/docs/site/reference/cli.md @@ -0,0 +1,359 @@ +# CLI + +`avito` — командная строка `avito-py`. Она использует тот же публичный SDK: +API-команды создают `AvitoClient`, вызывают публичную factory и публичный +доменный метод, а затем печатают результат через общий renderer. + +`python -m avito` запускает то же CLI-приложение. + +## Грамматика команд + +```text +avito [global flags] [arguments] [flags] +``` + +Глобальные флаги гарантированно поддержаны перед resource/action: + +```bash +avito --profile main account get-self +avito --json --no-input --profile main account get-balance --user-id 123 +``` + +Флаги после вложенной команды являются флагами этой команды. Перенос root-флагов +в конец команды не является контрактом первого релиза. + +Имена resources, actions и flags используют lowercase kebab-case. `resource-id` +запрещён: команды используют конкретные имена вроде `item-id`, `order-id`, +`chat-id`, `user-id`. + +## Входные точки и справка + +| Команда | Контракт | +|---|---| +| `avito --help` | Печатает root help без чтения account files и без сетевых вызовов. | +| `avito help` | Печатает тот же root help. | +| `avito help ` | Печатает registry-backed справку по resource. | +| `avito help ` | Печатает справку по команде, включая registry metadata и флаги. | +| `avito --version` | Печатает версию пакета. | +| `avito version` | Печатает версию; с `--json` выводит объект `version`. | +| `python -m avito --help` | Использует ту же root-команду, что и `avito --help`. | + +Registry-backed справка не создаёт `AvitoClient`, не читает локальные account +files и не делает HTTP-запросов. + +## Глобальные флаги + +| Флаг | Поведение | +|---|---| +| `--profile NAME` | Выбирает локальный профиль для API/helper-команд. Имеет приоритет над config. | +| `--config PATH` | Использует альтернативный `config.json` для локальной конфигурации. | +| `--json` | Печатает результат в stable JSON на stdout и JSON-ошибки на stderr. | +| `--plain` | Выбирает plain human output. | +| `--table` | Выбирает табличный human output, где команда его поддерживает. | +| `--wide` | Выбирает расширенный табличный output, где команда его поддерживает. | +| `--quiet` | Скрывает необязательный успешный output; ошибки остаются на stderr. | +| `--no-input` | Запрещает prompt. Если команде нужен ввод, она завершается ошибкой. | +| `--no-color` | Отключает цветной вывод. | +| `--verbose` | Включает дополнительные пользовательские детали без секретов. | +| `--debug` | Включает sanitized debug details в ошибках. | +| `--timeout SECONDS` | Передаётся в SDK-вызов, если публичный метод принимает `timeout`. | + +`--json`, `--plain`, `--table` и `--wide` взаимоисключающие. Комбинация двух или +более таких флагов завершается с exit code `2`. + +`NO_COLOR=1` также отключает цветной output. + +## Режимы вывода + +Результаты команд печатаются в stdout. Ошибки, предупреждения, progress и +debug-диагностика печатаются в stderr. + +Human output: + +- одиночные объекты печатаются как сгруппированные key/value строки; +- коллекции печатаются как таблицы, если есть стабильные колонки; +- write-команды печатают короткий результат действия; +- `--quiet` скрывает необязательный успешный текст. + +JSON output: + +- stdout содержит только JSON-результат; +- warnings, progress и debug details не попадают в stdout; +- JSON errors печатаются в stderr; +- SDK-модели сериализуются только через публичный `model_dump()` / `to_dict()`. + +Пример: + +```bash +avito --json --no-input --profile main account get-self +``` + +## Коды завершения + +| Code | Stable error code | Значение | +|---:|---|---| +| `0` | — | Успешное выполнение. | +| `1` | `SDK_METHOD_FAILED`, `CLI_INTERNAL_ERROR` | Общая ошибка или неожиданная внутренняя ошибка. | +| `2` | `CLI_USAGE_ERROR`, `INVALID_FLAG_COMBINATION`, `VALIDATION_FAILED` | Неверный синтаксис, несовместимые флаги или ошибка валидации CLI-аргумента. | +| `3` | `ACCOUNT_NOT_FOUND`, `COMMAND_UNSUPPORTED` | Объект или команда не найдены. | +| `4` | `PERMISSION_DENIED` | Недостаточно прав на локальный файл или upstream запретил действие. | +| `5` | `AUTH_REQUIRED`, `CONFIG_INVALID`, `CLI_CONFIGURATION_ERROR` | Нужна авторизация или локальная конфигурация некорректна. | +| `6` | `CONFLICT`, `ACCOUNT_EXISTS` | Конфликт состояния, например дублирующийся профиль. | +| `7` | `CLI_UPSTREAM_ERROR` | Upstream API вернул ошибку, не попавшую в более точный класс. | +| `8` | `EXTERNAL_DEPENDENCY_UNAVAILABLE`, `CLI_TRANSPORT_ERROR` | Недоступна внешняя зависимость или transport. | +| `70` | `CLI_INTERNAL_ERROR` | Зарезервировано для неожиданных внутренних сбоев. | + +Форма human error: + +```text +INVALID_FLAG_COMBINATION: Флаги --json, --plain, --table и --wide нельзя использовать вместе. +``` + +Форма JSON error: + +```json +{ + "code": "INVALID_FLAG_COMBINATION", + "exit_code": 2, + "message": "Флаги --json, --plain, --table и --wide нельзя использовать вместе." +} +``` + +`--debug` может добавить sanitized `details`, но не печатает сырые секреты. + +## Локальные файлы и переменные окружения + +CLI home выбирается так: + +1. `AVITO_PY_HOME` +2. `MY_SDK_HOME` +3. `~/.avito-py` + +Файлы: + +```text +~/.avito-py/ + config.json + accounts.json +``` + +Требования к файловой системе: + +- каталог создаётся лениво с правами `0700`; +- `config.json` и `accounts.json` записываются атомарно через временный файл и + `os.replace`; +- файлы создаются с правами `0600`, где это поддерживает платформа; +- импорт CLI-модулей не создаёт файлы и каталоги. + +`config.json` хранит активный профиль. `accounts.json` хранит локальные профили +и OAuth settings. Активность не дублируется флагом внутри account record. + +Первая версия хранит secrets в plaintext JSON. Это не secret manager и не OS +keychain. + +## Команды учетных записей + +| Команда | Контракт | +|---|---| +| `avito account add ACCOUNT-NAME --client-id CLIENT-ID` | Добавляет локальный профиль без сетевого вызова. | +| `avito account list` | Показывает сохранённые профили и активный профиль. | +| `avito account use ACCOUNT-NAME` | Сохраняет активный профиль в config. | +| `avito account current` | Показывает активный профиль и замаскированные поля учетной записи. | +| `avito account delete ACCOUNT-NAME` | Удаляет профиль после подтверждения. | +| `avito account remove ACCOUNT-NAME` | Alias для `account delete`; не canonical command. | + +Флаги `account add`: + +| Флаг | Поведение | +|---|---| +| `--client-id CLIENT-ID` | Обязательный OAuth client id. | +| `--client-secret CLIENT-SECRET` | Явный secret; может попасть в историю shell. | +| `--api-key API-KEY` | Совместимый alias для `--client-secret`. | +| `--client-secret-stdin` | Читает secret одной строкой из stdin. | +| `--endpoint URL` | Alias для base URL Avito API. | +| `--user-id USER-ID` | Пользователь по умолчанию для SDK settings. | +| `--scope SCOPE` | OAuth scope. | + +Если secret не передан и input разрешён, `account add` использует hidden prompt. +В `--no-input` режиме отсутствие secret даёт ошибку `AUTH_REQUIRED`. + +`--client-secret`, `--api-key` и `--client-secret-stdin` взаимоисключающие. + +Примеры: + +```bash +avito account add main --client-id client-id --user-id 123 +printf '%s\n' 'client-secret' | avito --no-input account add main \ + --client-id client-id \ + --client-secret-stdin +avito account use main +avito account delete old --confirm old +``` + +## Команды конфигурации + +Поддержанный ключ первого релиза: `active-profile`. + +```bash +avito config set active-profile main +avito config get active-profile +avito config get active-profile --show-source +avito config list --show-source +avito config unset active-profile +``` + +Source values: + +- `cli` — значение пришло из root-флага `--profile`; +- `config` — значение прочитано из `config.json`; +- `default` — значение не задано. + +С `--json` команды возвращают объект `config`. + +## Status, doctor и completion + +`status` проверяет локальную готовность профиля и account store без сетевых +вызовов: + +```bash +avito status +avito --json status +``` + +В JSON поле `network_checked` равно `false`. + +`doctor` проверяет локальные JSON-файлы и права доступа: + +```bash +avito doctor +``` + +Если найдены проблемы, команда печатает отчёт и завершается ошибкой +конфигурации. + +Completion: + +```bash +avito completion bash +avito completion zsh +avito completion fish +``` + +## API-команды + +API-команды registry-backed и вызывают только публичный SDK: + +```bash +avito --profile main account get-self +avito --profile main account get-balance --user-id 123 +avito --profile main ad get --user-id 123 --item-id 456 +``` + +Флаги API-команды выбираются из Swagger binding metadata: + +- `factory_args`; +- `method_args`. + +Публичная Python signature используется для проверки и приведения этих выбранных +аргументов. CLI не публикует все параметры метода автоматически. Per-operation +`timeout` управляется только root-флагом `--timeout`; `retry` не является CLI +флагом первого релиза. + +Поддержанное приведение значений: + +- `str`, `int`, `float`, `bool`; +- `date` и `datetime`; +- enum по имени или значению; +- optional values; +- repeated flags и comma-separated lists; +- public input models только через typed CLI adapter, когда он явно добавлен. + +## Вспомогательные workflows + +Helper-команды не входят в Swagger one-to-one coverage. Они вызывают публичные +non-Swagger методы `AvitoClient`. + +```bash +avito account-health show --user-id 123 +avito listing-health show --user-id 123 --limit 20 +avito chat-summary show --user-id 123 +avito order-summary show +avito review-summary show +avito promotion-summary show --item-ids 456 +avito capabilities show +``` + +`business_summary` является compatibility wrapper для `account_health` и не +получает отдельную canonical CLI-команду в первом релизе. + +## Флаги безопасности + +Write/destructive/expensive команды используют reviewed safety metadata из +registry. HTTP method может дать исходную классификацию, но не является +единственным источником политики. + +| Флаг | Поведение | +|---|---| +| `--yes` | Выполнить destructive/expensive команду без prompt. | +| `--confirm VALUE` | Выполнить команду только при точном подтверждении. | +| `--dry-run` | Показать план без transport-вызова, только если SDK-метод поддерживает `dry_run`. | + +`--yes` и `--confirm` взаимоисключающие. В `--no-input` режиме команда, которой +нужно подтверждение, завершается ошибкой вместо prompt. + +CLI не имитирует dry-run для SDK-методов, которые всё равно сделали бы сетевой +вызов. + +## Пагинация + +SDK `PaginatedList[T]` ленивый. CLI ограничивает paginated output по умолчанию: +первая страница или SDK/default page size. Полная материализация требует явного +opt-in командой, которая документирует соответствующий флаг. + +JSON-форма пагинации содержит `items` и metadata, когда она доступна. Progress и +warnings печатаются в stderr. + +## Маскирование секретов + +Один sanitizer применяется к: + +- human output; +- JSON output; +- errors; +- `--verbose` и `--debug`; +- `status` и `doctor`; +- coverage/debug reports. + +Редактируются вложенные поля, списки и exception metadata с ключами или +значениями, похожими на OAuth secrets: `client_secret`, `api_key`, +`refresh_token`, `access_token`, authorization headers и token-like values. + +## Политика покрытия + +CLI coverage строится из `discover_swagger_bindings()` и проверяется +`scripts/lint_cli_coverage.py`. + +Инварианты: + +```text +каждый sync Swagger binding -> одна canonical CLI-команда или documented exclusion +каждая canonical API CLI-команда -> один sync Swagger binding +каждый поддержанный helper workflow -> команда или documented exclusion +``` + +Aliases не считаются canonical coverage. + +Intentional exclusions первого релиза: + +- четыре token-client bindings без публичной `AvitoClient` factory; +- bindings, которым нужен typed CLI adapter, file/stdin/binary handling, + complex public input model или уточнение factory/method metadata. + +Strict gate: + +```bash +poetry run python scripts/lint_cli_coverage.py --strict +make cli-lint +``` + +`make cli-lint` входит в `make check`. diff --git a/docs/site/reference/index.md b/docs/site/reference/index.md index a03dfb8..534bb01 100644 --- a/docs/site/reference/index.md +++ b/docs/site/reference/index.md @@ -6,6 +6,7 @@ | Страница | Что искать | |---|---| | [AvitoClient и AsyncAvitoClient](client.md) | Sync/async инициализация, контекстные менеджеры, фабричные методы, `debug_info()` | +| [CLI](cli.md) | Команда `avito`, глобальные флаги, output modes, exit codes, локальные файлы, safety-флаги и coverage policy | | [Асинхронный режим](../how-to/async.md) | Практический lifecycle `AsyncAvitoClient`, ASGI и async fake transport | | [Конфигурация](config.md) | `AvitoSettings`, `AuthSettings`, env-переменные, per-operation overrides | | [Покрытие API](coverage.md) | 204/204 Swagger operations из binding report | diff --git a/docs/site/tutorials/getting-started.md b/docs/site/tutorials/getting-started.md index 04aa8c3..f838599 100644 --- a/docs/site/tutorials/getting-started.md +++ b/docs/site/tutorials/getting-started.md @@ -62,11 +62,31 @@ python main.py Вы увидите имя и email вашего аккаунта Avito. +## Альтернатива: первый вызов из CLI + +Если вам нужен терминальный режим, сохраните локальный профиль и выполните тот +же read-only запрос без Python-скрипта: + +```bash +avito account add main --client-id client-id --user-id 123 +avito --profile main account get-self +``` + +При добавлении профиля CLI спросит `Client Secret` скрытым prompt. Для +автоматизации используйте `--json --no-input`: + +```bash +avito --json --no-input --profile main account get-self +``` + +Полный практический рецепт: [CLI](../how-to/cli.md). + --- ## Что дальше - [Авторизация и конфигурация](../how-to/auth-and-config.md) — все способы создания клиента, env-переменные, `AvitoSettings`. +- [CLI](../how-to/cli.md) — локальные профили, JSON-вывод, status, doctor и completion. - [Работа с объявлениями](../how-to/index.md) — получение, фильтрация, статистика. - [Reference: AvitoClient](../reference/client.md) — полный список фабричных методов. diff --git a/poetry.lock b/poetry.lock index 1560c03..4920338 100644 --- a/poetry.lock +++ b/poetry.lock @@ -290,7 +290,7 @@ version = "8.3.3" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.10" -groups = ["dev", "docs"] +groups = ["main", "dev", "docs"] files = [ {file = "click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613"}, {file = "click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2"}, @@ -305,12 +305,12 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["dev", "docs"] +groups = ["main", "dev", "docs"] files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -markers = {dev = "sys_platform == \"win32\" or platform_system == \"Windows\""} +markers = {main = "platform_system == \"Windows\"", dev = "platform_system == \"Windows\" or sys_platform == \"win32\""} [[package]] name = "coverage" @@ -1948,4 +1948,4 @@ bracex = ">=2.1.1" [metadata] lock-version = "2.1" python-versions = ">=3.12,<4.0" -content-hash = "2700535ead0e54ad8fd11c01b4c2b13c6a2afdcf9936f662984fdfaae89d9ad3" +content-hash = "ce6a9cabe3043f96be78045862cfb6d43bd53308b65e26eb6073d014f0e0afe4" diff --git a/pyproject.toml b/pyproject.toml index abe128a..ce45f17 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,10 @@ classifiers=[ [tool.poetry.dependencies] python = ">=3.12,<4.0" httpx = "^0.28.1" +click = "^8.3.3" + +[tool.poetry.scripts] +avito = "avito.cli.app:main" [tool.poetry.group.dev.dependencies] pytest = ">=9.0.3,<10.0.0" @@ -54,6 +58,7 @@ pydocstyle = { version = ">=6.3", extras = ["toml"] } [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["scripts"] +addopts = ["--import-mode=importlib"] asyncio_mode = "strict" asyncio_default_fixture_loop_scope = "function" markers = [ diff --git a/scripts/lint_architecture.py b/scripts/lint_architecture.py index 06b95ab..5218ca8 100644 --- a/scripts/lint_architecture.py +++ b/scripts/lint_architecture.py @@ -69,6 +69,24 @@ DATE_SAFE_ANNOTATION_NAMES = frozenset({"DateInput", "date", "datetime"}) FORBIDDEN_OFFICIAL_ENV_ALIASES = frozenset({"SECRET", "TOKEN", "AVITO_SECRET", "AVITO_TOKEN"}) REQUIRED_AVITO_ERROR_FIELDS = frozenset({"attempt", "method", "endpoint", "request_id"}) +FORBIDDEN_CLI_EXACT_IMPORTS = frozenset( + { + "avito.auth.provider", + "avito.core.operations", + "avito.core.transport", + "avito.testing", + "tests", + } +) +FORBIDDEN_CLI_IMPORT_PREFIXES = frozenset( + { + "avito.auth.provider.", + "avito.core.operations.", + "avito.core.transport.", + "avito.testing.", + "tests.", + } +) @dataclass(frozen=True, slots=True) @@ -202,6 +220,7 @@ def lint_architecture( errors.extend(_lint_runtime_patching(normalized_root)) errors.extend(_lint_official_env_aliases(normalized_root)) errors.extend(_lint_public_exception_fields(normalized_root)) + errors.extend(_lint_cli_import_boundaries(normalized_root)) errors.extend(_lint_public_domain_methods(normalized_root, allowlist)) errors.extend(_lint_operation_models(normalized_root, allowlist)) return tuple(sorted(errors, key=lambda error: (error.path, error.line, error.code))) @@ -380,6 +399,30 @@ def _lint_public_exception_fields(root: Path) -> tuple[ArchitectureLintError, .. ) +def _lint_cli_import_boundaries(root: Path) -> tuple[ArchitectureLintError, ...]: + errors: list[ArchitectureLintError] = [] + cli_path = root / "avito" / "cli" + if not cli_path.exists(): + return () + for path in sorted(cli_path.rglob("*.py")): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for module, line in _imported_modules(tree): + if not _is_forbidden_cli_import(module): + continue + errors.append( + ArchitectureLintError( + code="ARCH_CLI_FORBIDDEN_IMPORT", + message=( + "Production CLI code не должен импортировать internal SDK layer " + f"или test helper `{module}`." + ), + path=_relative_path(path, root), + line=line, + ) + ) + return tuple(errors) + + def _lint_public_domain_methods( root: Path, allowlisted_domains: frozenset[str], @@ -684,6 +727,30 @@ def _string_constants(node: ast.AST | None) -> Iterable[ast.Constant]: ) +def _imported_modules(tree: ast.Module) -> tuple[tuple[str, int], ...]: + modules: list[tuple[str, int]] = [] + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.module is not None: + modules.append((node.module, node.lineno)) + elif isinstance(node, ast.Import): + modules.extend((alias.name, node.lineno) for alias in node.names) + return tuple(modules) + + +def _is_forbidden_cli_import(module: str) -> bool: + if module in FORBIDDEN_CLI_EXACT_IMPORTS: + return True + if any(module.startswith(prefix) for prefix in FORBIDDEN_CLI_IMPORT_PREFIXES): + return True + parts = module.split(".") + return ( + len(parts) >= 3 + and parts[0] == "avito" + and parts[1] in API_DOMAINS + and parts[2] == "operations" + ) + + def _call_name(node: ast.AST) -> str: if isinstance(node, ast.Name): return node.id diff --git a/scripts/lint_cli_coverage.py b/scripts/lint_cli_coverage.py new file mode 100644 index 0000000..47f5183 --- /dev/null +++ b/scripts/lint_cli_coverage.py @@ -0,0 +1,884 @@ +"""Static CLI registry coverage checks.""" + +from __future__ import annotations + +import argparse +import re +from collections import Counter +from collections.abc import Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import Literal, cast + +import click + +from avito.cli.adapters import CommandAdapterRegistry, get_command_adapter_registry +from avito.cli.app import app +from avito.cli.registry import ( + ApiCommandRecord, + CliRegistry, + ExclusionRecord, + HelperCommandRecord, + LocalCommandRecord, + build_cli_registry, +) +from avito.core.swagger_discovery import discover_swagger_bindings +from avito.core.swagger_registry import load_swagger_registry + +Phase = Literal["registry", "read", "write-safety", "write", "strict"] +CommandRecord = ApiCommandRecord | HelperCommandRecord | LocalCommandRecord + +_KEBAB_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +_CONTROL_METHOD_FLAGS = frozenset({"--timeout", "--retry"}) +_CONTROL_METHOD_NAMES = frozenset({"timeout", "retry"}) +_DEPRECATION_POLICY_MARKERS = frozenset({"устаревшая", "совместимая", "исключ"}) +_WRITE_WAVES: dict[str, frozenset[str]] = { + "wave-1": frozenset( + { + "rating_profile", + "review", + "review_answer", + "realty_analytics_report", + "realty_booking", + "realty_listing", + "realty_pricing", + "tariff", + "account", + "account_hierarchy", + } + ), + "wave-2": frozenset( + { + "ad", + "ad_promotion", + "ad_stats", + "cpa_archive", + "cpa_auction", + "cpa_call", + "cpa_chat", + "cpa_lead", + "chat", + "chat_media", + "chat_message", + "chat_webhook", + "special_offer_campaign", + } + ), + "wave-3": frozenset( + { + "application", + "resume", + "vacancy", + "job_dictionary", + "job_webhook", + "autoload_archive", + "autoload_profile", + "autoload_report", + } + ), + "wave-4": frozenset( + { + "order", + "order_label", + "delivery_order", + "delivery_task", + "sandbox_delivery", + "stock", + "promotion_order", + "autostrategy_campaign", + "bbip_promotion", + "trx_promotion", + "target_action_pricing", + } + ), + "wave-5": frozenset( + { + "autoteka_vehicle", + "autoteka_report", + "autoteka_monitoring", + "autoteka_scoring", + "autoteka_valuation", + } + ), +} + + +@dataclass(frozen=True, slots=True) +class CliCoverageLintError: + """Single CLI coverage lint violation.""" + + code: str + message: str + item: str + + def render(self) -> str: + """Render one text report line.""" + + return f"{self.item}: [{self.code}] {self.message}" + + +def main(argv: Sequence[str] | None = None) -> int: + """Run CLI coverage lint.""" + + parser = argparse.ArgumentParser(description="Проверить coverage registry CLI.") + parser.add_argument( + "--phase", + choices=("registry", "read", "write-safety", "write"), + default="registry", + help="Фаза проверки CLI coverage.", + ) + parser.add_argument( + "--strict", + action="store_true", + help="Включить строгую проверку полного CLI coverage.", + ) + parser.add_argument( + "--domain", + action="append", + default=None, + help="Factory/domain для ограничения write-проверки; можно передать несколько раз.", + ) + parser.add_argument( + "--root", + type=Path, + default=Path("."), + help="Корень репозитория.", + ) + args = parser.parse_args(argv) + + phase = "strict" if args.strict else cast(Phase, args.phase) + domains = tuple(args.domain or ()) + errors = lint_cli_coverage(root=args.root, phase=phase, domains=domains) + report = render_text_report(errors, phase=phase) + print(report, end="") + return 1 if errors else 0 + + +def lint_cli_coverage( + *, + root: Path = Path("."), + phase: Phase = "registry", + domains: Sequence[str] = (), +) -> tuple[CliCoverageLintError, ...]: + """Return CLI coverage lint violations for the real repository registry.""" + + normalized_root = root.resolve() + if not (normalized_root / "avito").exists(): + return ( + CliCoverageLintError( + code="CLI_ROOT_INVALID", + message="Корень репозитория не содержит каталог avito.", + item=normalized_root.as_posix(), + ), + ) + try: + swagger_registry = load_swagger_registry() + discovery = discover_swagger_bindings(registry=swagger_registry) + registry = build_cli_registry( + swagger_registry=swagger_registry, + discovery=discovery, + ) + except ValueError as exc: + return ( + CliCoverageLintError( + code="CLI_REGISTRY_INVALID", + message=str(exc), + item="registry", + ), + ) + + errors: list[CliCoverageLintError] = [] + errors.extend(_lint_sync_binding_inventory(registry)) + errors.extend(_lint_names(registry)) + errors.extend(_lint_binding_ownership(registry)) + errors.extend(_lint_aliases(registry)) + errors.extend(_lint_exclusions(registry)) + errors.extend(_lint_parameters(registry)) + errors.extend(_lint_deprecated_policy(registry)) + if phase in {"read", "write-safety", "write", "strict"}: + errors.extend(_lint_read_phase(registry)) + if phase in {"write-safety", "write", "strict"}: + errors.extend(_lint_write_safety_phase(registry)) + if phase in {"write", "strict"}: + errors.extend(_lint_write_phase(registry, domains=domains)) + if phase == "strict": + errors.extend(_lint_strict_phase(registry)) + errors.extend(_lint_helper_phase(registry)) + errors.extend(lint_cli_registry_adapters(registry, get_command_adapter_registry())) + return tuple(sorted(errors, key=lambda error: (error.item, error.code, error.message))) + + +def lint_cli_registry_adapters( + registry: CliRegistry, + adapter_registry: CommandAdapterRegistry, +) -> tuple[CliCoverageLintError, ...]: + """Return adapter metadata lint violations for a CLI registry.""" + + return _lint_adapters(registry, adapter_registry) + + +def render_text_report(errors: Sequence[CliCoverageLintError], *, phase: Phase) -> str: + """Render deterministic CLI coverage lint report.""" + + lines = [f"CLI coverage lint: phase={phase}, errors={len(errors)}"] + lines.extend(error.render() for error in errors) + return "\n".join(lines) + "\n" + + +def _lint_sync_binding_inventory(registry: CliRegistry) -> tuple[CliCoverageLintError, ...]: + swagger_registry = load_swagger_registry() + discovery = discover_swagger_bindings(registry=swagger_registry) + sync_operation_keys = { + binding.operation_key + for binding in discovery.bindings + if binding.variant == "sync" and binding.operation_key is not None + } + command_operation_keys = {record.operation_key for record in registry.api_commands} + excluded_operation_keys = { + exclusion.operation_key + for exclusion in registry.exclusions + if exclusion.category == "api" and exclusion.operation_key is not None + } + + errors: list[CliCoverageLintError] = [] + for operation_key in sorted(sync_operation_keys - command_operation_keys - excluded_operation_keys): + errors.append( + CliCoverageLintError( + code="CLI_BINDING_MISSING", + message="Sync Swagger binding отсутствует в registry report.", + item=operation_key, + ) + ) + for operation_key in sorted((command_operation_keys | excluded_operation_keys) - sync_operation_keys): + errors.append( + CliCoverageLintError( + code="CLI_BINDING_UNKNOWN", + message="Registry ссылается на неизвестный sync Swagger binding.", + item=operation_key, + ) + ) + for operation_key in sorted(command_operation_keys & excluded_operation_keys): + errors.append( + CliCoverageLintError( + code="CLI_BINDING_DUPLICATE_POLICY", + message="Swagger binding одновременно покрыт командой и исключением.", + item=operation_key, + ) + ) + return tuple(errors) + + +def _lint_names(registry: CliRegistry) -> tuple[CliCoverageLintError, ...]: + errors: list[CliCoverageLintError] = [] + for record in _canonical_records(registry): + errors.extend(_lint_command_name(record.command_id, record.resource, record.action)) + for alias in registry.aliases: + errors.extend(_lint_command_name(alias.alias_id, alias.resource, alias.action)) + return tuple(errors) + + +def _lint_command_name( + command_id: str, + resource: str, + action: str, +) -> tuple[CliCoverageLintError, ...]: + errors: list[CliCoverageLintError] = [] + expected_command_id = f"{resource}.{action}" + if command_id != expected_command_id: + errors.append( + CliCoverageLintError( + code="CLI_COMMAND_ID_INVALID", + message=f"Command id должен быть `{expected_command_id}`.", + item=command_id, + ) + ) + for label, value in (("resource", resource), ("action", action)): + if _KEBAB_RE.fullmatch(value) is None: + errors.append( + CliCoverageLintError( + code="CLI_NAME_NOT_KEBAB", + message=f"{label} должен быть lowercase kebab-case.", + item=command_id, + ) + ) + if value == "resource-id": + errors.append( + CliCoverageLintError( + code="CLI_RESOURCE_ID_FORBIDDEN", + message="Имя `resource-id` запрещено.", + item=command_id, + ) + ) + return tuple(errors) + + +def _lint_binding_ownership(registry: CliRegistry) -> tuple[CliCoverageLintError, ...]: + errors: list[CliCoverageLintError] = [] + operation_counts = Counter(record.operation_key for record in registry.api_commands) + command_counts = Counter(record.command_id for record in registry.api_commands) + canonical_key_counts = Counter( + (record.resource, record.action) for record in _canonical_records(registry) + ) + for operation_key, count in sorted(operation_counts.items()): + if count > 1: + errors.append( + CliCoverageLintError( + code="CLI_BINDING_DUPLICATE_COMMAND", + message=f"Swagger binding привязан к {count} canonical API commands.", + item=operation_key, + ) + ) + for command_id, count in sorted(command_counts.items()): + if count > 1: + errors.append( + CliCoverageLintError( + code="CLI_COMMAND_DUPLICATE", + message=f"Command id повторяется {count} раз.", + item=command_id, + ) + ) + for key, count in sorted(canonical_key_counts.items()): + if count > 1: + errors.append( + CliCoverageLintError( + code="CLI_COMMAND_COLLISION", + message=f"Путь команды занят {count} canonical records.", + item=" ".join(key), + ) + ) + return tuple(errors) + + +def _lint_aliases(registry: CliRegistry) -> tuple[CliCoverageLintError, ...]: + command_ids = {record.command_id for record in _canonical_records(registry)} + canonical_keys = {(record.resource, record.action) for record in _canonical_records(registry)} + alias_keys = Counter((alias.resource, alias.action) for alias in registry.aliases) + errors: list[CliCoverageLintError] = [] + for alias in registry.aliases: + if alias.target_command_id not in command_ids: + errors.append( + CliCoverageLintError( + code="CLI_ALIAS_TARGET_UNKNOWN", + message=f"Alias ссылается на неизвестную команду `{alias.target_command_id}`.", + item=alias.alias_id, + ) + ) + if (alias.resource, alias.action) in canonical_keys: + errors.append( + CliCoverageLintError( + code="CLI_ALIAS_COLLIDES_WITH_COMMAND", + message="Alias не должен занимать canonical command path.", + item=alias.alias_id, + ) + ) + if alias_keys[(alias.resource, alias.action)] > 1: + errors.append( + CliCoverageLintError( + code="CLI_ALIAS_DUPLICATE_PATH", + message="Alias path повторяется.", + item=alias.alias_id, + ) + ) + if not alias.reason: + errors.append( + CliCoverageLintError( + code="CLI_ALIAS_REASON_MISSING", + message="Alias должен содержать причину совместимости.", + item=alias.alias_id, + ) + ) + return tuple(errors) + + +def _lint_exclusions(registry: CliRegistry) -> tuple[CliCoverageLintError, ...]: + errors: list[CliCoverageLintError] = [] + exclusion_ids = Counter(exclusion.exclusion_id for exclusion in registry.exclusions) + for exclusion in registry.exclusions: + if exclusion_ids[exclusion.exclusion_id] > 1: + errors.append( + CliCoverageLintError( + code="CLI_EXCLUSION_DUPLICATE", + message="Exclusion id повторяется.", + item=exclusion.exclusion_id, + ) + ) + errors.extend(_lint_exclusion_required_fields(exclusion)) + return tuple(errors) + + +def _lint_exclusion_required_fields( + exclusion: ExclusionRecord, +) -> tuple[CliCoverageLintError, ...]: + errors: list[CliCoverageLintError] = [] + required_values = { + "reason": exclusion.reason, + "follow_up": exclusion.follow_up, + "owner": exclusion.owner, + } + for field_name, value in required_values.items(): + if not value: + errors.append( + CliCoverageLintError( + code="CLI_EXCLUSION_METADATA_MISSING", + message=f"Exclusion должен содержать `{field_name}`.", + item=exclusion.exclusion_id, + ) + ) + if exclusion.status == "temporary" and not exclusion.target_stage: + errors.append( + CliCoverageLintError( + code="CLI_EXCLUSION_TARGET_STAGE_MISSING", + message="Temporary exclusion должен содержать target_stage.", + item=exclusion.exclusion_id, + ) + ) + if exclusion.category == "api" and exclusion.operation_key is None: + errors.append( + CliCoverageLintError( + code="CLI_API_EXCLUSION_BINDING_MISSING", + message="API exclusion должен ссылаться на operation_key.", + item=exclusion.exclusion_id, + ) + ) + if exclusion.category != "api" and not exclusion.command_id and not exclusion.sdk_method: + errors.append( + CliCoverageLintError( + code="CLI_EXCLUSION_TARGET_MISSING", + message="Non-API exclusion должен ссылаться на command_id или sdk_method.", + item=exclusion.exclusion_id, + ) + ) + return tuple(errors) + + +def _lint_parameters(registry: CliRegistry) -> tuple[CliCoverageLintError, ...]: + errors: list[CliCoverageLintError] = [] + for record in registry.api_commands: + errors.extend(_lint_api_command_parameters(record)) + return tuple(errors) + + +def _lint_api_command_parameters( + record: ApiCommandRecord, +) -> tuple[CliCoverageLintError, ...]: + errors: list[CliCoverageLintError] = [] + expected = { + ("factory", name, expression) + for name, expression in record.factory_args.items() + } | { + ("method", name, expression) + for name, expression in record.method_args.items() + } + actual = { + (parameter.source, parameter.name, parameter.binding_expression) + for parameter in record.parameters + } + for source, name, expression in sorted(expected - actual): + errors.append( + CliCoverageLintError( + code="CLI_PARAMETER_MISSING", + message=( + f"Binding argument `{source}.{name}` с expression `{expression}` " + "не представлен CLI parameter metadata." + ), + item=record.command_id, + ) + ) + for source, name, expression in sorted(actual - expected): + errors.append( + CliCoverageLintError( + code="CLI_PARAMETER_NOT_FROM_BINDING", + message=( + f"CLI parameter `{source}.{name}` с expression `{expression}` " + "не выбран из factory_args/method_args." + ), + item=record.command_id, + ) + ) + for parameter in record.parameters: + if parameter.name == "resource_id" or parameter.flag == "--resource-id": + errors.append( + CliCoverageLintError( + code="CLI_RESOURCE_ID_FORBIDDEN", + message="Параметр `resource_id` / `--resource-id` запрещен.", + item=record.command_id, + ) + ) + if parameter.name in _CONTROL_METHOD_NAMES or parameter.flag in _CONTROL_METHOD_FLAGS: + errors.append( + CliCoverageLintError( + code="CLI_METHOD_CONTROL_FLAG_FORBIDDEN", + message="SDK control parameter нельзя публиковать как method flag.", + item=record.command_id, + ) + ) + if _KEBAB_RE.fullmatch(parameter.flag.removeprefix("--")) is None: + errors.append( + CliCoverageLintError( + code="CLI_FLAG_NOT_KEBAB", + message="CLI flag должен быть lowercase kebab-case.", + item=f"{record.command_id} {parameter.flag}", + ) + ) + return tuple(errors) + + +def _lint_deprecated_policy(registry: CliRegistry) -> tuple[CliCoverageLintError, ...]: + intentional_exclusions = { + exclusion.operation_key + for exclusion in registry.exclusions + if exclusion.category == "api" and exclusion.status == "intentional" + } + errors: list[CliCoverageLintError] = [] + for record in registry.api_commands: + if not (record.deprecated or record.legacy): + continue + policy_text = " ".join((record.description, record.safety_summary)).lower() + if record.operation_key in intentional_exclusions: + continue + if not any(marker in policy_text for marker in _DEPRECATION_POLICY_MARKERS): + errors.append( + CliCoverageLintError( + code="CLI_DEPRECATED_POLICY_MISSING", + message=( + "Deprecated/compatibility binding должен иметь warning/help metadata " + "или intentional exclusion." + ), + item=record.command_id, + ) + ) + return tuple(errors) + + +def _lint_read_phase(registry: CliRegistry) -> tuple[CliCoverageLintError, ...]: + read_commands = [ + record + for record in registry.api_commands + if record.http_method in {"GET", "HEAD"} + ] + errors: list[CliCoverageLintError] = [] + for record in read_commands: + if not record.implemented: + errors.append( + CliCoverageLintError( + code="CLI_READ_COMMAND_NOT_IMPLEMENTED", + message="Read-only sync binding должен иметь canonical CLI-команду.", + item=record.command_id, + ) + ) + if record.safety != "read": + errors.append( + CliCoverageLintError( + code="CLI_READ_SAFETY_INVALID", + message="Read-команда должна иметь safety=read.", + item=record.command_id, + ) + ) + required_stage8_ids = {"account.get-balance", "account.get-self"} + implemented_ids = {record.command_id for record in read_commands if record.implemented} + for command_id in sorted(required_stage8_ids - implemented_ids): + errors.append( + CliCoverageLintError( + code="CLI_READ_SLICE_MISSING", + message="Stage 8 read slice должен быть реализован.", + item=command_id, + ) + ) + return tuple(errors) + + +def _lint_write_safety_phase(registry: CliRegistry) -> tuple[CliCoverageLintError, ...]: + errors: list[CliCoverageLintError] = [] + for record in _canonical_records(registry): + if record.safety_policy.kind != record.safety: + errors.append( + CliCoverageLintError( + code="CLI_SAFETY_POLICY_KIND_MISMATCH", + message="Safety policy kind должен совпадать с command safety.", + item=record.command_id, + ) + ) + if not record.safety_policy.review_note: + errors.append( + CliCoverageLintError( + code="CLI_SAFETY_POLICY_REVIEW_MISSING", + message="Safety policy должен содержать review note.", + item=record.command_id, + ) + ) + if record.safety in {"destructive", "expensive"} and not record.safety_policy.confirmation_required: + errors.append( + CliCoverageLintError( + code="CLI_SAFETY_CONFIRMATION_MISSING", + message="Destructive/expensive команда должна требовать подтверждение.", + item=record.command_id, + ) + ) + if record.safety == "read" and record.safety_policy.confirmation_required: + errors.append( + CliCoverageLintError( + code="CLI_READ_CONFIRMATION_INVALID", + message="Read-команда не должна требовать подтверждение.", + item=record.command_id, + ) + ) + if record.safety == "read" and record.safety_policy.dry_run_supported: + errors.append( + CliCoverageLintError( + code="CLI_READ_DRY_RUN_INVALID", + message="Read-команда не должна публиковать dry-run.", + item=record.command_id, + ) + ) + return tuple(errors) + + +def _lint_write_phase( + registry: CliRegistry, + *, + domains: Sequence[str], +) -> tuple[CliCoverageLintError, ...]: + selected_domains = _expand_write_domains(domains) + write_commands = [ + record + for record in registry.api_commands + if record.http_method not in {"GET", "HEAD"} + and (not selected_domains or record.factory in selected_domains) + ] + write_exclusions = [ + exclusion + for exclusion in registry.exclusions + if exclusion.category == "api" + and exclusion.command_id is not None + and (not selected_domains or _resource_to_factory(exclusion.command_id) in selected_domains) + ] + errors: list[CliCoverageLintError] = [] + for record in write_commands: + if not record.implemented: + errors.append( + CliCoverageLintError( + code="CLI_WRITE_COMMAND_NOT_IMPLEMENTED", + message="Write sync binding должен иметь canonical CLI-команду.", + item=record.command_id, + ) + ) + if record.safety not in {"write", "destructive", "expensive"}: + errors.append( + CliCoverageLintError( + code="CLI_WRITE_SAFETY_INVALID", + message="Write-команда должна иметь write/destructive/expensive safety.", + item=record.command_id, + ) + ) + for exclusion in write_exclusions: + if exclusion.status == "temporary" and not exclusion.target_stage: + errors.append( + CliCoverageLintError( + code="CLI_WRITE_EXCLUSION_TARGET_STAGE_MISSING", + message="Temporary write exclusion должен содержать target_stage.", + item=exclusion.exclusion_id, + ) + ) + return tuple(errors) + + +def _lint_strict_phase(registry: CliRegistry) -> tuple[CliCoverageLintError, ...]: + errors: list[CliCoverageLintError] = [] + for exclusion in registry.exclusions: + if exclusion.status == "temporary": + errors.append( + CliCoverageLintError( + code="CLI_TEMPORARY_EXCLUSION_EXPIRED", + message="Strict mode не допускает temporary exclusions.", + item=exclusion.exclusion_id, + ) + ) + if exclusion.category == "api" and exclusion.status != "intentional": + errors.append( + CliCoverageLintError( + code="CLI_API_EXCLUSION_NOT_INTENTIONAL", + message="API exclusion в strict mode должен быть intentional.", + item=exclusion.exclusion_id, + ) + ) + for record in registry.api_commands: + if not record.implemented: + errors.append( + CliCoverageLintError( + code="CLI_API_COMMAND_NOT_IMPLEMENTED", + message="Canonical API command в strict mode должна быть реализована.", + item=record.command_id, + ) + ) + errors.extend(_lint_registered_api_commands(registry)) + return tuple(errors) + + +def _lint_helper_phase(registry: CliRegistry) -> tuple[CliCoverageLintError, ...]: + helper_exclusion_methods = { + exclusion.sdk_method + for exclusion in registry.exclusions + if exclusion.category == "helper" and exclusion.sdk_method is not None + } + errors: list[CliCoverageLintError] = [] + for record in registry.helper_commands: + if not record.implemented: + errors.append( + CliCoverageLintError( + code="CLI_HELPER_COMMAND_NOT_IMPLEMENTED", + message="Helper workflow должен иметь CLI-команду или exclusion.", + item=record.command_id, + ) + ) + if record.sdk_method in helper_exclusion_methods: + errors.append( + CliCoverageLintError( + code="CLI_HELPER_DUPLICATE_POLICY", + message="Helper workflow одновременно реализован и исключен.", + item=record.command_id, + ) + ) + errors.extend(_lint_registered_helper_commands(registry)) + return tuple(errors) + + +def _lint_registered_api_commands(registry: CliRegistry) -> tuple[CliCoverageLintError, ...]: + root_context = click.Context(app) + errors: list[CliCoverageLintError] = [] + for record in registry.api_commands: + group = app.get_command(root_context, record.resource) + if not isinstance(group, click.Group): + errors.append( + CliCoverageLintError( + code="CLI_API_RESOURCE_NOT_REGISTERED", + message="Resource group canonical API command не зарегистрирован.", + item=record.command_id, + ) + ) + continue + action = group.get_command(root_context, record.action) + if action is None: + errors.append( + CliCoverageLintError( + code="CLI_API_ACTION_NOT_REGISTERED", + message="Action canonical API command не зарегистрирован.", + item=record.command_id, + ) + ) + return tuple(errors) + + +def _lint_registered_helper_commands(registry: CliRegistry) -> tuple[CliCoverageLintError, ...]: + root_context = click.Context(app) + errors: list[CliCoverageLintError] = [] + for record in registry.helper_commands: + group = app.get_command(root_context, record.resource) + if not isinstance(group, click.Group): + errors.append( + CliCoverageLintError( + code="CLI_HELPER_RESOURCE_NOT_REGISTERED", + message="Resource group helper-команды не зарегистрирован.", + item=record.command_id, + ) + ) + continue + action = group.get_command(root_context, record.action) + if action is None: + errors.append( + CliCoverageLintError( + code="CLI_HELPER_ACTION_NOT_REGISTERED", + message="Action helper-команды не зарегистрирован.", + item=record.command_id, + ) + ) + return tuple(errors) + + +def _resource_to_factory(command_id: str) -> str: + resource = command_id.split(".", maxsplit=1)[0] + return resource.replace("-", "_") + + +def _expand_write_domains(domains: Sequence[str]) -> frozenset[str]: + expanded: set[str] = set() + for domain in domains: + normalized = domain.replace("_", "-").lower() + wave = _WRITE_WAVES.get(normalized) + if wave is None: + expanded.add(domain.replace("-", "_")) + continue + expanded.update(wave) + return frozenset(expanded) + + +def _lint_adapters( + registry: CliRegistry, + adapter_registry: CommandAdapterRegistry, +) -> tuple[CliCoverageLintError, ...]: + errors: list[CliCoverageLintError] = [] + known_adapter_ids = adapter_registry.ids() + used_adapter_ids: set[str] = set() + + metadata_counts = Counter(adapter.adapter_id for adapter in adapter_registry.adapters) + for adapter in adapter_registry.adapters: + if metadata_counts[adapter.adapter_id] > 1: + errors.append( + CliCoverageLintError( + code="CLI_ADAPTER_DUPLICATE", + message="Adapter id повторяется.", + item=adapter.adapter_id, + ) + ) + if not adapter.metadata.owner: + errors.append( + CliCoverageLintError( + code="CLI_ADAPTER_OWNER_MISSING", + message="Adapter metadata должен содержать owner.", + item=adapter.adapter_id, + ) + ) + if not adapter.metadata.reason: + errors.append( + CliCoverageLintError( + code="CLI_ADAPTER_REASON_MISSING", + message="Adapter metadata должен содержать reason.", + item=adapter.adapter_id, + ) + ) + + for record in _canonical_records(registry): + if isinstance(record, LocalCommandRecord): + continue + adapter_id = record.adapter_id + if adapter_id is None: + continue + used_adapter_ids.add(adapter_id) + if adapter_id not in known_adapter_ids: + errors.append( + CliCoverageLintError( + code="CLI_ADAPTER_UNKNOWN", + message=f"Adapter id `{adapter_id}` отсутствует в explicit adapter registry.", + item=record.command_id, + ) + ) + for adapter_id in sorted(known_adapter_ids - used_adapter_ids): + errors.append( + CliCoverageLintError( + code="CLI_ADAPTER_UNUSED", + message="Adapter id зарегистрирован, но не используется ни одной командой.", + item=adapter_id, + ) + ) + return tuple(errors) + + +def _canonical_records(registry: CliRegistry) -> tuple[CommandRecord, ...]: + records: list[CommandRecord] = [] + records.extend(registry.api_commands) + records.extend(registry.helper_commands) + records.extend(registry.local_commands) + return tuple(records) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/cli/test_account_api_commands.py b/tests/cli/test_account_api_commands.py new file mode 100644 index 0000000..fa1aa7a --- /dev/null +++ b/tests/cli/test_account_api_commands.py @@ -0,0 +1,141 @@ +"""Vertical smoke tests for first registry-backed account API commands.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from avito.cli.app import app +from avito.cli.config import ( + AccountsDocument, + AccountStore, + CliConfigDocument, + ConfigStore, + StoredAccount, +) +from avito.cli.registry import ApiCommandRecord, build_cli_registry +from avito.config import AvitoSettings +from avito.core.swagger_registry import load_swagger_registry +from avito.testing import SwaggerFakeTransport, error_payload + + +def test_account_get_self_runs_through_generic_cli_path( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + command = _implemented_api_command("account.get-self") + fake = SwaggerFakeTransport(registry=load_swagger_registry()) + fake.add_operation( + command.operation_key, + {"id": 7, "name": "Иван", "email": "user@example.test", "phone": "+7000"}, + ) + _install_fake_client(monkeypatch, fake) + _write_account(tmp_path) + + result = CliRunner(env={"AVITO_PY_HOME": str(tmp_path)}).invoke( + app, + ["--profile", "main", "account", "get-self"], + ) + + assert result.exit_code == 0 + assert "user_id: 7" in result.output + assert "name: Иван" in result.output + assert fake.count(method="GET", path="/core/v1/accounts/self") == 1 + + +def test_account_get_balance_supports_json_output_and_user_id_flag( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + command = _implemented_api_command("account.get-balance") + fake = SwaggerFakeTransport(registry=load_swagger_registry()) + fake.add_operation( + command.operation_key, + {"user_id": 7, "balance": {"real": 150.5, "bonus": 20.0, "currency": "RUB"}}, + ) + _install_fake_client(monkeypatch, fake) + _write_account(tmp_path) + + result = CliRunner(env={"AVITO_PY_HOME": str(tmp_path)}).invoke( + app, + ["--json", "--profile", "main", "account", "get-balance", "--user-id", "7"], + ) + + assert result.exit_code == 0 + assert json.loads(result.output) == { + "bonus": 20.0, + "currency": "RUB", + "real": 150.5, + "total": 170.5, + "user_id": 7, + } + assert fake.count(method="GET", path="/core/v1/accounts/7/balance/") == 1 + + +def test_account_api_command_errors_are_rendered_as_json( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + command = _implemented_api_command("account.get-self") + fake = SwaggerFakeTransport(registry=load_swagger_registry()) + fake.add_operation(command.operation_key, error_payload(401), status_code=401) + _install_fake_client(monkeypatch, fake) + _write_account(tmp_path) + + result = CliRunner(env={"AVITO_PY_HOME": str(tmp_path)}).invoke( + app, + ["--json", "--profile", "main", "account", "get-self"], + ) + + assert result.exit_code == 4 + assert result.stdout == "" + assert json.loads(result.stderr) == { + "code": "AUTH_REQUIRED", + "exit_code": 4, + "message": "Ошибка 401", + } + + +def test_account_api_command_help_is_registered_without_account_files(tmp_path: Path) -> None: + result = CliRunner(env={"AVITO_PY_HOME": str(tmp_path / "home")}).invoke( + app, + ["account", "get-balance", "--help"], + ) + + assert result.exit_code == 0 + assert "--user-id" in result.output + assert "Параметр SDK `user_id`" in result.output + assert not (tmp_path / "home").exists() + + +def _implemented_api_command(command_id: str) -> ApiCommandRecord: + matches = tuple( + command for command in build_cli_registry().api_commands if command.command_id == command_id + ) + assert len(matches) == 1 + assert matches[0].implemented is True + return matches[0] + + +def _install_fake_client( + monkeypatch: pytest.MonkeyPatch, + fake: SwaggerFakeTransport, +) -> None: + def client_factory(settings: AvitoSettings) -> object: + return fake.as_client(user_id=settings.user_id) + + monkeypatch.setattr("avito.cli.commands._default_client_factory", client_factory) + + +def _write_account(tmp_path: Path) -> None: + account = StoredAccount( + name="main", + client_id="client-id", + client_secret="client-secret", + user_id=7, + ) + AccountStore(tmp_path).save(AccountsDocument(accounts=(account,))) + ConfigStore(tmp_path).save(CliConfigDocument(active_profile="main")) diff --git a/tests/cli/test_accounts.py b/tests/cli/test_accounts.py new file mode 100644 index 0000000..6b52267 --- /dev/null +++ b/tests/cli/test_accounts.py @@ -0,0 +1,243 @@ +"""Tests for local CLI account commands.""" + +from __future__ import annotations + +import json +from io import StringIO +from pathlib import Path +from typing import TextIO + +from click.testing import CliRunner + +from avito.cli import accounts +from avito.cli.app import app + + +def test_account_add_reloads_and_sets_initial_active_account(tmp_path: Path) -> None: + runner = _runner(tmp_path) + + add_result = runner.invoke( + app, + [ + "account", + "add", + "main", + "--client-id", + "client-id", + "--client-secret-stdin", + "--endpoint", + "https://example.test", + "--user-id", + "123", + ], + input="client-secret\n", + ) + current_result = runner.invoke(app, ["--json", "account", "current"]) + + assert add_result.exit_code == 0 + assert "client-secret" not in add_result.output + assert current_result.exit_code == 0 + payload = json.loads(current_result.stdout) + assert payload["active_profile"] == "main" + assert payload["account"]["client_id"] == "client-id" + assert payload["account"]["client_secret"] == "***" + assert payload["account"]["base_url"] == "https://example.test" + assert "client-secret" not in current_result.stdout + + +def test_account_add_rejects_duplicate_name(tmp_path: Path) -> None: + runner = _runner(tmp_path) + args = [ + "account", + "add", + "main", + "--client-id", + "client-id", + "--client-secret", + "client-secret", + ] + + first_result = runner.invoke(app, args) + second_result = runner.invoke(app, args) + + assert first_result.exit_code == 0 + assert second_result.exit_code == 7 + assert "CONFIG_INVALID" in second_result.stderr + + +def test_account_use_current_and_delete_clear_active_account(tmp_path: Path) -> None: + runner = _runner(tmp_path) + _add_account(runner, "first") + _add_account(runner, "second") + + use_result = runner.invoke(app, ["account", "use", "second"]) + current_result = runner.invoke(app, ["--json", "account", "current"]) + delete_result = runner.invoke(app, ["account", "delete", "second", "--yes"]) + missing_current_result = runner.invoke(app, ["account", "current"]) + + assert use_result.exit_code == 0 + assert json.loads(current_result.stdout)["active_profile"] == "second" + assert delete_result.exit_code == 0 + assert missing_current_result.exit_code == 7 + assert "Активная учетная запись не выбрана" in missing_current_result.stderr + + +def test_account_remove_is_delete_alias(tmp_path: Path) -> None: + runner = _runner(tmp_path) + _add_account(runner, "main") + + result = runner.invoke(app, ["--json", "account", "remove", "main", "--confirm", "main"]) + + assert result.exit_code == 0 + assert json.loads(result.stdout) == {"deleted": "main"} + + +def test_account_list_json_masks_secrets(tmp_path: Path) -> None: + runner = _runner(tmp_path) + _add_account(runner, "main", secret="raw-secret") + + result = runner.invoke(app, ["--json", "account", "list"]) + + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["accounts"][0]["client_secret"] == "***" + assert "raw-secret" not in result.stdout + + +def test_account_add_no_input_without_secret_fails_without_prompt(tmp_path: Path) -> None: + result = _runner(tmp_path).invoke( + app, + ["--no-input", "account", "add", "main", "--client-id", "client-id"], + ) + + assert result.exit_code == 4 + assert "AUTH_REQUIRED" in result.stderr + + +def test_account_add_accepts_ticket_aliases_api_key_and_endpoint(tmp_path: Path) -> None: + runner = _runner(tmp_path) + + result = runner.invoke( + app, + [ + "account", + "add", + "main", + "--client-id", + "client-id", + "--api-key", + "api-secret", + "--endpoint", + "https://endpoint.test", + ], + ) + current_result = runner.invoke(app, ["--json", "account", "current"]) + + assert result.exit_code == 0 + payload = json.loads(current_result.stdout) + assert payload["account"]["base_url"] == "https://endpoint.test" + assert "api-secret" not in current_result.stdout + + +def test_account_add_hidden_prompt_path(tmp_path: Path) -> None: + result = _runner(tmp_path).invoke( + app, + ["account", "add", "main", "--client-id", "client-id"], + input="prompt-secret\n", + ) + + assert result.exit_code == 0 + assert "Client Secret" in result.output + assert "prompt-secret" not in result.output + + +def test_client_secret_stdin_reads_one_secret_value(tmp_path: Path) -> None: + runner = _runner(tmp_path) + + result = runner.invoke( + app, + [ + "account", + "add", + "main", + "--client-id", + "client-id", + "--client-secret-stdin", + ], + input="stdin-secret\n", + ) + current_result = runner.invoke(app, ["--json", "account", "current"]) + + assert result.exit_code == 0 + assert "stdin-secret" not in result.output + assert json.loads(current_result.stdout)["account"]["client_secret"] == "***" + + +def test_client_secret_stdin_rejects_tty_stdin( + monkeypatch, + tmp_path: Path, +) -> None: + class TtyInput(StringIO): + def isatty(self) -> bool: + return True + + def fake_text_stream(name: str) -> TextIO: + assert name == "stdin" + return TtyInput("stdin-secret\n") + + monkeypatch.setattr(accounts.click, "get_text_stream", fake_text_stream) + + result = _runner(tmp_path).invoke( + app, + [ + "account", + "add", + "main", + "--client-id", + "client-id", + "--client-secret-stdin", + ], + ) + + assert result.exit_code == 2 + assert "неинтерактивный stdin" in result.stderr + + +def test_secret_flags_are_mutually_exclusive(tmp_path: Path) -> None: + result = _runner(tmp_path).invoke( + app, + [ + "account", + "add", + "main", + "--client-id", + "client-id", + "--client-secret", + "client-secret", + "--api-key", + "api-secret", + ], + ) + + assert result.exit_code == 2 + assert "INVALID_FLAG_COMBINATION" in result.stderr + + +def _runner(tmp_path: Path) -> CliRunner: + return CliRunner(env={"AVITO_PY_HOME": str(tmp_path / "home")}) + + +def _add_account(runner: CliRunner, name: str, *, secret: str = "client-secret") -> None: + result = runner.invoke( + app, + [ + "account", + "add", + name, + "--client-id", + f"{name}-client", + "--client-secret", + secret, + ], + ) + assert result.exit_code == 0 diff --git a/tests/cli/test_adapters.py b/tests/cli/test_adapters.py new file mode 100644 index 0000000..64a7407 --- /dev/null +++ b/tests/cli/test_adapters.py @@ -0,0 +1,320 @@ +"""Tests for CLI command adapter extension point.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import replace +from pathlib import Path +from types import TracebackType + +import pytest + +from avito.cli.adapters import ( + AdapterMetadata, + ClientFactory, + CommandInvocationEngine, + RegisteredCommandAdapter, + build_command_adapter_registry, +) +from avito.cli.commands import invoke_api_command +from avito.cli.config import ( + AccountsDocument, + AccountStore, + CliConfigDocument, + ConfigStore, + StoredAccount, +) +from avito.cli.context import CliContext +from avito.cli.errors import CliValidationError +from avito.cli.help import render_registry_help +from avito.cli.registry import ApiCommandRecord, CliRegistry, build_cli_registry +from avito.config import AvitoSettings +from scripts.lint_cli_coverage import lint_cli_registry_adapters + + +def test_adapter_transforms_cli_input_and_uses_shared_public_sdk_path( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + command = _adapter_command("account.get-balance", adapter_id="test-input") + adapter_registry = build_command_adapter_registry( + ( + RegisteredCommandAdapter( + metadata=AdapterMetadata( + adapter_id="test-input", + owner="cli", + reason="Тестовая нормализация CLI-only параметра.", + ), + adapter=_RenamingAdapter(source_name="profile_user", target_name="user_id"), + ), + ) + ) + factory = _RecordingClientFactory() + _write_accounts(tmp_path, active_profile="main") + monkeypatch.setenv("AVITO_PY_HOME", str(tmp_path)) + monkeypatch.setattr( + "avito.cli.commands.get_command_adapter_registry", + lambda: adapter_registry, + ) + + result = invoke_api_command( + _ctx(), + command, + {"profile_user": ("123",)}, + client_factory=factory, + ) + + assert result == {"ok": True} + assert factory.constructed_client_ids == ["main-client"] + assert factory.client.entered == 1 + assert factory.client.exited == 1 + assert factory.client.account_calls == [123] + assert factory.client.account_domain.get_balance_calls == [{}] + + +def test_adapter_errors_are_sanitized_and_mapped_to_validation_error( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + command = _adapter_command("account.get-balance", adapter_id="bad-input") + adapter_registry = build_command_adapter_registry( + ( + RegisteredCommandAdapter( + metadata=AdapterMetadata( + adapter_id="bad-input", + owner="cli", + reason="Тестовая обработка ошибки adapter.", + ), + adapter=_FailingAdapter(), + ), + ) + ) + _write_accounts(tmp_path, active_profile="main") + monkeypatch.setenv("AVITO_PY_HOME", str(tmp_path)) + monkeypatch.setattr( + "avito.cli.commands.get_command_adapter_registry", + lambda: adapter_registry, + ) + + with pytest.raises(CliValidationError) as exc_info: + invoke_api_command(_ctx(), command, {"user_id": ("123",)}) + + assert exc_info.value.code == "VALIDATION_FAILED" + assert "secret-token" not in exc_info.value.message + assert exc_info.value.details == { + "adapter_id": "bad-input", + "command_id": "account.get-balance", + "error_type": "OSError", + } + + +def test_adapter_registry_rejects_duplicate_adapter_ids() -> None: + first = RegisteredCommandAdapter( + metadata=AdapterMetadata( + adapter_id="duplicate", + owner="cli", + reason="Первый adapter.", + ), + adapter=_RenamingAdapter(source_name="a", target_name="b"), + ) + second = RegisteredCommandAdapter( + metadata=AdapterMetadata( + adapter_id="duplicate", + owner="cli", + reason="Второй adapter.", + ), + adapter=_RenamingAdapter(source_name="a", target_name="b"), + ) + + with pytest.raises(ValueError, match="повторяется"): + build_command_adapter_registry((first, second)) + + +def test_adapter_lint_rejects_unknown_duplicate_and_unused_adapter_ids() -> None: + registry = build_cli_registry() + unknown_command = _adapter_command("account.get-balance", adapter_id="unknown") + duplicate_adapter = RegisteredCommandAdapter( + metadata=AdapterMetadata( + adapter_id="duplicate", + owner="cli", + reason="Повторяющийся adapter.", + ), + adapter=_RenamingAdapter(source_name="a", target_name="b"), + ) + adapter_registry = replace( + build_command_adapter_registry( + ( + RegisteredCommandAdapter( + metadata=AdapterMetadata( + adapter_id="unused", + owner="cli", + reason="Неиспользуемый adapter.", + ), + adapter=_RenamingAdapter(source_name="a", target_name="b"), + ), + ) + ), + adapters=(duplicate_adapter, duplicate_adapter), + ) + linted_registry = _registry_with_command(registry, unknown_command) + + errors = lint_cli_registry_adapters(linted_registry, adapter_registry) + codes = {error.code for error in errors} + + assert "CLI_ADAPTER_UNKNOWN" in codes + assert "CLI_ADAPTER_DUPLICATE" in codes + assert "CLI_ADAPTER_UNUSED" in codes + + +def test_adapter_backed_command_help_and_report_keep_serializable_adapter_id() -> None: + registry = build_cli_registry() + command = _adapter_command("account.get-balance", adapter_id="test-input") + adapter_registry = build_command_adapter_registry( + ( + RegisteredCommandAdapter( + metadata=AdapterMetadata( + adapter_id="test-input", + owner="cli", + reason="Тестовая нормализация CLI-only параметра.", + ), + adapter=_RenamingAdapter(source_name="profile_user", target_name="user_id"), + ), + ) + ) + registry_with_adapter = _registry_with_command(registry, command) + + help_text = render_registry_help( + ("account", "get-balance"), + registry=registry_with_adapter, + ) + errors = lint_cli_registry_adapters(registry_with_adapter, adapter_registry) + + assert help_text is not None + assert "Справка: avito account get-balance" in help_text + command_report = next( + item + for item in registry_with_adapter.to_dict()["api_commands"] + if item["command_id"] == "account.get-balance" + ) + assert command_report["adapter_id"] == "test-input" + assert errors == () + + +def _adapter_command(command_id: str, *, adapter_id: str) -> ApiCommandRecord: + matches = tuple( + command for command in build_cli_registry().api_commands if command.command_id == command_id + ) + assert len(matches) == 1 + return replace(matches[0], implemented=True, adapter_id=adapter_id) + + +def _registry_with_command(registry: CliRegistry, command: ApiCommandRecord) -> CliRegistry: + return replace( + registry, + api_commands=tuple( + command if existing.command_id == command.command_id else existing + for existing in registry.api_commands + ), + ) + + +def _write_accounts(tmp_path: Path, *, active_profile: str) -> None: + account = StoredAccount( + name=active_profile, + client_id=f"{active_profile}-client", + client_secret=f"{active_profile}-secret", + ) + AccountStore(tmp_path).save(AccountsDocument(accounts=(account,))) + ConfigStore(tmp_path).save(CliConfigDocument(active_profile=active_profile)) + + +def _ctx() -> CliContext: + return CliContext( + profile=None, + config=None, + json_output=False, + plain=False, + table=False, + wide=False, + quiet=False, + no_input=True, + no_color=True, + verbose=False, + debug=False, + timeout=None, + ) + + +class _RenamingAdapter: + def __init__(self, *, source_name: str, target_name: str) -> None: + self.source_name = source_name + self.target_name = target_name + + def invoke( + self, + ctx: CliContext, + command: ApiCommandRecord, + raw_values: Mapping[str, Sequence[str]], + *, + engine: CommandInvocationEngine, + client_factory: ClientFactory | None = None, + ) -> object: + normalized = dict(raw_values) + normalized[self.target_name] = raw_values[self.source_name] + return engine(ctx, command, normalized, client_factory=client_factory) + + +class _FailingAdapter: + def invoke( + self, + ctx: CliContext, + command: ApiCommandRecord, + raw_values: Mapping[str, Sequence[str]], + *, + engine: CommandInvocationEngine, + client_factory: ClientFactory | None = None, + ) -> object: + raise OSError("secret-token must not leak") + + +class _RecordingClientFactory: + def __init__(self) -> None: + self.constructed_client_ids: list[str] = [] + self.client = _RecordingClient() + + def __call__(self, settings: AvitoSettings) -> _RecordingClient: + self.constructed_client_ids.append(settings.auth.client_id) + return self.client + + +class _RecordingClient: + def __init__(self) -> None: + self.entered = 0 + self.exited = 0 + self.account_calls: list[int | None] = [] + self.account_domain = _RecordingAccountDomain() + + def __enter__(self) -> _RecordingClient: + self.entered += 1 + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: + self.exited += 1 + + def account(self, user_id: int | None = None) -> _RecordingAccountDomain: + self.account_calls.append(user_id) + return self.account_domain + + +class _RecordingAccountDomain: + def __init__(self) -> None: + self.get_balance_calls: list[dict[str, object]] = [] + + def get_balance(self) -> dict[str, bool]: + self.get_balance_calls.append({}) + return {"ok": True} diff --git a/tests/cli/test_app.py b/tests/cli/test_app.py new file mode 100644 index 0000000..13cff93 --- /dev/null +++ b/tests/cli/test_app.py @@ -0,0 +1,160 @@ +"""Tests for the root CLI shell.""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from avito.cli.accounts import account_group +from avito.cli.app import app + + +def test_help_outputs_root_help_without_filesystem_side_effects(tmp_path: Path) -> None: + runner = CliRunner(env={"AVITO_PY_HOME": str(tmp_path / "home")}) + + result = runner.invoke(app, ["--help"]) + + assert result.exit_code == 0 + assert "Командная строка для Avito API SDK." in result.output + assert "--profile" in result.output + assert not (tmp_path / "home").exists() + + +def test_help_command_delegates_to_root_help_without_filesystem_side_effects( + tmp_path: Path, +) -> None: + runner = CliRunner(env={"AVITO_PY_HOME": str(tmp_path / "home")}) + + result = runner.invoke(app, ["help"]) + + assert result.exit_code == 0 + assert "Командная строка для Avito API SDK." in result.output + assert "--version" in result.output + assert not (tmp_path / "home").exists() + + +def test_help_command_renders_registry_resource_without_client_construction( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + runner = CliRunner(env={"AVITO_PY_HOME": str(tmp_path / "home")}) + + def fail_client_init(self: object) -> None: + raise AssertionError("AvitoClient must not be constructed by registry help") + + monkeypatch.setattr("avito.client.AvitoClient.__init__", fail_client_init) + + result = runner.invoke(app, ["help", "account"]) + + assert result.exit_code == 0 + assert "Справка: avito account" in result.output + assert "get-self" in result.output + assert "delete" in result.output + assert "remove" in result.output + assert not (tmp_path / "home").exists() + + +def test_help_command_renders_registry_action_without_client_construction( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + runner = CliRunner(env={"AVITO_PY_HOME": str(tmp_path / "home")}) + + def fail_client_init(self: object) -> None: + raise AssertionError("AvitoClient must not be constructed by registry help") + + monkeypatch.setattr("avito.client.AvitoClient.__init__", fail_client_init) + + result = runner.invoke(app, ["help", "account", "get-balance"]) + + assert result.exit_code == 0 + assert "Справка: avito account get-balance" in result.output + assert "--user-id" in result.output + assert "Команда только читает данные Avito API." in result.output + assert not (tmp_path / "home").exists() + + +def test_help_command_renders_registry_alias() -> None: + result = CliRunner().invoke(app, ["help", "account", "remove"]) + + assert result.exit_code == 0 + assert "Совместимое имя для `avito account delete`." in result.output + + +def test_account_remove_alias_reuses_delete_callback() -> None: + parent_context = None + delete_command = account_group.get_command(parent_context, "delete") + remove_command = account_group.get_command(parent_context, "remove") + + assert delete_command is not None + assert remove_command is not None + assert remove_command.callback is delete_command.callback + + +def test_version_command_outputs_human_version() -> None: + result = CliRunner().invoke(app, ["version"]) + + assert result.exit_code == 0 + assert result.output.startswith("avito-py ") + + +def test_version_command_outputs_json_when_requested() -> None: + result = CliRunner().invoke(app, ["--json", "version"]) + + assert result.exit_code == 0 + assert result.output.startswith('{"version":') + + +def test_version_option_outputs_version() -> None: + result = CliRunner().invoke(app, ["--version"]) + + assert result.exit_code == 0 + assert "avito" in result.output + + +def test_python_module_entrypoint_uses_cli_help(tmp_path: Path) -> None: + result = subprocess.run( + [sys.executable, "-m", "avito", "--help"], + check=False, + capture_output=True, + env={"AVITO_PY_HOME": str(tmp_path / "home")}, + text=True, + ) + + assert result.returncode == 0 + assert "Командная строка для Avito API SDK." in result.stdout + assert result.stderr == "" + assert not (tmp_path / "home").exists() + + +def test_root_global_options_are_accepted_before_subcommand(tmp_path: Path) -> None: + config_path = tmp_path / "config.json" + + result = CliRunner().invoke( + app, + [ + "--profile", + "main", + "--config", + str(config_path), + "--no-input", + "--timeout", + "3.5", + "version", + ], + ) + + assert result.exit_code == 0 + assert result.output.startswith("avito-py ") + assert not config_path.exists() + + +def test_output_format_flags_are_mutually_exclusive() -> None: + result = CliRunner().invoke(app, ["--json", "--plain", "version"]) + + assert result.exit_code == 2 + assert "нельзя использовать вместе" in result.stderr diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py new file mode 100644 index 0000000..80ce8b2 --- /dev/null +++ b/tests/cli/test_commands.py @@ -0,0 +1,242 @@ +"""Tests for generic CLI invocation through public SDK methods.""" + +from __future__ import annotations + +from dataclasses import replace +from pathlib import Path +from types import TracebackType + +import pytest + +from avito.cli.commands import invoke_api_command, map_sdk_error +from avito.cli.config import ( + AccountsDocument, + AccountStore, + CliConfigDocument, + ConfigStore, + StoredAccount, +) +from avito.cli.context import CliContext +from avito.cli.errors import CliAuthRequiredError, CliError +from avito.cli.registry import ApiCommandRecord, build_cli_registry +from avito.config import AvitoSettings +from avito.core.exceptions import ( + AuthenticationError, + AuthorizationError, + ConflictError, + RateLimitError, + TransportError, + UpstreamApiError, + ValidationError, +) +from avito.core.types import ApiTimeouts + + +def test_invocation_uses_active_profile_and_public_factory_method_path( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + command = _api_command("account.get-balance") + factory = _RecordingClientFactory() + _write_accounts(tmp_path, active_profile="main") + monkeypatch.setenv("AVITO_PY_HOME", str(tmp_path)) + + result = invoke_api_command( + _ctx(), + command, + {"user_id": ("123",)}, + client_factory=factory, + ) + + assert result == {"ok": True} + assert factory.constructed_client_ids == ["main-client"] + assert factory.client.entered == 1 + assert factory.client.exited == 1 + assert factory.client.account_calls == [123] + assert factory.client.account_domain.get_balance_calls == [{}] + + +def test_profile_flag_overrides_active_profile_before_client_construction( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + command = _api_command("account.get-balance") + factory = _RecordingClientFactory() + _write_accounts(tmp_path, active_profile="main") + monkeypatch.setenv("AVITO_PY_HOME", str(tmp_path)) + + invoke_api_command( + _ctx(profile="other"), + command, + {}, + client_factory=factory, + ) + + assert factory.constructed_client_ids == ["other-client"] + assert factory.client.account_calls == [None] + + +def test_missing_profile_fails_before_client_construction( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + command = _api_command("account.get-balance") + factory = _RecordingClientFactory() + AccountStore(tmp_path).save(AccountsDocument()) + monkeypatch.setenv("AVITO_PY_HOME", str(tmp_path)) + + with pytest.raises(CliAuthRequiredError) as exc_info: + invoke_api_command(_ctx(), command, {}, client_factory=factory) + + assert exc_info.value.code == "AUTH_REQUIRED" + assert factory.constructed_client_ids == [] + + +def test_root_timeout_is_passed_only_when_sdk_method_accepts_timeout( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + command = _api_command("account.get-balance") + factory = _RecordingClientFactory() + _write_accounts(tmp_path, active_profile="main") + monkeypatch.setenv("AVITO_PY_HOME", str(tmp_path)) + + invoke_api_command(_ctx(timeout=2.5), command, {}, client_factory=factory) + + call = factory.client.account_domain.get_balance_calls[0] + assert isinstance(call["timeout"], ApiTimeouts) + assert call["timeout"].connect == 2.5 + assert call["timeout"].read == 2.5 + assert call["timeout"].write == 2.5 + assert call["timeout"].pool == 2.5 + + +def test_root_timeout_is_not_passed_to_method_without_timeout( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + command = _api_command("account.get-self") + factory = _RecordingClientFactory() + _write_accounts(tmp_path, active_profile="main") + monkeypatch.setenv("AVITO_PY_HOME", str(tmp_path)) + + invoke_api_command(_ctx(timeout=2.5), command, {}, client_factory=factory) + + assert factory.client.account_domain.get_self_calls == [{}] + + +def test_sdk_exceptions_map_to_documented_cli_errors() -> None: + command = _api_command("account.get-balance") + + cases = ( + (AuthenticationError("Нужна аутентификация"), "AUTH_REQUIRED", 4), + (AuthorizationError("Нет прав"), "PERMISSION_DENIED", 5), + (ValidationError("Некорректный запрос"), "VALIDATION_FAILED", 7), + (ConflictError("Конфликт"), "CONFLICT", 7), + (RateLimitError("Слишком много запросов"), "RATE_LIMITED", 6), + (TransportError("Сетевой сбой"), "TRANSPORT_FAILED", 8), + (UpstreamApiError("Ошибка API"), "SDK_METHOD_FAILED", 7), + ) + + for error, code, exit_code in cases: + cli_error = map_sdk_error(error, command=command) + assert isinstance(cli_error, CliError) + assert cli_error.code == code + assert cli_error.exit_code == exit_code + + +def _write_accounts(tmp_path: Path, *, active_profile: str) -> None: + accounts = ( + _account("main"), + _account("other"), + ) + AccountStore(tmp_path).save(AccountsDocument(accounts=accounts)) + ConfigStore(tmp_path).save(CliConfigDocument(active_profile=active_profile)) + + +def _account(name: str) -> StoredAccount: + return StoredAccount( + name=name, + client_id=f"{name}-client", + client_secret=f"{name}-secret", + ) + + +def _ctx( + *, + profile: str | None = None, + timeout: float | None = None, +) -> CliContext: + return CliContext( + profile=profile, + config=None, + json_output=False, + plain=False, + table=False, + wide=False, + quiet=False, + no_input=True, + no_color=True, + verbose=False, + debug=False, + timeout=timeout, + ) + + +def _api_command(command_id: str) -> ApiCommandRecord: + matches = tuple( + command for command in build_cli_registry().api_commands if command.command_id == command_id + ) + assert len(matches) == 1 + return replace(matches[0], implemented=True) + + +class _RecordingClientFactory: + def __init__(self) -> None: + self.constructed_client_ids: list[str] = [] + self.client = _RecordingClient() + + def __call__(self, settings: AvitoSettings) -> _RecordingClient: + self.constructed_client_ids.append(settings.auth.client_id) + return self.client + + +class _RecordingClient: + def __init__(self) -> None: + self.entered = 0 + self.exited = 0 + self.account_calls: list[int | None] = [] + self.account_domain = _RecordingAccountDomain() + + def __enter__(self) -> _RecordingClient: + self.entered += 1 + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: + self.exited += 1 + + def account(self, user_id: int | None = None) -> _RecordingAccountDomain: + self.account_calls.append(user_id) + return self.account_domain + + +class _RecordingAccountDomain: + def __init__(self) -> None: + self.get_balance_calls: list[dict[str, object]] = [] + self.get_self_calls: list[dict[str, object]] = [] + + def get_balance(self, *, timeout: ApiTimeouts | None = None) -> dict[str, bool]: + kwargs: dict[str, object] = {} + if timeout is not None: + kwargs["timeout"] = timeout + self.get_balance_calls.append(kwargs) + return {"ok": True} + + def get_self(self) -> dict[str, bool]: + self.get_self_calls.append({}) + return {"ok": True} diff --git a/tests/cli/test_completion.py b/tests/cli/test_completion.py new file mode 100644 index 0000000..a829250 --- /dev/null +++ b/tests/cli/test_completion.py @@ -0,0 +1,37 @@ +"""Tests for shell completion commands.""" + +from __future__ import annotations + +import json + +from click.testing import CliRunner + +from avito.cli.app import app + + +def test_completion_commands_render_shell_instructions() -> None: + runner = CliRunner() + + bash = runner.invoke(app, ["completion", "bash"]) + zsh = runner.invoke(app, ["completion", "zsh"]) + fish = runner.invoke(app, ["completion", "fish"]) + + assert bash.exit_code == 0 + assert "_AVITO_COMPLETE=bash_source avito" in bash.stdout + assert zsh.exit_code == 0 + assert "_AVITO_COMPLETE=zsh_source avito" in zsh.stdout + assert fish.exit_code == 0 + assert "_AVITO_COMPLETE=fish_source avito | source" in fish.stdout + + +def test_completion_json_output_is_stable() -> None: + result = CliRunner().invoke(app, ["--json", "completion", "bash"]) + + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload == { + "completion": { + "command": 'eval "$(_AVITO_COMPLETE=bash_source avito)"', + "shell": "bash", + } + } diff --git a/tests/cli/test_config.py b/tests/cli/test_config.py new file mode 100644 index 0000000..de3483c --- /dev/null +++ b/tests/cli/test_config.py @@ -0,0 +1,283 @@ +"""Tests for local CLI account and config stores.""" + +from __future__ import annotations + +import json +import os +import stat +from pathlib import Path + +import pytest + +from avito.auth.settings import AuthSettings +from avito.cli.config import ( + ACCOUNTS_FILENAME, + CLI_HOME_ENV, + CONFIG_FILENAME, + TICKET_HOME_ENV, + AccountsDocument, + AccountStore, + CliConfigDocument, + ConfigStore, + StoredAccount, + resolve_cli_home, +) +from avito.cli.errors import CliConfigFileError, CliPermissionError +from avito.config import AvitoSettings + + +def test_resolve_cli_home_prefers_project_environment_variable(tmp_path: Path) -> None: + avito_home = tmp_path / "avito" + ticket_home = tmp_path / "ticket" + + home = resolve_cli_home( + { + CLI_HOME_ENV: str(avito_home), + TICKET_HOME_ENV: str(ticket_home), + } + ) + + assert home == avito_home + + +def test_resolve_cli_home_uses_ticket_compatibility_environment_variable( + tmp_path: Path, +) -> None: + ticket_home = tmp_path / "ticket" + + home = resolve_cli_home({TICKET_HOME_ENV: str(ticket_home)}) + + assert home == ticket_home + + +def test_resolve_cli_home_defaults_to_user_home(monkeypatch: pytest.MonkeyPatch) -> None: + user_home = Path("/tmp/example-user") + monkeypatch.setattr(Path, "home", lambda: user_home) + + home = resolve_cli_home({}) + + assert home == user_home / ".avito-py" + + +def test_import_and_store_construction_do_not_create_files(tmp_path: Path) -> None: + home = tmp_path / "home" + + AccountStore(home) + ConfigStore(home) + + assert not home.exists() + + +def test_missing_files_load_empty_documents_without_creating_home(tmp_path: Path) -> None: + home = tmp_path / "home" + + accounts = AccountStore(home).load() + config = ConfigStore(home).load() + + assert accounts == AccountsDocument() + assert config == CliConfigDocument() + assert not home.exists() + + +def test_save_creates_home_and_files_with_restricted_permissions(tmp_path: Path) -> None: + home = tmp_path / "home" + account_store = AccountStore(home) + config_store = ConfigStore(home) + + account_store.save( + AccountsDocument( + accounts=( + StoredAccount( + name="main", + client_id="client-id", + client_secret="client-secret", + ), + ) + ) + ) + config_store.save(CliConfigDocument(active_profile="main")) + + assert _mode(home) == 0o700 + assert _mode(home / ACCOUNTS_FILENAME) == 0o600 + assert _mode(home / CONFIG_FILENAME) == 0o600 + + +def test_account_store_round_trip_preserves_account_data(tmp_path: Path) -> None: + home = tmp_path / "home" + store = AccountStore(home) + document = AccountsDocument( + accounts=( + StoredAccount( + name="main", + client_id="client-id", + client_secret="client-secret", + base_url="https://example.test", + user_id=123, + refresh_token="refresh-token", + autoteka_client_secret="autoteka-secret", + ), + ) + ) + + store.save(document) + loaded = store.load() + + assert loaded == document + + +def test_config_stores_active_profile_once(tmp_path: Path) -> None: + home = tmp_path / "home" + ConfigStore(home).save(CliConfigDocument(active_profile="main")) + + config_payload = json.loads((home / CONFIG_FILENAME).read_text(encoding="utf-8")) + + assert config_payload["active_profile"] == "main" + assert "active" not in config_payload + + +def test_atomic_write_uses_same_directory_temporary_file_and_replace( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + home = tmp_path / "home" + replaced_paths: list[tuple[Path, Path]] = [] + real_replace = os.replace + + def recording_replace(source: str | os.PathLike[str], target: str | os.PathLike[str]) -> None: + source_path = Path(source) + target_path = Path(target) + replaced_paths.append((source_path, target_path)) + real_replace(source, target) + + monkeypatch.setattr(os, "replace", recording_replace) + + ConfigStore(home).save(CliConfigDocument(active_profile="main")) + + assert len(replaced_paths) == 1 + source_path, target_path = replaced_paths[0] + assert source_path.parent == home + assert target_path == home / CONFIG_FILENAME + assert not source_path.exists() + + +def test_malformed_json_maps_to_typed_config_error(tmp_path: Path) -> None: + home = tmp_path / "home" + home.mkdir() + (home / CONFIG_FILENAME).write_text("{invalid", encoding="utf-8") + + with pytest.raises(CliConfigFileError) as exc_info: + ConfigStore(home).load() + + assert exc_info.value.code == "CONFIG_INVALID" + assert exc_info.value.exit_code == 7 + + +def test_unsupported_schema_version_maps_to_typed_config_error(tmp_path: Path) -> None: + home = tmp_path / "home" + home.mkdir() + (home / ACCOUNTS_FILENAME).write_text( + json.dumps({"schema_version": 999, "accounts": []}), + encoding="utf-8", + ) + + with pytest.raises(CliConfigFileError): + AccountStore(home).load() + + +def test_permission_error_maps_to_typed_permission_error( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + home = tmp_path / "home" + + def denied_mkdir( + self: Path, + mode: int = 0o777, + parents: bool = False, + exist_ok: bool = False, + ) -> None: + raise PermissionError("denied") + + monkeypatch.setattr(Path, "mkdir", denied_mkdir) + + with pytest.raises(CliPermissionError) as exc_info: + ConfigStore(home).save(CliConfigDocument(active_profile="main")) + + assert exc_info.value.code == "PERMISSION_DENIED" + assert exc_info.value.exit_code == 4 + + +def test_account_json_masks_secrets_for_output_helpers() -> None: + account = StoredAccount( + name="main", + client_id="client-id", + client_secret="client-secret", + refresh_token="refresh-token", + autoteka_client_secret="autoteka-secret", + ) + + payload = account.to_json(mask_secrets=True) + + assert payload["client_id"] == "client-id" + assert payload["client_secret"] == "***" + assert payload["refresh_token"] == "***" + assert payload["autoteka_client_secret"] == "***" + + +def test_accounts_document_masks_nested_account_secrets() -> None: + document = AccountsDocument( + accounts=( + StoredAccount( + name="main", + client_id="client-id", + client_secret="client-secret", + ), + ) + ) + + payload = document.to_json(mask_secrets=True) + + accounts = payload["accounts"] + assert isinstance(accounts, list) + first_account = accounts[0] + assert isinstance(first_account, dict) + assert first_account["client_secret"] == "***" + + +def test_stored_account_converts_to_public_avito_settings() -> None: + account = StoredAccount( + name="main", + client_id="client-id", + client_secret="client-secret", + base_url="https://example.test", + user_id=123, + scope="messenger", + refresh_token="refresh-token", + token_url="/custom-token", + alternate_token_url="/alternate-token", + autoteka_token_url="/autoteka-token", + autoteka_client_id="autoteka-client", + autoteka_client_secret="autoteka-secret", + autoteka_scope="autoteka", + ) + + settings = account.to_avito_settings() + + assert isinstance(settings, AvitoSettings) + assert isinstance(settings.auth, AuthSettings) + assert settings.base_url == "https://example.test" + assert settings.user_id == 123 + assert settings.auth.client_id == "client-id" + assert settings.auth.client_secret == "client-secret" + assert settings.auth.scope == "messenger" + assert settings.auth.refresh_token == "refresh-token" + assert settings.auth.token_url == "/custom-token" + assert settings.auth.alternate_token_url == "/alternate-token" + assert settings.auth.autoteka_token_url == "/autoteka-token" + assert settings.auth.autoteka_client_id == "autoteka-client" + assert settings.auth.autoteka_client_secret == "autoteka-secret" + assert settings.auth.autoteka_scope == "autoteka" + + +def _mode(path: Path) -> int: + return stat.S_IMODE(path.stat().st_mode) diff --git a/tests/cli/test_config_commands.py b/tests/cli/test_config_commands.py new file mode 100644 index 0000000..92bfcaa --- /dev/null +++ b/tests/cli/test_config_commands.py @@ -0,0 +1,56 @@ +"""Tests for local CLI config commands.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from click.testing import CliRunner + +from avito.cli.app import app + + +def test_config_set_get_list_and_unset_active_profile(tmp_path: Path) -> None: + runner = _runner(tmp_path) + + set_result = runner.invoke(app, ["config", "set", "active-profile", "main"]) + get_result = runner.invoke(app, ["config", "get", "active-profile"]) + list_result = runner.invoke(app, ["--json", "config", "list"]) + unset_result = runner.invoke(app, ["config", "unset", "active-profile"]) + missing_result = runner.invoke(app, ["config", "get", "active-profile"]) + + assert set_result.exit_code == 0 + assert get_result.exit_code == 0 + assert get_result.stdout.strip() == "main" + assert json.loads(list_result.stdout)["config"]["active-profile"]["value"] == "main" + assert unset_result.exit_code == 0 + assert missing_result.exit_code == 0 + assert missing_result.stdout == "\n" + + +def test_config_list_show_source_prefers_cli_profile_override(tmp_path: Path) -> None: + runner = _runner(tmp_path) + runner.invoke(app, ["config", "set", "active-profile", "stored"]) + + result = runner.invoke( + app, + ["--profile", "override", "--json", "config", "list", "--show-source"], + ) + + assert result.exit_code == 0 + entry = json.loads(result.stdout)["config"]["active-profile"] + assert entry["value"] == "override" + assert entry["source"] == "cli" + assert entry["path"] is None + + +def test_config_rejects_unknown_key(tmp_path: Path) -> None: + result = _runner(tmp_path).invoke(app, ["config", "get", "unknown"]) + + assert result.exit_code == 2 + assert "CLI_USAGE_ERROR" in result.stderr + assert "не поддерживается" in result.stderr + + +def _runner(tmp_path: Path) -> CliRunner: + return CliRunner(env={"AVITO_PY_HOME": str(tmp_path / "home")}) diff --git a/tests/cli/test_domain_smoke_commands.py b/tests/cli/test_domain_smoke_commands.py new file mode 100644 index 0000000..91dd552 --- /dev/null +++ b/tests/cli/test_domain_smoke_commands.py @@ -0,0 +1,226 @@ +"""Generated API command smoke tests.""" + +from __future__ import annotations + +import warnings +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from avito.cli.app import app +from avito.cli.config import ( + AccountsDocument, + AccountStore, + CliConfigDocument, + ConfigStore, + StoredAccount, +) +from avito.cli.registry import ApiCommandRecord, build_cli_registry +from avito.cli.safety import confirmation_value +from avito.cli.schemas import CliParameterSchema, CliValueKind +from avito.config import AvitoSettings +from avito.core.swagger_registry import load_swagger_registry +from avito.testing import SwaggerFakeTransport + +_READ_COMMANDS = tuple( + command + for command in build_cli_registry().api_commands + if command.http_method in {"GET", "HEAD"} +) +_WRITE_COMMANDS = tuple( + command + for command in build_cli_registry().api_commands + if command.http_method not in {"GET", "HEAD"} +) + + +@pytest.mark.parametrize("command", _READ_COMMANDS, ids=lambda command: command.command_id) +def test_read_only_api_command_is_registered_and_renders_help( + command: ApiCommandRecord, + tmp_path: Path, +) -> None: + result = CliRunner(env={"AVITO_PY_HOME": str(tmp_path / "home")}).invoke( + app, + [command.resource, command.action, "--help"], + ) + + assert result.exit_code == 0 + assert _squash_whitespace(command.description) in _squash_whitespace(result.output) + for parameter in command.parameters: + assert parameter.flag in result.output + assert not (tmp_path / "home").exists() + + +@pytest.mark.parametrize("command", _READ_COMMANDS, ids=lambda command: command.command_id) +def test_read_only_api_command_runs_through_fake_transport( + command: ApiCommandRecord, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + fake = SwaggerFakeTransport(registry=load_swagger_registry()) + fake.add_success_operation(command.operation_key) + _install_fake_client(monkeypatch, fake) + _write_account(tmp_path) + + args = ["--profile", "main", command.resource, command.action, *_cli_args(command)] + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + result = CliRunner(env={"AVITO_PY_HOME": str(tmp_path)}).invoke(app, args) + + assert result.exit_code == 0, result.output + assert fake.count() >= 1 + + +def test_every_read_factory_has_at_least_one_smoke_command() -> None: + smoked_factories = {command.factory for command in _READ_COMMANDS} + read_factories = { + command.factory + for command in build_cli_registry().api_commands + if command.http_method in {"GET", "HEAD"} + } + + assert smoked_factories == read_factories + + +@pytest.mark.parametrize("command", _WRITE_COMMANDS, ids=lambda command: command.command_id) +def test_write_api_command_is_registered_and_renders_help( + command: ApiCommandRecord, + tmp_path: Path, +) -> None: + result = CliRunner(env={"AVITO_PY_HOME": str(tmp_path / "home")}).invoke( + app, + [command.resource, command.action, "--help"], + ) + + assert result.exit_code == 0 + assert _squash_whitespace(command.description) in _squash_whitespace(result.output) + for parameter in command.parameters: + assert parameter.flag in result.output + if command.safety in {"write", "destructive", "expensive"}: + assert "--yes" in result.output + assert "--confirm" in result.output + assert not (tmp_path / "home").exists() + + +@pytest.mark.parametrize("command", _WRITE_COMMANDS, ids=lambda command: command.command_id) +def test_write_api_command_runs_through_fake_transport( + command: ApiCommandRecord, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + fake = SwaggerFakeTransport(registry=load_swagger_registry()) + fake.add_success_operation(command.operation_key) + _install_fake_client(monkeypatch, fake) + _write_account(tmp_path) + + args = ["--profile", "main", command.resource, command.action, *_cli_args(command)] + if command.safety_policy.confirmation_required: + args.extend(("--confirm", confirmation_value(command))) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + result = CliRunner(env={"AVITO_PY_HOME": str(tmp_path)}).invoke(app, args) + + assert result.exit_code == 0, result.output + assert fake.count() >= 1 + + +def test_every_write_factory_has_at_least_one_smoke_command_or_exclusion() -> None: + registry = build_cli_registry() + smoked_factories = {command.factory for command in _WRITE_COMMANDS} + excluded_factories = { + _resource_to_factory(exclusion.command_id) + for exclusion in registry.exclusions + if exclusion.category == "api" + and exclusion.command_id is not None + and exclusion.status == "temporary" + } + write_factories = { + command.factory + for command in registry.api_commands + if command.http_method not in {"GET", "HEAD"} + } + + assert write_factories <= smoked_factories | excluded_factories + + +def _install_fake_client( + monkeypatch: pytest.MonkeyPatch, + fake: SwaggerFakeTransport, +) -> None: + def client_factory(settings: AvitoSettings) -> object: + return fake.as_client(user_id=settings.user_id) + + monkeypatch.setattr("avito.cli.commands._default_client_factory", client_factory) + + +def _write_account(tmp_path: Path) -> None: + account = StoredAccount( + name="main", + client_id="client-id", + client_secret="client-secret", + user_id=7, + ) + AccountStore(tmp_path).save(AccountsDocument(accounts=(account,))) + ConfigStore(tmp_path).save(CliConfigDocument(active_profile="main")) + + +def _cli_args(command: ApiCommandRecord) -> tuple[str, ...]: + args: list[str] = [] + for parameter in command.parameters: + args.extend((parameter.flag, _value_for_parameter(parameter))) + return tuple(args) + + +def _value_for_parameter(parameter: CliParameterSchema) -> str: + if parameter.value_kind == "list": + item_kind = parameter.item_value_kind or "string" + return ",".join((_value_for_kind(parameter.name, item_kind),)) + if parameter.value_kind == "enum": + enum_values = tuple( + value + for value in parameter.enum_values + if value not in {"__unknown__", "unknown"} + ) + if enum_values: + return enum_values[0] + return parameter.enum_values[0] + return _value_for_kind(parameter.name, parameter.value_kind) + + +def _value_for_kind(name: str, value_kind: CliValueKind) -> str: + if value_kind == "integer": + if name == "user_id": + return "7" + return "101" + if value_kind == "float": + return "100.5" + if value_kind == "boolean": + return "true" + if value_kind == "date": + return "2026-05-01" + if value_kind == "datetime": + return "2026-05-01T00:00:00+00:00" + if value_kind == "enum": + raise AssertionError("Enum value requires parameter metadata.") + return _string_value(name) + + +def _resource_to_factory(command_id: str) -> str: + return command_id.split(".", maxsplit=1)[0].replace("-", "_") + + +def _string_value(name: str) -> str: + if name.endswith("_id"): + return "101" + if "date" in name or name.endswith("_from") or name.endswith("_to"): + return "2026-05-01" + if name.endswith("_slug"): + return "cars" + if name == "price": + return "1500" + return "value" + + +def _squash_whitespace(value: str) -> str: + return " ".join(value.split()) diff --git a/tests/cli/test_errors.py b/tests/cli/test_errors.py new file mode 100644 index 0000000..0eabb7c --- /dev/null +++ b/tests/cli/test_errors.py @@ -0,0 +1,63 @@ +"""Tests for CLI error rendering.""" + +from __future__ import annotations + +import json + +from click.testing import CliRunner + +from avito.cli.app import app + + +def test_human_errors_go_to_stderr() -> None: + result = CliRunner().invoke(app, ["--plain", "--table", "version"]) + + assert result.exit_code == 2 + assert result.stdout == "" + assert "INVALID_FLAG_COMBINATION" in result.stderr + assert "нельзя использовать вместе" in result.stderr + + +def test_json_errors_are_valid_json_on_stderr() -> None: + result = CliRunner().invoke(app, ["--json", "--plain", "version"]) + + assert result.exit_code == 2 + assert result.stdout == "" + payload = json.loads(result.stderr) + assert payload == { + "code": "INVALID_FLAG_COMBINATION", + "exit_code": 2, + "message": "Флаги --json, --plain, --table и --wide нельзя использовать вместе.", + } + + +def test_debug_diagnostics_are_sanitized() -> None: + result = CliRunner().invoke( + app, + ["--debug", "help", "client_secret=super-secret"], + ) + + assert result.exit_code == 2 + assert "details=" in result.stderr + assert "client_secret" not in result.stderr + assert "super-secret" not in result.stderr + assert "***" in result.stderr + + +def test_json_debug_diagnostics_are_sanitized() -> None: + result = CliRunner().invoke( + app, + ["--json", "--debug", "help", "client_secret=super-secret"], + ) + + assert result.exit_code == 2 + payload = json.loads(result.stderr) + assert payload["details"] == {"topic": ["***"]} + assert "client_secret" not in result.stderr + assert "super-secret" not in result.stderr + + +def test_invalid_flag_combinations_exit_with_code_2() -> None: + result = CliRunner().invoke(app, ["--json", "--plain", "version"]) + + assert result.exit_code == 2 diff --git a/tests/cli/test_helper_workflows.py b/tests/cli/test_helper_workflows.py new file mode 100644 index 0000000..9d752ae --- /dev/null +++ b/tests/cli/test_helper_workflows.py @@ -0,0 +1,270 @@ +"""Tests for public helper workflow CLI commands.""" + +from __future__ import annotations + +from pathlib import Path +from types import TracebackType + +import pytest +from click.testing import CliRunner + +from avito.cli.app import app +from avito.cli.commands import invoke_helper_command +from avito.cli.config import ( + AccountsDocument, + AccountStore, + CliConfigDocument, + ConfigStore, + StoredAccount, +) +from avito.cli.context import CliContext +from avito.cli.registry import HelperCommandRecord, build_cli_registry +from avito.config import AvitoSettings + +_HELPER_COMMANDS = build_cli_registry().helper_commands + + +def test_helper_metadata_covers_public_workflows_and_business_summary_exclusion() -> None: + registry = build_cli_registry() + + assert {record.command_id for record in registry.helper_commands} == { + "account-health.show", + "capabilities.show", + "chat-summary.show", + "listing-health.show", + "order-summary.show", + "promotion-summary.show", + "review-summary.show", + } + assert all(record.implemented for record in registry.helper_commands) + assert { + exclusion.command_id + for exclusion in registry.exclusions + if exclusion.category == "helper" + } == {"business-summary.show"} + + +def test_helper_invocation_uses_public_client_method_path( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + command = _helper_command("listing-health.show") + factory = _RecordingHelperClientFactory() + _write_account(tmp_path) + monkeypatch.setenv("AVITO_PY_HOME", str(tmp_path)) + + result = invoke_helper_command( + _ctx(), + command, + { + "user_id": ("7",), + "limit": ("3",), + "page_size": ("2",), + "date_from": ("2026-05-01",), + }, + client_factory=factory, + ) + + assert result["helper"] == "listing_health" + assert factory.constructed_client_ids == ["client-id"] + assert factory.client.entered == 1 + assert factory.client.exited == 1 + assert factory.client.calls["listing_health"][0]["user_id"] == 7 + assert factory.client.calls["listing_health"][0]["limit"] == 3 + + +def test_helper_commands_do_not_conflict_with_api_or_local_commands() -> None: + registry = build_cli_registry() + helper_paths = {(record.resource, record.action) for record in registry.helper_commands} + api_paths = {(record.resource, record.action) for record in registry.api_commands} + local_paths = {(record.resource, record.action) for record in registry.local_commands} + + assert helper_paths.isdisjoint(api_paths) + assert helper_paths.isdisjoint(local_paths) + + +def test_helper_help_is_registered_without_creating_account_files(tmp_path: Path) -> None: + command = _helper_command("promotion-summary.show") + home = tmp_path / "home" + + result = CliRunner(env={"AVITO_PY_HOME": str(home)}).invoke( + app, + [command.resource, command.action, "--help"], + ) + + assert result.exit_code == 0 + assert command.description in result.output + assert "--item-ids" in result.output + assert not home.exists() + + +def test_helper_json_output_is_sanitized( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _write_account(tmp_path) + factory = _RecordingHelperClientFactory() + monkeypatch.setattr( + "avito.cli.commands._default_client_factory", + factory, + ) + + result = CliRunner(env={"AVITO_PY_HOME": str(tmp_path)}).invoke( + app, + [ + "--profile", + "main", + "--json", + "--no-input", + "capabilities", + "show", + ], + ) + + assert result.exit_code == 0, result.output + assert "raw-secret" not in result.output + assert "***" in result.output + assert factory.client.calls["capabilities"] == [{}] + + +def test_all_helper_commands_are_registered_and_execute_through_fake_client( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _write_account(tmp_path) + + for command in _HELPER_COMMANDS: + factory = _RecordingHelperClientFactory() + monkeypatch.setattr( + "avito.cli.commands._default_client_factory", + factory, + ) + args = [ + "--profile", + "main", + "--json", + "--no-input", + command.resource, + command.action, + *_cli_args(command), + ] + + result = CliRunner(env={"AVITO_PY_HOME": str(tmp_path)}).invoke(app, args) + + assert result.exit_code == 0, result.output + assert factory.client.calls[command.sdk_method_name] + + +def _helper_command(command_id: str) -> HelperCommandRecord: + matches = [record for record in _HELPER_COMMANDS if record.command_id == command_id] + assert len(matches) == 1 + return matches[0] + + +def _cli_args(command: HelperCommandRecord) -> tuple[str, ...]: + args: list[str] = [] + for parameter in command.parameters: + args.extend((parameter.flag, _value_for_parameter(parameter.name))) + return tuple(args) + + +def _value_for_parameter(name: str) -> str: + if name in {"user_id", "limit", "page_size", "listing_limit", "listing_page_size"}: + return "7" + if name in {"date_from", "date_to"}: + return "2026-05-01" + if name == "item_ids": + return "101,102" + return "value" + + +def _write_account(tmp_path: Path) -> None: + account = StoredAccount( + name="main", + client_id="client-id", + client_secret="client-secret", + user_id=7, + ) + AccountStore(tmp_path).save(AccountsDocument(accounts=(account,))) + ConfigStore(tmp_path).save(CliConfigDocument(active_profile="main")) + + +def _ctx() -> CliContext: + return CliContext( + profile="main", + config=None, + json_output=False, + plain=False, + table=False, + wide=False, + quiet=False, + no_input=True, + no_color=True, + verbose=False, + debug=False, + timeout=None, + ) + + +class _RecordingHelperClientFactory: + def __init__(self) -> None: + self.constructed_client_ids: list[str] = [] + self.client = _RecordingHelperClient() + + def __call__(self, settings: AvitoSettings) -> _RecordingHelperClient: + self.constructed_client_ids.append(settings.auth.client_id) + return self.client + + +class _RecordingHelperClient: + def __init__(self) -> None: + self.entered = 0 + self.exited = 0 + self.calls: dict[str, list[dict[str, object]]] = { + "account_health": [], + "listing_health": [], + "chat_summary": [], + "order_summary": [], + "review_summary": [], + "promotion_summary": [], + "capabilities": [], + } + + def __enter__(self) -> _RecordingHelperClient: + self.entered += 1 + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: + self.exited += 1 + + def account_health(self, **kwargs: object) -> dict[str, object]: + return self._record("account_health", kwargs) + + def listing_health(self, **kwargs: object) -> dict[str, object]: + return self._record("listing_health", kwargs) + + def chat_summary(self, **kwargs: object) -> dict[str, object]: + return self._record("chat_summary", kwargs) + + def order_summary(self) -> dict[str, object]: + return self._record("order_summary", {}) + + def review_summary(self) -> dict[str, object]: + return self._record("review_summary", {}) + + def promotion_summary(self, **kwargs: object) -> dict[str, object]: + return self._record("promotion_summary", kwargs) + + def capabilities(self) -> dict[str, object]: + result = self._record("capabilities", {}) + result["client_secret"] = "raw-secret" + return result + + def _record(self, name: str, kwargs: dict[str, object]) -> dict[str, object]: + self.calls[name].append(kwargs) + return {"helper": name, "kwargs": kwargs} diff --git a/tests/cli/test_registry.py b/tests/cli/test_registry.py new file mode 100644 index 0000000..27f68bd --- /dev/null +++ b/tests/cli/test_registry.py @@ -0,0 +1,228 @@ +"""Tests for CLI command registry metadata.""" + +from __future__ import annotations + +import json +from collections import Counter +from dataclasses import replace +from pathlib import Path + +import pytest + +from avito.cli.registry import ( + ApiCommandRecord, + build_cli_registry, + kebab_case, + validate_cli_registry, +) +from avito.core.swagger_discovery import discover_swagger_bindings +from avito.core.swagger_registry import load_swagger_registry + + +def test_registry_builds_without_account_files_or_client_construction( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + home = tmp_path / "home" + monkeypatch.setenv("AVITO_PY_HOME", str(home)) + + def fail_client_init(self: object) -> None: + raise AssertionError("AvitoClient must not be constructed by registry") + + monkeypatch.setattr("avito.client.AvitoClient.__init__", fail_client_init) + + registry = build_cli_registry() + + assert registry.to_dict()["summary"] == { + "api_command_candidates": 162, + "api_exclusions": 42, + "helper_command_candidates": 7, + "helper_exclusions": 1, + "local_commands": 16, + "aliases": 1, + "execution_smoke_exclusions": 0, + } + assert not home.exists() + + +def test_registry_represents_every_sync_binding_as_candidate_or_exclusion() -> None: + swagger_registry = load_swagger_registry() + discovery = discover_swagger_bindings(registry=swagger_registry) + registry = build_cli_registry(swagger_registry=swagger_registry, discovery=discovery) + + sync_bindings = tuple( + binding + for binding in discovery.bindings + if binding.variant == "sync" and binding.operation_key is not None + ) + command_operation_keys = {record.operation_key for record in registry.api_commands} + excluded_operation_keys = { + exclusion.operation_key + for exclusion in registry.exclusions + if exclusion.category == "api" + } + + assert len(sync_bindings) == 204 + assert len(command_operation_keys) == 162 + assert len(excluded_operation_keys) == 42 + assert command_operation_keys.isdisjoint(excluded_operation_keys) + assert command_operation_keys | excluded_operation_keys == { + binding.operation_key for binding in sync_bindings + } + + +def test_api_command_records_preserve_swagger_and_sdk_metadata() -> None: + registry = build_cli_registry() + + command = _api_command(registry.api_commands, "account.get-balance") + + assert command.operation_key == ( + "Информацияопользователе.json GET /core/v1/accounts/{user_id}/balance" + ) + assert command.resource == "account" + assert command.action == "get-balance" + assert command.sdk_module == "avito.accounts.domain" + assert command.sdk_class == "Account" + assert command.sdk_method_name == "get_balance" + assert command.sdk_method == "avito.accounts.domain.Account.get_balance" + assert command.factory == "account" + assert command.factory_args == {"user_id": "path.user_id"} + assert command.method_args == {} + assert command.spec == "Информацияопользователе.json" + assert command.http_method == "GET" + assert command.path == "/core/v1/accounts/{user_id}/balance" + assert command.operation_id == "getUserBalance" + assert command.domain == "accounts" + assert command.deprecated is False + assert command.legacy is False + assert len(command.parameters) == 1 + parameter = command.parameters[0] + assert parameter.name == "user_id" + assert parameter.source == "factory" + assert parameter.binding_expression == "path.user_id" + assert parameter.flag == "--user-id" + assert parameter.value_kind == "integer" + + +def test_registry_keeps_record_categories_separate() -> None: + registry = build_cli_registry() + + assert _api_command(registry.api_commands, "account.get-self").factory == "account" + assert {record.command_id for record in registry.helper_commands} == { + "account-health.show", + "capabilities.show", + "chat-summary.show", + "listing-health.show", + "order-summary.show", + "promotion-summary.show", + "review-summary.show", + } + assert {record.command_id for record in registry.local_commands} >= { + "account.add", + "account.delete", + "help.show", + "version.show", + } + assert [alias.alias_id for alias in registry.aliases] == ["account.remove"] + assert { + exclusion.category + for exclusion in registry.exclusions + } == {"api", "helper"} + + +def test_registry_records_include_help_metadata() -> None: + registry = build_cli_registry() + + api_command = _api_command(registry.api_commands, "account.get-self") + helper_command = registry.helper_commands[0] + local_command = registry.local_commands[0] + + assert api_command.description + assert api_command.examples[0].startswith("avito account get-self") + assert api_command.safety == "read" + assert api_command.safety_summary + assert api_command.output_hint == "object" + assert helper_command.examples + assert helper_command.safety == "read" + assert local_command.examples + assert local_command.safety in {"local", "destructive"} + + +def test_registry_rejects_local_api_command_collision() -> None: + registry = build_cli_registry() + colliding_local = replace( + registry.local_commands[0], + command_id="account.get-self-local", + resource="account", + action="get-self", + ) + invalid_registry = replace( + registry, + local_commands=(colliding_local, *registry.local_commands[1:]), + ) + + with pytest.raises(ValueError, match="конфликт команд"): + validate_cli_registry(invalid_registry) + + +def test_registry_rejects_alias_collision_and_unknown_target() -> None: + registry = build_cli_registry() + colliding_alias = replace( + registry.aliases[0], + alias_id="account.get-self", + resource="account", + action="get-self", + ) + unknown_target_alias = replace( + registry.aliases[0], + alias_id="account.unknown", + target_command_id="account.unknown-target", + ) + + with pytest.raises(ValueError, match="конфликтует с canonical command"): + validate_cli_registry(replace(registry, aliases=(colliding_alias,))) + + with pytest.raises(ValueError, match="неизвестную команду"): + validate_cli_registry(replace(registry, aliases=(unknown_target_alias,))) + + +def test_registry_report_is_json_compatible_and_deterministic() -> None: + first = build_cli_registry().to_dict() + second = build_cli_registry().to_dict() + + first_text = json.dumps(first, ensure_ascii=False, sort_keys=True) + second_text = json.dumps(second, ensure_ascii=False, sort_keys=True) + + assert first_text == second_text + assert json.loads(first_text) == first + + +def test_api_command_ids_are_canonical_and_unique() -> None: + registry = build_cli_registry() + command_ids = [record.command_id for record in registry.api_commands] + operation_keys = [record.operation_key for record in registry.api_commands] + flags = [ + parameter.flag + for command in registry.api_commands + for parameter in command.parameters + ] + + assert len(command_ids) == len(set(command_ids)) + assert len(operation_keys) == len(set(operation_keys)) + assert all(flag.startswith("--") for flag in flags) + assert all("_" not in flag for flag in flags) + + +def test_kebab_case_rejects_empty_names() -> None: + with pytest.raises(ValueError): + kebab_case("___") + + +def _api_command( + commands: tuple[ApiCommandRecord, ...], + command_id: str, +) -> ApiCommandRecord: + matches = [record for record in commands if record.command_id == command_id] + counts = Counter(record.command_id for record in commands) + assert counts[command_id] == 1 + return matches[0] diff --git a/tests/cli/test_schemas.py b/tests/cli/test_schemas.py new file mode 100644 index 0000000..61996a3 --- /dev/null +++ b/tests/cli/test_schemas.py @@ -0,0 +1,166 @@ +"""Tests for CLI input schema metadata and coercion.""" + +from __future__ import annotations + +from datetime import date, datetime +from typing import cast + +import pytest + +from avito.cli.errors import CliValidationError +from avito.cli.registry import ApiCommandRecord, build_cli_registry +from avito.cli.schemas import ( + CliParameterSchema, + CliValueKind, + coerce_cli_value, + coerce_cli_values, +) + + +def test_generated_parameter_metadata_uses_binding_arguments_only() -> None: + registry = build_cli_registry() + + command = _api_command(registry.api_commands, "account.get-balance") + + assert [parameter.name for parameter in command.parameters] == ["user_id"] + assert command.factory_args == {"user_id": "path.user_id"} + assert command.method_args == {} + assert command.parameters[0].source == "factory" + assert command.parameters[0].value_kind == "integer" + assert command.parameters[0].required is False + + +def test_generated_parameter_metadata_excludes_timeout_and_retry_controls() -> None: + registry = build_cli_registry() + + names = { + parameter.name + for command in registry.api_commands + for parameter in command.parameters + } + flags = { + parameter.flag + for command in registry.api_commands + for parameter in command.parameters + } + + assert "timeout" not in names + assert "retry" not in names + assert "--timeout" not in flags + assert "--retry" not in flags + + +def test_generated_metadata_classifies_dates_enums_and_lists() -> None: + registry = build_cli_registry() + + history = _api_command(registry.api_commands, "account.get-operations-history") + apply = _api_command(registry.api_commands, "application.apply") + budget = _api_command(registry.api_commands, "autostrategy-campaign.create-budget") + + assert _parameter(history, "date_from").value_kind == "datetime" + assert _parameter(history, "date_to").value_kind == "datetime" + assert _parameter(apply, "ids").value_kind == "list" + assert _parameter(apply, "ids").item_value_kind == "string" + assert _parameter(budget, "campaign_type").value_kind == "enum" + assert _parameter(budget, "campaign_type").enum_values + + +def test_coercion_supports_primitive_date_datetime_enum_and_list_values() -> None: + schemas = ( + _schema("user_id", "--user-id", "integer"), + _schema("price", "--price", "float"), + _schema("enabled", "--enabled", "boolean"), + _schema("date_from", "--date-from", "date"), + _schema("created_at", "--created-at", "datetime"), + _schema("campaign_type", "--campaign-type", "enum", enum_values=("vas", "cpa")), + _schema("item_ids", "--item-ids", "list", item_value_kind="integer"), + ) + + coerced = coerce_cli_values( + schemas, + { + "user_id": ("123",), + "price": ("12.5",), + "enabled": ("да",), + "date_from": ("2026-05-10",), + "created_at": ("2026-05-10T12:30:00Z",), + "campaign_type": ("CPA",), + "item_ids": ("1,2", "3"), + }, + no_input=True, + ) + + assert coerced["user_id"] == 123 + assert coerced["price"] == 12.5 + assert coerced["enabled"] is True + assert coerced["date_from"] == date(2026, 5, 10) + assert coerced["created_at"] == datetime.fromisoformat("2026-05-10T12:30:00+00:00") + assert coerced["campaign_type"] == "cpa" + assert coerced["item_ids"] == [1, 2, 3] + + +def test_repeated_flags_and_comma_separated_values_are_equivalent() -> None: + schema = _schema("ids", "--ids", "list", item_value_kind="string") + + repeated = coerce_cli_value(schema, ("one", "two")) + comma_separated = coerce_cli_value(schema, ("one,two",)) + + assert repeated == comma_separated + + +def test_invalid_values_raise_russian_validation_error() -> None: + schema = _schema("user_id", "--user-id", "integer") + + with pytest.raises(CliValidationError) as exc_info: + coerce_cli_value(schema, ("not-number",)) + + assert exc_info.value.code == "VALIDATION_FAILED" + assert "Параметр --user-id должен быть целым числом." in exc_info.value.message + + +def test_missing_required_value_with_no_input_raises_validation_error() -> None: + schema = _schema("date_from", "--date-from", "date", required=True) + + with pytest.raises(CliValidationError) as exc_info: + coerce_cli_values((schema,), {}, no_input=True) + + assert exc_info.value.code == "VALIDATION_FAILED" + assert "Интерактивный ввод отключен" in exc_info.value.message + + +def _schema( + name: str, + flag: str, + value_kind: str, + *, + required: bool = False, + item_value_kind: str | None = None, + enum_values: tuple[str, ...] = (), +) -> CliParameterSchema: + return CliParameterSchema( + name=name, + source="method", + binding_expression=f"body.{name}", + flag=flag, + value_kind=cast(CliValueKind, value_kind), + required=required, + multiple=value_kind == "list", + item_value_kind=cast(CliValueKind | None, item_value_kind), + annotation=value_kind, + enum_values=enum_values, + ) + + +def _api_command( + commands: tuple[ApiCommandRecord, ...], + command_id: str, +) -> ApiCommandRecord: + matches = tuple(record for record in commands if record.command_id == command_id) + assert len(matches) == 1 + return matches[0] + + +def _parameter(command: ApiCommandRecord, name: str) -> CliParameterSchema: + matches = tuple(parameter for parameter in command.parameters if parameter.name == name) + assert len(matches) == 1 + return matches[0] diff --git a/tests/cli/test_serialization.py b/tests/cli/test_serialization.py new file mode 100644 index 0000000..ed4ea5e --- /dev/null +++ b/tests/cli/test_serialization.py @@ -0,0 +1,187 @@ +"""Tests for CLI result serialization and pagination rendering.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from datetime import date, datetime +from enum import StrEnum + +from avito.cli.context import CliContext +from avito.cli.serialization import ( + SerializationOptions, + render_cli_result, + serialize_cli_result, +) +from avito.core.models import ApiModel +from avito.core.pagination import PaginatedList +from avito.core.types import JsonPage + + +class _Status(StrEnum): + ACTIVE = "active" + + +@dataclass(frozen=True, slots=True) +class _SdkModel(ApiModel): + item_id: int + status: _Status + created_at: datetime + client_secret: str + + +@dataclass(frozen=True, slots=True) +class _LocalModel: + name: str + today: date + tags: tuple[str, ...] + + +def test_sdk_model_serializes_through_public_contract_and_masks_secrets() -> None: + model = _SdkModel( + item_id=10, + status=_Status.ACTIVE, + created_at=datetime(2026, 5, 10, 12, 30, 0), + client_secret="raw-secret", + ) + + result = serialize_cli_result(model) + + assert result == { + "item_id": 10, + "status": "active", + "created_at": "2026-05-10T12:30:00", + "client_secret": "***", + } + + +def test_cli_local_dataclasses_enums_dates_lists_and_primitives_serialize_safely() -> None: + result = serialize_cli_result( + { + "model": _LocalModel( + name="local", + today=date(2026, 5, 10), + tags=("a", "b"), + ), + "enabled": True, + "count": 2, + "binary": b"abc", + } + ) + + assert result == { + "model": { + "name": "local", + "today": "2026-05-10", + "tags": ["a", "b"], + }, + "enabled": True, + "count": 2, + "binary": "YWJj", + } + + +def test_paginated_result_defaults_to_one_loaded_page_without_unbounded_fetch() -> None: + calls: list[int] = [] + pages = { + 1: JsonPage(items=[_LocalModel("one", date(2026, 5, 10), ())], page=1, per_page=1, total=3), + 2: JsonPage(items=[_LocalModel("two", date(2026, 5, 11), ())], page=2, per_page=1, total=3), + 3: JsonPage(items=[_LocalModel("three", date(2026, 5, 12), ())], page=3, per_page=1, total=3), + } + + def fetch(page: int | None, cursor: str | None) -> JsonPage[_LocalModel]: + resolved_page = page or 1 + calls.append(resolved_page) + return pages[resolved_page] + + items = PaginatedList(fetch) + + result = serialize_cli_result(items) + + assert calls == [1] + assert result == { + "items": [ + { + "name": "one", + "today": "2026-05-10", + "tags": [], + } + ], + "pagination": { + "loaded_count": 1, + "known_total": 3, + "source_total": None, + "is_materialized": False, + "limit": None, + "page_limit": 1, + "truncated": True, + }, + } + + +def test_paginated_result_materializes_only_with_explicit_all_option() -> None: + calls: list[int] = [] + pages = { + 1: JsonPage(items=[1], page=1, per_page=1, total=2), + 2: JsonPage(items=[2], page=2, per_page=1, total=2), + } + + def fetch(page: int | None, cursor: str | None) -> JsonPage[int]: + resolved_page = page or 1 + calls.append(resolved_page) + return pages[resolved_page] + + items = PaginatedList(fetch) + + result = serialize_cli_result(items, options=SerializationOptions(all_items=True)) + + assert calls == [1, 2] + assert result == { + "items": [1, 2], + "pagination": { + "loaded_count": 2, + "known_total": 2, + "source_total": None, + "is_materialized": True, + "limit": None, + "page_limit": None, + "truncated": False, + }, + } + + +def test_json_and_human_rendering_use_same_sanitized_payload() -> None: + value = [{"item_id": 1, "name": "First", "client_secret": "raw-secret"}] + + json_output = render_cli_result(_ctx(json_output=True), value) + table_output = render_cli_result(_ctx(table=True), value) + grouped_output = render_cli_result(_ctx(), {"item_id": 1, "name": "First"}) + + assert json.loads(json_output) == [ + {"client_secret": "***", "item_id": 1, "name": "First"} + ] + assert table_output == "ITEM_ID NAME CLIENT_SECRET\n1 First ***" + assert grouped_output == "item_id: 1\nname: First" + assert "raw-secret" not in json_output + assert "raw-secret" not in table_output + + +def _ctx( + *, + json_output: bool = False, + table: bool = False, +) -> CliContext: + return CliContext( + profile=None, + config=None, + json_output=json_output, + plain=False, + table=table, + wide=False, + quiet=False, + no_input=True, + no_color=True, + verbose=False, + debug=False, + timeout=None, + ) diff --git a/tests/cli/test_status_doctor.py b/tests/cli/test_status_doctor.py new file mode 100644 index 0000000..0f343ca --- /dev/null +++ b/tests/cli/test_status_doctor.py @@ -0,0 +1,98 @@ +"""Tests for CLI status and doctor commands.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from click.testing import CliRunner + +from avito.cli.app import app + + +def test_status_reports_ready_account_without_network(tmp_path: Path) -> None: + runner = _runner(tmp_path) + _add_account(runner) + + result = runner.invoke(app, ["--json", "status"]) + + assert result.exit_code == 0 + payload = json.loads(result.stdout)["status"] + assert payload["ready"] is True + assert payload["selected_profile"] == "main" + assert payload["account_found"] is True + assert payload["network_checked"] is False + + +def test_status_reports_missing_account_as_not_ready(tmp_path: Path) -> None: + result = _runner(tmp_path).invoke(app, ["--json", "status"]) + + assert result.exit_code == 0 + payload = json.loads(result.stdout)["status"] + assert payload["ready"] is False + assert payload["selected_profile"] is None + assert payload["configured_accounts"] == 0 + + +def test_doctor_reports_ok_for_valid_local_files(tmp_path: Path) -> None: + runner = _runner(tmp_path) + _add_account(runner) + + result = runner.invoke(app, ["--json", "doctor"]) + + assert result.exit_code == 0 + payload = json.loads(result.stdout)["doctor"] + assert payload["status"] == "ok" + assert payload["issues"] == [] + assert payload["network_checked"] is False + + +def test_doctor_reports_malformed_config_without_leaking_secrets(tmp_path: Path) -> None: + home = tmp_path / "home" + home.mkdir() + (home / "config.json").write_text("{invalid", encoding="utf-8") + (home / "accounts.json").write_text( + json.dumps( + { + "schema_version": 1, + "accounts": [ + { + "name": "main", + "client_id": "client-id", + "client_secret": "raw-secret", + } + ], + } + ), + encoding="utf-8", + ) + + result = _runner(tmp_path).invoke(app, ["--json", "doctor"]) + + assert result.exit_code == 7 + assert "CONFIG_INVALID" in result.stderr + assert "raw-secret" not in result.stdout + assert "raw-secret" not in result.stderr + payload = json.loads(result.stdout)["doctor"] + assert payload["status"] == "error" + assert payload["issues"][0]["name"] == "config" + + +def _runner(tmp_path: Path) -> CliRunner: + return CliRunner(env={"AVITO_PY_HOME": str(tmp_path / "home")}) + + +def _add_account(runner: CliRunner) -> None: + result = runner.invoke( + app, + [ + "account", + "add", + "main", + "--client-id", + "client-id", + "--client-secret", + "client-secret", + ], + ) + assert result.exit_code == 0 diff --git a/tests/cli/test_ui.py b/tests/cli/test_ui.py new file mode 100644 index 0000000..6b2b47a --- /dev/null +++ b/tests/cli/test_ui.py @@ -0,0 +1,65 @@ +"""Tests for CLI UI helpers and global output flags.""" + +from __future__ import annotations + +import os + +from click.testing import CliRunner + +from avito.cli.app import app + + +def test_quiet_suppresses_non_essential_success_output() -> None: + result = CliRunner().invoke(app, ["--quiet", "version"]) + + assert result.exit_code == 0 + assert result.stdout == "" + assert result.stderr == "" + + +def test_quiet_keeps_json_command_result() -> None: + result = CliRunner().invoke(app, ["--json", "--quiet", "version"]) + + assert result.exit_code == 0 + assert result.stdout.startswith('{"version":') + assert result.stderr == "" + + +def test_verbose_global_flag_is_accepted() -> None: + result = CliRunner().invoke(app, ["--verbose", "version"]) + + assert result.exit_code == 0 + assert result.stdout.startswith("avito-py ") + + +def test_debug_global_flag_is_accepted_on_success() -> None: + result = CliRunner().invoke(app, ["--debug", "version"]) + + assert result.exit_code == 0 + assert result.stdout.startswith("avito-py ") + assert result.stderr == "" + + +def test_no_color_disables_error_color() -> None: + result = CliRunner().invoke( + app, + ["--no-color", "--plain", "--table", "version"], + color=True, + ) + + assert result.exit_code == 2 + assert "\x1b[" not in result.stderr + + +def test_no_color_environment_disables_error_color() -> None: + env = dict(os.environ) + env["NO_COLOR"] = "1" + + result = CliRunner(env=env).invoke( + app, + ["--plain", "--table", "version"], + color=True, + ) + + assert result.exit_code == 2 + assert "\x1b[" not in result.stderr diff --git a/tests/cli/test_write_safety.py b/tests/cli/test_write_safety.py new file mode 100644 index 0000000..5016028 --- /dev/null +++ b/tests/cli/test_write_safety.py @@ -0,0 +1,234 @@ +"""Tests for CLI write safety primitives.""" + +from __future__ import annotations + +from dataclasses import replace +from pathlib import Path +from types import TracebackType + +import pytest + +from avito.cli.commands import invoke_api_command +from avito.cli.config import ( + AccountsDocument, + AccountStore, + CliConfigDocument, + ConfigStore, + StoredAccount, +) +from avito.cli.context import CliContext +from avito.cli.errors import CliUsageError +from avito.cli.registry import ApiCommandRecord, SafetyKind, build_cli_registry +from avito.cli.safety import CommandSafetyPolicy, SafetyOptions +from avito.config import AvitoSettings + + +def test_destructive_command_requires_confirmation_before_client_construction( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + command = _write_command(safety="destructive", confirmation_required=True) + factory = _RecordingClientFactory() + _write_accounts(tmp_path) + monkeypatch.setenv("AVITO_PY_HOME", str(tmp_path)) + + with pytest.raises(CliUsageError) as exc_info: + invoke_api_command( + _ctx(), + command, + {}, + safety_options=SafetyOptions(), + client_factory=factory, + ) + + assert exc_info.value.code == "CLI_USAGE_ERROR" + assert factory.constructed_client_ids == [] + + +def test_destructive_command_accepts_yes_confirmation( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + command = _write_command(safety="destructive", confirmation_required=True) + factory = _RecordingClientFactory() + _write_accounts(tmp_path) + monkeypatch.setenv("AVITO_PY_HOME", str(tmp_path)) + + result = invoke_api_command( + _ctx(), + command, + {}, + safety_options=SafetyOptions(yes=True), + client_factory=factory, + ) + + assert result == {"applied": True} + assert factory.client.write_domain.apply_calls == [{}] + + +def test_destructive_command_accepts_exact_confirm_value( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + command = _write_command(safety="destructive", confirmation_required=True) + factory = _RecordingClientFactory() + _write_accounts(tmp_path) + monkeypatch.setenv("AVITO_PY_HOME", str(tmp_path)) + + invoke_api_command( + _ctx(), + command, + {}, + safety_options=SafetyOptions(confirm=command.command_id), + client_factory=factory, + ) + + assert factory.client.write_domain.apply_calls == [{}] + + +def test_dry_run_is_rejected_when_policy_does_not_support_it( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + command = _write_command(dry_run_supported=False) + factory = _RecordingClientFactory() + _write_accounts(tmp_path) + monkeypatch.setenv("AVITO_PY_HOME", str(tmp_path)) + + with pytest.raises(CliUsageError) as exc_info: + invoke_api_command( + _ctx(), + command, + {}, + safety_options=SafetyOptions(dry_run=True), + client_factory=factory, + ) + + assert "--dry-run" in exc_info.value.message + assert factory.constructed_client_ids == [] + + +def test_supported_dry_run_is_passed_to_sdk_method( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + command = _write_command(dry_run_supported=True) + factory = _RecordingClientFactory() + _write_accounts(tmp_path) + monkeypatch.setenv("AVITO_PY_HOME", str(tmp_path)) + + result = invoke_api_command( + _ctx(), + command, + {}, + safety_options=SafetyOptions(dry_run=True), + client_factory=factory, + ) + + assert result == {"dry_run": True} + assert factory.client.write_domain.apply_calls == [{"dry_run": True}] + + +def _write_command( + *, + safety: SafetyKind = "write", + confirmation_required: bool = False, + dry_run_supported: bool = False, +) -> ApiCommandRecord: + base = next( + command + for command in build_cli_registry().api_commands + if command.command_id == "account.get-self" + ) + return replace( + base, + command_id="write-resource.apply", + resource="write-resource", + action="apply", + factory="write_resource", + factory_args={}, + method_args={}, + parameters=(), + sdk_method_name="apply", + sdk_method="tests.cli.test_write_safety._WriteDomain.apply", + http_method="POST", + safety=safety, + safety_summary="Команда может изменить состояние тестового ресурса.", + safety_policy=CommandSafetyPolicy( + kind=safety, + confirmation_required=confirmation_required, + dry_run_supported=dry_run_supported, + review_note="Тестовая write safety policy.", + ), + implemented=True, + ) + + +def _write_accounts(tmp_path: Path) -> None: + account = StoredAccount( + name="main", + client_id="main-client", + client_secret="main-secret", + ) + AccountStore(tmp_path).save(AccountsDocument(accounts=(account,))) + ConfigStore(tmp_path).save(CliConfigDocument(active_profile="main")) + + +def _ctx() -> CliContext: + return CliContext( + profile=None, + config=None, + json_output=False, + plain=False, + table=False, + wide=False, + quiet=False, + no_input=True, + no_color=True, + verbose=False, + debug=False, + timeout=None, + ) + + +class _RecordingClientFactory: + def __init__(self) -> None: + self.constructed_client_ids: list[str] = [] + self.client = _RecordingClient() + + def __call__(self, settings: AvitoSettings) -> _RecordingClient: + self.constructed_client_ids.append(settings.auth.client_id) + return self.client + + +class _RecordingClient: + def __init__(self) -> None: + self.write_domain = _WriteDomain() + + def __enter__(self) -> _RecordingClient: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: + return None + + def write_resource(self) -> _WriteDomain: + return self.write_domain + + +class _WriteDomain: + def __init__(self) -> None: + self.apply_calls: list[dict[str, object]] = [] + + def apply(self, *, dry_run: bool = False) -> dict[str, bool]: + call: dict[str, object] = {} + if dry_run: + call["dry_run"] = dry_run + self.apply_calls.append(call) + if dry_run: + return {"dry_run": True} + return {"applied": True} diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..2a64f4f --- /dev/null +++ b/todo.md @@ -0,0 +1,2243 @@ +# Task Plan: Add CLI Mode to avito-py + +## Developer Context and Execution Instructions + +This plan is intended for a developer who opens the task for the first time and must bring CLI mode to a stable, tested state without violating the SDK architecture. + +Core idea: the CLI is a thin wrapper over the public SDK. It does not know how HTTP requests, authorization, retries, Swagger operation specs, transport, response mapping, or pagination internals work. For Avito API commands, the CLI always goes through `AvitoClient` -> public factory -> public domain method -> public SDK model serialization. + +How to work with this plan: + +1. Before making any changes, read this section, `Goal`, `Normative Rules`, `Current Baseline Findings`, `CLI Architecture`, `SDK Reuse Strategy`, and `Registry and Coverage`. +2. Then read the required documents: + - `.ai/STYLEGUIDE.md` + - `.ai/cli-guidelines.md` + - `.ai/python-guidelines.md` + - `docs/site/explanations/domain-architecture-v2.md` + - `docs/site/explanations/swagger-binding-subsystem.md` +3. Execute stages strictly in order. Do not start the next stage until the current stage has passed its tests, verification commands, and stage checklist. +4. Make small changes. One stage should be a separate reviewable increment: minimal new functionality, tests, checks, and an updated checklist. +5. If a stage is too large, split it into sub-stages inside the same stage, but do not skip ahead to the next architectural area. +6. After each stage, leave the repository in a working state: stage tests pass, mypy/ruff verification passes, and there are no temporary workarounds or dead code. +7. When implementation convenience conflicts with the guides, follow the guides. If a guide blocks the task, first record the architectural decision in the plan or documentation instead of silently bypassing the rule. + +The preliminary baseline audit has already been completed and recorded in +`Completed Pre-Coding Audit`. Before starting Stage 1, the developer must use +these results as the initial state: + +- Current `AvitoClient`: which public factory methods exist, which ones are helper/workflow methods, and which ones must not become API commands. +- Current Swagger discovery: sync binding count and presence of `factory`, `factory_args`, `method_args`, and `operation_key`. +- Public domain methods: which methods are sync, async, legacy/deprecated, and helper methods without a Swagger binding. +- Current serialization models: where `model_dump()` / `to_dict()` exist, and how `PaginatedList`, enums, dates, and datetimes work. +- Current fake transport/testing helpers: what tests may use and what production CLI code must not import. +- Current `Makefile` and script linters: where `cli-lint` must be integrated and which commands already run in `make check`. +- Current `avito/__main__.py`: its smoke behavior must be replaced with CLI handoff in Stage 1. + +Stage execution rules: + +- Each stage must put production code only in the required files, include tests for new logic, and pass verification. +- Each stage checklist must be filled only after actual verification, not in advance. +- If a verification command fails for a reason unrelated to the stage change, record it next to the stage result with the exact command and error. +- If an exclusion is needed, it must include a reason, impact area, and follow-up. Silent exclusions are forbidden. +- If a new public command appears, it must have a kebab-case name, stable flags, localized help/error text according to the styleguide, JSON behavior, secret masking, and tests. +- If a command can modify state or trigger an expensive operation, classify the safety policy first, then add `--dry-run`, `--yes`, and `--confirm` only according to this plan. + +Definition of done for the whole plan: + +- All stage checklists are complete. +- `avito` and `python -m avito` work through one CLI app. +- All sync Swagger-bound domain methods are covered by a canonical CLI command or an explicit documented exclusion. +- In this plan, "100% CLI coverage" means that each sync Swagger-bound domain method has exactly one canonical CLI command, and each non-domain Swagger binding, deprecated/compatibility path, or unsupported helper has a documented exclusion with a reason. Direct coverage of auth-token internals through the CLI is outside the first release and is not a coverage defect. +- All supported helper workflows are covered by a command or documented exclusion. +- The coverage linter passes and is included in `make check`. +- The CLI does not duplicate SDK contracts and does not bypass the public `AvitoClient` surface. +- Secrets do not appear in any output mode. +- The final gate from Stage 14 passes. + +## Goal + +Build a convenient, stable, scriptable CLI for `avito-py` that covers every supported sync SDK domain method with maximum reuse of the existing SDK surface. + +The CLI must be a thin product interface over the SDK, not a second SDK implementation. API commands must construct `AvitoClient`, call its public factories, call public domain methods, serialize public SDK models, and leave HTTP/auth/retry/pagination/mapping behavior inside the existing SDK layers. + +Target outcome: + +- `avito` console command and `python -m avito` expose the same CLI. +- Local account/profile/config commands work without Avito network calls. +- Every sync Swagger-bound public SDK method has exactly one canonical CLI command, unless it has a documented intentional exclusion. +- The first release coverage target is strict for sync domain API methods discovered through `AvitoClient` factory metadata. Non-domain auth-token bindings and compatibility-only wrappers are intentionally excluded unless a separate public SDK facade is designed for them. +- Every supported public non-Swagger helper has a CLI command or a documented exclusion. +- CLI coverage is checked automatically against Swagger binding discovery. +- Human output is useful by default; JSON output is stable for automation. +- Secrets are never printed in human, JSON, verbose, debug, error, coverage, or diagnostic output. +- Implementation is delivered in small reviewable stages, each with tests and verification commands. + +## Normative Rules + +Mandatory documents: + +- `.ai/STYLEGUIDE.md` +- `.ai/cli-guidelines.md` +- `.ai/python-guidelines.md`, through `.ai/STYLEGUIDE.md` +- `docs/site/explanations/domain-architecture-v2.md` +- `docs/site/explanations/swagger-binding-subsystem.md` + +Repository contracts: + +- Package name: `avito-py`. +- Import package: `avito`. +- Public sync facade: `avito.client.AvitoClient`. +- Swagger/OpenAPI specs in `docs/avito/api/` are the API contract source. +- Swagger bindings discovered by `avito.core.swagger_discovery.discover_swagger_bindings()` are the canonical SDK coverage source. + +Hard constraints: + +- CLI code belongs under `avito/cli/`. +- Keep SDK core/domain/transport/auth layers free of Click and CLI behavior. +- Production CLI code must not import domain `operations.py`, transport implementations, auth provider internals, or testing fake transports. +- Production CLI code must not import from `tests`, `avito.testing`, `tests/fake_transport.py`, or `avito.core.operations`. +- Production CLI code must not import private SDK modules or private names unless the import is explicitly documented as a CLI-only compatibility exception in this plan and covered by an architecture lint rule. +- API commands must not call `OperationSpec`, `OperationExecutor`, `Transport`, or `AuthProvider` directly. +- API commands must not instantiate domain objects directly. +- Do not duplicate Swagger contract data in CLI metadata. CLI metadata may store command names, examples, aliases, safety policy, output hints, and documented exclusions only. +- Do not add or change public Avito API SDK methods as part of CLI work unless the normal SDK rules are followed: typed model, operation spec, docstring, and `@swagger_operation(...)`. +- Human-facing CLI text is Russian only: help descriptions, prompts, warnings, errors, and success output. Stable error codes remain uppercase English identifiers. +- No `setattr`, `globals()`, monkey-patching, generated Python source, or dynamic SDK method injection. Deterministic Click command registration from typed registry records is allowed. +- No dead code, unused aliases, unused `TypeVar`s, broad `Any`, or layer mixing. +- No dynamic imports for optional CLI dependencies. Runtime dependency failures must fail at import/install time and be fixed in `pyproject.toml`. +- No broad `except Exception` in CLI command flow unless the handler sanitizes output and immediately re-raises or converts to a typed `CliError`. + +Non-goals for the first complete release: + +- Async CLI surface. +- OS keychain integration. First release stores plaintext JSON files protected by permissions and documents that clearly. +- Reimplementing SDK validation in CLI. CLI only coerces shell strings into typed public method arguments and reports invalid CLI syntax early. +- A second public command alias such as `avito-cli`, unless there is a separate product requirement. + +## Current Baseline Findings + +Recorded and re-verified on 2026-05-10 while preparing this plan: + +```text +sync Swagger bindings: 204 +sync Swagger canonical map entries: 204 +AvitoClient public callable methods, excluding close/from_env/auth/debug_info: 56 +sync Swagger bindings with factory metadata: 200 +sync Swagger factory buckets with factory metadata: 48 +sync bindings without factory metadata: 4 +sync read bindings by HTTP method GET/HEAD: 60 +sync write/non-read bindings by HTTP method: 144 +``` + +Reproducible verification commands: + +```bash +poetry run python -c "from avito.core.swagger_discovery import discover_swagger_bindings; d=discover_swagger_bindings(); sync=[b for b in d.bindings if b.variant == 'sync' and b.operation_key is not None]; print(len(sync)); print(len(d.canonical_map)); print(len([b for b in sync if b.factory is None]))" +poetry run python -c "from collections import Counter; from avito.core.swagger_discovery import discover_swagger_bindings; from avito.core.swagger_registry import load_swagger_registry; reg=load_swagger_registry(); ops={op.key: op for op in reg.operations}; d=discover_swagger_bindings(registry=reg); c=Counter(); [c.update([ops[b.operation_key].method, 'read' if ops[b.operation_key].method in {'GET','HEAD'} else 'write']) for b in d.bindings if b.variant == 'sync' and b.operation_key is not None]; print(c)" +poetry run python -c "import inspect; from avito.client import AvitoClient; excluded={'close','from_env','auth','debug_info'}; print(len([name for name, value in inspect.getmembers(AvitoClient) if not name.startswith('_') and callable(value) and name not in excluded]))" +poetry run pytest tests/core/test_swagger_linter.py tests/contracts/test_swagger_contracts.py +``` + +Verification result: + +```text +tests/core/test_swagger_linter.py tests/contracts/test_swagger_contracts.py: +1913 passed +``` + +Do not use Swagger tag/domain labels as canonical CLI coverage buckets. Current +Swagger labels are human-facing and may be localized. CLI coverage and wave +planning must use discovered `factory` metadata as the stable grouping key. + +Coverage arithmetic for the first CLI release: + +- 204 sync Swagger bindings are discovered. +- 200 bindings are normal domain/factory bindings and are candidates for + canonical API commands. +- 4 bindings have no `AvitoClient` factory metadata and are the intentional + auth-token exclusions listed below. +- The final strict gate must therefore prove exactly 200 canonical API commands + plus 4 documented intentional non-domain exclusions, unless a later stage + records a deliberate scope change in this plan. + +Sync binding count by discovered factory: + +```text +: 4 +account: 3 +account_hierarchy: 5 +ad: 3 +ad_promotion: 4 +ad_stats: 4 +application: 5 +autoload_archive: 4 +autoload_profile: 5 +autoload_report: 8 +autostrategy_campaign: 7 +autoteka_monitoring: 4 +autoteka_report: 7 +autoteka_scoring: 2 +autoteka_valuation: 1 +autoteka_vehicle: 12 +bbip_promotion: 3 +call_tracking_call: 3 +chat: 4 +chat_media: 2 +chat_message: 4 +chat_webhook: 3 +cpa_archive: 3 +cpa_auction: 2 +cpa_call: 2 +cpa_chat: 4 +cpa_lead: 2 +delivery_order: 5 +delivery_task: 1 +job_dictionary: 2 +job_webhook: 4 +order: 9 +order_label: 3 +promotion_order: 4 +rating_profile: 1 +realty_analytics_report: 2 +realty_booking: 2 +realty_listing: 2 +realty_pricing: 1 +resume: 3 +review: 1 +review_answer: 2 +sandbox_delivery: 25 +special_offer_campaign: 5 +stock: 2 +target_action_pricing: 5 +tariff: 1 +trx_promotion: 3 +vacancy: 11 +``` + +Bindings without factory metadata: + +- `avito.auth.provider.AlternateTokenClient.request_client_credentials_token` +- `avito.auth.provider.AlternateTokenClient.request_refresh_token` +- `avito.auth.provider.TokenClient.request_autoteka_client_credentials_token` +- `avito.auth.provider.TokenClient.request_client_credentials_token` + +Implementation impact: + +- These 4 auth-token bindings are not normal domain commands through `AvitoClient` factories. +- Treat these 4 auth-token bindings as intentional non-domain API exclusions for the first CLI release. +- Expose user-facing credential/account readiness through local `account`, `status`, and `doctor` workflows, not by turning token client methods into generic API commands. +- If a future release exposes direct token exchange commands, it must be a separate SDK architecture change with explicit public facade design; CLI must not call `TokenClient` or `AlternateTokenClient` directly. +- The final coverage linter must count this decision explicitly, not silently treat missing factory metadata as success. + +Current public non-Swagger helper/workflow candidates on `AvitoClient`: + +- `account_health` +- `business_summary` compatibility wrapper for `account_health` +- `listing_health` +- `chat_summary` +- `order_summary` +- `review_summary` +- `promotion_summary` +- `capabilities` + +Initial helper policy: + +- Canonical CLI commands may cover `account_health`, `listing_health`, `chat_summary`, `order_summary`, `review_summary`, `promotion_summary`, and `capabilities`. +- `business_summary` is a compatibility helper and should not receive a second canonical command unless product requirements explicitly demand an alias. If exposed, it is an alias and does not count as helper coverage. +- `auth()` and `debug_info()` remain SDK support surfaces, not API coverage commands. Their CLI equivalents are `status` and `doctor`. + +## Documentation Structure Findings + +Current documentation uses MkDocs Material with `docs_dir: docs/site`. +Navigation is controlled by `awesome-pages` through `.pages` files: + +- `docs/site/.pages` is the top-level nav: Home, `Tutorials`, `How-to`, `Reference`, `Explanations`, `Changelog`. +- `docs/site/tutorials/.pages` contains onboarding tutorials. +- `docs/site/how-to/.pages` contains task-oriented recipes. +- `docs/site/reference/.pages` contains stable public contracts and generated reference pages. +- `docs/site/explanations/.pages` contains architecture and rationale pages. + +Generated reference pages are produced by `docs/site/assets/_gen_reference.py` during MkDocs builds: + +- `reference/coverage.md` +- `reference/api-report.md` +- `reference/operations.md` +- `reference/domains/*.md` +- `reference/enums.md` + +CLI documentation must follow this structure instead of adding an isolated page: + +- README: short CLI quickstart only, with link to full docs. +- `docs/site/index.md`: add CLI as a first-class entry point after CLI release. +- `docs/site/tutorials/getting-started.md`: add the shortest first CLI call path, or a short cross-link if the page would become noisy. +- `docs/site/how-to/cli.md`: practical CLI setup and daily workflows. +- `docs/site/reference/cli.md`: stable CLI contract: grammar, global flags, output formats, exit codes, config files, environment variables, safety flags, command coverage policy. +- `docs/site/explanations/cli-architecture.md`: design rationale: thin wrapper over `AvitoClient`, registry/discovery, coverage linter phases, exclusions, secret masking, pagination policy. +- `docs/site/explanations/security-and-redaction.md`: add CLI secret-storage and output-redaction notes when account store lands. +- `docs/site/explanations/api-coverage-and-deprecations.md`: add CLI coverage guarantee and documented-exclusion policy after the coverage linter exists. +- `docs/site/how-to/auth-and-config.md`: link CLI profile/account setup to SDK env-based configuration without duplicating the whole config reference. + +Navigation updates required: + +- Add `cli.md` to `docs/site/how-to/.pages`. +- Add `cli.md` to `docs/site/reference/.pages`. +- Add `cli-architecture.md` to `docs/site/explanations/.pages`. +- If README/index/tutorial links are added before the CLI is usable, mark them clearly as planned only. Prefer adding public-facing docs after Stage 12 when commands exist. + +## Plan Review Findings + +Additional findings from reviewing this plan against `.ai/STYLEGUIDE.md`, `.ai/cli-guidelines.md`, and `.ai/python-guidelines.md`: + +- The plan must treat CLI commands as public contracts. Renames, output schema changes, exit-code changes, and flag removals need deprecation, not silent replacement. +- The CLI must have static architecture enforcement, not only review discipline. Import boundaries for `avito/cli/` must be covered by `scripts/lint_architecture.py` or a dedicated CLI architecture linter before broad command generation starts. +- CLI coverage grouping must be based on discovered `factory` metadata, not localized Swagger tag/domain labels. Tags may be useful in reports, but they are not stable enough to drive command coverage gates. +- Python guideline compliance must be part of every stage that changes Python code. `ruff` and `mypy` are necessary but not sufficient. +- The write-command rollout is too large as a single stage. It is split into safety primitives, domain coverage waves, and strict coverage gate so each increment remains reviewable and testable. +- Coverage must distinguish three statuses: implemented canonical command, documented temporary exclusion, and documented intentional permanent exclusion. Temporary exclusions require an owner/follow-up and must fail after the configured target stage if still present. +- Generated command registration must be deterministic and inspectable by the CLI coverage linter without constructing `AvitoClient`, reading account files, or touching the network. +- Public docs must only describe implemented commands. Future commands stay in this plan until the implementation exists. +- The console entry point must be a stable callable, not a Click command object itself. Use `avito.cli.app:main` for packaging and keep `app` as the reusable Click root command/group for tests and `python -m avito`. +- Root-level global options are the canonical syntax for the first release: `avito --profile main account get-self`. Supporting trailing global options such as `avito account get-self --profile main` is optional and must be implemented deliberately, not assumed from Click behavior. +- The generated API command layer must use deterministic Click command objects attached to the Click root group. This keeps registration inspectable without generating Python source. +- `click` is the CLI framework for every stage and must be an explicit runtime dependency from Stage 1. +- Safety classification cannot rely on HTTP method alone. HTTP method may provide a default, but final command safety must come from explicit registry metadata and reviewed overrides for write, destructive, expensive, and local commands. +- CLI import-boundary checks should extend the existing `scripts/lint_architecture.py` unless there is a concrete reason to split them into a dedicated script. Avoid two overlapping architecture linters. +- The stable CLI contract should be documented as soon as the corresponding surface exists. Create or update `docs/site/reference/cli.md` from the first stage that introduces user-visible flags, output fields, exit codes, or command names; Stage 13 remains the full documentation pass. +- Every stage that adds or changes user-visible CLI behavior must update `CHANGELOG.md`. The SDK styleguide treats CLI commands, flags, output fields, and exit codes as public contracts. +- Dependency stages must update both `pyproject.toml` and `poetry.lock`. Verification must include a lock consistency check after adding `click` or changing CLI runtime dependencies. +- Representative smoke tests by factory are not enough for the final "all methods" claim. Before strict coverage, every canonical CLI command must be registered, render help, and execute at least once through a fake client or `SwaggerFakeTransport` when safe synthetic arguments exist. Commands that cannot be executed generically need a documented exclusion or a custom adapter with tests. +- Secret input must not force users to put `client_secret` in shell history. `--client-secret` remains supported for explicit automation, but `account add` must also support a hidden TTY prompt and at least one non-interactive alternative such as `--client-secret-stdin` or documented environment/config input. +- Some Swagger-bound methods may need command-specific adapters for file input, multipart data, binary responses, or complex public input models. These adapters are allowed only inside `avito/cli/` and must still call public `AvitoClient` factories and public domain methods. +- New CLI scripts must be type-checked explicitly when they are outside the configured `avito` mypy package scope. Stage verification should include `poetry run mypy scripts/lint_cli_coverage.py` once that script exists. +- Generated API command inputs must be derived from Swagger binding metadata + (`factory_args` and `method_args`) first, with public signatures/type hints + used only to validate and coerce those selected arguments. Do not expose every + public SDK parameter automatically. +- Per-operation SDK controls such as `timeout` and `retry` are not method + command flags. `timeout` is controlled only by the root `--timeout` option in + the first release. `retry` is intentionally not exposed in the first release + unless a later stage adds a deliberate global policy and tests. +- Deprecated or legacy Swagger-bound SDK methods need an explicit CLI policy: + either one canonical command with deprecation help/warning behavior, or a + documented intentional exclusion. Compatibility aliases never count as + canonical coverage. + +## Test and Lint Boundaries + +`.ai/STYLEGUIDE.md` has a closed testing policy. CLI work must follow it from +Stage 1 instead of using pytest as a general policy checker. + +Use pytest only for runtime behavior that a user or integration can observe: + +- CLI command execution, exit codes, stdout/stderr routing, and output formats; +- profile/account/config persistence behavior through temporary directories; +- secret masking on success, error, verbose, debug, and JSON paths; +- generic invocation through public `AvitoClient` factories and public domain methods; +- fake-transport API smoke flows with request/response behavior; +- pagination materialization behavior and dry-run transport behavior. + +Use linters/scripts, not pytest, for static or inventory checks: + +- architecture/import boundaries for `avito/cli/`; +- generated command naming, kebab-case resources/actions/flags, and forbidden `resource-id`; +- duplicate canonical commands, local/API collisions, alias policy, and exclusion metadata completeness; +- coverage inventory: missing bindings, extra commands, expired temporary exclusions, and strict one-to-one mapping; +- report determinism and sanitized CLI coverage report content. + +Do not add pytest tests whose only purpose is to exercise the CLI coverage linter +with synthetic broken inputs. The linter is verified by running it against the +real repository in each stage gate. If a linter rule needs implementation-level +confidence, keep its parser/checker simple and cover it through deterministic +real-code fixtures or move the check into an existing static lint script. + +## CLI Architecture + +Use a small hand-written CLI shell plus registry/discovery-driven API commands: + +```text +avito/ + cli/ + __init__.py + app.py # root Click group and global context + accounts.py # local account/profile commands only + client.py # CLI-only AvitoClient construction + commands.py # generic invocation engine for SDK methods + config.py # CLI home, JSON persistence, account/config store + coverage.py # CLI coverage report and linter helpers + errors.py # CLI errors, exit-code mapping, secret sanitization + help.py # help command compatibility when Click defaults are insufficient + registry.py # command registry built from SDK metadata + schemas.py # CLI input coercion from signatures/type hints + serialization.py # model/pagination result serialization + ui.py # stdout/stderr, table/json/plain output +``` + +Do not add domain-specific CLI modules for every API package unless a command needs custom UX. The default path must be metadata-driven to avoid hand-copying 204 operations. + +Command registration approach: + +- Use Click for the root group, global options, local workflow commands, and help/version/status/doctor/config/account commands. +- Register generated API commands deterministically from registry records. +- Generated API commands are typed `click.Command` objects attached to Click groups from registry metadata. This is deterministic command registration, not SDK method injection. +- Do not generate Python source files for commands. +- Do not use `setattr`, `globals()`, monkey-patching, or modifying SDK/domain classes to create commands. +- Generated command callbacks must all delegate to one invocation engine; command-specific behavior belongs in registry metadata only when the generic path cannot infer it safely. + +Package boundary: + +- `avito/cli/*` may import `avito.client`, `avito.config`, public models, public exceptions, and Swagger discovery/reporting helpers. +- `avito/__main__.py` must contain only the CLI handoff after Stage 1. +- Tests may use `avito.testing.*`, `tests/fake_transport.py`, and public testing helpers; production CLI code must not. + +Register only the canonical command: + +```toml +[tool.poetry.scripts] +avito = "avito.cli.app:main" +``` + +## Command Model + +Follow `.ai/cli-guidelines.md`: + +```text +avito [primary arguments] [flags] +``` + +Resource names are derived from `AvitoClient` factory names with kebab-case. Actions are derived from public SDK method names with kebab-case. + +Default generated shape: + +```bash +avito [factory args] [method args] +``` + +Examples: + +```bash +avito account get-self +avito account get-balance --user-id 123 +avito ad get --item-id 456 --user-id 123 +avito ad-stats get-item-stats --user-id 123 --item-ids 456,789 --date-from 2026-05-01 +avito promotion-order list-services --item-id 456 --json +``` + +Rules: + +- Factory and method arguments become named flags by default. +- Positional arguments are allowed only for obvious single primary identifiers after explicit design review. +- Same SDK concept uses the same flag everywhere: `--user-id`, `--item-id`, `--order-id`, `--chat-id`, `--date-from`, `--date-to`, `--limit`, `--offset`. +- `resource_id` and `--resource-id` are forbidden. +- Generated commands preserve one obvious path per operation. +- Compatibility aliases delegate to canonical commands and do not count toward coverage. +- Local workflows may share a resource with API commands only when the action is unambiguous. `avito account add` is local profile management; `avito account get-self` is an Avito API call. +- Registry construction fails if a local command and generated API command claim the same canonical `resource action`. + +## Global Flags + +Supported from root and subcommands through one typed CLI context: + +```text +-h, --help +--version +--profile +--config +--json +--plain +--table +--wide +--quiet +--no-input +--no-color +--verbose +--debug +--timeout +``` + +Canonical invocation syntax for global options: + +```bash +avito --profile main account get-self +avito --json --no-input account get-self +``` + +The first release only guarantees root-level global options before the resource/action path. +If trailing global options are added later, they must be additive, tested, and documented. + +Write/destructive commands additionally support: + +```text +--dry-run +--yes +--confirm +``` + +Precedence: + +1. CLI flags +2. Environment variables +3. Project config +4. User config +5. System config +6. Built-in defaults + +Initial implementation may support only user config, but the resolver must reserve the full precedence contract without breaking users later. + +Flag behavior: + +- `--json` emits stable undecorated JSON for success output and JSON errors on stderr. +- JSON stdout must not contain progress, warnings, hints, colors, or prose. +- `--quiet` suppresses non-essential success output; combined with `--json`, commands returning data still emit JSON. +- `--plain`, `--table`, and `--wide` are mutually exclusive with `--json`; invalid combinations exit with code `2`. +- `--verbose` is user-facing extra detail and never overrides `--quiet`. +- `--debug` may include diagnostics but must never leak secrets. +- `--no-color` and `NO_COLOR=1` disable color everywhere. +- Commands must not prompt when `--no-input` is set or stdin is not a TTY. + +## Exit Codes + +```text +0 success +1 general error +2 invalid usage +3 not found +4 permission denied +5 authentication/config required +6 conflict +7 validation failed +8 external dependency unavailable +``` + +Stable error codes include: + +- `CONFIG_INVALID` +- `ACCOUNT_NOT_FOUND` +- `ACCOUNT_EXISTS` +- `AUTH_REQUIRED` +- `PERMISSION_DENIED` +- `VALIDATION_FAILED` +- `COMMAND_UNSUPPORTED` +- `SDK_METHOD_FAILED` + +Errors, warnings, progress, and debug diagnostics go to stderr. Command results go to stdout. JSON errors are valid JSON on stderr. + +## Account and Config Model + +Persist CLI-local data under: + +```text +~/.avito-py/ + config.json + accounts.json +``` + +Home override precedence: + +1. `AVITO_PY_HOME` +2. `MY_SDK_HOME` +3. `Path.home() / ".avito-py"` + +`MY_SDK_HOME` is ticket compatibility. `AVITO_PY_HOME` is the project-specific name. + +File-system requirements: + +- Create the CLI home lazily with `0700` permissions. +- Write `accounts.json` and `config.json` with `0600` permissions. +- Save JSON atomically through a temporary file in the same directory and `os.replace`. +- Never create files or directories on import. +- Map permission failures to exit code `4` with `PERMISSION_DENIED`. +- Map malformed JSON to exit code `7` or `5` depending on whether the command can continue without config. + +Stored account fields: + +- `name: str` +- `client_id: str` +- `client_secret: str` +- `base_url: str` +- `user_id: int | None` +- OAuth fields already supported by `AuthSettings`: `scope`, `refresh_token`, `token_url`, `alternate_token_url`, `autoteka_token_url`, `autoteka_client_id`, `autoteka_client_secret`, `autoteka_scope` + +Active account belongs in config as one profile name. Do not store contradictory `active: bool` flags on each account. + +Canonical flags: + +- `--client-id` +- `--client-secret` +- `--client-secret-stdin` +- `--base-url` +- `--user-id` + +Ticket-compatible aliases: + +- `--api-key` as an alias for `--client-secret` +- `--endpoint` as an alias for `--base-url` + +## SDK Reuse Strategy + +Generic API invocation pipeline: + +1. Resolve global flags and validate CLI mode. +2. Resolve profile/account and build `AvitoSettings`. +3. Create `AvitoClient(settings)` in a context manager. +4. Resolve CLI resource to an `AvitoClient` factory. +5. Coerce CLI strings into the selected factory and method arguments from + binding metadata, validating those arguments against public signatures/type + hints. +6. Call the SDK factory. +7. Call the public domain method. +8. Serialize the SDK return value through `model_dump()` / `to_dict()` / bounded pagination helpers. +9. Render as table, grouped text, plain value, or JSON. + +Constraints: + +- Build `AvitoClient` only after command syntax, config/profile resolution, and secret masking context are ready. +- Never instantiate domain objects directly in CLI. +- Never call operation specs directly from CLI commands. +- CLI may inspect public signatures and type hints, but not private domain object attributes. +- Dataclass serialization fallback is allowed only for CLI-local dataclasses, not SDK response models. +- CLI must not expose every public SDK parameter automatically. For + Swagger-bound commands, generated inputs are the union of `binding.factory_args` + and `binding.method_args`; public signatures/type hints only validate and + coerce those selected names. +- Per-operation SDK parameters `timeout` and `retry` must be filtered out of + generated method flags. The first release maps root `--timeout` into the SDK + call path where supported and does not expose `retry`. + +The coercion engine must support: + +- `str`, `int`, `float`, `bool` +- `date` and `datetime` strings with validation +- enums by value/name with clear validation errors +- optional values +- list values from repeated flags or documented comma-separated values +- public dataclass input models only when they are already public SDK input models +- `PaginatedList[T]` with explicit materialization limits or streaming-safe iteration +- file inputs only for methods whose public signature already accepts file/path-like public inputs + +Complex input policy: + +- Do not expose raw Avito request bodies. +- Do not expose internal request DTOs. +- If a public SDK method already accepts a documented public input model, CLI may accept either explicit model-field flags or `--input-json ` that is parsed into that public model. +- `--input-json -` reads from stdin and is forbidden when stdin is not available or when another prompt would be required. +- JSON input errors are `VALIDATION_FAILED` with Russian messages and no echoed secrets. + +If a method cannot be safely exposed by the generic engine, add it to a typed exclusion list with reason, owner, and follow-up. Final acceptance target is zero unsupported sync Swagger-bound methods unless intentionally excluded and documented. + +Unsupported here means one of: + +- the binding is non-domain and has no public `AvitoClient` factory; +- selected binding arguments cannot be represented as stable CLI flags; +- the public SDK method needs file, stdin, binary, multipart, or public-model + construction behavior that has not yet received a typed CLI adapter; +- the method is deprecated/legacy and the release deliberately excludes it with + documented replacement guidance. + +## Registry and Coverage + +Build a CLI registry from existing SDK metadata: + +- `discover_swagger_bindings(registry=load_swagger_registry(...))` +- `binding.factory` +- `binding.factory_args` +- `binding.method_name` +- `binding.method_args` +- public Python signatures and type hints for validation/coercion only + +Registry records contain: + +- stable canonical command id, for example `account.get-self`; +- resource and action in lowercase kebab-case; +- binding operation key for Swagger-bound API commands; +- SDK factory name and public method name; +- factory and method argument metadata from discovery/signatures; +- selected CLI input arguments derived from `binding.factory_args` and + `binding.method_args`, with `timeout` and `retry` excluded from generated + method flags; +- safety classification: read, write, destructive, expensive, local; +- deprecation/legacy policy for deprecated Swagger bindings; +- output hint: object, collection, mutation result, plain value, unknown; +- examples and related commands for help; +- aliases stored separately from canonical records; +- exclusions stored separately with reason and follow-up. + +Coverage invariant: + +```text +each sync discovered Swagger binding -> exactly one canonical CLI command or documented intentional exclusion +each canonical API CLI command -> exactly one sync discovered Swagger binding +each supported public non-Swagger helper -> CLI command or documented exclusion +``` + +Coverage report fields: + +- `api_bound_commands` +- `api_bound_missing_commands` +- `api_bound_exclusions` +- `helper_commands` +- `helper_exclusions` +- `local_cli_commands` +- `aliases` + +Add a linter: + +```bash +poetry run python scripts/lint_cli_coverage.py --strict +``` + +The linter fails when: + +- a sync discovered Swagger binding has no canonical CLI command or explicit exclusion; +- a canonical API CLI command has no binding; +- two canonical CLI commands map to the same binding; +- a supported public helper has neither a command nor an exclusion; +- a local command conflicts with a generated API command; +- a command exposes `resource-id`; +- a command exposes a secret in an output schema; +- a command uses non-kebab-case resource/action/flag names; +- a generated API command exposes `timeout` or `retry` as a method-level flag; +- a deprecated/legacy binding lacks command warning/help metadata or an + intentional exclusion; +- an exclusion lacks reason and follow-up. + +Add `make cli-lint` and include it in `make check` after full CLI coverage is implemented. + +## Output Contract + +Default output: + +- Human-readable Russian text. +- Tables for collections. +- Grouped key-value output for one object. +- Concise success text for writes. +- Next-step hints only when helpful and not noisy. + +Machine output: + +- `--json` emits stable, undecorated JSON. +- Top-level objects are stable and named by resource/action where useful. +- SDK models are serialized via their public serialization contract. +- Pagination output includes enough metadata when available. + +Secret masking: + +- Mask by key name and value pattern where practical. +- Use the same sanitizer for human output, JSON output, errors, verbose/debug output, and coverage/debug reports. +- Cover nested structures, lists, exception metadata, and debug mode in tests. +- Never print raw `client_secret`, `api_key`, `refresh_token`, `access_token`, `Authorization`, or token-like fields. + +## Help and Completion + +Help requirements: + +```bash +avito --help +avito account --help +avito account get-self --help +avito help account +avito help account get-self +``` + +Help must include: + +- Russian description; +- usage; +- at least one minimal example; +- one automation-friendly `--json --no-input` example where relevant; +- flags with stable names; +- related commands when useful. + +Implementation requirement: + +- `avito help` is a public compatibility command, not a private Click behavior assumption. +- `avito help ` and `avito help ` must be tested no later than the stage that introduces nested generated commands. +- If Click's default help cannot provide this shape, implement a small `help.py` adapter that reads the same command registry metadata used for command registration. + +Completion commands: + +```bash +avito completion bash +avito completion zsh +avito completion fish +``` + +Completion can start with static command/flag completion and later add profile/account names. + +## Completed Pre-Coding Audit + +Baseline audit was completed before CLI implementation on 2026-05-10. This is +recorded here as pre-work, not as an implementation stage. + +Verified repository state: + +```text +sync Swagger canonical map entries: 204 +sync Swagger bindings without factory metadata: 4 +sync Swagger bindings: 204 +sync Swagger bindings with factory metadata: 200 +sync binding factory buckets with factory metadata: 48 +sync read bindings by HTTP method GET/HEAD: 60 +sync write/non-read bindings by HTTP method: 144 +AvitoClient public callable methods, excluding close/from_env/auth/debug_info: 56 +python -m avito behavior: silent smoke entry point that constructs AvitoClient +CLI package/dependency state: no avito/cli package, no click dependency, no console script +Makefile state: no cli-lint target; quality currently runs typecheck, lint, + python-guidelines-lint, swagger-lint, architecture-lint, async-parity-lint, + docstring-lint, build +``` + +Verification commands run: + +```bash +poetry run python -c "from avito.core.swagger_discovery import discover_swagger_bindings; print(len(discover_swagger_bindings().canonical_map))" +poetry run python -c "from avito.core.swagger_discovery import discover_swagger_bindings; d=discover_swagger_bindings(); print(len([b for b in d.bindings if b.variant == 'sync' and b.operation_key is not None and b.factory is None]))" +poetry run python -m avito +poetry run pytest tests/core/test_swagger_linter.py tests/contracts/test_swagger_contracts.py +``` + +Verification result: + +```text +canonical map entries: 204 +sync bindings without factory metadata: 4 +sync bindings with factory metadata: 200 +sync read/write split: 60 read, 144 write/non-read +python -m avito: exited 0 with no output +tests/core/test_swagger_linter.py tests/contracts/test_swagger_contracts.py: +1913 passed in 8.67s +``` + +The 4 bindings without factory metadata remain the planned first-release +auth-token exclusions: + +- `avito.auth.provider.AlternateTokenClient.request_client_credentials_token` +- `avito.auth.provider.AlternateTokenClient.request_refresh_token` +- `avito.auth.provider.TokenClient.request_autoteka_client_credentials_token` +- `avito.auth.provider.TokenClient.request_client_credentials_token` + +## Implementation Stages + +Stage policy: + +- Each stage must leave the branch in a releasable state. +- Every behavior stage includes tests in the same change. +- Stage deliverables are additive to this policy section. If a stage changes + public CLI behavior but does not repeat `CHANGELOG.md` or CLI reference docs + in its own deliverables, the policy here still requires those updates. +- After Stage 4C, CLI coverage report changes must be intentional in every CLI metadata change. +- After Stage 10C, `scripts/lint_cli_coverage.py --strict` is a required gate for all CLI changes. +- Do not broaden command coverage before the previous stage's verification passes. +- Keep each stage small enough for review. If a stage needs more than roughly 300-500 lines of production code or touches more than three production modules, split it into lettered sub-stages in this file before implementing. +- A sub-stage has its own deliverables, tests, verification commands, and checked-off exit criteria. +- If a coverage wave contains methods with file input, multipart payloads, + binary responses, complex public input models, destructive operations, or + expensive side effects, split that wave before coding. Do not absorb that + complexity into a broad generated-command change. +- Do not mark a checklist item complete from inspection alone when a command or test can verify it. +- Every stage that changes Python code must run `poetry run python scripts/lint_python_guidelines.py`. +- Every stage that adds or changes CLI production imports must run `poetry run python scripts/lint_architecture.py` or the dedicated CLI architecture lint command introduced by that stage. +- Every stage that changes command metadata must run the current `scripts/lint_cli_coverage.py` phase, even before strict mode is enabled. +- Every stage that changes persisted config/account JSON shape must include migration/backward-compatibility tests or explicitly state why no existing persisted shape exists yet. +- Every stage that changes user-visible CLI text, flags, output fields, or exit codes must update `docs/site/reference/cli.md` once that reference page exists. +- Every stage that changes CLI public behavior must update `CHANGELOG.md` in the same change. +- Every stage that adds runtime dependencies must update `poetry.lock` and verify that dependency resolution is consistent. +- After `scripts/lint_cli_coverage.py` exists, every stage that touches CLI + metadata, adapters, coverage, or registration must run + `poetry run mypy scripts/lint_cli_coverage.py`. + +Coverage linter phase policy: + +- Stage 4C introduces `scripts/lint_cli_coverage.py` in report/partial mode. It must validate registry invariants that exist at that stage, but it must not require full all-domain command coverage yet. +- Stages 8-9 use the linter in read-coverage mode. +- Stage 10C switches the linter to strict mode and adds `make cli-lint` to `make check`. +- Strict mode fails on every missing sync Swagger-bound command unless there is a documented intentional exclusion. +- Linter output must be deterministic and sanitized so it can be committed as an audit artifact when needed. + +Execution coverage policy: + +- A command being registered and help-renderable is not enough for final + coverage. Every canonical command must execute at least once through a fake + client or fake transport unless it has a documented execution-smoke exclusion. +- Execution-smoke exclusions are separate from API coverage exclusions. They + must include reason, owner, follow-up, and whether the command is still + user-supported. +- Commands needing file input, stdin, binary output, multipart handling, or + complex public input models should receive explicit CLI adapters rather than + weakening the generic invocation path. + +### Stage 1: CLI Dependency and Shell + +Deliverables: + +- Add `click` as an explicit runtime dependency. +- Update `poetry.lock` after dependency changes. +- Use Click test utilities only in tests; do not add a custom subprocess harness unless behavior specifically requires `python -m avito`. +- Add `avito/cli/` package skeleton. +- Add root `avito` Click group with typed global context. +- Add `avito --help`, `avito --version`, `avito version`. +- Add `avito help` as the user-facing help entry point. At Stage 1 it may delegate to root help only; nested help such as `avito help account get-self` becomes mandatory once nested commands exist. +- Route `python -m avito` to the same CLI app. +- Register Poetry script as `avito = "avito.cli.app:main"`; keep `app` as the reusable Click command/group object. +- Use Russian help text from the beginning. +- Add a `CHANGELOG.md` entry for the new CLI shell and public entry points. + +Tests: + +- `tests/cli/test_app.py` +- help output smoke tests; +- version command tests; +- global flag parsing tests. + +Verification: + +```bash +poetry run pytest tests/cli/test_app.py +poetry run python scripts/lint_python_guidelines.py +poetry run python scripts/lint_architecture.py +poetry run mypy avito +poetry run ruff check avito/cli tests/cli +poetry check --lock +poetry build +``` + +Exit criteria: + +- Help/version commands do not touch network, config, or account files. +- `python -m avito --help` and `avito --help` exercise the same app. +- Importing `avito.cli.app` has no filesystem side effects and does not construct `AvitoClient`. +- Root-level global options are parsed in the canonical position before subcommands. + +Stage checklist: + +- [x] `click` is added as a runtime dependency. +- [x] `poetry.lock` is updated and lock consistency is verified. +- [x] `avito/cli/` package exists with only the minimal shell files. +- [x] `avito --help`, `avito help`, `avito --version`, `avito version`, and `python -m avito --help` work. +- [x] Poetry script points to `avito.cli.app:main`, not directly to the Click command/group object. +- [x] Canonical root-level global option syntax is covered by tests. +- [x] No config directory or account file is created by help/version commands. +- [x] `tests/cli/test_app.py` covers the shell behavior. +- [x] Stage verification commands pass. + +Stage result: + +```text +Completed on 2026-05-10. +poetry run pytest tests/cli/test_app.py: 8 passed +poetry run python scripts/lint_python_guidelines.py: OK +poetry run python scripts/lint_architecture.py: errors=0 +poetry run mypy avito: Success +poetry run ruff check avito/cli tests/cli: All checks passed +poetry check --lock: passed with existing Poetry deprecation warnings +poetry build: built sdist and wheel +``` + +### Stage 2: Errors, UI, and Safe Output + +Deliverables: + +- Add `CliContext`. +- Add `CliError` hierarchy and exit-code mapping. +- Add stdout/stderr output helpers. +- Add JSON/human error rendering. +- Add one reusable sanitizer used by all renderers. +- Add color handling for `--no-color` and `NO_COLOR=1`. +- Add invalid global flag-combination validation. +- Create or update `docs/site/reference/cli.md` with the exit codes, global flags, output modes, stdout/stderr split, and current implemented commands. +- Add `cli.md` to `docs/site/reference/.pages` when the reference page is created. +- Update `CHANGELOG.md` with the first documented CLI contract: global flags, output modes, and exit codes. + +Tests: + +- human errors go to stderr; +- JSON errors are valid JSON on stderr; +- `--debug` does not reveal secrets; +- `--quiet` suppresses non-essential success output; +- invalid flag combinations exit with code `2`. + +Verification: + +```bash +poetry run pytest tests/cli/test_errors.py tests/cli/test_ui.py +poetry run python scripts/lint_python_guidelines.py +poetry run python scripts/lint_architecture.py +poetry run mypy avito +poetry run ruff check avito/cli tests/cli +poetry run mkdocs build --strict +``` + +Exit criteria: + +- Every CLI error has a Russian message, stable uppercase code, and documented exit code. +- No traceback is printed by default; diagnostics are sanitized. +- The reference CLI contract documents only implemented behavior. + +Stage checklist: + +- [x] `CliContext` is typed and shared by commands through one code path. +- [x] `CliError` maps to documented exit codes. +- [x] Human and JSON errors use the same sanitized error payload. +- [x] Invalid output flag combinations exit with code `2`. +- [x] `--quiet`, `--debug`, `--verbose`, `--no-color`, and `NO_COLOR=1` are covered by tests. +- [x] `docs/site/reference/cli.md` documents implemented global flags, output modes, and exit codes. +- [x] `docs/site/reference/.pages` includes `cli.md` once the page exists. +- [x] Stage verification commands pass. + +### Stage 3: Account Store and Profile Commands + +Split Stage 3 into two reviewable sub-stages. Do not implement API invocation in +this stage; account/profile commands are local only. + +#### Stage 3A: Account Store Primitives + +Deliverables: + +- Implement CLI home resolver and atomic JSON persistence. +- Implement account/config dataclasses and stores. +- Implement safe store loading: missing files, malformed JSON, permission + failures, and schema-version handling. +- Implement conversion from stored account data to `AvitoSettings` without + constructing `AvitoClient`. +- Store active account name in config, not per-account flags. + +Tests: + +- default home and environment override precedence; +- lazy directory creation; +- file permissions where platform supports it; +- atomic JSON writes through same-directory temporary files and `os.replace`; +- malformed JSON handling; +- account/config dataclass serialization masks secrets in JSON output helpers; +- conversion to `AvitoSettings` uses only SDK public settings types. + +Verification: + +```bash +poetry run pytest tests/cli/test_config.py +poetry run python scripts/lint_python_guidelines.py +poetry run python scripts/lint_architecture.py +poetry run mypy avito +poetry run ruff check avito/cli tests/cli +``` + +Exit criteria: + +- Importing account/config modules creates no directories or files. +- Store code performs no Avito API network calls. +- Permission and malformed-file failures map to typed CLI errors. + +Stage checklist: + +- [x] CLI home resolution follows `AVITO_PY_HOME`, `MY_SDK_HOME`, then `~/.avito-py`. +- [x] Directory/file creation is lazy and uses required permissions where supported. +- [x] JSON writes are atomic through same-directory temp files and `os.replace`. +- [x] Active account is stored once in config, not as per-account boolean state. +- [x] Store loading and malformed JSON behavior are tested. +- [x] Stage 3A verification commands pass. + +#### Stage 3B: Account Commands and Secret Input + +Deliverables: + +- Add account commands: + - `avito account add` + - `avito account list` + - `avito account use ` + - `avito account current` + - `avito account delete ` +- Add optional `account remove` only as documented alias for `account delete`. +- Support safe secret entry for `client_secret`: hidden TTY prompt by default when input is allowed, plus a non-interactive path that does not require putting the secret directly in shell history. +- Add `--client-secret-stdin` for non-interactive secret input. It reads exactly + one secret value from stdin, strips one trailing newline, refuses TTY stdin, and + is mutually exclusive with `--client-secret` and `--api-key`. +- Keep `--client-secret` and ticket-compatible `--api-key` for explicit automation, but document the shell-history tradeoff. +- Ensure `--no-input` fails with `AUTH_REQUIRED`/`CONFIG_INVALID` instead of + prompting when no secret was provided. +- Update `CHANGELOG.md` for account/profile commands and local plaintext secret storage behavior. + +Tests: + +- add/reload account; +- duplicate account conflict; +- active account set/get/clear; +- no-input behavior; +- ticket aliases `--api-key` and `--endpoint`; +- hidden prompt path; +- `--client-secret-stdin` path; +- mutually exclusive secret flags; +- JSON output contains no raw secrets. + +Verification: + +```bash +poetry run pytest tests/cli/test_accounts.py +poetry run python scripts/lint_python_guidelines.py +poetry run python scripts/lint_architecture.py +poetry run mypy avito +poetry run ruff check avito/cli tests/cli +``` + +Exit criteria: + +- Account commands perform no Avito API network calls. +- Secret fields are masked in every output mode. +- Users have one interactive and one non-interactive way to provide secrets + without putting them in shell history. + +Stage checklist: + +- [x] Account add/list/use/current/delete commands work without network. +- [x] `--api-key` and `--endpoint` aliases are tested. +- [x] Hidden prompt secret input is tested. +- [x] `--client-secret-stdin` is tested and refuses TTY stdin. +- [x] `--client-secret`, `--api-key`, and `--client-secret-stdin` are mutually exclusive. +- [x] Public docs or reference text clearly describe plaintext local storage and safe secret input. +- [x] Stage 3B verification commands pass. + +### Stage 4: CLI Registry From SDK Metadata + +Split Stage 4 into three reviewable sub-stages. The goal is to introduce the +registry foundation, then help/alias behavior, then static coverage and +architecture enforcement. Do not start generic input coercion in Stage 5 until +all Stage 4 sub-stages pass. + +#### Stage 4A: Typed Registry and Discovery Report + +Deliverables: + +- Build `avito/cli/registry.py`. +- Convert sync discovered Swagger bindings into canonical API command records. +- Preserve factory name, factory args, method name, method args, operation key, + spec, path, HTTP method, domain, deprecated flag, and legacy flag. +- Derive canonical resource/action names from factory and method names using + lowercase kebab-case. +- Register local commands and public non-Swagger helpers in separate categories, + but do not implement nested registry-backed help yet. +- Add exclusion record types for API bindings, helper workflows, and execution + smoke coverage. +- Add deterministic registry JSON/report data that can be produced without + constructing `AvitoClient`, reading account files, or touching the network. +- Keep adapter references as stable string ids only; do not import adapter + implementation modules during registry construction. + +Tests: + +- registry can be built without constructing `AvitoClient`, reading account + files, or touching the network; +- sync discovered Swagger bindings are represented in report mode as command + candidates or explicit exclusions; +- API, helper, local, alias placeholder, and exclusion records are separate typed + categories; +- registry report output is deterministic. + +Verification: + +```bash +poetry run pytest tests/cli/test_registry.py +poetry run python scripts/lint_python_guidelines.py +poetry run python scripts/lint_architecture.py +poetry run mypy avito +poetry run ruff check avito/cli tests/cli +``` + +Exit criteria: + +- Registry records are typed, deterministic, and serializable. +- Registry builds without creating `AvitoClient`. +- Registry pytest tests cover runtime behavior only. +- Full missing-command failures are deferred to later coverage-linter phases, not + silently skipped. + +Stage checklist: + +- [x] `avito/cli/registry.py` exists with typed API, helper, local, alias, and exclusion records. +- [x] Sync Swagger bindings are converted into deterministic command candidates. +- [x] Factory/method metadata and Swagger binding identifiers are preserved. +- [x] Registry construction has no network, config, account-file, or `AvitoClient` side effects. +- [x] Registry records can reference named adapters without importing adapter implementation modules. +- [x] Stage 4A verification commands pass. + +Stage result: + +```text +Completed on 2026-05-10. +poetry run pytest tests/cli/test_registry.py: 7 passed +poetry run python scripts/lint_python_guidelines.py: OK +poetry run python scripts/lint_architecture.py: errors=0 +poetry run mypy avito: Success +poetry run ruff check avito/cli tests/cli: All checks passed +``` + +#### Stage 4B: Registry Help, Aliases, and Collisions + +Deliverables: + +- Add nested help support for registry-backed resources and actions: + - `avito help `; + - `avito help `; + - generated help must use registry metadata and must not instantiate + `AvitoClient`. +- Add alias support separate from canonical command records. +- Ensure aliases delegate to canonical records and never count as command + coverage. +- Add deterministic collision detection for `resource action` across local, + helper, generated API, and alias records. +- Add local/API collision errors before command registration. +- Add help metadata fields needed by later generated commands: examples, related + commands, safety summary, output hint, and adapter id. + +Tests: + +- `avito help ` and `avito help ` render + registry-backed help without constructing `AvitoClient`; +- local helper command metadata is visible to help/registration code separately + from API command metadata; +- aliases delegate to canonical command records at runtime and do not produce + duplicate callbacks; +- local/API command collisions fail during registry construction. + +Verification: + +```bash +poetry run pytest tests/cli/test_registry.py tests/cli/test_app.py +poetry run python scripts/lint_python_guidelines.py +poetry run python scripts/lint_architecture.py +poetry run mypy avito +poetry run ruff check avito/cli tests/cli +``` + +Exit criteria: + +- Registry-backed help works for resources and actions. +- Alias behavior is deterministic and does not create duplicate canonical + commands. +- Local/API command collisions fail before runtime command registration. + +Stage checklist: + +- [x] Registry-backed `avito help ` and `avito help ` are implemented and tested. +- [x] API, helper, local, alias, and exclusion records remain separate categories. +- [x] Compatibility aliases delegate to canonical commands and do not count as coverage. +- [x] Local/API command collisions fail during registry construction. +- [x] Stage 4B verification commands pass. + +Stage result: + +```text +Completed on 2026-05-10. +poetry run pytest tests/cli/test_registry.py tests/cli/test_app.py: 22 passed +poetry run python scripts/lint_python_guidelines.py: OK +poetry run python scripts/lint_architecture.py: errors=0 +poetry run mypy avito: Success +poetry run ruff check avito/cli tests/cli: All checks passed +``` + +#### Stage 4C: CLI Coverage and Architecture Lint + +Deliverables: + +- Add `scripts/lint_cli_coverage.py`. +- Implement `scripts/lint_cli_coverage.py --phase registry`. +- In registry phase, verify that the registry includes all sync discovered + bindings in report mode without requiring full all-domain command coverage yet. +- Verify lowercase kebab-case resource/action names, duplicate canonical + commands, one-to-one binding ownership for records present at this stage, + alias policy, local/API collisions, forbidden `resource-id`, and required + exclusion metadata. +- Verify that generated API command input metadata is selected from + `factory_args` and `method_args`, not by blindly exposing the whole SDK method + signature. +- Verify that generated method flags do not include `timeout` or `retry`. +- Verify that deprecated/legacy Swagger bindings have either command + warning/help metadata or an intentional exclusion. +- Verify that adapter references, if present, point to an explicit adapter + registry entry rather than ad hoc callback names. +- Extend `scripts/lint_architecture.py` with CLI import-boundary checks that + forbid production `avito/cli/` imports from `tests`, `avito.testing`, domain + operation modules, transport implementations, auth provider internals, and + `avito.core.operations`. Add a dedicated CLI architecture linter only if the + existing script becomes materially unsuitable. + +Tests: + +- runtime registry tests from Stage 4A and Stage 4B still pass; +- linter verification is performed by running the linter against the real + repository, not by adding synthetic policy-only pytest cases. + +Verification: + +```bash +poetry run pytest tests/cli/test_registry.py tests/cli/test_app.py +poetry run python scripts/lint_cli_coverage.py --phase registry +poetry run python scripts/lint_python_guidelines.py +poetry run python scripts/lint_architecture.py +poetry run mypy avito +poetry run mypy scripts/lint_cli_coverage.py +poetry run ruff check avito/cli tests/cli scripts/lint_cli_coverage.py +``` + +Exit criteria: + +- CLI coverage linter fails on duplicate records, invalid names, local/API + collisions, forbidden `resource-id`, invalid adapter references, or missing + required exclusion metadata. +- CLI coverage linter fails if a generated API command exposes SDK control + parameters `timeout` or `retry` as method flags. +- CLI coverage linter fails if a deprecated/legacy binding has no explicit CLI + policy. +- Architecture lint statically enforces CLI production import boundaries. +- Full missing-command failures are deferred to read/full coverage phases, not + silently skipped. + +Stage checklist: + +- [x] `scripts/lint_cli_coverage.py` exists and exercises the registry in `--phase registry`. +- [x] Canonical API commands present at this stage map one-to-one to sync Swagger bindings. +- [x] CLI coverage linter checks kebab-case names, alias policy, local/API collisions, forbidden `resource-id`, and exclusion metadata. +- [x] CLI coverage linter checks selected generated inputs and rejects + method-level `timeout` / `retry` flags. +- [x] CLI coverage linter checks deprecated/legacy command or exclusion policy. +- [x] Existing `scripts/lint_architecture.py` statically checks CLI production import boundaries, unless a documented dedicated-linter exception exists. +- [x] Stage 4C verification commands pass. + +Stage result: + +```text +Completed on 2026-05-10. +poetry run pytest tests/cli/test_registry.py tests/cli/test_app.py: 22 passed +poetry run python scripts/lint_cli_coverage.py --phase registry: errors=0 +poetry run python scripts/lint_python_guidelines.py: OK +poetry run python scripts/lint_architecture.py: errors=0 +poetry run mypy avito: Success +poetry run mypy scripts/lint_cli_coverage.py: Success +poetry run ruff check avito/cli tests/cli scripts/lint_cli_coverage.py: All checks passed +``` + +### Stage 5: Generic Input Coercion + +Deliverables: + +- Implement `avito/cli/schemas.py`. +- Implement typed CLI parameter metadata. +- Build generated command parameter metadata from `binding.factory_args` and + `binding.method_args`. +- Use public signatures/type hints to validate and coerce only those selected + binding arguments. +- Filter out per-operation SDK control parameters such as `timeout` and `retry` + from generated method flags. Root `--timeout` is handled by the global context; + `retry` is intentionally unsupported in the first release. +- Support repeated flags and documented comma-separated list parsing. +- Validate enum names/values and date/datetime formats with Russian errors. + +Tests: + +- coercion for primitives, bools, dates, datetimes, enums, optionals, and lists; +- generated parameter selection excludes `timeout` and `retry` for representative + methods that have those SDK parameters; +- missing required values fail without prompt in `--no-input`; +- invalid values produce `VALIDATION_FAILED`; +- supported repeated flags and comma-separated values coerce to the same typed list result. + +Static lint responsibilities: + +- generated flag names are lowercase kebab-case; +- generated flags never expose `--resource-id`. +- generated method flags never expose `--timeout` or `--retry`. + +Verification: + +```bash +poetry run pytest tests/cli/test_schemas.py +poetry run python scripts/lint_cli_coverage.py --phase registry +poetry run python scripts/lint_python_guidelines.py +poetry run python scripts/lint_architecture.py +poetry run mypy avito +poetry run mypy scripts/lint_cli_coverage.py +poetry run ruff check avito/cli tests/cli +``` + +Exit criteria: + +- Input coercion is testable without constructing `AvitoClient` or invoking network code. + +Stage checklist: + +- [x] CLI parameter metadata is typed and independent from Click internals where practical. +- [x] Generated parameter metadata is selected from Swagger binding + `factory_args` / `method_args`, not from the whole SDK signature. +- [x] `timeout` and `retry` are filtered out of generated method flags. +- [x] Primitive, bool, date, datetime, enum, optional, and list coercion are tested. +- [x] Repeated flags and documented comma-separated values behave consistently. +- [x] Invalid values produce Russian `VALIDATION_FAILED` errors. +- [x] Generated flag names are checked by the CLI coverage linter for kebab-case and absence of `--resource-id`. +- [x] Stage verification commands pass. + +Stage result: + +```text +Completed on 2026-05-10. +poetry run pytest tests/cli/test_schemas.py: 7 passed +poetry run python scripts/lint_cli_coverage.py --phase registry: errors=0 +poetry run python scripts/lint_python_guidelines.py: OK +poetry run python scripts/lint_architecture.py: errors=0 +poetry run mypy avito: Success +poetry run mypy scripts/lint_cli_coverage.py: Success +poetry run ruff check avito/cli tests/cli scripts/lint_cli_coverage.py: All checks passed +``` + +### Stage 6: Generic Invocation Engine + +Deliverables: + +- Implement `avito/cli/commands.py`. +- Build and call `AvitoClient` through active account/profile. +- Invoke public SDK factory and method. +- Pass root `--timeout` through the invocation context only for SDK methods that + expose `timeout`; do not generate per-command `--timeout` flags. +- Keep SDK `retry` overrides unsupported in the first release unless a later + stage adds a documented global retry policy and tests. +- Map SDK exceptions to CLI errors. +- Add explicit unsupported-method registry only for documented exclusions. +- Add a typed client factory protocol for tests so invocation behavior can be verified without real HTTP. +- Production code must default to constructing `AvitoClient`; tests may inject a fake client through the protocol. + +Tests: + +- active profile is used by default; +- `--profile` overrides active profile; +- CLI invokes expected factory and public method with expected arguments; +- root `--timeout` reaches a representative SDK method without appearing as a + generated method flag; +- `retry` is not accepted as a command flag in the first release; +- SDK `AuthenticationError`, `AuthorizationError`, `ValidationError`, `ConflictError`, and not-found equivalents map to documented exit codes. + +Static lint responsibilities: + +- CLI production code does not import or call operation specs, operation executor, transport implementations, auth provider internals, or testing fake transports. + +Verification: + +```bash +poetry run pytest tests/cli/test_commands.py +poetry run python scripts/lint_cli_coverage.py --phase registry +poetry run python scripts/lint_python_guidelines.py +poetry run python scripts/lint_architecture.py +poetry run mypy avito +poetry run mypy scripts/lint_cli_coverage.py +poetry run ruff check avito/cli tests/cli +``` + +Exit criteria: + +- API invocation path is a thin adapter over `AvitoClient` factories and public domain methods. + +Stage checklist: + +- [x] API command invocation resolves profile/config before constructing `AvitoClient`. +- [x] `AvitoClient` is always used as a context manager. +- [x] Invocation calls factory method, then public domain method. +- [x] Root `--timeout` is mapped deliberately and tested. +- [x] `retry` is not exposed as a generated CLI flag. +- [x] Test-only fake clients are injected through typed protocols and are not imported by production CLI modules. +- [x] Architecture lint proves operation specs and transport are not called directly by CLI production code. +- [x] SDK exceptions map to documented CLI exit codes and sanitized messages. +- [x] Stage verification commands pass. + +Stage result: + +```text +Completed on 2026-05-10. +poetry run pytest tests/cli/test_commands.py: 6 passed +poetry run python scripts/lint_cli_coverage.py --phase registry: errors=0 +poetry run python scripts/lint_python_guidelines.py: OK +poetry run python scripts/lint_architecture.py: errors=0 +poetry run mypy avito: Success +poetry run mypy scripts/lint_cli_coverage.py: Success +poetry run ruff check avito/cli tests/cli: All checks passed +``` + +### Stage 6B: Command Adapter Extension Point + +Deliverables: + +- Add `avito/cli/adapters.py` with a typed adapter protocol for commands whose + public SDK signature is valid but cannot be exposed safely by the fully generic + path. +- Restrict adapters to CLI input/output concerns: file opening, stdin handling, + multipart-friendly path arguments, binary result rendering, and public input + model construction. +- Require every adapter to call the same invocation engine or public + `AvitoClient` factory/domain method path. Adapters must not call operation + specs, operation executor, transport, auth provider internals, or domain object + constructors directly. +- Add adapter metadata to registry records by stable adapter id. Do not store raw + callables in metadata that must be serialized by the coverage report. +- Add linter checks that every adapter id referenced by a command exists, is + used by at least one command, and has an owner/reason note. + +Tests: + +- a simple adapter can transform CLI-only input and still invokes a public SDK + method through the shared path; +- adapter errors are sanitized and mapped to documented CLI exit codes; +- adapter registry rejects unknown adapter ids and duplicate adapter ids; +- an adapter-backed command still renders help and appears in coverage reports. + +Verification: + +```bash +poetry run pytest tests/cli/test_adapters.py tests/cli/test_commands.py +poetry run python scripts/lint_cli_coverage.py --phase registry +poetry run python scripts/lint_python_guidelines.py +poetry run python scripts/lint_architecture.py +poetry run mypy avito +poetry run mypy scripts/lint_cli_coverage.py +poetry run ruff check avito/cli tests/cli +``` + +Exit criteria: + +- Adapter support exists before any all-domain command wave needs it. +- Adapter-backed commands remain auditable by the coverage linter. + +Stage checklist: + +- [x] Adapter protocol is typed and documented in code. +- [x] Adapter metadata is stable and serializable in registry/coverage reports. +- [x] Adapter-backed invocation still uses public SDK factories and methods only. +- [x] Architecture lint prevents adapters from importing forbidden internal layers. +- [x] Unknown, duplicate, or unused adapter ids fail lint. +- [x] Stage 6B verification commands pass. + +Stage result: + +```text +Completed on 2026-05-10. +poetry run pytest tests/cli/test_adapters.py tests/cli/test_commands.py: 11 passed +poetry run python scripts/lint_cli_coverage.py --phase registry: errors=0 +poetry run python scripts/lint_python_guidelines.py: OK +poetry run python scripts/lint_architecture.py: errors=0 +poetry run mypy avito: Success +poetry run mypy scripts/lint_cli_coverage.py: Success +poetry run ruff check avito/cli tests/cli: All checks passed +``` + +### Stage 7: Result Serialization and Pagination + +Deliverables: + +- Implement `avito/cli/serialization.py`. +- Serialize SDK models through `model_dump()` / `to_dict()`. +- Serialize CLI-local dataclasses, enums, dates, datetimes, lists, and primitive values safely. +- Handle `PaginatedList[T]` with documented bounded defaults. +- Add `--limit`, `--page-limit`, and `--all` consistently for paginated commands when needed to avoid unbounded materialization. +- Default paginated output must be bounded. Conservative default: first page only or at most the SDK/default page size when the operation exposes a page size. +- `--all` must require an explicit opt-in and should show progress on stderr for long materialization. +- Render default tables for collections and grouped output for single models. + +Tests: + +- model serialization uses public model contract; +- paginated results do not fetch unbounded pages by default; +- JSON output is stable; +- tables have stable columns for repeated models; +- no secrets appear in serialized output. + +Verification: + +```bash +poetry run pytest tests/cli/test_serialization.py +poetry run python scripts/lint_python_guidelines.py +poetry run python scripts/lint_architecture.py +poetry run mypy avito +poetry run ruff check avito/cli tests/cli +``` + +Exit criteria: + +- JSON output schema is stable for object, collection, primitive, and paginated values. +- Default pagination cannot accidentally materialize an unbounded result set. + +Stage checklist: + +- [x] SDK models serialize through `model_dump()` or `to_dict()`. +- [x] CLI-local dataclasses, enums, dates, datetimes, lists, and primitives serialize safely. +- [x] Pagination defaults are bounded and documented in the CLI output contract; command help integration remains for the first paginated command stage. +- [x] Human table/grouped output and JSON output are both tested. +- [x] Secret sanitizer is applied after serialization and before rendering. +- [x] Stage verification commands pass. + +Stage result: + +```text +Completed on 2026-05-10. +poetry run pytest tests/cli/test_serialization.py: 5 passed +poetry run python scripts/lint_python_guidelines.py: OK +poetry run python scripts/lint_architecture.py: errors=0 +poetry run mypy avito: Success +poetry run ruff check avito/cli tests/cli: All checks passed +``` + +### Stage 8: First Vertical API Slice + +Deliverables: + +- Expose and test a small read-only slice: + - `avito account get-self` + - `avito account get-balance` + - one paginated/list command if available. +- Use `SwaggerFakeTransport` or existing fake transport infrastructure in tests only. +- Do not make real network calls in tests. + +Tests: + +- command invokes expected SDK method; +- request path/query/body match fake transport expectations; +- human output works; +- JSON output works; +- errors map correctly. + +Verification: + +```bash +poetry run pytest tests/cli/test_account_api_commands.py +poetry run pytest tests/contracts/test_swagger_contracts.py +poetry run python scripts/lint_cli_coverage.py --phase read +poetry run python scripts/lint_python_guidelines.py +poetry run python scripts/lint_architecture.py +poetry run mypy avito +poetry run mypy scripts/lint_cli_coverage.py +poetry run ruff check avito/cli tests/cli +``` + +Exit criteria: + +- At least one registry-bound API command runs end to end through generic path and fake transport. + +Stage checklist: + +- [x] `account get-self` runs through registry, coercion, invocation, serialization, and UI layers. +- [x] `account get-balance` runs through the same generic path. +- [x] At least one list/paginated command is covered if an account-domain candidate exists. + No account-domain read-only list/paginated candidate exists in the current sync binding set. +- [x] Tests use fake transport only and make no real network calls. +- [x] Human output and `--json` output are both covered. +- [x] Stage verification commands pass. + +Stage result: + +```text +Completed on 2026-05-10. +poetry run pytest tests/cli/test_account_api_commands.py: 4 passed +poetry run pytest tests/contracts/test_swagger_contracts.py: 1911 passed +poetry run python scripts/lint_cli_coverage.py --phase read: errors=0 +poetry run python scripts/lint_python_guidelines.py: OK +poetry run python scripts/lint_architecture.py: errors=0 +poetry run mypy avito: Success +poetry run mypy scripts/lint_cli_coverage.py: Success +poetry run ruff check avito/cli tests/cli scripts/lint_cli_coverage.py: All checks passed +``` + +### Stage 9: Read-Only All-Domain API Coverage + +Deliverables: + +- Register generated read/list/get commands for every sync Swagger-bound read method supported by the generic engine. +- Add domain/resource help pages for read-only commands. +- Add generated examples where safe and meaningful. +- Add command metadata for methods needing custom list/file/enum parsing. +- Document every temporarily unsupported read-only sync binding. +- Before registering a whole factory group, classify each read-only method as + generic, adapter-backed, or excluded. Methods requiring file/stdin/binary + handling or complex public input models must not be forced through the generic + path just to satisfy coverage. + +Required coverage groups: + +- Use discovered `factory` names as the canonical grouping key. +- Keep smoke-test grouping human-sized by clustering related factories only for + test organization, not for coverage accounting. +- Every factory that owns at least one read-only sync binding must have either a + smoke invocation in this stage or an explicit temporary exclusion with follow-up. + +Tests: + +- one read-only smoke invocation per completed factory group with fake transport; +- every canonical read-only command is registered, renders help, and has either a successful fake execution test or a documented temporary exclusion from execution smoke with reason and follow-up; +- human and JSON output for representative object and collection commands; +- fake-transport behavior proves no real network calls are made. + +Static lint responsibilities: + +- every discovered read-only sync binding has a canonical command or explicit temporary exclusion; +- generated read-only commands do not expose forbidden names or secret fields; +- local/API command collisions and alias policy remain valid. + +Verification: + +```bash +poetry run pytest tests/cli/test_domain_smoke_commands.py +poetry run python scripts/lint_cli_coverage.py --phase read +poetry run python scripts/lint_python_guidelines.py +poetry run python scripts/lint_architecture.py +poetry run mypy avito +poetry run mypy scripts/lint_cli_coverage.py +poetry run ruff check avito/cli tests/cli scripts/lint_cli_coverage.py +``` + +Exit criteria: + +- All sync Swagger-bound read-only methods are exposed or documented with temporary exclusions. +- Coverage report separates complete read coverage from pending write/destructive coverage. + +Stage checklist: + +- [x] Every completed factory group has at least one read-only smoke command test. +- [x] Every canonical read-only command has registration/help coverage and execution coverage or a documented temporary execution-smoke exclusion. +- [x] CLI coverage linter covers every discovered read-only sync binding. +- [x] Unsupported read-only bindings have explicit temporary exclusions with follow-up. +- [x] Domain/resource help exists for generated read-only commands. +- [x] Coverage linter distinguishes read coverage from pending write coverage. +- [x] Stage verification commands pass. + +Stage result: + +```text +Completed on 2026-05-10. +Read sync Swagger bindings: 60 +Registered canonical read commands: 53 +Temporary read API exclusions: 7 +Excluded temporarily until Stage 10C: +- autoteka-vehicle.get-preview +- autoteka-vehicle.get-specification-by-id +- autoteka-vehicle.get-teaser +- cpa-chat.get +- order-label.download +- realty-analytics-report.get-market-price-correspondence +- target-action-pricing.get-bids + +poetry run pytest tests/cli/test_domain_smoke_commands.py: 107 passed +poetry run python scripts/lint_cli_coverage.py --phase read: errors=0 +poetry run python scripts/lint_python_guidelines.py: OK +poetry run python scripts/lint_architecture.py: errors=0 +poetry run mypy avito: Success +poetry run mypy scripts/lint_cli_coverage.py: Success +poetry run ruff check avito/cli tests/cli scripts/lint_cli_coverage.py: All checks passed +``` + +### Stage 10A: Write Safety Primitives + +Deliverables: + +- Classify write/destructive commands from explicit registry safety metadata. HTTP method may provide defaults, but reviewed metadata is the source of truth. +- Require confirmation for destructive commands unless `--yes` or exact `--confirm` is supplied. +- Support `--dry-run` only when the SDK public method already supports `dry_run` or when CLI can safely preview without changing SDK behavior. +- Do not fake dry-run for SDK methods that would still execute transport. +- Ensure write commands build the same SDK call in dry-run and apply modes where `dry_run` exists. +- Add write/destructive command metadata fields without broadening all-domain write coverage yet. +- Add safety help text and examples for commands that can modify state or trigger expensive operations. + +Tests: + +- delete/reset-like commands require confirmation; +- `--no-input` fails instead of prompting; +- `--yes` and `--confirm` behave deterministically; +- dry-run methods do not call transport when SDK contract says they should not; +- non-dry-run write commands call transport exactly once. + +Static lint responsibilities: + +- safety metadata cannot be absent for write/destructive/expensive records; +- HTTP-method-derived safety defaults must be reviewed into explicit registry metadata before a command is exposed; +- destructive/expensive command help includes required safety flags and examples. + +Verification: + +```bash +poetry run pytest tests/cli/test_write_safety.py +poetry run python scripts/lint_cli_coverage.py --phase write-safety +poetry run python scripts/lint_python_guidelines.py +poetry run python scripts/lint_architecture.py +poetry run mypy avito +poetry run mypy scripts/lint_cli_coverage.py +poetry run ruff check avito/cli tests/cli scripts/lint_cli_coverage.py +``` + +Exit criteria: + +- No destructive command can run accidentally in non-interactive mode. +- `--dry-run` is exposed only where the SDK method can actually avoid transport or apply mode can be proven equivalent by tests. + +Stage checklist: + +- [x] Write/destructive/expensive classification is deterministic and tested. +- [x] Exposed write/destructive/expensive commands have explicit reviewed safety metadata. +- [x] Destructive commands require prompt, `--yes`, or exact `--confirm`. +- [x] `--no-input` never hangs and fails safely when confirmation is required. +- [x] `--dry-run` is exposed only for SDK methods that safely support it. +- [x] Safety behavior is reflected in command help. +- [x] Stage verification commands pass. + +Stage result: + +```text +Completed on 2026-05-10. +poetry run pytest tests/cli/test_write_safety.py: 5 passed +poetry run pytest tests/cli/test_registry.py tests/cli/test_app.py tests/cli/test_domain_smoke_commands.py: 129 passed +poetry run python scripts/lint_cli_coverage.py --phase write-safety: errors=0 +poetry run python scripts/lint_python_guidelines.py: OK +poetry run python scripts/lint_architecture.py: errors=0 +poetry run mypy avito: Success +poetry run mypy scripts/lint_cli_coverage.py: Success +poetry run ruff check avito/cli tests/cli scripts/lint_cli_coverage.py: All checks passed +``` + +### Stage 10B: Write Command Coverage by Domain Waves + +Deliverables: + +- Register generated commands for remaining write sync Swagger-bound methods in small domain waves. +- Treat the suggested waves as planning defaults, not mandatory commit size. If + a wave exceeds the stage size policy, split it into smaller sub-waves in this + file before implementation and give each sub-wave its own tests, + verification, and checklist. +- Use discovered `factory` names as the wave unit. Suggested waves, based on the 2026-05-10 baseline: + - Wave 1: low-count/low-risk factories: `rating_profile`, `review`, `review_answer`, `realty_analytics_report`, `realty_booking`, `realty_listing`, `realty_pricing`, `tariff`, `account`, `account_hierarchy`. + - Wave 2: medium factories: `ad`, `ad_promotion`, `ad_stats`, `cpa_archive`, `cpa_auction`, `cpa_call`, `cpa_chat`, `cpa_lead`, `chat`, `chat_media`, `chat_message`, `chat_webhook`, `special_offer_campaign`. + - Wave 3: jobs and autoload factories: `application`, `resume`, `vacancy`, `job_dictionary`, `job_webhook`, `autoload_archive`, `autoload_profile`, `autoload_report`. + - Wave 4: large/high-risk commerce and promotion factories: `order`, `order_label`, `delivery_order`, `delivery_task`, `sandbox_delivery`, `stock`, `promotion_order`, `autostrategy_campaign`, `bbip_promotion`, `trx_promotion`, `target_action_pricing`. + - Wave 5: Autoteka factories: `autoteka_vehicle`, `autoteka_report`, `autoteka_monitoring`, `autoteka_scoring`, `autoteka_valuation`. +- Each wave must update command metadata, smoke tests, exclusions, and coverage report together. +- Eliminate or document every unsupported sync binding in the wave before moving to the next wave. +- Temporary exclusions are allowed only inside a wave and must include owner, reason, target stage, and follow-up. +- Before exposing a write command, record its safety classification explicitly + in registry metadata. HTTP method defaults can propose a classification, but + reviewed metadata is required before the command becomes public. + +Tests: + +- one write smoke invocation per write-capable factory group in the current wave with fake transport; +- every canonical write command in the current wave is registered, renders help, and has either a successful fake execution test or a documented temporary execution-smoke exclusion with reason and follow-up; +- safety tests run for at least one destructive or expensive command when the wave contains one. + +Static lint responsibilities: + +- coverage linter fails on missing write commands for completed waves; +- coverage linter covers every write sync binding in completed waves; +- coverage linter verifies command naming, alias policy, and exclusion metadata for completed waves. + +Verification for each wave: + +```bash +poetry run pytest tests/cli/test_write_safety.py +poetry run pytest tests/cli/test_domain_smoke_commands.py +poetry run python scripts/lint_cli_coverage.py --phase write --domain +poetry run python scripts/lint_python_guidelines.py +poetry run python scripts/lint_architecture.py +poetry run mypy avito +poetry run mypy scripts/lint_cli_coverage.py +poetry run ruff check avito/cli tests/cli scripts/lint_cli_coverage.py +``` + +Exit criteria: + +- Every write sync binding in completed waves has a canonical command or explicit temporary/intentional exclusion. +- Domain smoke tests use fake transport only and make no real network calls. +- No broad write coverage change lands without matching tests. + +Stage checklist: + +- [x] Wave 1 write commands are covered or explicitly excluded. +- [x] Wave 2 write commands are covered or explicitly excluded. +- [x] Wave 3 write commands are covered or explicitly excluded. +- [x] Wave 4 write commands are covered or explicitly excluded. +- [x] Wave 5 write commands are covered or explicitly excluded. +- [x] Every completed wave has fake-transport smoke tests. +- [x] Every canonical write command in completed waves has registration/help coverage and execution coverage or a documented temporary execution-smoke exclusion. +- [x] Temporary exclusions have owner, reason, target stage, and follow-up. +- [x] Stage verification commands pass for each wave. + +Stage result: + +```text +Completed on 2026-05-10. +Generic-safe write coverage: 109 canonical write API commands. +Temporary write/API exclusions requiring adapters or binding metadata fixes: 31. +poetry run pytest tests/cli/test_write_safety.py tests/cli/test_domain_smoke_commands.py: passed +poetry run python scripts/lint_cli_coverage.py --phase write: errors=0 +poetry run python scripts/lint_cli_coverage.py --phase write --domain wave-1: errors=0 +poetry run python scripts/lint_cli_coverage.py --phase write --domain wave-2 --domain wave-3 --domain wave-4 --domain wave-5: errors=0 +poetry run python scripts/lint_python_guidelines.py: OK +poetry run python scripts/lint_architecture.py: errors=0 +poetry run mypy avito: Success +poetry run mypy scripts/lint_cli_coverage.py: Success +poetry run ruff check avito/cli tests/cli scripts/lint_cli_coverage.py: All checks passed +``` + +### Stage 10C: Strict CLI Coverage Gate + +Deliverables: + +- Switch `scripts/lint_cli_coverage.py --strict` to fail on every missing sync Swagger-bound command unless it has a documented intentional exclusion. +- Fail strict mode on expired temporary exclusions. +- Add `make cli-lint` and include it in `quality` after `swagger-lint` and before `architecture-lint`. +- Ensure the strict report is deterministic, sanitized, and suitable for CI output. +- Enforce that every canonical API command is registered and help-renderable. +- Enforce that every canonical API command has execution-smoke coverage or an intentional documented execution exclusion. + +Tests: + +- smoke command suite still passes for every completed factory group; +- representative strict-covered commands still run through fake transport with human and JSON output. + +Static lint responsibilities: + +- strict linter enforces that the real registry has no missing sync binding without an intentional exclusion; +- strict linter enforces that the real registry has no duplicate canonical command for one binding; +- strict linter enforces that the real registry has no canonical API command without a binding; +- strict linter enforces that the real registry has no expired temporary exclusions; +- strict linter passes with only implemented commands and intentional exclusions. + +Verification: + +```bash +poetry run pytest tests/cli/test_domain_smoke_commands.py +poetry run python scripts/lint_cli_coverage.py --strict +poetry run python scripts/lint_python_guidelines.py +poetry run python scripts/lint_architecture.py +make cli-lint +poetry run mypy avito +poetry run mypy scripts/lint_cli_coverage.py +poetry run ruff check avito/cli tests/cli scripts/lint_cli_coverage.py +``` + +Exit criteria: + +- Every sync discovered Swagger binding has exactly one canonical CLI command or documented intentional exclusion. +- `make cli-lint` is part of `make check` through `quality`. +- Every canonical CLI command is registered, help-renderable, and covered by execution smoke or explicit execution exclusion. + +Makefile integration: + +- Add `cli-lint` as `poetry run python scripts/lint_cli_coverage.py --strict`. +- Include `cli-lint` in `quality` after `swagger-lint` and before `architecture-lint`. +- Do not add strict `cli-lint` to `make check` before Stage 10C; earlier stages use explicit phase commands only. + +Stage checklist: + +- [x] Remaining sync Swagger bindings are covered or intentionally excluded. +- [x] All canonical API commands have registration/help coverage. +- [x] All canonical API commands have execution-smoke coverage or documented intentional execution exclusions. +- [x] `make cli-lint` is added to `make check`. +- [x] Stage verification commands pass. + +Stage result: + +```text +Completed on 2026-05-10. +Canonical API commands: 162. +Intentional API exclusions: 42. +Temporary exclusions: 0. +poetry run pytest tests/cli/test_domain_smoke_commands.py: 326 passed +poetry run python scripts/lint_cli_coverage.py --strict: errors=0 +poetry run python scripts/lint_python_guidelines.py: OK +poetry run python scripts/lint_architecture.py: errors=0 +make cli-lint: errors=0 +poetry run mypy avito: Success +poetry run mypy scripts/lint_cli_coverage.py: Success +poetry run ruff check avito/cli tests/cli scripts/lint_cli_coverage.py: All checks passed +``` + +### Stage 11: Public Helper Workflows + +Deliverables: + +- Expose supported non-Swagger helper workflows or document exclusions: + - account health/business summary; + - chat summary; + - order summary; + - review summary; + - promotion summary; + - capability discovery. +- Keep helper commands out of the Swagger one-to-one coverage count. +- Ensure helper commands use public `AvitoClient`/SDK methods only. + +Tests: + +- helper command metadata or explicit exclusions are covered; +- helper commands do not conflict with API-bound commands; +- helper outputs are sanitized and support `--json`. + +Verification: + +```bash +poetry run pytest tests/cli/test_helper_workflows.py +poetry run python scripts/lint_cli_coverage.py --strict +poetry run python scripts/lint_python_guidelines.py +poetry run python scripts/lint_architecture.py +poetry run mypy avito +poetry run mypy scripts/lint_cli_coverage.py +poetry run ruff check avito/cli tests/cli scripts/lint_cli_coverage.py +``` + +Stage checklist: + +- [x] Each supported helper workflow has a command or explicit exclusion. +- [x] Helper commands are excluded from Swagger one-to-one coverage counts. +- [x] Helper commands use only public `AvitoClient`/SDK methods. +- [x] Helper commands do not collide with generated API commands. +- [x] Helper outputs support human and JSON modes and are sanitized. +- [x] Stage verification commands pass. + +Stage result: + +```text +Completed on 2026-05-10. +poetry run pytest tests/cli/test_helper_workflows.py: 6 passed +poetry run pytest tests/cli/test_helper_workflows.py tests/cli/test_app.py tests/cli/test_registry.py: 28 passed +poetry run python scripts/lint_cli_coverage.py --strict: errors=0 +poetry run python scripts/lint_python_guidelines.py: OK +poetry run python scripts/lint_architecture.py: errors=0 +poetry run mypy avito: Success +poetry run mypy scripts/lint_cli_coverage.py: Success +poetry run ruff check avito/cli tests/cli scripts/lint_cli_coverage.py: All checks passed +``` + +### Stage 12: Config, Status, Doctor, and Completion + +Deliverables: + +- Add explicit config commands: + - `avito config get` + - `avito config set` + - `avito config unset` + - `avito config list` + - `avito config list --show-source` +- Add `avito status` for profile/config/auth readiness without leaking secrets. +- Add `avito doctor` for local diagnostics. +- Add shell completion commands for bash, zsh, and fish. + +Tests: + +- config precedence and source display; +- status works without network where possible; +- doctor reports malformed config and permission issues; +- completion commands render scripts or clear instructions. + +Verification: + +```bash +poetry run pytest tests/cli/test_config_commands.py tests/cli/test_status_doctor.py tests/cli/test_completion.py +poetry run python scripts/lint_python_guidelines.py +poetry run python scripts/lint_architecture.py +poetry run mypy avito +poetry run ruff check avito/cli tests/cli +``` + +Stage checklist: + +- [x] `config get/set/unset/list/list --show-source` work and are tested. +- [x] Config source precedence is visible in debug/source output. +- [x] `status` reports local readiness without leaking secrets. +- [x] `doctor` reports malformed config and permission problems. +- [x] Completion commands exist for bash, zsh, and fish. +- [x] Stage verification commands pass. + +Stage result: + +```text +Completed on 2026-05-10. +poetry run pytest tests/cli/test_config_commands.py tests/cli/test_status_doctor.py tests/cli/test_completion.py: 9 passed +poetry run python scripts/lint_python_guidelines.py: OK +poetry run python scripts/lint_architecture.py: errors=0 +poetry run mypy avito: Success +poetry run ruff check avito/cli tests/cli: All checks passed +``` + +### Stage 13: Documentation + +Deliverables: + +- Update `README.md` with a short CLI quickstart and links to docs. +- Update `docs/site/index.md` so CLI is visible as a first-class usage mode. +- Update `docs/site/tutorials/getting-started.md` with the shortest first CLI call path or a concise link to the CLI how-to. +- Add `docs/site/how-to/cli.md` for practical account/profile setup and daily CLI workflows: + - install and verify `avito --version`; + - add/use/list/delete local accounts; + - run read-only API commands; +- run JSON automation commands with `--json --no-input`; +- use `status`, `doctor`, and completion commands; +- explain safe handling of local plaintext secrets. +- show safe secret input examples with hidden prompt and `--client-secret-stdin`; +- Complete `docs/site/reference/cli.md` for stable CLI contracts: + - command grammar `avito `; + - global flags; + - output modes and stdout/stderr split; + - exit codes and stable error codes; + - config files, CLI home resolution, environment variables, and profile precedence; + - safety flags `--dry-run`, `--yes`, and `--confirm`; + - generated command naming algorithm and compatibility alias policy; + - sync Swagger-bound coverage guarantee and documented exclusion policy. +- Add `docs/site/explanations/cli-architecture.md` for design rationale: + - CLI as a thin wrapper over `AvitoClient`; + - registry/discovery-driven command generation; + - coverage linter phases and strict gate; + - auth-token binding exclusions; + - secret masking and no raw SDK internals in CLI; + - bounded pagination and `--all` policy. +- Update `docs/site/how-to/auth-and-config.md` with a short cross-link to CLI account/profile setup. +- Update `docs/site/explanations/security-and-redaction.md` with CLI secret-storage and output-redaction notes. +- Update `docs/site/explanations/api-coverage-and-deprecations.md` with CLI coverage and exclusion policy. +- Update navigation files: + - add `cli.md` to `docs/site/how-to/.pages`; + - add `cli.md` to `docs/site/reference/.pages`; + - add `cli-architecture.md` to `docs/site/explanations/.pages`. +- Do not add generated CLI reference pages to `docs/site/assets/_gen_reference.py` unless the implementation has a stable CLI registry JSON/report that can be generated deterministically during MkDocs builds. +- If CLI docs mention commands that are not implemented yet, keep them in this plan only. Public docs must describe only implemented commands by the time Stage 13 is complete. + +Documentation style requirements: + +- Keep docs in Russian, with command names/flags/error codes unchanged. +- Keep how-to pages task-oriented; do not duplicate the entire reference contract there. +- Keep reference pages exhaustive and stable; avoid marketing language. +- Link to existing config, security, pagination, and API coverage pages instead of copying large sections. +- Include both human output and JSON automation examples. +- Never show real-looking secrets; examples must use placeholders such as `client-secret`. + +Verification: + +```bash +poetry run mkdocs build --strict +make docs-check +rg -n "client_secret|access_token|Authorization: Bearer|api_key" README.md docs/site +``` + +Stage checklist: + +- [x] README includes a CLI quickstart. +- [x] `CHANGELOG.md` summarizes the CLI feature and points to the stable reference docs. +- [x] `docs/site/index.md` links to the CLI docs. +- [x] `docs/site/tutorials/getting-started.md` has a first CLI path or a clear CLI how-to link. +- [x] `docs/site/how-to/cli.md` explains account/profile setup, daily workflows, automation, status/doctor, completion, and local plaintext secret storage. +- [x] `docs/site/reference/cli.md` lists global flags, output modes, exit codes, config files, environment variables, safety flags, command grammar, naming, alias, and coverage contracts. +- [x] `docs/site/explanations/cli-architecture.md` explains SDK reuse, registry/discovery, coverage linter phases, exclusions, secret masking, and pagination policy. +- [x] Existing auth/config, security/redaction, and API coverage/deprecation pages link to or describe relevant CLI behavior. +- [x] `.pages` navigation files include the new CLI pages in the correct sections. +- [x] Docs examples do not contain real-looking secrets or bearer tokens. +- [x] Stage verification commands pass. + +Stage result: + +```text +Completed on 2026-05-10. +poetry run mkdocs build --strict: passed +make docs-check: passed; lychee reported 1 redirect warning and 0 errors +rg -n "client_secret|access_token|Authorization: Bearer|api_key" README.md docs/site: + matched only placeholder terms and redaction/security documentation, no real + bearer tokens or real-looking secrets. +``` + +### Stage 14: Final Gate + +Run the full gate before completing the branch: + +```bash +poetry run pytest tests/cli +poetry run pytest tests/core/test_swagger*.py tests/contracts/test_swagger_contracts.py +poetry run mypy avito +poetry run mypy scripts/lint_cli_coverage.py +poetry run ruff check . +poetry run python scripts/lint_python_guidelines.py +poetry run python scripts/lint_architecture.py +poetry run python scripts/lint_cli_coverage.py --strict +poetry build +make check +``` + +If generated docs, snippets, coverage pages, or reference output changed: + +```bash +make docs-strict +``` + +Stage checklist: + +- [x] `poetry run pytest tests/cli` passes. +- [x] Swagger registry/contract tests pass. +- [x] `poetry run mypy avito` passes. +- [x] `poetry run ruff check .` passes. +- [x] Python guidelines, architecture, and CLI coverage linters pass. +- [x] `poetry build` passes. +- [x] `make check` passes. +- [x] `make docs-strict` is not required because docs/reference output did not change. + +Stage result: + +```text +Completed on 2026-05-10. +poetry run pytest tests/cli: 432 passed +poetry run pytest tests/core/test_swagger*.py tests/contracts/test_swagger_contracts.py: 1927 passed +poetry run mypy avito: Success +poetry run mypy scripts/lint_cli_coverage.py: Success +poetry run ruff check .: All checks passed +poetry run python scripts/lint_python_guidelines.py: OK +poetry run python scripts/lint_architecture.py: errors=0 +poetry run python scripts/lint_cli_coverage.py --strict: errors=0 +poetry build: built sdist and wheel +make check: 3855 passed, all quality gates and build passed +make docs-strict: not run; no generated docs, snippets, coverage pages, or reference output changed in Stage 14. +``` + +## Acceptance Checklist + +- [x] `click` dependency added. +- [x] `avito/cli/` exists and is isolated from SDK core/domain/transport/auth layers. +- [x] Console command `avito` is registered in `pyproject.toml`. +- [x] Console command entry point is `avito.cli.app:main`. +- [x] `python -m avito` exposes the same CLI. +- [x] `avito --help`, `avito --version`, and `avito version` work. +- [x] Root-level global flags work in the documented canonical syntax, for example `avito --profile main account get-self`. +- [x] Trailing/subcommand global flags are either deliberately implemented, tested, and documented as additive behavior, or explicitly documented as unsupported in the first release. +- [x] CLI home defaults to `~/.avito-py/`. +- [x] `AVITO_PY_HOME` and `MY_SDK_HOME` override CLI home with documented precedence. +- [x] CLI home directory is created lazily with `0700` permissions. +- [x] `accounts.json` and `config.json` are written atomically with `0600` permissions. +- [x] Account commands add/list/use/current/delete accounts. +- [x] `account remove` is omitted or implemented only as documented alias for `account delete`. +- [x] `account add` supports `--client-id`, `--client-secret`, `--base-url`, `--api-key`, and `--endpoint`. +- [x] `account add` supports `--client-secret-stdin` and hidden prompt input so secrets do not have to appear in shell history. +- [x] No CLI output leaks raw secrets. +- [x] CLI errors use stable error codes and documented exit codes. +- [x] Results go to stdout; errors, warnings, progress, and debug diagnostics go to stderr. +- [x] `--json` emits stable JSON for success and errors. +- [x] CLI registry is built from SDK Swagger binding metadata. +- [x] Every sync discovered Swagger binding has exactly one canonical CLI command or documented intentional exclusion. +- [x] Every canonical API CLI command maps to exactly one sync discovered Swagger binding. +- [x] Every supported public non-Swagger helper has a CLI command or documented exclusion. +- [x] Compatibility aliases do not count as canonical coverage. +- [x] Generated command names and flags are lowercase kebab-case. +- [x] No command exposes `resource-id`. +- [x] Generic invocation uses `AvitoClient` factories and public domain methods. +- [x] CLI does not call `OperationSpec` or transport directly for API commands. +- [x] Input coercion covers primitives, booleans, dates, datetimes, enums, optionals, and lists. +- [x] Pagination behavior is bounded and documented. +- [x] Destructive commands require confirmation unless `--yes` or `--confirm` is supplied. +- [x] `--dry-run` is exposed only for SDK methods that safely support it. +- [x] Every completed factory group has at least one representative smoke command tested through fake transport. +- [x] CLI coverage linter exists, passes, and is included in `make check` after full coverage. +- [x] README and docs include CLI usage, config, output, and exit-code contracts. +- [x] Minimum stage verification commands pass during implementation. +- [x] Final `make check` passes before completion. + +## Resolved Defaults + +- `avito cli coverage` is hidden/internal for the first release. The supported public surface is the script `scripts/lint_cli_coverage.py --strict` and documented coverage guarantees. +- Paginated commands default to bounded output: first page only or the SDK/default page size when applicable. Full materialization requires explicit `--all`. +- Generated API commands use named flags only in the first release. Positional primary IDs can be added later as additive aliases after command stability is proven. +- The 4 auth-token Swagger bindings are documented intentional exclusions for the first release and are represented by local account/status/doctor workflows instead of direct token-client commands. +- Click is the only planned CLI framework for the first release and is an + explicit runtime dependency from Stage 1. +- Command coverage and execution-smoke coverage are separate gates: a command + may satisfy Swagger coverage only when it is canonical and registered, but it + still needs execution-smoke coverage or a documented execution exclusion + before the final strict gate.