Skip to content

feat(keywords): batch add via --from-file / --keywords-json (#203) - #218

Merged
axisrow merged 5 commits into
mainfrom
worktree-issue-203-batch-keywords-add
May 21, 2026
Merged

feat(keywords): batch add via --from-file / --keywords-json (#203)#218
axisrow merged 5 commits into
mainfrom
worktree-issue-203-batch-keywords-add

Conversation

@axisrow

@axisrow axisrow commented May 21, 2026

Copy link
Copy Markdown
Owner

Summary

  • direct keywords add now accepts batch input (--from-file PATH JSONL or --keywords-json TEXT inline JSON array), pages through chunks of 10 (the Yandex Direct API limit for keywords.add), preserves order, and merges all AddResults into one response.
  • Item-level errors no longer abort the whole batch — the merged output includes successful Ids and per-item errors side by side.
  • Row keys mirror the WSDL KeywordAddItem CamelCase exactly (Keyword, AdGroupId, Bid, ContextBid, UserParam1, UserParam2); unknown keys are rejected with the row number.
  • --adgroup-id is now optional in batch mode and acts as a default; rows may override it. Single-item mode (--keyword) is unchanged, including the existing fail-on-Errors behaviour.

Contract fidelity (Yandex Direct WSDL 1:1)

  • WSDL: tests/wsdl_cache/keywords.xml:114-127 declares Keywords with maxOccurs="unbounded". The 10-item cap is a documentation-level runtime limit (https://yandex.ru/dev/direct/doc/dg/objects/keyword.html), recorded as KEYWORDS_ADD_MAX_BATCH = 10 with the doc URL inline.
  • WSDL KeywordAddItem minOccurs=1 fields: Keyword, AdGroupId — both registered in INTERNAL_VALIDATION with matching INTERNAL_VALIDATION_PROBES so the parity gate keeps enforcing them after the Click required=True flags went away.
  • Row whitelist matches exactly the WSDL fields already covered by the single-mode flags. Extending the whitelist to StrategyPriority / AutotargetingSettings etc. is left to a follow-up issue to avoid scope creep.

Out of scope (intentional)

Test plan

  • pytest tests/test_dry_run.py -k keywords_add -v — 15 new tests pass (chunking, mutex, per-row validation, unknown-field reject, mode-mode rejection, --format other than json rejection, single-mode regression on Errors).
  • pytest tests/test_wsdl_parity_gate.py — 48 passed, 6 skipped (parity gate enforces Keyword/AdGroupId through INTERNAL_VALIDATION).
  • pytest -m "not integration and not integration_write and not integration_live_write" — 791 passed, 11 skipped, no regressions.
  • black . clean on changed files; flake8 on changed files — no new warnings (the three pre-existing E501s in _DEPRECATED_KEYWORDS_UPDATE_OPTIONS lines pre-date this PR).
  • direct keywords add --help shows the new options.
  • direct keywords add --adgroup-id N --keyword X --dry-run payload is byte-identical to the pre-PR single-mode payload.
  • 12-row JSONL --dry-runchunks=2, totalItems=12, firstChunkLen=10.

Closes #203

🤖 Generated with Claude Code

`direct keywords add` now accepts batch input through either:

- `--from-file PATH` — JSONL, one keyword object per line
- `--keywords-json TEXT` — inline JSON array of keyword objects

CLI chunks input into groups of 10 (Yandex Direct API limit for
`keywords.add`; the WSDL declares `maxOccurs="unbounded"`, the cap
comes from the documentation), preserves input order, calls the API
chunk-by-chunk, and merges all `AddResults` into a single response.
Item-level errors no longer abort the whole batch — successful Ids
and per-item errors land in the merged output together.

Row keys mirror the WSDL `KeywordAddItem` CamelCase exactly:
`Keyword`, `AdGroupId`, `Bid`, `ContextBid`, `UserParam1`,
`UserParam2`. Unknown keys are rejected with the row number.
`--adgroup-id` is now optional in batch mode and acts as a default
that rows may override via per-row `AdGroupId`. `--dry-run` prints
the first chunk's payload plus `{chunks, totalItems, chunkSize}`.

Single-item mode (`--keyword`) is unchanged, including its existing
behaviour of failing the command on any `Errors` from the API.

WSDL parity gate: `--keyword` and `--adgroup-id` are no longer
Click-required, so `Keyword` and `AdGroupId` are registered in
`INTERNAL_VALIDATION` with matching probes — running `keywords add`
with no flags still raises a `UsageError` covering both fields.

Closes #203
Copilot AI review requested due to automatic review settings May 21, 2026 03:54

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds batch input support to direct keywords add so users can upload multiple keywords via JSONL (--from-file) or inline JSON (--keywords-json), auto-chunked to the API limit (10/request), with merged AddResults and per-row validation aligned to WSDL field names.

Changes:

  • Implemented batch mode for keywords add with JSONL/inline JSON ingestion, validation, chunking, and merged results output.
  • Extended dry-run and WSDL parity gate tests to cover the new batch behavior and internal required-field validation.
  • Documented batch keyword upload in README (EN/RU) and added release notes to the changelog.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
direct_cli/commands/keywords.py Implements batch mode ingestion/validation/chunking and merged AddResults for keywords add.
tests/test_dry_run.py Adds dry-run/unit tests for batch payload shaping, chunking, validation, and mode mutex behavior.
tests/test_wsdl_parity_gate.py Updates parity gate internal validation mappings/probes for keywords add required fields.
README.md Documents batch keyword upload usage (JSONL and inline JSON) in English and Russian sections.
CHANGELOG.md Adds an unreleased 0.3.9 entry describing the new batch keywords add feature.

Comment thread direct_cli/commands/keywords.py Outdated
Comment on lines +40 to +48
if kind == "int":
if isinstance(raw_value, bool) or not isinstance(raw_value, int):
try:
return int(raw_value)
except (TypeError, ValueError):
raise click.UsageError(
f"Row {row_index} field {field!r}: expected integer, "
f"got {raw_value!r}"
)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit e7ba3ee. _coerce_keyword_field now rejects isinstance(raw_value, bool) at the top, before any int() or MICRO_RUBLES.convert conversion attempt. Test: test_keywords_add_rejects_bool_in_row.

Comment on lines +276 to +287
"""Add one or many keywords (batch via --from-file / --keywords-json)."""
modes_used = sum(1 for value in (keyword, from_file, keywords_json) if value)
if modes_used == 0:
raise click.UsageError(
"Provide exactly one of: --keyword (single), --from-file (JSONL), "
"or --keywords-json (inline JSON array)."
)
if modes_used > 1:
raise click.UsageError(
"Provide exactly one of: --keyword, --from-file, or "
"--keywords-json — they are mutually exclusive."
)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit e7ba3ee. Mode-detection now uses is not None instead of truthiness, so --keyword "" registers as mode-provided and reaches the normal payload-building path. Test: test_keywords_add_empty_string_keyword_counts_as_mode.

Comment on lines +101 to +112
text = file_path.read_text(encoding="utf-8")
except OSError as exc:
raise click.UsageError(f"Cannot read --from-file {path!r}: {exc}")

for line_number, raw_line in enumerate(text.splitlines(), start=1):
line = raw_line.strip()
if not line:
continue
try:
rows.append(json.loads(line))
except json.JSONDecodeError as exc:
raise click.UsageError(f"Row {line_number}: invalid JSON: {exc.msg}")

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changing — the Yandex Direct keywords.add endpoint is capped at 10 items per request, and an account-level limit caps practical batches at tens of thousands of keywords. A realistic JSONL fits in memory long before any other constraint binds, and the second-pass bot review explicitly agreed that streaming is YAGNI here. Adding it would complicate the code path that already needs per-row index tracking for error reporting.

@axisrow

axisrow commented May 21, 2026

Copy link
Copy Markdown
Owner Author

@claude review. Focus on critical issues: bugs, security vulnerabilities, logical errors, data loss risks, performance problems. Do NOT nitpick style, naming conventions, minor formatting, or subjective preferences — only flag issues that could break functionality or cause real harm in production.

@claude

claude Bot commented May 21, 2026

Copy link
Copy Markdown

Claude finished @axisrow's task in 2m 14s —— View job


Code review — critical issues only

Focused on bugs, data-loss, security, perf per the request. Three findings, ranked by impact.


🔴 1. Data loss on chunk-level failure (high impact)

direct_cli/commands/keywords.py:389-399 — when any chunk past the first raises (network blip, 5xx, auth expiry, rate-limit), all_results is silently discarded:

all_results: List[Any] = []
for index, chunk in enumerate(chunks, start=1):
    click.echo(f"Sending chunk {index}/{len(chunks)}: {len(chunk)} items", err=True)
    body = {"method": "add", "params": {"Keywords": chunk}}
    response = client.keywords().post(data=body)               # <- raises
    all_results.extend(_normalize_add_results(response().extract()))

print(format_json({"AddResults": all_results}, indent=2))      # <- never reached

The PR description claims "item-level errors no longer abort the whole batch", which is true for per-item Errors inside the response. But a chunk-level transport/HTTP exception aborts the loop and the prior successful AddResults (Ids that did land in Yandex Direct) are thrown away. The user has no idea which keywords already exist, so a naive retry creates duplicates.

For a batch mutation tool this is the most important property to get right. At minimum, on exception print whatever all_results you have to stdout (or stderr as a recovery hint) before re-raising, e.g.:

except Exception as e:
    if all_results:
        click.echo(
            "Partial success before failure (re-running will duplicate these):",
            err=True,
        )
        click.echo(format_json({"AddResults": all_results}, indent=2), err=True)
    print_error(str(e))
    raise click.Abort()

Fix this →


🟠 2. Boolean values silently coerced to 1/0 for integer fields (real bug)

direct_cli/commands/keywords.py:40-49:

if kind == "int":
    if isinstance(raw_value, bool) or not isinstance(raw_value, int):
        try:
            return int(raw_value)                              # int(True) -> 1
        except (TypeError, ValueError):
            raise click.UsageError(...)
    return raw_value

The explicit isinstance(raw_value, bool) check clearly intends to reject bools (since boolint in Python and would otherwise fall into the return raw_value branch). But the branch it routes bools into is int(raw_value), which happily returns 1 / 0. Net effect: a JSON row with "AdGroupId": true becomes "AdGroupId": 1 and gets sent to the API, instead of failing with the friendly "expected integer, got True" message.

Practical blast radius is bounded because Yandex will likely reject AdGroupId: 1 — but a Bid field uses the micro branch (which goes through MICRO_RUBLES.convert and would also coerce True→1 before the min-value check rejects it), so the validation layer is doing the wrong thing in two places. Fix by short-circuiting bools at the top of the int branch:

if kind == "int":
    if isinstance(raw_value, bool):
        raise click.UsageError(
            f"Row {row_index} field {field!r}: expected integer, got bool"
        )
    if not isinstance(raw_value, int):
        try:
            return int(raw_value)
        ...

This is the same issue Copilot flagged at line 48; agreeing because the existing code reads as if the author already noticed the bool/int subclass gotcha but didn't complete the fix.

Fix this →


🟡 3. Mode detection mis-classifies explicit empty strings (low impact)

direct_cli/commands/keywords.py:277modes_used = sum(1 for value in (keyword, from_file, keywords_json) if value). Truthiness instead of is not None means --keyword "" registers as "not provided" and the user gets "Provide exactly one of …" rather than "empty keyword text not allowed". Same risk Copilot flagged.

Real-world blast radius is small (empty keyword/file path is nonsense), but the fix is one line — sum(1 for v in (...) if v is not None) — and surfaces clearer errors. Not blocking, mentioning for completeness.


Looked at but not flagged

  • _load_keyword_rows_from_file reading the whole JSONL via read_text() — Copilot raised this, but keywords.add is capped at 10/req and a realistic batch is bounded by Yandex account limits (tens of thousands max), so streaming is YAGNI here. Whole file fits in memory long before chunks/items do.
  • WSDL parity / INTERNAL_VALIDATION plumbing in tests/test_wsdl_parity_gate.py — looks correctly wired.
  • Dry-run preview only printing the first chunk — totals are reported, fine.
  • No security issues (no token/credential exposure, no injection, file path goes through click.Path(exists=True)).

Summary

1 high-impact data-loss issue (#1) and 1 real validation bug (#2) worth fixing before merge. #3 is a polish nit. Otherwise the batch design is sound: WSDL-aligned field names, sane chunking constant with a doc URL inline, single-mode regression preserved.

  • Read changed files
  • Analyze for critical issues
  • Post review
    · branch worktree-issue-203-batch-keywords-add

Address three review findings on PR #218:

1. Partial-batch data loss on chunk failure (high impact)
   When a later chunk raised (network blip, 5xx, rate-limit), Ids from
   earlier successful chunks were already created in Yandex Direct but
   silently dropped from output, so a retry would create duplicates.
   On exception, write any accumulated AddResults to stderr with a
   recovery note before failing.

2. Boolean values silently coerced to 1/0 (real bug)
   `isinstance(True, int) is True` in Python, so the explicit
   `isinstance(raw_value, bool)` guard was routing bools into
   `int(True) -> 1` instead of raising. Reject bools at the top of
   `_coerce_keyword_field` for every kind (int, micro, str), before
   any conversion attempt.

3. Mode detection mis-classified explicit empty strings
   `sum(1 for v in (...) if v)` treated `--keyword ""` as
   "not provided"; switch to `is not None` so empty strings reach
   normal validation paths instead of the "Provide exactly one of"
   error.

Tests cover all three: partial-success-on-stderr (mocked chunk-2
RuntimeError after chunk-1 returns 10 Ids), bool rejection per row,
and empty-string-as-mode acceptance.
@axisrow

axisrow commented May 21, 2026

Copy link
Copy Markdown
Owner Author

Addressed all three reviewer findings in commit e7ba3ee:

1. Partial-batch data loss (bot finding #1, high impact) — Fixed in _bulk_add: on any chunk-level exception, accumulated AddResults (Ids that already landed in Yandex Direct) are now written to stderr with a 'Partial success before failure — retrying will duplicate them' note before the Abort. Covered by test_keywords_add_batch_partial_success_on_failure (mocks chunk-1 success + chunk-2 RuntimeError, asserts the partial AddResults appear on stderr).

2. Bool coerced to 1/0 (bot finding #2 / Copilot inline comment, real bug) — Fixed by moving the bool guard to the top of _coerce_keyword_field, before any int() or MICRO_RUBLES.convert call. Now raises UsageError mentioning 'bool' for any kind (int/micro/str). Covered by test_keywords_add_rejects_bool_in_row.

3. Mode detection truthiness (bot finding #3 / Copilot inline comment) — Switched from if value to is not None in modes_used. Empty string now reaches the normal payload-building path instead of falling through to 'Provide exactly one of'. Covered by test_keywords_add_empty_string_keyword_counts_as_mode.

Copilot inline comment on streaming JSONL (line 112) — Not changed. The 10-item-per-request API limit caps a realistic batch at tens of thousands of keywords, which fits in memory long before any other constraint binds. Streaming is YAGNI here; the bot review explicitly agreed.

All 21 keywords_add tests pass; full non-integration suite is green (797 passed, 11 skipped).

@axisrow

axisrow commented May 21, 2026

Copy link
Copy Markdown
Owner Author

@claude review. Focus on critical issues: bugs, security vulnerabilities, logical errors, data loss risks, performance problems. Do NOT nitpick style, naming conventions, minor formatting, or subjective preferences — only flag issues that could break functionality or cause real harm in production.

@claude

claude Bot commented May 21, 2026

Copy link
Copy Markdown

Claude finished @axisrow's task in 2m 24s —— View job


Follow-up review — verifying fixes in e7ba3ee

Re-reviewed the three findings from my previous round against direct_cli/commands/keywords.py. All three are correctly fixed and well-covered by tests. No new critical issues found.


✅ Fix #1 — Partial-batch data loss (high impact)

direct_cli/commands/keywords.py:388-417all_results is initialised outside the try; on chunk-level exception the handler dumps it to stderr with a clear "retrying will duplicate them" warning before Abort. Successful Ids land on stderr (not stdout) so a > out.json redirect still preserves them, and stdout is only written on the all-chunks-succeeded path. That's the right channel split.

Test: test_keywords_add_batch_partial_success_on_failure (test_dry_run.py:1926) mocks chunk-1 success + chunk-2 RuntimeError, asserts "Partial success before failure" and '"Id": 1'..'"Id": 10' are present in result.stderr with mix_stderr=False. Solid.

Minor follow-up consideration (not blocking): the dumped AddResults are Ids in insertion order, not paired with input row numbers. A user comparing 1000-row input to stderr output has to count by index. If you want to tighten this further, attaching the originating row index alongside each result would help — but tracking it as an issue rather than a blocker, since the order is deterministic.


✅ Fix #2 — Bool coerced to 1/0 (real bug)

direct_cli/commands/keywords.py:33-36 — the bool guard is now at the very top of _coerce_keyword_field, before any int() / MICRO_RUBLES.convert / str branch. This means True/False is rejected uniformly for all field kinds, not just int. The micro-rubles branch (which previously would have routed True → MICRO_RUBLES.convert(True, ...) and silently coerced) is now also protected. Strong fix.

Test: test_keywords_add_rejects_bool_in_row (test_dry_run.py:1910) feeds {"Keyword": "kw", "AdGroupId": true} and asserts the error mentions "Row 1 field 'AdGroupId'" and "bool". Correct.


✅ Fix #3 — Mode detection (low impact polish)

direct_cli/commands/keywords.py:281-283is not None correctly treats explicit --keyword "" / --keywords-json "" as mode-provided. Empty strings now reach their natural validation path:

  • --keyword "" → single-mode payload with Keyword: "" (API will reject — that's the right failure mode).
  • --keywords-json ""json.loads("")UsageError: "--keywords-json: invalid JSON: Expecting value" (clear error).

Test: test_keywords_add_empty_string_keyword_counts_as_mode confirms the dry-run payload shape. Good.


Other things scanned, not flagged

  • except click.UsageError: raise (keywords.py:406-407) — defensive; no code in that try-block currently raises UsageError. Dead-ish but harmless and future-proof. Not worth changing.
  • _normalize_add_results on unexpected response shape — if extract() returns something that's not {AddResults: [...]} (e.g., raw error dict), it gets wrapped in a list and appended. In practice tapi_yandex_direct raises on protocol errors before this point, so this isn't a live concern.
  • No inter-chunk rate-limit pacing — Yandex Direct enforces server-side; sequential post() calls are reasonable here. YAGNI.
  • No credential leakage — stderr partial-success message contains only Ids and per-item Errors; print_error(str(e)) from the tapi exception won't include the Bearer token (it's a header, not URL-borne).
  • Memory for JSONL — capped at tens of thousands of rows by Yandex account-level limits, not worth streaming. Agreed with previous round.

Verdict

Ship it. All three fixes are correct, narrowly scoped, and well-tested. The PR also picked up two additional defensive tests in 8d982f4 (empty JSON array, non-object row, file+inline mutex) that close adjacent gaps.

…recovery test

- Add `KEYWORDS_PER_ADGROUP_LIMIT = 200` (Yandex Direct per-ad-group cap).
  When the input distributes >200 keywords to a single `AdGroupId`, print
  a stderr warning before any chunk is sent. Doesn't block — the API
  already rejects the excess as per-item errors and the existing
  partial-recovery surfaces them.
- Drop `CliRunner(mix_stderr=False)` in the partial-success test: that
  kwarg is Click 8.2+; the project pins `click>=8.0.0` and CI runs an
  older Click. The CliRunner default mixes stderr into `result.output`,
  which works for the same assertion across all supported Click versions.

Tests:
- `test_keywords_add_batch_warns_when_over_200_per_adgroup` — input with
  201 kw for adgroup 1 + 1 kw for adgroup 2 → warning lists only adgroup
  1 with `(1 over the limit)`.
- `test_keywords_add_batch_no_warning_under_200` — 150 kw for one
  adgroup → no warning.
- `test_keywords_add_batch_partial_success_on_failure` — rewritten to
  use CliRunner's combined-output default; same assertion semantics.

README (EN/RU) + CHANGELOG now mention the per-ad-group limit, the
partial-success-to-stderr behaviour, and the explicit boolean rejection.
@axisrow

axisrow commented May 21, 2026

Copy link
Copy Markdown
Owner Author

Follow-up commit 1eefede:

Pre-flight 200/adgroup warning (per user feedback) — added KEYWORDS_PER_ADGROUP_LIMIT = 200 and a _warn_on_adgroup_overflow pre-flight check. If the input has >200 keywords for a single AdGroupId, stderr gets a warning naming every offending group before any chunk is sent. Doesn't block — the API already rejects the excess as per-item errors, and the partial-recovery from the previous commit surfaces them. Tests: test_keywords_add_batch_warns_when_over_200_per_adgroup, test_keywords_add_batch_no_warning_under_200.

CI fix — the partial-success test used CliRunner(mix_stderr=False), which is a Click 8.2+ kwarg, but the project pins click>=8.0.0. Rewrote the assertion against CliRunner's default combined-output behaviour, same semantics.

README (EN/RU) and CHANGELOG updated. 23/23 keywords_add tests pass locally; full non-integration suite still 799 passed.

`direct_cli.commands.__init__` rebinds the submodule name `keywords` to
the Click Group object, so `from direct_cli.commands import keywords as
keywords_module` (and `import direct_cli.commands.keywords as ...`)
return the Group, not the module. Python 3.11+ tolerated this in
unittest.mock.patch's dotted resolution; Python 3.9 doesn't, hence:

    AttributeError: <Group keywords> has no attribute 'create_client'

Use `importlib.import_module(\"direct_cli.commands.keywords\")` to
unambiguously get the module object — the same pattern already used in
tests/test_api_coverage.py for `reports_module`.
@axisrow
axisrow merged commit 8148969 into main May 21, 2026
12 checks passed
@axisrow
axisrow deleted the worktree-issue-203-batch-keywords-add branch May 21, 2026 04:19
axisrow added a commit that referenced this pull request May 21, 2026
…rom-file (#221) (#223)

* feat(sitelinks): support escaped pipe + --sitelink-json/--sitelinks-from-file (#221)

UTM templates in Yandex Direct (e.g. cid|{campaign_id}|gid|{gbid}) contain
literal '|' characters in the URL, which broke parse_sitelink_specs's
unbounded split('|') and rejected valid sitelinks with "Invalid sitelink".
URL-encoding the pipe as %7C is not a workaround because Yandex does not
expand macros inside URL-encoded templates.

This change introduces two independent, backwards-compatible improvements:

1. `\|` is recognised as a literal pipe inside --sitelink spec strings.
   The new posix-style scanner _split_sitelink_spec ignores backslash-
   escaped pipes and unescapes them in the output. Specs without `\|`
   behave bit-for-bit identically to before, so existing scripts and the
   existing payload coverage test pass unchanged.

2. Two structural sources are added for callers that don't want to fight
   the pipe convention at all, mirroring the keywords-add #218 pattern:
   * --sitelink-json '<JSON-array>'      (inline)
   * --sitelinks-from-file <path.jsonl>  (one object per line)
   Sources are mutually exclusive; exactly one must be provided. Missing
   Title/Href in a structural row is rejected with the row index.

The previously-required Click option --sitelink is now validated manually,
so the new INTERNAL_VALIDATION entry in test_wsdl_parity_gate.py keeps the
WSDL parity gate honest. api_coverage_payloads.py grows two extra
PAYLOAD_CASES (escape variant + JSON variant) for the same reason.

ValueError from parse_sitelink_specs is wrapped as click.UsageError so all
three input paths return exit code 2 on invalid input.

Closes #221
Closes #220

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sitelinks): reject unknown JSON keys + tighten source-selection (PR #223 review)

Copilot review on PR #223 flagged two real risks; both align with the
``keywords.add`` precedent in this codebase:

1. ``_normalize_sitelink_row`` silently dropped unknown keys, so a typo
   like ``"Decsription"`` would produce a sitelink without a description
   and no warning to the user — silent data loss. Mirror
   ``_normalize_keyword_row`` (``direct_cli/commands/keywords.py:80``):
   compute the unknown-key set, raise ``UsageError`` with the offending
   key and the allowed list.

2. ``sources_used`` used boolean truthiness, so ``--sitelink-json ""``
   was treated as "no source" and produced "Provide exactly one of: ..."
   instead of "invalid JSON". Match the ``keywords.add`` mutex (``is not
   None`` for string options, length-based for the ``--sitelink`` tuple)
   so an explicitly-empty JSON argument falls into the JSON-validation
   error path.

Two regression tests added: typo-key rejection with offending key
quoted; ``--sitelink-json ""`` → "invalid JSON".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: axisrow <axisrow@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
axisrow added a commit that referenced this pull request May 21, 2026
PR #212 wired the API item-level error renderer into format_output so
every mutating command exits non-zero with a human-readable message when
the response contains embedded Errors. The bulk-add path introduced by
PR #218 lands its final result via print(format_json(...)) and the
per-chunk extract via _normalize_add_results — neither goes through
format_output, so item-level Errors in any chunk were swallowed and the
CLI exited 0 with raw JSON. That was the one remaining mutating bypass
of the renderer.

This change wires raise_for_api_result_errors into the per-chunk loop
and replaces the final print(format_json(...)) with format_output so the
same renderer fires:

* After each chunk response, only items without per-item Errors are
  appended to all_results — they are the keywords Yandex actually
  accepted. The partial-success diagnostic ("these keywords were
  already created in Yandex Direct (retrying will duplicate them)")
  therefore lists only success items, never the failed item that
  triggered the abort.
* raise_for_api_result_errors then raises on the raw (unfiltered)
  chunk_results, propagating the 8800 Client-Login guidance and the
  full Errors payload through the existing except Exception handler.
* The final happy-path printout goes through format_output, so a
  hypothetical regression that leaks Errors past the per-chunk check
  still gets caught at the boundary.

Tests added in tests/test_cli.py (mock client, no HTTP):

* test_keywords_bulk_add_surfaces_item_errors — single chunk with only
  Errors → non-zero exit, "Yandex Direct API returned errors", "Error
  8800: Object not found", "Details: Ad group not found", and the
  current Client-Login/account guidance.
* test_keywords_bulk_add_multi_chunk_partial_success — chunk 1 all
  success, chunk 2 mixed (one Id + one Errors). The Id from the mixed
  chunk appears in the partial-success diagnostic; the Errors entry
  does not.
* test_keywords_bulk_add_all_failure_second_chunk — chunk 1 all
  success, chunk 2 all Errors. The diagnostic shows only chunk 1's
  ids; the failing chunk contributes nothing.

Closes #211

Co-authored-by: axisrow <axisrow@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

keywords add: support batch input (--from-file / --keywords-json) respecting API 10-per-request limit

2 participants