Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,23 @@ Notes:
- Authorization is performed via `direct auth login`.
- Alias `auth_login` is not supported.

Credential resolution priority:

| Priority | Source | Example |
|----------|--------|---------|
| 1 | Explicit CLI options | `direct --token TOKEN --login LOGIN campaigns get` |
| 2 | OAuth profile storage | `direct --profile agency1 campaigns get` |
| 3 | Profile-specific env vars | `YANDEX_DIRECT_TOKEN_AGENCY1`, `YANDEX_DIRECT_LOGIN_AGENCY1` |
| 4 | Base env vars or project `.env` | `YANDEX_DIRECT_TOKEN`, `YANDEX_DIRECT_LOGIN` |
| 5 | 1Password references | `--op-token-ref`, `YANDEX_DIRECT_OP_TOKEN_REF` |
| 6 | Bitwarden references | `--bw-token-ref`, `YANDEX_DIRECT_BW_TOKEN_REF` |

The project `.env` file is loaded automatically. If a profile is selected
with `--profile` or `direct auth use --profile NAME`, Direct CLI does not
fall back to base `YANDEX_DIRECT_LOGIN`; this prevents mixing a profile token
with a login from the project `.env`. For multi-account setups, prefer OAuth
profiles or profile-specific env vars instead of base credentials.

