direct-cli 0.2.4__tar.gz → 0.2.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. {direct_cli-0.2.4 → direct_cli-0.2.5}/PKG-INFO +1 -1
  2. {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/adextensions.py +8 -5
  3. {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/adgroups.py +27 -5
  4. {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/ads.py +35 -6
  5. {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/audiencetargets.py +1 -1
  6. {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/bidmodifiers.py +127 -24
  7. {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/bids.py +4 -4
  8. {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/campaigns.py +32 -7
  9. {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/feeds.py +39 -5
  10. {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/keywordbids.py +2 -2
  11. {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/keywords.py +2 -2
  12. {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/reports.py +13 -4
  13. {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/retargeting.py +20 -1
  14. {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/smartadtargets.py +37 -9
  15. {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli.egg-info/PKG-INFO +1 -1
  16. {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli.egg-info/SOURCES.txt +0 -1
  17. {direct_cli-0.2.4 → direct_cli-0.2.5}/pyproject.toml +2 -1
  18. {direct_cli-0.2.4 → direct_cli-0.2.5}/tests/cassettes/test_integration_write/TestWriteAdExtensions.test_add_delete.yaml +8 -8
  19. {direct_cli-0.2.4 → direct_cli-0.2.5}/tests/cassettes/test_integration_write/TestWriteAdGroups.test_add_update_delete.yaml +27 -27
  20. direct_cli-0.2.5/tests/cassettes/test_integration_write/TestWriteAdImages.test_add_delete.yaml +67 -0
  21. {direct_cli-0.2.4 → direct_cli-0.2.5}/tests/cassettes/test_integration_write/TestWriteAds.test_add_text_ad_update_delete.yaml +28 -28
  22. {direct_cli-0.2.4 → direct_cli-0.2.5}/tests/cassettes/test_integration_write/TestWriteAudienceTargets.test_add_delete.yaml +141 -32
  23. {direct_cli-0.2.4 → direct_cli-0.2.5}/tests/cassettes/test_integration_write/TestWriteBidModifiersAdd.test_add_delete_mobile.yaml +22 -22
  24. {direct_cli-0.2.4 → direct_cli-0.2.5}/tests/cassettes/test_integration_write/TestWriteBidModifiersSet.test_set_without_id_is_rejected.yaml +17 -17
  25. direct_cli-0.2.5/tests/cassettes/test_integration_write/TestWriteBids.test_set_bid.yaml +275 -0
  26. {direct_cli-0.2.4 → direct_cli-0.2.5}/tests/cassettes/test_integration_write/TestWriteCampaigns.test_campaign_lifecycle.yaml +33 -92
  27. direct_cli-0.2.5/tests/cassettes/test_integration_write/TestWriteDynamicAds.test_add_update_delete.yaml +59 -0
  28. {direct_cli-0.2.4 → direct_cli-0.2.5}/tests/cassettes/test_integration_write/TestWriteFeeds.test_add_update_delete.yaml +16 -16
  29. {direct_cli-0.2.4 → direct_cli-0.2.5}/tests/cassettes/test_integration_write/TestWriteKeywordBids.test_set_keyword_bid.yaml +27 -27
  30. {direct_cli-0.2.4 → direct_cli-0.2.5}/tests/cassettes/test_integration_write/TestWriteKeywords.test_add_update_delete.yaml +27 -27
  31. {direct_cli-0.2.4 → direct_cli-0.2.5}/tests/cassettes/test_integration_write/TestWriteNegativeKeywordSharedSets.test_add_update_delete.yaml +17 -17
  32. {direct_cli-0.2.4 → direct_cli-0.2.5}/tests/cassettes/test_integration_write/TestWriteRetargeting.test_add_delete.yaml +10 -10
  33. {direct_cli-0.2.4 → direct_cli-0.2.5}/tests/cassettes/test_integration_write/TestWriteSitelinks.test_add_delete.yaml +5 -5
  34. direct_cli-0.2.4/tests/cassettes/test_integration_write/TestWriteBids.test_set_bid.yaml → direct_cli-0.2.5/tests/cassettes/test_integration_write/TestWriteSmartAdTargets.test_add_update_delete.yaml +31 -28
  35. {direct_cli-0.2.4 → direct_cli-0.2.5}/tests/cassettes/test_integration_write/TestWriteVCards.test_add_delete.yaml +23 -23
  36. {direct_cli-0.2.4 → direct_cli-0.2.5}/tests/conftest.py +110 -9
  37. direct_cli-0.2.5/tests/test_dry_run.py +1273 -0
  38. {direct_cli-0.2.4 → direct_cli-0.2.5}/tests/test_integration_write.py +189 -68
  39. direct_cli-0.2.4/tests/cassettes/test_integration_write/TestWriteAdImages.test_add_delete.yaml +0 -67
  40. direct_cli-0.2.4/tests/cassettes/test_integration_write/TestWriteBidModifiers.test_toggle_existing.yaml +0 -110
  41. direct_cli-0.2.4/tests/cassettes/test_integration_write/TestWriteDynamicAds.test_add_update_delete.yaml +0 -275
  42. direct_cli-0.2.4/tests/cassettes/test_integration_write/TestWriteSmartAdTargets.test_add_update_delete.yaml +0 -275
  43. direct_cli-0.2.4/tests/test_dry_run.py +0 -663
  44. {direct_cli-0.2.4 → direct_cli-0.2.5}/.env.example +0 -0
  45. {direct_cli-0.2.4 → direct_cli-0.2.5}/.github/copilot-instructions.md +0 -0
  46. {direct_cli-0.2.4 → direct_cli-0.2.5}/.github/workflows/claude-code-review.yml +0 -0
  47. {direct_cli-0.2.4 → direct_cli-0.2.5}/.github/workflows/claude.yml +0 -0
  48. {direct_cli-0.2.4 → direct_cli-0.2.5}/.gitignore +0 -0
  49. {direct_cli-0.2.4 → direct_cli-0.2.5}/AGENTS.md +0 -0
  50. {direct_cli-0.2.4 → direct_cli-0.2.5}/CLAUDE.md +0 -0
  51. {direct_cli-0.2.4 → direct_cli-0.2.5}/MANIFEST.in +0 -0
  52. {direct_cli-0.2.4 → direct_cli-0.2.5}/README.md +0 -0
  53. {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/__init__.py +0 -0
  54. {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/api.py +0 -0
  55. {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/auth.py +0 -0
  56. {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/cli.py +0 -0
  57. {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/__init__.py +0 -0
  58. {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/adimages.py +0 -0
  59. {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/agencyclients.py +0 -0
  60. {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/businesses.py +0 -0
  61. {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/changes.py +0 -0
  62. {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/clients.py +0 -0
  63. {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/creatives.py +0 -0
  64. {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/dictionaries.py +0 -0
  65. {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/dynamicads.py +0 -0
  66. {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/keywordsresearch.py +0 -0
  67. {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/leads.py +0 -0
  68. {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/negativekeywordsharedsets.py +0 -0
  69. {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/sitelinks.py +0 -0
  70. {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/turbopages.py +0 -0
  71. {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/vcards.py +0 -0
  72. {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/output.py +0 -0
  73. {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/utils.py +0 -0
  74. {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli.egg-info/dependency_links.txt +0 -0
  75. {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli.egg-info/entry_points.txt +0 -0
  76. {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli.egg-info/requires.txt +0 -0
  77. {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli.egg-info/top_level.txt +0 -0
  78. {direct_cli-0.2.4 → direct_cli-0.2.5}/scripts/release_pypi.sh +0 -0
  79. {direct_cli-0.2.4 → direct_cli-0.2.5}/setup.cfg +0 -0
  80. {direct_cli-0.2.4 → direct_cli-0.2.5}/setup.py +0 -0
  81. {direct_cli-0.2.4 → direct_cli-0.2.5}/tests/__init__.py +0 -0
  82. {direct_cli-0.2.4 → direct_cli-0.2.5}/tests/test_auth_bw.py +0 -0
  83. {direct_cli-0.2.4 → direct_cli-0.2.5}/tests/test_auth_op.py +0 -0
  84. {direct_cli-0.2.4 → direct_cli-0.2.5}/tests/test_cli.py +0 -0
  85. {direct_cli-0.2.4 → direct_cli-0.2.5}/tests/test_comprehensive.py +0 -0
  86. {direct_cli-0.2.4 → direct_cli-0.2.5}/tests/test_integration.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: direct-cli
3
- Version: 0.2.4
3
+ Version: 0.2.5
4
4
  Summary: Command-line interface for Yandex Direct API
5
5
  Author: axisrow
6
6
  License: MIT
@@ -68,11 +68,14 @@ def get(ctx, ids, types, limit, fetch_all, output_format, output, fields):
68
68
  @click.option(
69
69
  "--type",
70
70
  "ext_type",
71
- required=True,
72
71
  help=(
73
- "Extension type (UX hint Callout, Sitelinks, Vcard, …). "
74
- "Not sent to the API; the API derives the type from the nested "
75
- "field name inside --json."
72
+ "Legacy UX hint; NOT forwarded to the API. The Yandex Direct "
73
+ "API derives the extension type from the nested field name "
74
+ "inside --json (Callout / Sitelinks / Vcard / ...), so the "
75
+ "only flag that actually matters is --json. Previously this "
76
+ "option was required=True but silently discarded, which "
77
+ "forced every caller to pass a value that did nothing. See "
78
+ "axisrow/direct-cli#25."
76
79
  ),
77
80
  )
78
81
  @click.option("--json", "extra_json", required=True, help="Extension data in JSON")
@@ -88,7 +91,7 @@ def add(ctx, ext_type, extra_json, dry_run):
88
91
  ``{"Type": ext_type, ...}`` and the sandbox rejected the extra
89
92
  key as ``unknown parameter Type``.
90
93
  """
91
- _ = ext_type # consumed only for argument validation / UX clarity
94
+ _ = ext_type # intentionally unused kept as UX hint only
92
95
  try:
93
96
  ext_data = json.loads(extra_json)
94
97
 
@@ -84,7 +84,18 @@ def get(
84
84
  @adgroups.command()
85
85
  @click.option("--name", required=True, help="Ad group name")
86
86
  @click.option("--campaign-id", required=True, type=int, help="Campaign ID")
87
- @click.option("--type", "group_type", default="TEXT_AD_GROUP", help="Ad group type")
87
+ @click.option(
88
+ "--type",
89
+ "group_type",
90
+ default="TEXT_AD_GROUP",
91
+ help=(
92
+ "Ad group type (case-insensitive). The Yandex Direct API derives "
93
+ "the group type from nested objects (MobileAppAdGroup / "
94
+ "DynamicTextAdGroup / SmartAdGroup / ...), not from a top-level "
95
+ "Type discriminator. Convenience flags only build a TEXT_AD_GROUP; "
96
+ "for other types pass the matching nested object via --json."
97
+ ),
98
+ )
88
99
  @click.option("--region-ids", help="Comma-separated region IDs")
89
100
  @click.option("--json", "extra_json", help="Additional JSON parameters")
90
101
  @click.option("--dry-run", is_flag=True, help="Show request without sending")
@@ -96,12 +107,21 @@ def add(ctx, name, campaign_id, group_type, region_ids, extra_json, dry_run):
96
107
  # AdGroupAddItem — the group type is inferred from the presence of
97
108
  # MobileAppAdGroup / DynamicTextAdGroup / SmartAdGroup / etc.
98
109
  # sub-objects, exactly like Ads (see fix in commands/ads.py).
99
- # The --type CLI option is preserved for backward compatibility but
100
- # is no longer forwarded to the API; users wanting non-text group
101
- # types must pass the matching sub-object via --json.
110
+ # Previously --type was accepted but silently discarded users
111
+ # passing --type MOBILE_APP_AD_GROUP got a TEXT_AD_GROUP with no
112
+ # warning. Now we normalize case and fail loudly if the caller
113
+ # asks for anything except TEXT_AD_GROUP. See axisrow/direct-cli#23.
102
114
  # Refs: https://yandex.ru/dev/direct/doc/ref-v5/adgroups/add.html
115
+ group_type_norm = (group_type or "TEXT_AD_GROUP").upper().replace("-", "_")
116
+
117
+ if group_type_norm != "TEXT_AD_GROUP" and not extra_json:
118
+ raise click.UsageError(
119
+ f"--type {group_type} requires --json with the "
120
+ f"ad-group-type-specific nested object "
121
+ f"(e.g. DynamicTextAdGroup, SmartAdGroup, MobileAppAdGroup)."
122
+ )
123
+
103
124
  adgroup_data = {"Name": name, "CampaignId": campaign_id}
104
- _ = group_type # acknowledged-but-unused
105
125
 
106
126
  if region_ids:
107
127
  adgroup_data["RegionIds"] = parse_ids(region_ids)
@@ -125,6 +145,8 @@ def add(ctx, name, campaign_id, group_type, region_ids, extra_json, dry_run):
125
145
  result = client.adgroups().post(data=body)
126
146
  format_output(result().extract(), "json", None)
127
147
 
148
+ except click.UsageError:
149
+ raise
128
150
  except Exception as e:
129
151
  print_error(str(e))
130
152
  raise click.Abort()
@@ -107,20 +107,47 @@ def get(
107
107
 
108
108
  @ads.command()
109
109
  @click.option("--adgroup-id", required=True, type=int, help="Ad group ID")
110
- @click.option("--type", "ad_type", default="TEXT_AD", help="Ad type")
111
- @click.option("--title", help="Ad title")
112
- @click.option("--text", help="Ad text")
113
- @click.option("--href", help="Ad URL")
110
+ @click.option(
111
+ "--type",
112
+ "ad_type",
113
+ default="TEXT_AD",
114
+ help=(
115
+ "Ad type (case-insensitive). The convenience flags "
116
+ "--title/--text/--href only build a payload for TEXT_AD. "
117
+ "For other ad types (e.g. TEXT_IMAGE_AD, MOBILE_APP_AD) "
118
+ "pass the nested object via --json."
119
+ ),
120
+ )
121
+ @click.option("--title", help="Ad title (TEXT_AD only)")
122
+ @click.option("--text", help="Ad text (TEXT_AD only)")
123
+ @click.option("--href", help="Ad URL (TEXT_AD only)")
114
124
  @click.option("--json", "extra_json", help="Additional JSON parameters")
115
125
  @click.option("--dry-run", is_flag=True, help="Show request without sending")
116
126
  @click.pass_context
117
127
  def add(ctx, adgroup_id, ad_type, title, text, href, extra_json, dry_run):
118
128
  """Add new ad"""
119
129
  try:
130
+ # Normalize --type so case variants and hyphen forms (``text_ad``,
131
+ # ``text-ad``) behave the same as ``TEXT_AD``. Without this
132
+ # normalization, the previous implementation silently dropped
133
+ # --title/--text/--href for any value other than the exact
134
+ # string ``"TEXT_AD"`` and the API responded with the very
135
+ # misleading ``5008 None of the required fields were sent``
136
+ # error — see axisrow/direct-cli#21.
137
+ ad_type_norm = (ad_type or "TEXT_AD").upper().replace("-", "_")
138
+ has_convenience_flags = any([title, text, href])
139
+
140
+ if ad_type_norm != "TEXT_AD" and has_convenience_flags:
141
+ raise click.UsageError(
142
+ f"--type {ad_type} does not support --title/--text/--href "
143
+ f"(these convenience flags only build a TEXT_AD payload). "
144
+ f"Pass the nested ad object via --json, or use --type TEXT_AD."
145
+ )
146
+
120
147
  ad_data = {"AdGroupId": adgroup_id}
121
148
 
122
- if ad_type == "TEXT_AD":
123
- ad_data["TextAd"] = {}
149
+ if ad_type_norm == "TEXT_AD" and has_convenience_flags:
150
+ ad_data["TextAd"] = {"Mobile": "NO"}
124
151
  if title:
125
152
  ad_data["TextAd"]["Title"] = title
126
153
  if text:
@@ -147,6 +174,8 @@ def add(ctx, adgroup_id, ad_type, title, text, href, extra_json, dry_run):
147
174
  result = client.ads().post(data=body)
148
175
  format_output(result().extract(), "json", None)
149
176
 
177
+ except click.UsageError:
178
+ raise
150
179
  except Exception as e:
151
180
  print_error(str(e))
152
181
  raise click.Abort()
@@ -84,7 +84,7 @@ def add(ctx, adgroup_id, retargeting_list_id, bid, extra_json, dry_run):
84
84
  "RetargetingListId": retargeting_list_id,
85
85
  }
86
86
 
87
- if bid:
87
+ if bid is not None:
88
88
  target_data["Bid"] = int(bid * 1000000)
89
89
 
90
90
  if extra_json:
@@ -136,14 +136,20 @@ def add(ctx, campaign_id, adgroup_id, modifier_type, value, extra_json, dry_run)
136
136
  """
137
137
  try:
138
138
  if (campaign_id is None) == (adgroup_id is None):
139
- raise click.ClickException(
139
+ raise click.UsageError(
140
140
  "Exactly one of --campaign-id or --adgroup-id is required"
141
141
  )
142
142
 
143
143
  nested_key = _BIDMODIFIER_TYPE_TO_NESTED[modifier_type.upper()]
144
144
  nested = {"BidModifier": value}
145
145
  if extra_json:
146
- nested.update(json.loads(extra_json))
146
+ extra = json.loads(extra_json)
147
+ if not isinstance(extra, dict):
148
+ raise click.UsageError(
149
+ "--json must be a JSON object (dict), "
150
+ f"got {type(extra).__name__}"
151
+ )
152
+ nested.update(extra)
147
153
 
148
154
  modifier_data = {nested_key: nested}
149
155
  if campaign_id is not None:
@@ -166,31 +172,103 @@ def add(ctx, campaign_id, adgroup_id, modifier_type, value, extra_json, dry_run)
166
172
  result = client.bidmodifiers().post(data=body)
167
173
  format_output(result().extract(), "json", None)
168
174
 
175
+ except click.UsageError:
176
+ raise
169
177
  except Exception as e:
170
178
  print_error(str(e))
171
179
  raise click.Abort()
172
180
 
173
181
 
174
182
  @bidmodifiers.command()
175
- @click.option("--campaign-id", required=True, type=int, help="Campaign ID")
183
+ @click.option(
184
+ "--id",
185
+ "modifier_id",
186
+ type=int,
187
+ help=(
188
+ "Existing BidModifier ID to update. This is the shape Yandex "
189
+ "Direct's ``bidmodifiers/set`` method actually supports — pass "
190
+ "the Id of a modifier created via ``bidmodifiers add`` and the "
191
+ "new ``--value``. Mutually exclusive with --campaign-id/--type."
192
+ ),
193
+ )
194
+ @click.option(
195
+ "--campaign-id",
196
+ type=int,
197
+ help=(
198
+ "Campaign ID (legacy path, broken by design — kept for "
199
+ "backwards compatibility and regression coverage; the API "
200
+ "rejects this shape with ``required field Id is omitted``). "
201
+ "Use --id for real updates."
202
+ ),
203
+ )
176
204
  @click.option(
177
205
  "--type",
178
206
  "modifier_type",
179
- required=True,
180
- help="Modifier type (DEMOGRAPHICS, MOBILE, etc.)",
207
+ type=click.Choice(sorted(_BIDMODIFIER_TYPE_TO_NESTED.keys()), case_sensitive=False),
208
+ help=(
209
+ "Modifier category (legacy path). Uses the same enum as "
210
+ "``bidmodifiers add`` (MOBILE_ADJUSTMENT / DEMOGRAPHICS_ADJUSTMENT "
211
+ "/ ...), case-insensitive."
212
+ ),
181
213
  )
182
214
  @click.option("--value", type=float, required=True, help="Modifier value")
183
215
  @click.option("--json", "extra_json", help="Additional JSON parameters")
184
216
  @click.option("--dry-run", is_flag=True, help="Show request without sending")
185
217
  @click.pass_context
186
- def set(ctx, campaign_id, modifier_type, value, extra_json, dry_run):
187
- """Set bid modifier"""
218
+ def set(ctx, modifier_id, campaign_id, modifier_type, value, extra_json, dry_run):
219
+ """Set (update) an existing bid modifier
220
+
221
+ The Yandex Direct API's ``bidmodifiers/set`` method updates existing
222
+ modifiers by ``Id``. The correct payload shape is simply::
223
+
224
+ {"BidModifiers": [{"Id": <long>, "BidModifier": <value>}]}
225
+
226
+ To create a new modifier, use ``bidmodifiers add`` instead.
227
+
228
+ This CLI command supports two shapes:
229
+
230
+ 1. **Correct shape** — pass ``--id`` + ``--value``. The request
231
+ body becomes exactly ``{"Id": ..., "BidModifier": ...}`` and is
232
+ accepted by the API.
233
+
234
+ 2. **Legacy shape** (broken by design) — pass ``--campaign-id`` +
235
+ ``--type`` + ``--value``. The request body is
236
+ ``{"CampaignId": ..., "Type": ..., "BidModifier": ...}`` and the
237
+ API rejects it with ``required field Id is omitted``. This path
238
+ is preserved so the existing regression cassette in
239
+ ``TestWriteBidModifiersSet.test_set_without_id_is_rejected``
240
+ keeps passing; it also gives a clear deprecation signal to
241
+ callers who land on this command by mistake.
242
+ """
188
243
  try:
189
- modifier_data = {
190
- "CampaignId": campaign_id,
191
- "Type": modifier_type,
192
- "BidModifier": value,
193
- }
244
+ # Validate the mutex up front.
245
+ if modifier_id is not None and (
246
+ campaign_id is not None or modifier_type is not None
247
+ ):
248
+ raise click.UsageError(
249
+ "--id is mutually exclusive with --campaign-id/--type. "
250
+ "Use --id + --value for the correct bidmodifiers/set shape."
251
+ )
252
+
253
+ if modifier_id is None and (campaign_id is None or modifier_type is None):
254
+ raise click.UsageError(
255
+ "Provide either --id (preferred) or both --campaign-id "
256
+ "and --type (legacy)."
257
+ )
258
+
259
+ if modifier_id is not None:
260
+ # Correct API shape: Id + BidModifier. Nothing else.
261
+ modifier_data = {"Id": modifier_id, "BidModifier": value}
262
+ else:
263
+ # Legacy broken-by-design path — kept for backwards
264
+ # compatibility with the existing regression test. The
265
+ # click.Choice above has already validated/normalized
266
+ # modifier_type, so we forward it unchanged.
267
+ modifier_data = {
268
+ "CampaignId": campaign_id,
269
+ "Type": modifier_type,
270
+ "BidModifier": value,
271
+ }
194
272
 
195
273
  if extra_json:
196
274
  extra = json.loads(extra_json)
@@ -211,29 +289,52 @@ def set(ctx, campaign_id, modifier_type, value, extra_json, dry_run):
211
289
  result = client.bidmodifiers().post(data=body)
212
290
  format_output(result().extract(), "json", None)
213
291
 
292
+ except click.UsageError:
293
+ raise
214
294
  except Exception as e:
215
295
  print_error(str(e))
216
296
  raise click.Abort()
217
297
 
218
298
 
219
299
  @bidmodifiers.command()
220
- @click.option("--id", "modifier_id", required=True, type=int, help="Modifier ID")
300
+ @click.option("--campaign-id", type=int, help="Campaign ID (mutually exclusive with --adgroup-id)")
301
+ @click.option("--adgroup-id", type=int, help="Ad group ID (mutually exclusive with --campaign-id)")
302
+ @click.option(
303
+ "--type",
304
+ "modifier_type",
305
+ required=True,
306
+ type=click.Choice([
307
+ "DEMOGRAPHICS_ADJUSTMENT",
308
+ "RETARGETING_ADJUSTMENT",
309
+ "REGIONAL_ADJUSTMENT",
310
+ "SERP_LAYOUT_ADJUSTMENT",
311
+ "INCOME_GRADE_ADJUSTMENT",
312
+ ], case_sensitive=False),
313
+ help="Adjustment type to toggle",
314
+ )
221
315
  @click.option("--enabled/--disabled", "enabled", default=True, help="Enable or disable")
222
316
  @click.option("--dry-run", is_flag=True, help="Show request without sending")
223
317
  @click.pass_context
224
- def toggle(ctx, modifier_id, enabled, dry_run):
225
- """Toggle bid modifier state"""
318
+ def toggle(ctx, campaign_id, adgroup_id, modifier_type, enabled, dry_run):
319
+ """Toggle bid modifier state (enable/disable by type)"""
226
320
  try:
321
+ if not campaign_id and not adgroup_id:
322
+ raise click.UsageError("Either --campaign-id or --adgroup-id is required.")
323
+ if campaign_id and adgroup_id:
324
+ raise click.UsageError("Use either --campaign-id or --adgroup-id, not both.")
325
+
326
+ item = {
327
+ "Type": modifier_type.upper(),
328
+ "Enabled": "YES" if enabled else "NO",
329
+ }
330
+ if campaign_id:
331
+ item["CampaignId"] = campaign_id
332
+ else:
333
+ item["AdGroupId"] = adgroup_id
334
+
227
335
  body = {
228
- "method": "set",
229
- "params": {
230
- "BidModifiers": [
231
- {
232
- "Id": modifier_id,
233
- "Enabled": "YES" if enabled else "NO",
234
- }
235
- ]
236
- },
336
+ "method": "toggle",
337
+ "params": {"BidModifierToggleItems": [item]},
237
338
  }
238
339
 
239
340
  if dry_run:
@@ -249,6 +350,8 @@ def toggle(ctx, modifier_id, enabled, dry_run):
249
350
  result = client.bidmodifiers().post(data=body)
250
351
  format_output(result().extract(), "json", None)
251
352
 
353
+ except click.UsageError:
354
+ raise
252
355
  except Exception as e:
253
356
  print_error(str(e))
254
357
  raise click.Abort()
@@ -70,17 +70,17 @@ def get(
70
70
 
71
71
 
72
72
  @bids.command()
73
- @click.option("--campaign-id", required=True, type=int, help="Campaign ID")
73
+ @click.option("--keyword-id", required=True, type=int, help="Keyword ID")
74
74
  @click.option("--bid", type=float, help="Bid amount")
75
75
  @click.option("--json", "extra_json", help="Additional JSON parameters")
76
76
  @click.option("--dry-run", is_flag=True, help="Show request without sending")
77
77
  @click.pass_context
78
- def set(ctx, campaign_id, bid, extra_json, dry_run):
78
+ def set(ctx, keyword_id, bid, extra_json, dry_run):
79
79
  """Set bids"""
80
80
  try:
81
- bid_data = {"CampaignId": campaign_id}
81
+ bid_data = {"KeywordId": keyword_id}
82
82
 
83
- if bid:
83
+ if bid is not None:
84
84
  bid_data["Bid"] = int(bid * 1000000)
85
85
 
86
86
  if extra_json:
@@ -81,7 +81,17 @@ def get(ctx, ids, status, types, limit, fetch_all, output_format, output, fields
81
81
  @campaigns.command()
82
82
  @click.option("--name", required=True, help="Campaign name")
83
83
  @click.option("--start-date", required=True, help="Start date (YYYY-MM-DD)")
84
- @click.option("--type", "campaign_type", default="TEXT_CAMPAIGN", help="Campaign type")
84
+ @click.option(
85
+ "--type",
86
+ "campaign_type",
87
+ default="TEXT_CAMPAIGN",
88
+ help=(
89
+ "Campaign type (case-insensitive). Convenience flags only build "
90
+ "a TEXT_CAMPAIGN payload; for other types "
91
+ "(MOBILE_APP_CAMPAIGN, DYNAMIC_TEXT_CAMPAIGN, ...) pass the "
92
+ "matching nested object via --json."
93
+ ),
94
+ )
85
95
  @click.option("--budget", type=int, help="Daily budget in currency units")
86
96
  @click.option("--end-date", help="End date (YYYY-MM-DD)")
87
97
  @click.option("--json", "extra_json", help="Additional JSON parameters")
@@ -90,17 +100,30 @@ def get(ctx, ids, status, types, limit, fetch_all, output_format, output, fields
90
100
  def add(ctx, name, start_date, campaign_type, budget, end_date, extra_json, dry_run):
91
101
  """Add new campaign"""
92
102
  try:
93
- campaign_data = {
94
- "Name": name,
95
- "StartDate": start_date,
96
- "TextCampaign": {
103
+ # Normalize --type so ``text_campaign`` / ``text-campaign`` /
104
+ # ``TEXT-CAMPAIGN`` all map to ``TEXT_CAMPAIGN``. Previously any
105
+ # non-default value was silently dropped and the CLI hard-coded
106
+ # ``TextCampaign`` regardless — see axisrow/direct-cli#23.
107
+ campaign_type_norm = (
108
+ (campaign_type or "TEXT_CAMPAIGN").upper().replace("-", "_")
109
+ )
110
+
111
+ campaign_data = {"Name": name, "StartDate": start_date}
112
+
113
+ if campaign_type_norm == "TEXT_CAMPAIGN":
114
+ campaign_data["TextCampaign"] = {
97
115
  "BiddingStrategy": {
98
116
  "Search": {"BiddingStrategyType": "HIGHEST_POSITION"},
99
117
  "Network": {"BiddingStrategyType": "SERVING_OFF"},
100
118
  },
101
119
  "Settings": [],
102
- },
103
- }
120
+ }
121
+ elif not extra_json:
122
+ raise click.UsageError(
123
+ f"--type {campaign_type} requires --json with the "
124
+ f"campaign-type-specific nested object "
125
+ f"(e.g. DynamicTextCampaign, SmartCampaign, MobileAppCampaign)."
126
+ )
104
127
 
105
128
  if budget:
106
129
  campaign_data["DailyBudget"] = {
@@ -130,6 +153,8 @@ def add(ctx, name, start_date, campaign_type, budget, end_date, extra_json, dry_
130
153
  result = client.campaigns().post(data=body)
131
154
  format_output(result().extract(), "json", None)
132
155
 
156
+ except click.UsageError:
157
+ raise
133
158
  except Exception as e:
134
159
  print_error(str(e))
135
160
  raise click.Abort()
@@ -79,14 +79,30 @@ def add(ctx, name, url, extra_json, dry_run):
79
79
  URL). Pass ``--json`` to override for file feeds or business feeds.
80
80
  """
81
81
  try:
82
+ # Detect the --url / --json UrlFeed collision up front. Without
83
+ # this check, ``feed_data.update(extra)`` below would silently
84
+ # replace the ``UrlFeed`` object built from --url with whatever
85
+ # the caller passed in --json, and the --url value would vanish
86
+ # from the request — see axisrow/direct-cli#23.
87
+ extra = json.loads(extra_json) if extra_json else {}
88
+ if not isinstance(extra, dict):
89
+ raise click.UsageError(
90
+ "--json must be a JSON object (dict), "
91
+ f"got {type(extra).__name__}"
92
+ )
93
+ if "UrlFeed" in extra:
94
+ raise click.UsageError(
95
+ "Pass the feed URL via exactly one of --url or "
96
+ "--json '{\"UrlFeed\":{...}}', not both."
97
+ )
98
+
82
99
  feed_data = {
83
100
  "Name": name,
84
101
  "SourceType": "URL",
85
102
  "UrlFeed": {"Url": url},
86
103
  }
87
104
 
88
- if extra_json:
89
- extra = json.loads(extra_json)
105
+ if extra:
90
106
  feed_data.update(extra)
91
107
 
92
108
  body = {"method": "add", "params": {"Feeds": [feed_data]}}
@@ -104,6 +120,8 @@ def add(ctx, name, url, extra_json, dry_run):
104
120
  result = client.feeds().post(data=body)
105
121
  format_output(result().extract(), "json", None)
106
122
 
123
+ except click.UsageError:
124
+ raise
107
125
  except Exception as e:
108
126
  print_error(str(e))
109
127
  raise click.Abort()
@@ -119,17 +137,31 @@ def add(ctx, name, url, extra_json, dry_run):
119
137
  def update(ctx, feed_id, name, url, extra_json, dry_run):
120
138
  """Update feed"""
121
139
  try:
140
+ # Mirror of the --url / --json UrlFeed collision check in
141
+ # ``add`` — see axisrow/direct-cli#23. Without it, --url would
142
+ # be silently replaced by a UrlFeed object in --json.
143
+ extra = json.loads(extra_json) if extra_json else {}
144
+ if not isinstance(extra, dict):
145
+ raise click.UsageError(
146
+ "--json must be a JSON object (dict), "
147
+ f"got {type(extra).__name__}"
148
+ )
149
+ if url and "UrlFeed" in extra:
150
+ raise click.UsageError(
151
+ "Pass the feed URL via exactly one of --url or "
152
+ "--json '{\"UrlFeed\":{...}}', not both."
153
+ )
154
+
122
155
  feed_data = {"Id": feed_id}
123
156
 
124
157
  if name:
125
158
  feed_data["Name"] = name
126
159
  if url:
127
160
  feed_data["UrlFeed"] = {"Url": url}
128
- if extra_json:
129
- extra = json.loads(extra_json)
161
+ if extra:
130
162
  feed_data.update(extra)
131
163
  if len(feed_data) == 1:
132
- raise click.ClickException(
164
+ raise click.UsageError(
133
165
  "Provide at least one of --name, --url, or --json for update"
134
166
  )
135
167
 
@@ -148,6 +180,8 @@ def update(ctx, feed_id, name, url, extra_json, dry_run):
148
180
  result = client.feeds().post(data=body)
149
181
  format_output(result().extract(), "json", None)
150
182
 
183
+ except click.UsageError:
184
+ raise
151
185
  except Exception as e:
152
186
  print_error(str(e))
153
187
  raise click.Abort()
@@ -81,9 +81,9 @@ def set(ctx, keyword_id, search_bid, network_bid, extra_json, dry_run):
81
81
  try:
82
82
  bid_data = {"KeywordId": keyword_id}
83
83
 
84
- if search_bid:
84
+ if search_bid is not None:
85
85
  bid_data["SearchBid"] = int(search_bid * 1000000)
86
- if network_bid:
86
+ if network_bid is not None:
87
87
  bid_data["NetworkBid"] = int(network_bid * 1000000)
88
88
 
89
89
  if extra_json:
@@ -106,9 +106,9 @@ def add(
106
106
  try:
107
107
  keyword_data = {"AdGroupId": adgroup_id, "Keyword": keyword}
108
108
 
109
- if bid:
109
+ if bid is not None:
110
110
  keyword_data["Bid"] = int(bid * 1000000)
111
- if context_bid:
111
+ if context_bid is not None:
112
112
  keyword_data["ContextBid"] = int(context_bid * 1000000)
113
113
  if user_param_1:
114
114
  keyword_data["UserParam1"] = user_param_1
@@ -28,7 +28,11 @@ def reports():
28
28
  "--type",
29
29
  "report_type",
30
30
  required=True,
31
- help="Report type (CAMPAIGN_PERFORMANCE_REPORT, etc.)",
31
+ type=click.Choice(REPORT_TYPES, case_sensitive=False),
32
+ help=(
33
+ "Report type (case-insensitive). Validated against the official "
34
+ "Yandex Direct report-type enum — see axisrow/direct-cli#25."
35
+ ),
32
36
  )
33
37
  @click.option("--from", "date_from", required=True, help="Start date (YYYY-MM-DD)")
34
38
  @click.option("--to", "date_to", required=True, help="End date (YYYY-MM-DD)")
@@ -43,7 +47,6 @@ def reports():
43
47
  help="Output format (json/table/csv/tsv)",
44
48
  )
45
49
  @click.option("--output", help="Output file")
46
- @click.option("--mode", default="auto", help="Processing mode (online/offline/auto)")
47
50
  @click.pass_context
48
51
  def get(
49
52
  ctx,
@@ -56,9 +59,15 @@ def get(
56
59
  adgroup_ids,
57
60
  output_format,
58
61
  output,
59
- mode,
60
62
  ):
61
- """Get report"""
63
+ """Get report
64
+
65
+ The underlying ``create_client`` uses processing mode ``auto``
66
+ — previously the CLI also exposed a ``--mode`` option that was
67
+ declared but never read in the function body, silently dropping
68
+ any value the user passed. That dead option was removed in
69
+ axisrow/direct-cli#25.
70
+ """
62
71
  try:
63
72
  client = create_client(
64
73
  token=ctx.obj.get("token"),
@@ -66,10 +66,26 @@ def get(ctx, ids, types, limit, fetch_all, output_format, output, fields):
66
66
  raise click.Abort()
67
67
 
68
68
 
69
+ #: Valid ``RetargetingListAddItem.Type`` values per Yandex Direct API docs
70
+ #: (ref-v5/retargetinglists/add). The API defaults to ``RETARGETING`` when
71
+ #: ``Type`` is omitted, so the CLI follows the same default.
72
+ _RETARGETING_LIST_TYPES = ["RETARGETING", "AUDIENCE"]
73
+
74
+
69
75
  @retargeting.command()
70
76
  @click.option("--name", required=True, help="List name")
71
77
  @click.option(
72
- "--type", "list_type", required=True, help="List type (AUDIENCE_SEGMENT, etc.)"
78
+ "--type",
79
+ "list_type",
80
+ default="RETARGETING",
81
+ type=click.Choice(_RETARGETING_LIST_TYPES, case_sensitive=False),
82
+ help=(
83
+ "Retargeting list type (case-insensitive). Yandex Direct accepts "
84
+ "only RETARGETING (default — Metrica goals/segments + Audience "
85
+ "segments, usable in text & image / mobile-app campaigns) or "
86
+ "AUDIENCE (any goals/segments, usable in display campaigns). "
87
+ "See axisrow/direct-cli#25."
88
+ ),
73
89
  )
74
90
  @click.option("--json", "extra_json", help="Additional JSON parameters")
75
91
  @click.option("--dry-run", is_flag=True, help="Show request without sending")
@@ -77,6 +93,9 @@ def get(ctx, ids, types, limit, fetch_all, output_format, output, fields):
77
93
  def add(ctx, name, list_type, extra_json, dry_run):
78
94
  """Add new retargeting list"""
79
95
  try:
96
+ # click.Choice normalizes the case when case_sensitive=False but
97
+ # leaves the original casing of the canonical choice; pass it
98
+ # through unchanged.
80
99
  list_data = {"Name": name, "Type": list_type}
81
100
 
82
101
  if extra_json: