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.
- {direct_cli-0.2.4 → direct_cli-0.2.5}/PKG-INFO +1 -1
- {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/adextensions.py +8 -5
- {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/adgroups.py +27 -5
- {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/ads.py +35 -6
- {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/audiencetargets.py +1 -1
- {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/bidmodifiers.py +127 -24
- {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/bids.py +4 -4
- {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/campaigns.py +32 -7
- {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/feeds.py +39 -5
- {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/keywordbids.py +2 -2
- {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/keywords.py +2 -2
- {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/reports.py +13 -4
- {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/retargeting.py +20 -1
- {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/smartadtargets.py +37 -9
- {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli.egg-info/PKG-INFO +1 -1
- {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli.egg-info/SOURCES.txt +0 -1
- {direct_cli-0.2.4 → direct_cli-0.2.5}/pyproject.toml +2 -1
- {direct_cli-0.2.4 → direct_cli-0.2.5}/tests/cassettes/test_integration_write/TestWriteAdExtensions.test_add_delete.yaml +8 -8
- {direct_cli-0.2.4 → direct_cli-0.2.5}/tests/cassettes/test_integration_write/TestWriteAdGroups.test_add_update_delete.yaml +27 -27
- direct_cli-0.2.5/tests/cassettes/test_integration_write/TestWriteAdImages.test_add_delete.yaml +67 -0
- {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
- {direct_cli-0.2.4 → direct_cli-0.2.5}/tests/cassettes/test_integration_write/TestWriteAudienceTargets.test_add_delete.yaml +141 -32
- {direct_cli-0.2.4 → direct_cli-0.2.5}/tests/cassettes/test_integration_write/TestWriteBidModifiersAdd.test_add_delete_mobile.yaml +22 -22
- {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
- direct_cli-0.2.5/tests/cassettes/test_integration_write/TestWriteBids.test_set_bid.yaml +275 -0
- {direct_cli-0.2.4 → direct_cli-0.2.5}/tests/cassettes/test_integration_write/TestWriteCampaigns.test_campaign_lifecycle.yaml +33 -92
- direct_cli-0.2.5/tests/cassettes/test_integration_write/TestWriteDynamicAds.test_add_update_delete.yaml +59 -0
- {direct_cli-0.2.4 → direct_cli-0.2.5}/tests/cassettes/test_integration_write/TestWriteFeeds.test_add_update_delete.yaml +16 -16
- {direct_cli-0.2.4 → direct_cli-0.2.5}/tests/cassettes/test_integration_write/TestWriteKeywordBids.test_set_keyword_bid.yaml +27 -27
- {direct_cli-0.2.4 → direct_cli-0.2.5}/tests/cassettes/test_integration_write/TestWriteKeywords.test_add_update_delete.yaml +27 -27
- {direct_cli-0.2.4 → direct_cli-0.2.5}/tests/cassettes/test_integration_write/TestWriteNegativeKeywordSharedSets.test_add_update_delete.yaml +17 -17
- {direct_cli-0.2.4 → direct_cli-0.2.5}/tests/cassettes/test_integration_write/TestWriteRetargeting.test_add_delete.yaml +10 -10
- {direct_cli-0.2.4 → direct_cli-0.2.5}/tests/cassettes/test_integration_write/TestWriteSitelinks.test_add_delete.yaml +5 -5
- 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
- {direct_cli-0.2.4 → direct_cli-0.2.5}/tests/cassettes/test_integration_write/TestWriteVCards.test_add_delete.yaml +23 -23
- {direct_cli-0.2.4 → direct_cli-0.2.5}/tests/conftest.py +110 -9
- direct_cli-0.2.5/tests/test_dry_run.py +1273 -0
- {direct_cli-0.2.4 → direct_cli-0.2.5}/tests/test_integration_write.py +189 -68
- direct_cli-0.2.4/tests/cassettes/test_integration_write/TestWriteAdImages.test_add_delete.yaml +0 -67
- direct_cli-0.2.4/tests/cassettes/test_integration_write/TestWriteBidModifiers.test_toggle_existing.yaml +0 -110
- direct_cli-0.2.4/tests/cassettes/test_integration_write/TestWriteDynamicAds.test_add_update_delete.yaml +0 -275
- direct_cli-0.2.4/tests/cassettes/test_integration_write/TestWriteSmartAdTargets.test_add_update_delete.yaml +0 -275
- direct_cli-0.2.4/tests/test_dry_run.py +0 -663
- {direct_cli-0.2.4 → direct_cli-0.2.5}/.env.example +0 -0
- {direct_cli-0.2.4 → direct_cli-0.2.5}/.github/copilot-instructions.md +0 -0
- {direct_cli-0.2.4 → direct_cli-0.2.5}/.github/workflows/claude-code-review.yml +0 -0
- {direct_cli-0.2.4 → direct_cli-0.2.5}/.github/workflows/claude.yml +0 -0
- {direct_cli-0.2.4 → direct_cli-0.2.5}/.gitignore +0 -0
- {direct_cli-0.2.4 → direct_cli-0.2.5}/AGENTS.md +0 -0
- {direct_cli-0.2.4 → direct_cli-0.2.5}/CLAUDE.md +0 -0
- {direct_cli-0.2.4 → direct_cli-0.2.5}/MANIFEST.in +0 -0
- {direct_cli-0.2.4 → direct_cli-0.2.5}/README.md +0 -0
- {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/__init__.py +0 -0
- {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/api.py +0 -0
- {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/auth.py +0 -0
- {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/cli.py +0 -0
- {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/__init__.py +0 -0
- {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/adimages.py +0 -0
- {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/agencyclients.py +0 -0
- {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/businesses.py +0 -0
- {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/changes.py +0 -0
- {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/clients.py +0 -0
- {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/creatives.py +0 -0
- {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/dictionaries.py +0 -0
- {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/dynamicads.py +0 -0
- {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/keywordsresearch.py +0 -0
- {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/leads.py +0 -0
- {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/negativekeywordsharedsets.py +0 -0
- {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/sitelinks.py +0 -0
- {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/turbopages.py +0 -0
- {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/commands/vcards.py +0 -0
- {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/output.py +0 -0
- {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli/utils.py +0 -0
- {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli.egg-info/dependency_links.txt +0 -0
- {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli.egg-info/entry_points.txt +0 -0
- {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli.egg-info/requires.txt +0 -0
- {direct_cli-0.2.4 → direct_cli-0.2.5}/direct_cli.egg-info/top_level.txt +0 -0
- {direct_cli-0.2.4 → direct_cli-0.2.5}/scripts/release_pypi.sh +0 -0
- {direct_cli-0.2.4 → direct_cli-0.2.5}/setup.cfg +0 -0
- {direct_cli-0.2.4 → direct_cli-0.2.5}/setup.py +0 -0
- {direct_cli-0.2.4 → direct_cli-0.2.5}/tests/__init__.py +0 -0
- {direct_cli-0.2.4 → direct_cli-0.2.5}/tests/test_auth_bw.py +0 -0
- {direct_cli-0.2.4 → direct_cli-0.2.5}/tests/test_auth_op.py +0 -0
- {direct_cli-0.2.4 → direct_cli-0.2.5}/tests/test_cli.py +0 -0
- {direct_cli-0.2.4 → direct_cli-0.2.5}/tests/test_comprehensive.py +0 -0
- {direct_cli-0.2.4 → direct_cli-0.2.5}/tests/test_integration.py +0 -0
|
@@ -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
|
-
"
|
|
74
|
-
"
|
|
75
|
-
"
|
|
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 #
|
|
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(
|
|
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
|
-
#
|
|
100
|
-
#
|
|
101
|
-
#
|
|
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(
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
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()
|
|
@@ -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.
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
180
|
-
help=
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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",
|
|
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,
|
|
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": "
|
|
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("--
|
|
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,
|
|
78
|
+
def set(ctx, keyword_id, bid, extra_json, dry_run):
|
|
79
79
|
"""Set bids"""
|
|
80
80
|
try:
|
|
81
|
-
bid_data = {"
|
|
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(
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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",
|
|
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:
|