Install with `pip install direct-cli`, then run commands with `direct`.
Invoking the deprecated `direct-cli` entrypoint exits with
`use direct instead of direct-cli`.
Expand Down Expand Up @@ -595,6 +612,45 @@ YANDEX_DIRECT_LOGIN=ваш_логин_на_яндексе
direct --token ВАШ_ТОКЕН --login ВАШ_ЛОГИН campaigns get
```

Используйте профильные credentials из `.env`:

```env
YANDEX_DIRECT_TOKEN_AGENCY1=token-1
YANDEX_DIRECT_LOGIN_AGENCY1=client-login-1
YANDEX_DIRECT_TOKEN_AGENCY2=token-2
YANDEX_DIRECT_LOGIN_AGENCY2=client-login-2
```

OAuth и profile-команды:

```bash
direct auth login
direct auth login --profile agency1
direct auth login --code abc123 --profile agency1
direct auth login --oauth-token y0_example --profile agency1
direct auth list
direct auth use --profile agency1
direct auth status --profile agency1
direct --profile agency1 campaigns get
```

Порядок выбора credentials:

| Приоритет | Источник | Пример |
|-----------|----------|--------|
| 1 | Явные CLI-опции | `direct --token TOKEN --login LOGIN campaigns get` |
| 2 | OAuth profile storage | `direct --profile agency1 campaigns get` |
| 3 | Профильные env vars | `YANDEX_DIRECT_TOKEN_AGENCY1`, `YANDEX_DIRECT_LOGIN_AGENCY1` |
| 4 | Базовые env vars или project `.env` | `YANDEX_DIRECT_TOKEN`, `YANDEX_DIRECT_LOGIN` |
| 5 | 1Password references | `--op-token-ref`, `YANDEX_DIRECT_OP_TOKEN_REF` |
| 6 | Bitwarden references | `--bw-token-ref`, `YANDEX_DIRECT_BW_TOKEN_REF` |

Файл `.env` в проекте загружается автоматически. Если профиль выбран через
`--profile` или `direct auth use --profile NAME`, Direct CLI не подставляет
base `YANDEX_DIRECT_LOGIN`; это защищает от смешивания токена из профиля с
логином из project `.env`. Для нескольких аккаунтов используйте OAuth profiles
или профильные env vars, а не базовые credentials.

Установка остаётся через `pip install direct-cli`, а запуск команд теперь идет
через `direct`. Вызов deprecated entrypoint `direct-cli` завершается ошибкой с
подсказкой `use direct instead of direct-cli`.
Expand All @@ -605,6 +661,7 @@ direct --token ВАШ_ТОКЕН --login ВАШ_ЛОГИН campaigns get
|-------|----------|
| `--token` | OAuth-токен доступа к API |
| `--login` | Direct client login |
| `--profile` | Имя credential profile |
| `--sandbox` | Использовать тестовое API (песочница) |

### Использование
Expand Down
46 changes: 32 additions & 14 deletions direct_cli/commands/agencyclients.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from ..api import create_client
from ..output import format_output, print_error
from ..utils import get_default_fields, parse_ids
from ..utils import get_default_fields


def _build_notification(
Expand All @@ -21,10 +21,24 @@ def _build_notification(
notification["Email"] = notification_email
if notification_lang:
notification["Lang"] = notification_lang
if send_account_news is not None:
notification["SendAccountNews"] = "YES" if send_account_news else "NO"
if send_warnings is not None:
notification["SendWarnings"] = "YES" if send_warnings else "NO"
if notification_email:
subscriptions = []
if send_account_news is not None:
subscriptions.append(
{
"Option": "RECEIVE_RECOMMENDATIONS",
"Value": "YES" if send_account_news else "NO",
}
)
if send_warnings is not None:
subscriptions.append(
{
"Option": "TRACK_POSITION_CHANGES",
"Value": "YES" if send_warnings else "NO",
}
)
if subscriptions:
notification["EmailSubscriptions"] = subscriptions
return notification


Expand All @@ -34,14 +48,21 @@ def agencyclients():


@agencyclients.command()
@click.option("--ids", help="Comma-separated client IDs")
@click.option("--logins", help="Comma-separated client logins")
@click.option(
"--archived",
type=click.Choice(["YES", "NO"]),
default="NO",
show_default=True,
help="Filter archived clients",
)
@click.option("--limit", type=int, help="Limit number of results")
@click.option("--fetch-all", is_flag=True, help="Fetch all pages")
@click.option("--format", "output_format", default="json", help="Output format")
@click.option("--output", help="Output file")
@click.option("--fields", help="Comma-separated field names")
@click.pass_context
def get(ctx, ids, limit, fetch_all, output_format, output, fields):
def get(ctx, logins, archived, limit, fetch_all, output_format, output, fields):
"""Get agency clients"""
try:
client = create_client(
Expand All @@ -52,14 +73,11 @@ def get(ctx, ids, limit, fetch_all, output_format, output, fields):

field_names = fields.split(",") if fields else get_default_fields("clients")

criteria = {}
if ids:
criteria["ClientIds"] = parse_ids(ids)

params = {"FieldNames": field_names}
criteria = {"Archived": archived}
if logins:
criteria["Logins"] = [login.strip() for login in logins.split(",")]

if criteria:
params["SelectionCriteria"] = criteria
params = {"SelectionCriteria": criteria, "FieldNames": field_names}

if limit:
params["Page"] = {"Limit": limit}
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "direct-cli"
version = "0.2.10"
version = "0.2.11"
description = "Command-line interface for Yandex Direct API"
readme = "README.md"
license = {text = "MIT"}
Expand Down
40 changes: 37 additions & 3 deletions scripts/test_safe_commands.sh
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,42 @@ run_test() {
fi
}

is_agency_access_denied() {
local output="$1"
local pattern='(^|[^0-9])403([^0-9]|$)|error_code=54|Access denied|No rights to access the agency service'
grep -Eiq "$pattern" <<<"$output"
}

run_agencyclients_sandbox_get() {
local name="agencyclients get --sandbox"
local output exit_code has_dedicated_token
local -a cmd

has_dedicated_token=0
if [ -n "${YANDEX_DIRECT_AGENCY_TOKEN:-}" ]; then
has_dedicated_token=1
cmd=(direct --sandbox --token "$YANDEX_DIRECT_AGENCY_TOKEN")
if [ -n "${YANDEX_DIRECT_AGENCY_LOGIN:-}" ]; then
cmd+=(--login "$YANDEX_DIRECT_AGENCY_LOGIN")
fi
cmd+=(agencyclients get --limit 1 --format json)
else
cmd=(direct --sandbox agencyclients get --limit 1 --format json)
fi

output=$("${cmd[@]}" 2>&1) && exit_code=0 || exit_code=$?
if [ "$exit_code" -eq 0 ]; then
echo -e " ${GREEN}[PASS]${RESET} $name"
((PASS++)) || true
elif [ "$has_dedicated_token" -eq 0 ] && is_agency_access_denied "$output"; then
echo -e " ${YELLOW}[SKIP]${RESET} $name — no agency sandbox credentials"
else
echo -e " ${RED}[FAIL]${RESET} $name"
echo "$output" | head -3 | sed 's/^/ /'
((FAIL++)) || true
fi
}

# Known-bug skip: prints [BUG #N] and counts separately, does not affect FAIL
skip_bug() {
local issue="$1"
Expand Down Expand Up @@ -165,9 +201,7 @@ run_test "sitelinks get --ids (env auth)" direct sitelinks get --ids 1
run_test "vcards get --ids (env auth)" direct vcards get --ids 1
run_test "leads get --turbo-page-ids (env auth)" direct leads get --turbo-page-ids 1 --limit 1
run_test "clients get (env auth)" direct clients get
# agencyclients requires agency account — tracked in #73
echo -e " ${CYAN}[BUG #73]${RESET} agencyclients get — требует агентский аккаунт (sandbox)"
((KNOWN++)) || true
run_agencyclients_sandbox_get
run_test "feeds get --ids (env auth)" direct feeds get --ids 1
run_test "creatives get (env auth)" direct creatives get
# businesses requires Ids/Name/Url in SelectionCriteria
Expand Down
14 changes: 14 additions & 0 deletions tests/API_COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,20 @@ Sandbox re-check is not useful — this is an account-tier limitation.
Testing strategy: manual-only on an account where DYNAMIC_TEXT_CAMPAIGN and
SMART_CAMPAIGN are enabled. See `tests/MANUAL_COVERAGE.md`.

### Category C — account-permission limited (code 3001)

The endpoint is available, but the current sandbox agency account does not
have rights to create agency clients. This differs from read-only
`agencyclients get`, which is covered against sandbox.

| Scenario | Symptom | Error code | Test class |
|---|---|---|---|
| agencyclients add-passport-organization | no rights to create clients | 3001 | manual-only |

Testing strategy: keep `agencyclients get` in read-only integration coverage;
keep agency-client creation manual-only unless a sandbox agency account with
client-creation rights is available.

Originally classified in
[#28 issuecomment-4275359621](https://github.com/axisrow/direct-cli/issues/28#issuecomment-4275359621)
and
Expand Down
11 changes: 7 additions & 4 deletions tests/MANUAL_COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,12 @@ account requirements, or external dependencies.
## Account-Scoped Operations

- **agencyclients add/update/delete** — requires an agency-type account.
Non-agency accounts receive 403.
Non-agency accounts receive 403. The current sandbox agency account can
read agency clients, but creation returns error 3001: "No rights to create
clients".
- **agencyclients add-passport-organization** — creates a real Passport
organization linked to the account.
organization linked to the account. The current sandbox agency account is
sandbox-limited for this operation with error 3001.
- **agencyclients add-passport-organization-member** — sends an invitation
email to an external user.

Expand Down Expand Up @@ -64,8 +67,8 @@ Live tests skip gracefully when the API returns error 3500.
|---|---|---|
| ads moderate | Irreversible | Moderate |
| campaigns/ads suspend/resume (live) | Traffic impact | High |
| agencyclients add/update/delete | Account type | None (403) |
| agencyclients add-passport-organization* | External state | Moderate |
| agencyclients add/update/delete | Account type / sandbox rights (403 or 3001) | None (skip) |
| agencyclients add-passport-organization* | External state / sandbox rights (3001) | Moderate |
| bids/keywordbids/bidmodifiers set | Financial | High |
| dynamicads (all) | Account type (3500) | None (skip) |
| smartadtargets (all) | Account type (3500) | None (skip) |
Expand Down
12 changes: 8 additions & 4 deletions tests/test_dry_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -1378,8 +1378,10 @@ def test_agencyclients_add_builds_notification_from_typed_flags():
"Notification": {
"Email": "ops@example.com",
"Lang": "RU",
"SendAccountNews": "YES",
"SendWarnings": "NO",
"EmailSubscriptions": [
{"Option": "RECEIVE_RECOMMENDATIONS", "Value": "YES"},
{"Option": "TRACK_POSITION_CHANGES", "Value": "NO"},
],
},
}
body = _dry_run(
Expand Down Expand Up @@ -1584,8 +1586,10 @@ def test_agencyclients_add_passport_organization_payload():
"Notification": {
"Email": "ops@example.com",
"Lang": "EN",
"SendAccountNews": "NO",
"SendWarnings": "YES",
"EmailSubscriptions": [
{"Option": "RECEIVE_RECOMMENDATIONS", "Value": "NO"},
{"Option": "TRACK_POSITION_CHANGES", "Value": "YES"},
],
},
}

Expand Down
Loading
Loading