direct-cli 0.3.9__tar.gz → 0.3.10__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 (230) hide show
  1. {direct_cli-0.3.9 → direct_cli-0.3.10}/CHANGELOG.md +60 -0
  2. {direct_cli-0.3.9 → direct_cli-0.3.10}/PKG-INFO +1 -1
  3. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/auth.py +7 -1
  4. direct_cli-0.3.10/direct_cli/commands/changes.py +175 -0
  5. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/keywords.py +16 -3
  6. direct_cli-0.3.10/direct_cli/commands/sitelinks.py +249 -0
  7. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/utils.py +33 -3
  8. direct_cli-0.3.10/direct_cli/v4/__init__.py +57 -0
  9. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/v4_contracts.py +33 -0
  10. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli.egg-info/PKG-INFO +1 -1
  11. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli.egg-info/SOURCES.txt +3 -0
  12. {direct_cli-0.3.9 → direct_cli-0.3.10}/pyproject.toml +1 -1
  13. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/api_coverage_payloads.py +23 -0
  14. direct_cli-0.3.10/tests/test_auth_write_json.py +96 -0
  15. direct_cli-0.3.10/tests/test_changes.py +179 -0
  16. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_cli.py +186 -0
  17. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_dry_run.py +208 -0
  18. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_low_coverage_payloads.py +2 -2
  19. direct_cli-0.3.10/tests/test_v4_runtime_shape.py +188 -0
  20. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_wsdl_parity_gate.py +166 -0
  21. direct_cli-0.3.9/direct_cli/commands/changes.py +0 -104
  22. direct_cli-0.3.9/direct_cli/commands/sitelinks.py +0 -134
  23. direct_cli-0.3.9/direct_cli/v4/__init__.py +0 -17
  24. {direct_cli-0.3.9 → direct_cli-0.3.10}/.env.example +0 -0
  25. {direct_cli-0.3.9 → direct_cli-0.3.10}/.github/copilot-instructions.md +0 -0
  26. {direct_cli-0.3.9 → direct_cli-0.3.10}/.github/workflows/api-coverage.yml +0 -0
  27. {direct_cli-0.3.9 → direct_cli-0.3.10}/.github/workflows/claude.yml +0 -0
  28. {direct_cli-0.3.9 → direct_cli-0.3.10}/.github/workflows/quality.yml +0 -0
  29. {direct_cli-0.3.9 → direct_cli-0.3.10}/.gitignore +0 -0
  30. {direct_cli-0.3.9 → direct_cli-0.3.10}/AGENTS.md +0 -0
  31. {direct_cli-0.3.9 → direct_cli-0.3.10}/CLAUDE.md +0 -0
  32. {direct_cli-0.3.9 → direct_cli-0.3.10}/MANIFEST.in +0 -0
  33. {direct_cli-0.3.9 → direct_cli-0.3.10}/README.md +0 -0
  34. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/__init__.py +0 -0
  35. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/_deprecated.py +0 -0
  36. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/_smoke_probes.py +0 -0
  37. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/_vendor/__init__.py +0 -0
  38. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/_vendor/tapi_yandex_direct/__init__.py +0 -0
  39. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/_vendor/tapi_yandex_direct/endpoints.py +0 -0
  40. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/_vendor/tapi_yandex_direct/exceptions.py +0 -0
  41. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/_vendor/tapi_yandex_direct/resource_mapping.py +0 -0
  42. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/_vendor/tapi_yandex_direct/tapi_yandex_direct.py +0 -0
  43. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/_vendor/tapi_yandex_direct/tapi_yandex_direct.pyi +0 -0
  44. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/_vendor/tapi_yandex_direct/v4/__init__.py +0 -0
  45. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/_vendor/tapi_yandex_direct/v4/adapter.py +0 -0
  46. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/_vendor/tapi_yandex_direct/v4/adapter.pyi +0 -0
  47. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/_vendor/tapi_yandex_direct/v4/resource_mapping.py +0 -0
  48. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/api.py +0 -0
  49. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/cli.py +0 -0
  50. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/__init__.py +0 -0
  51. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/adextensions.py +0 -0
  52. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/adgroups.py +0 -0
  53. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/adimages.py +0 -0
  54. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/ads.py +0 -0
  55. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/advideos.py +0 -0
  56. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/agencyclients.py +0 -0
  57. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/audiencetargets.py +0 -0
  58. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/auth.py +0 -0
  59. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/balance.py +0 -0
  60. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/bidmodifiers.py +0 -0
  61. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/bids.py +0 -0
  62. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/businesses.py +0 -0
  63. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/campaigns.py +0 -0
  64. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/clients.py +0 -0
  65. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/creatives.py +0 -0
  66. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/dictionaries.py +0 -0
  67. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/dynamicads.py +0 -0
  68. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/dynamicfeedadtargets.py +0 -0
  69. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/feeds.py +0 -0
  70. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/keywordbids.py +0 -0
  71. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/keywordsresearch.py +0 -0
  72. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/leads.py +0 -0
  73. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/negativekeywordsharedsets.py +0 -0
  74. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/reports.py +0 -0
  75. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/retargeting.py +0 -0
  76. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/smartadtargets.py +0 -0
  77. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/strategies.py +0 -0
  78. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/turbopages.py +0 -0
  79. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/v4account.py +0 -0
  80. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/v4events.py +0 -0
  81. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/v4finance.py +0 -0
  82. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/v4forecast.py +0 -0
  83. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/v4goals.py +0 -0
  84. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/v4shells.py +0 -0
  85. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/v4tags.py +0 -0
  86. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/v4wordstat.py +0 -0
  87. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/vcards.py +0 -0
  88. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/output.py +0 -0
  89. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/reports_coverage.py +0 -0
  90. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/smoke_matrix.py +0 -0
  91. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/v4/money.py +0 -0
  92. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/wsdl_coverage.py +0 -0
  93. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli.egg-info/dependency_links.txt +0 -0
  94. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli.egg-info/entry_points.txt +0 -0
  95. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli.egg-info/requires.txt +0 -0
  96. {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli.egg-info/top_level.txt +0 -0
  97. {direct_cli-0.3.9 → direct_cli-0.3.10}/docs/audits/issue-198-mutating-wsdl-audit.md +0 -0
  98. {direct_cli-0.3.9 → direct_cli-0.3.10}/docs/superpowers/plans/2026-04-12-issue-32-completion.md +0 -0
  99. {direct_cli-0.3.9 → direct_cli-0.3.10}/docs/superpowers/specs/2026-04-23-vendor-tapi-yandex-direct-design.md +0 -0
  100. {direct_cli-0.3.9 → direct_cli-0.3.10}/scripts/anonymize_cassettes.py +0 -0
  101. {direct_cli-0.3.9 → direct_cli-0.3.10}/scripts/build_api_coverage_checklist.py +0 -0
  102. {direct_cli-0.3.9 → direct_cli-0.3.10}/scripts/build_api_coverage_report.py +0 -0
  103. {direct_cli-0.3.9 → direct_cli-0.3.10}/scripts/check_reports_drift.py +0 -0
  104. {direct_cli-0.3.9 → direct_cli-0.3.10}/scripts/check_wsdl_drift.py +0 -0
  105. {direct_cli-0.3.9 → direct_cli-0.3.10}/scripts/patch_vendor_imports.py +0 -0
  106. {direct_cli-0.3.9 → direct_cli-0.3.10}/scripts/refresh_reports_cache.py +0 -0
  107. {direct_cli-0.3.9 → direct_cli-0.3.10}/scripts/refresh_wsdl_cache.py +0 -0
  108. {direct_cli-0.3.9 → direct_cli-0.3.10}/scripts/release_pypi.sh +0 -0
  109. {direct_cli-0.3.9 → direct_cli-0.3.10}/scripts/sandbox_write_audit.py +0 -0
  110. {direct_cli-0.3.9 → direct_cli-0.3.10}/scripts/sandbox_write_live.py +0 -0
  111. {direct_cli-0.3.9 → direct_cli-0.3.10}/scripts/test_dangerous_commands.sh +0 -0
  112. {direct_cli-0.3.9 → direct_cli-0.3.10}/scripts/test_safe_commands.sh +0 -0
  113. {direct_cli-0.3.9 → direct_cli-0.3.10}/scripts/test_sandbox_write.sh +0 -0
  114. {direct_cli-0.3.9 → direct_cli-0.3.10}/scripts/update_vendor.sh +0 -0
  115. {direct_cli-0.3.9 → direct_cli-0.3.10}/setup.cfg +0 -0
  116. {direct_cli-0.3.9 → direct_cli-0.3.10}/setup.py +0 -0
  117. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/API_COVERAGE.md +0 -0
  118. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/API_ISSUE_AUDIT.md +0 -0
  119. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/MANUAL_COVERAGE.md +0 -0
  120. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/__init__.py +0 -0
  121. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/_orphan_store.py +0 -0
  122. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_integration_write/TestWriteAdExtensions.test_add_delete.yaml +0 -0
  123. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_integration_write/TestWriteAdGroups.test_add_update_delete.yaml +0 -0
  124. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_integration_write/TestWriteAdImages.test_add_delete.yaml +0 -0
  125. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_integration_write/TestWriteAds.test_add_text_ad_update_delete.yaml +0 -0
  126. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_integration_write/TestWriteAudienceTargets.test_add_delete.yaml +0 -0
  127. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_integration_write/TestWriteBidModifiersAdd.test_add_delete_mobile.yaml +0 -0
  128. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_integration_write/TestWriteBidModifiersSet.test_set_without_id_is_rejected.yaml +0 -0
  129. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_integration_write/TestWriteBids.test_set_bid.yaml +0 -0
  130. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_integration_write/TestWriteBidsRead.test_bids_get.yaml +0 -0
  131. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_integration_write/TestWriteBidsRead.test_bids_set_auto.yaml +0 -0
  132. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_integration_write/TestWriteCampaignDraftLifecycle.test_draft_create_get_delete.yaml +0 -0
  133. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_integration_write/TestWriteCampaigns.test_campaign_lifecycle.yaml +0 -0
  134. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_integration_write/TestWriteDynamicAds.test_add_update_delete.yaml +0 -0
  135. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_integration_write/TestWriteFeeds.test_add_update_delete.yaml +0 -0
  136. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_integration_write/TestWriteKeywordBids.test_set_keyword_bid.yaml +0 -0
  137. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_integration_write/TestWriteKeywords.test_add_update_delete.yaml +0 -0
  138. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_integration_write/TestWriteNegativeKeywordSharedSets.test_add_update_delete.yaml +0 -0
  139. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_integration_write/TestWriteRetargeting.test_add_delete.yaml +0 -0
  140. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_integration_write/TestWriteRetargetingUpdate.test_retargeting_update.yaml +0 -0
  141. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_integration_write/TestWriteSitelinks.test_add_delete.yaml +0 -0
  142. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_integration_write/TestWriteSmartAdTargets.test_add_update_delete.yaml +0 -0
  143. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_integration_write/TestWriteStrategies.test_strategies_lifecycle.yaml +0 -0
  144. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_integration_write/TestWriteVCards.test_add_delete.yaml +0 -0
  145. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_v5_live_write/test_v5_live_draft_adgroups_add_update_delete.yaml +0 -0
  146. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_v5_live_write/test_v5_live_draft_adimages_add_get_delete.yaml +0 -0
  147. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_v5_live_write/test_v5_live_draft_ads_add_update_delete.yaml +0 -0
  148. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_v5_live_write/test_v5_live_draft_ads_suspend_resume_archive_unarchive.yaml +0 -0
  149. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_v5_live_write/test_v5_live_draft_advideos_add_get.yaml +0 -0
  150. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_v5_live_write/test_v5_live_draft_audiencetargets_add_delete.yaml +0 -0
  151. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_v5_live_write/test_v5_live_draft_audiencetargets_suspend_resume.yaml +0 -0
  152. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_v5_live_write/test_v5_live_draft_bids_set.yaml +0 -0
  153. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_v5_live_write/test_v5_live_draft_campaign_create_get_delete.yaml +0 -0
  154. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_v5_live_write/test_v5_live_draft_creatives_chain_advideo_to_creative.yaml +0 -0
  155. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_v5_live_write/test_v5_live_draft_dynamicads_add_delete.yaml +0 -0
  156. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_v5_live_write/test_v5_live_draft_dynamicads_suspend_resume.yaml +0 -0
  157. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_v5_live_write/test_v5_live_draft_keywordbids_set.yaml +0 -0
  158. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_v5_live_write/test_v5_live_draft_keywords_add_update_delete.yaml +0 -0
  159. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_v5_live_write/test_v5_live_draft_keywords_suspend_resume.yaml +0 -0
  160. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_v5_live_write/test_v5_live_draft_sitelinks_add_get_delete.yaml +0 -0
  161. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_v5_live_write/test_v5_live_draft_smartadtargets_add_update_delete.yaml +0 -0
  162. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_v5_live_write/test_v5_live_draft_smartadtargets_suspend_resume.yaml +0 -0
  163. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/conftest.py +0 -0
  164. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/fixtures/test-video.mp4 +0 -0
  165. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/reports_cache/raw/fields-list.html +0 -0
  166. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/reports_cache/raw/headers.html +0 -0
  167. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/reports_cache/raw/period.html +0 -0
  168. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/reports_cache/raw/spec.html +0 -0
  169. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/reports_cache/raw/type.html +0 -0
  170. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/reports_cache/spec.json +0 -0
  171. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_api_coverage.py +0 -0
  172. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_auth_bw.py +0 -0
  173. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_auth_oauth.py +0 -0
  174. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_auth_op.py +0 -0
  175. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_balance.py +0 -0
  176. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_cli_contract.py +0 -0
  177. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_comprehensive.py +0 -0
  178. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_integration.py +0 -0
  179. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_integration_write.py +0 -0
  180. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_reports_drift.py +0 -0
  181. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_reports_parsing.py +0 -0
  182. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_sandbox_write_audit.py +0 -0
  183. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_smoke_matrix.py +0 -0
  184. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_transport_contract.py +0 -0
  185. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_v4_contracts.py +0 -0
  186. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_v4_foundation.py +0 -0
  187. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_v4_live_contracts.py +0 -0
  188. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_v4_safety.py +0 -0
  189. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_v4account.py +0 -0
  190. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_v4events.py +0 -0
  191. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_v4finance_money.py +0 -0
  192. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_v4finance_read.py +0 -0
  193. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_v4forecast.py +0 -0
  194. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_v4goals.py +0 -0
  195. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_v4tags.py +0 -0
  196. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_v4wordstat.py +0 -0
  197. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_v5_live_write.py +0 -0
  198. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_vendor_imports.py +0 -0
  199. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/adextensions.xml +0 -0
  200. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/adgroups.xml +0 -0
  201. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/adimages.xml +0 -0
  202. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/ads.xml +0 -0
  203. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/advideos.xml +0 -0
  204. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/agencyclients.xml +0 -0
  205. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/audiencetargets.xml +0 -0
  206. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/bidmodifiers.xml +0 -0
  207. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/bids.xml +0 -0
  208. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/businesses.xml +0 -0
  209. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/campaigns.xml +0 -0
  210. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/changes.xml +0 -0
  211. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/clients.xml +0 -0
  212. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/creatives.xml +0 -0
  213. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/dictionaries.xml +0 -0
  214. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/dynamicfeedadtargets.xml +0 -0
  215. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/dynamictextadtargets.xml +0 -0
  216. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/feeds.xml +0 -0
  217. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/imports/adextensiontypes.xsd +0 -0
  218. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/imports/general.xsd +0 -0
  219. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/imports/generalclients.xsd +0 -0
  220. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/keywordbids.xml +0 -0
  221. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/keywords.xml +0 -0
  222. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/keywordsresearch.xml +0 -0
  223. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/leads.xml +0 -0
  224. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/negativekeywordsharedsets.xml +0 -0
  225. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/retargetinglists.xml +0 -0
  226. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/sitelinks.xml +0 -0
  227. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/smartadtargets.xml +0 -0
  228. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/strategies.xml +0 -0
  229. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/turbopages.xml +0 -0
  230. {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/vcards.xml +0 -0
@@ -1,5 +1,65 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.10
4
+
5
+ **Added:**
6
+
7
+ - `direct changes check` now exposes all three mutually-exclusive ID
8
+ filters from the WSDL — `--campaign-ids` (≤3000), `--ad-group-ids`
9
+ (≤10 000) and `--ad-ids` (≤50 000); exactly one is required and the
10
+ mutex is enforced via `click.UsageError` (exit code 2) before any
11
+ request is built. `--fields` is now validated against the
12
+ `CheckFieldEnum` (`CampaignIds`, `AdGroupIds`, `AdIds`,
13
+ `CampaignsStat`); unknown values, empty / comma-only inputs and the
14
+ WSDL `minOccurs=1` violation are caught up-front. Refs: Closes #228.
15
+ - `direct sitelinks add` accepts `\|` as a literal pipe inside
16
+ `--sitelink` spec strings, so UTM templates like
17
+ `cid|{campaign_id}|gid|{gbid}` survive parsing. Two new structural
18
+ sources mirror the `keywords.add` #218 pattern:
19
+ `--sitelink-json '<JSON-array>'` (inline) and
20
+ `--sitelinks-from-file <path.jsonl>` (one object per line); sources
21
+ are mutually exclusive. Unknown JSON keys are rejected with the
22
+ offending key surfaced (no silent data loss), and missing
23
+ `Title`/`Href` rows are rejected with the row index. Refs:
24
+ Closes #221, Closes #220.
25
+ - `direct v4 *` commands now validate request body shape against
26
+ `V4_METHOD_CONTRACTS` before sending. Documented param shapes
27
+ (`PARAM_ARRAY` / `PARAM_OBJECT` / `PARAM_OPTIONAL_OBJECT` /
28
+ `PARAM_SCALAR`) raise `click.UsageError` on mismatch — the request
29
+ never reaches the network. Undocumented-shape methods are split by
30
+ contract safety: `SAFETY_READ` (e.g. `GetKeywordsSuggestion`)
31
+ emits a stderr warning and proceeds; `SAFETY_WRITE` /
32
+ `SAFETY_DANGEROUS` (e.g. `PayCampaignsByCard`) fail-closed with a
33
+ remediation pointer to `V4_METHOD_CONTRACTS`. Refs: Closes #182.
34
+ - Regression tests that lock down subtype validation invariants from
35
+ the `#210` umbrella repro matrix. Nine new `SILENT_LOSS_PROBES` in
36
+ `tests/test_wsdl_parity_gate.py` cover per-type rejection across
37
+ `campaigns add`, `adgroups add`, `ads add`, `bidmodifiers add` and
38
+ `strategies add` (test-only — the corrected rejection behavior was
39
+ shipped earlier in 0.3.9 via #198 audit follow-up PRs). Three new
40
+ non-regression tests in `tests/test_dry_run.py` lock down
41
+ `strategies update` field aliases (`AverageCpcPerFilter →
42
+ FilterAverageCpc`, `PayForConversion → Cpa`) and confirm that
43
+ `AverageCpa` update without `--goal-id` stays WSDL-valid
44
+ (`GoalId` is `minOccurs=0` on update). Refs: Closes #210.
45
+
46
+ **Fixed:**
47
+
48
+ - `direct keywords add` in bulk mode (`--from-file` / `--keywords-json`,
49
+ shipped in 0.3.9 / #218) now surfaces per-item `Errors` instead of
50
+ swallowing them and exiting 0 with raw JSON. The per-chunk loop now
51
+ calls `raise_for_api_result_errors` and the final response goes
52
+ through `format_output`, so the 8800 Client-Login guidance and the
53
+ full `Errors` payload propagate through the existing exception
54
+ handler. The partial-success diagnostic ("these keywords were
55
+ already created in Yandex Direct") only lists items Yandex actually
56
+ accepted. Refs: Closes #211.
57
+ - `direct_cli/auth.py::_write_json` no longer leaks a file descriptor
58
+ when `chmod` fails between `tempfile.mkstemp` and `os.fdopen`.
59
+ Descriptor ownership is now tracked via a sentinel; cleanup errors
60
+ in `os.close` / `os.unlink` use `contextlib.suppress(OSError)` so
61
+ the original exception is preserved. Refs: Closes #154.
62
+
3
63
  ## 0.3.9
4
64
 
5
65
  **Added:**
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: direct-cli
3
- Version: 0.3.9
3
+ Version: 0.3.10
4
4
  Summary: Command-line interface for Yandex Direct API
5
5
  Author: axisrow
6
6
  License: MIT
@@ -3,6 +3,7 @@ Authentication module for Direct CLI
3
3
  """
4
4
 
5
5
  import base64
6
+ import contextlib
6
7
  import hashlib
7
8
  import json
8
9
  import logging
@@ -148,10 +149,15 @@ def _write_json(path: Path, payload: Dict[str, Any]) -> None:
148
149
  try:
149
150
  os.chmod(tmp, 0o600)
150
151
  with os.fdopen(fd, "w", encoding="utf-8") as f:
152
+ fd = -1 # ownership transferred to the file object
151
153
  f.write(json.dumps(payload, ensure_ascii=False, indent=2))
152
154
  os.replace(tmp, path)
153
155
  except Exception:
154
- os.unlink(tmp)
156
+ if fd != -1:
157
+ with contextlib.suppress(OSError):
158
+ os.close(fd)
159
+ with contextlib.suppress(OSError):
160
+ os.unlink(tmp)
155
161
  raise
156
162
 
157
163
 
@@ -0,0 +1,175 @@
1
+ """
2
+ Changes commands
3
+ """
4
+
5
+ import click
6
+
7
+ from ..api import create_client
8
+ from ..output import format_output, print_error
9
+ from ..utils import get_default_fields, parse_datetime, parse_ids
10
+
11
+
12
+ @click.group()
13
+ def changes():
14
+ """Check for changes"""
15
+
16
+
17
+ _CHECK_FIELD_NAMES = frozenset({"CampaignIds", "AdGroupIds", "AdIds", "CampaignsStat"})
18
+
19
+
20
+ @changes.command()
21
+ @click.option(
22
+ "--campaign-ids",
23
+ help="Comma-separated campaign IDs (up to 3000). Mutually exclusive with "
24
+ "--ad-group-ids and --ad-ids.",
25
+ )
26
+ @click.option(
27
+ "--ad-group-ids",
28
+ help="Comma-separated ad group IDs (up to 10000). Mutually exclusive with "
29
+ "--campaign-ids and --ad-ids.",
30
+ )
31
+ @click.option(
32
+ "--ad-ids",
33
+ help="Comma-separated ad IDs (up to 50000). Mutually exclusive with "
34
+ "--campaign-ids and --ad-group-ids.",
35
+ )
36
+ @click.option(
37
+ "--timestamp",
38
+ required=True,
39
+ help="Timestamp for changes check (YYYY-MM-DDTHH:MM:SS)",
40
+ )
41
+ @click.option(
42
+ "--fields",
43
+ help="Comma-separated FieldNames; allowed values: "
44
+ "CampaignIds, AdGroupIds, AdIds, CampaignsStat. "
45
+ "Defaults to all four when omitted.",
46
+ )
47
+ @click.option("--format", "output_format", default="json", help="Output format")
48
+ @click.option("--output", help="Output file")
49
+ @click.pass_context
50
+ def check(
51
+ ctx, campaign_ids, ad_group_ids, ad_ids, timestamp, fields, output_format, output
52
+ ):
53
+ """Check changes for campaigns, ad groups, or ads.
54
+
55
+ Exactly one of --campaign-ids, --ad-group-ids, --ad-ids must be provided —
56
+ the Yandex Direct ``Changes.check`` method declares these three filters as
57
+ mutually exclusive.
58
+ """
59
+ sources_used = (
60
+ (1 if campaign_ids else 0) + (1 if ad_group_ids else 0) + (1 if ad_ids else 0)
61
+ )
62
+ if sources_used == 0:
63
+ raise click.UsageError(
64
+ "Provide exactly one of: --campaign-ids, --ad-group-ids, --ad-ids."
65
+ )
66
+ if sources_used > 1:
67
+ raise click.UsageError(
68
+ "--campaign-ids, --ad-group-ids, and --ad-ids are mutually "
69
+ "exclusive — provide exactly one."
70
+ )
71
+
72
+ if fields:
73
+ field_names = [f.strip() for f in fields.split(",") if f.strip()]
74
+ if not field_names:
75
+ raise click.UsageError(
76
+ "--fields produced an empty list; provide at least one of: "
77
+ f"{', '.join(sorted(_CHECK_FIELD_NAMES))}."
78
+ )
79
+ unknown = [f for f in field_names if f not in _CHECK_FIELD_NAMES]
80
+ if unknown:
81
+ raise click.UsageError(
82
+ "Unknown --fields value(s): "
83
+ f"{', '.join(unknown)}. Allowed: "
84
+ f"{', '.join(sorted(_CHECK_FIELD_NAMES))}."
85
+ )
86
+ else:
87
+ field_names = get_default_fields("changes")
88
+
89
+ if campaign_ids:
90
+ id_field, id_flag, id_raw = "CampaignIds", "--campaign-ids", campaign_ids
91
+ elif ad_group_ids:
92
+ id_field, id_flag, id_raw = "AdGroupIds", "--ad-group-ids", ad_group_ids
93
+ else:
94
+ id_field, id_flag, id_raw = "AdIds", "--ad-ids", ad_ids
95
+ try:
96
+ id_value = parse_ids(id_raw)
97
+ except ValueError as exc:
98
+ raise click.UsageError(f"{id_flag}: {exc}")
99
+ if not id_value:
100
+ raise click.UsageError(f"{id_flag} produced no valid IDs.")
101
+
102
+ try:
103
+ client = create_client(
104
+ token=ctx.obj.get("token"),
105
+ login=ctx.obj.get("login"),
106
+ sandbox=ctx.obj.get("sandbox"),
107
+ )
108
+
109
+ params = {
110
+ id_field: id_value,
111
+ "Timestamp": parse_datetime(timestamp),
112
+ "FieldNames": field_names,
113
+ }
114
+
115
+ body = {"method": "check", "params": params}
116
+
117
+ result = client.changes().post(data=body)
118
+ format_output(result.data, output_format, output)
119
+
120
+ except Exception as e:
121
+ print_error(str(e))
122
+ raise click.Abort()
123
+
124
+
125
+ @changes.command()
126
+ @click.option(
127
+ "--timestamp",
128
+ required=True,
129
+ help="Timestamp for changes check (YYYY-MM-DDTHH:MM:SS)",
130
+ )
131
+ @click.option("--format", "output_format", default="json", help="Output format")
132
+ @click.option("--output", help="Output file")
133
+ @click.pass_context
134
+ def check_campaigns(ctx, timestamp, output_format, output):
135
+ """Check campaigns changes"""
136
+ try:
137
+ client = create_client(
138
+ token=ctx.obj.get("token"),
139
+ login=ctx.obj.get("login"),
140
+ sandbox=ctx.obj.get("sandbox"),
141
+ )
142
+
143
+ params = {"Timestamp": parse_datetime(timestamp)}
144
+
145
+ body = {"method": "checkCampaigns", "params": params}
146
+
147
+ result = client.changes().post(data=body)
148
+ format_output(result.data, output_format, output)
149
+
150
+ except Exception as e:
151
+ print_error(str(e))
152
+ raise click.Abort()
153
+
154
+
155
+ @changes.command()
156
+ @click.option("--format", "output_format", default="json", help="Output format")
157
+ @click.option("--output", help="Output file")
158
+ @click.pass_context
159
+ def check_dictionaries(ctx, output_format, output):
160
+ """Check dictionaries changes"""
161
+ try:
162
+ client = create_client(
163
+ token=ctx.obj.get("token"),
164
+ login=ctx.obj.get("login"),
165
+ sandbox=ctx.obj.get("sandbox"),
166
+ )
167
+
168
+ body = {"method": "checkDictionaries", "params": {}}
169
+
170
+ result = client.changes().post(data=body)
171
+ format_output(result.data, output_format, output)
172
+
173
+ except Exception as e:
174
+ print_error(str(e))
175
+ raise click.Abort()
@@ -9,7 +9,12 @@ from typing import Any, Dict, Iterator, List, Optional
9
9
  import click
10
10
 
11
11
  from ..api import create_client
12
- from ..output import format_json, format_output, print_error
12
+ from ..output import (
13
+ format_json,
14
+ format_output,
15
+ print_error,
16
+ raise_for_api_result_errors,
17
+ )
13
18
  from ..utils import add_criteria_csv, parse_ids, get_default_fields, MICRO_RUBLES
14
19
 
15
20
  # Yandex Direct API "keywords.add" caps a single AddItems request at 10
@@ -434,9 +439,17 @@ def _bulk_add(
434
439
  )
435
440
  body = {"method": "add", "params": {"Keywords": chunk}}
436
441
  response = client.keywords().post(data=body)
437
- all_results.extend(_normalize_add_results(response().extract()))
442
+ chunk_results = _normalize_add_results(response().extract())
443
+ # Only items without per-item Errors are "already created" — the
444
+ # partial-success diagnostic must not lie about failed items.
445
+ all_results.extend(
446
+ item
447
+ for item in chunk_results
448
+ if not (isinstance(item, dict) and item.get("Errors"))
449
+ )
450
+ raise_for_api_result_errors(chunk_results)
438
451
 
439
- print(format_json({"AddResults": all_results}, indent=2))
452
+ format_output({"AddResults": all_results}, "json", None)
440
453
  except click.UsageError:
441
454
  raise
442
455
  except Exception as e:
@@ -0,0 +1,249 @@
1
+ """
2
+ Sitelinks commands
3
+ """
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any, Dict, List
8
+
9
+ import click
10
+
11
+ from ..api import create_client
12
+ from ..output import format_output, print_error
13
+ from ..utils import get_default_fields, parse_ids, parse_sitelink_specs
14
+
15
+ _SITELINK_FIELDS = ("Title", "Href", "Description")
16
+
17
+
18
+ def _normalize_sitelink_row(row: Any, index: int) -> Dict[str, str]:
19
+ if not isinstance(row, dict):
20
+ raise click.UsageError(
21
+ f"Sitelink #{index}: expected a JSON object, got {type(row).__name__}"
22
+ )
23
+
24
+ unknown = sorted(set(row) - set(_SITELINK_FIELDS))
25
+ if unknown:
26
+ allowed = ", ".join(_SITELINK_FIELDS)
27
+ raise click.UsageError(
28
+ f"Unknown field {unknown[0]!r} in sitelink #{index}; allowed: {allowed}"
29
+ )
30
+
31
+ if "Title" not in row or not str(row.get("Title") or "").strip():
32
+ raise click.UsageError(f"Sitelink #{index}: missing required field 'Title'")
33
+ if "Href" not in row or not str(row.get("Href") or "").strip():
34
+ raise click.UsageError(f"Sitelink #{index}: missing required field 'Href'")
35
+
36
+ item: Dict[str, str] = {
37
+ "Title": str(row["Title"]).strip(),
38
+ "Href": str(row["Href"]).strip(),
39
+ }
40
+ description = row.get("Description")
41
+ if description is not None and str(description).strip():
42
+ item["Description"] = str(description).strip()
43
+ return item
44
+
45
+
46
+ def _load_sitelinks_from_inline(json_str: str) -> List[Any]:
47
+ try:
48
+ decoded = json.loads(json_str)
49
+ except json.JSONDecodeError as exc:
50
+ raise click.UsageError(f"--sitelink-json: invalid JSON: {exc.msg}")
51
+ if not isinstance(decoded, list):
52
+ raise click.UsageError(
53
+ "--sitelink-json must be a JSON array of sitelink objects"
54
+ )
55
+ return decoded
56
+
57
+
58
+ def _load_sitelinks_from_file(path: str) -> List[Any]:
59
+ file_path = Path(path)
60
+ try:
61
+ text = file_path.read_text(encoding="utf-8")
62
+ except OSError as exc:
63
+ raise click.UsageError(f"Cannot read --sitelinks-from-file {path!r}: {exc}")
64
+
65
+ rows: List[Any] = []
66
+ for line_number, raw_line in enumerate(text.splitlines(), start=1):
67
+ line = raw_line.strip()
68
+ if not line:
69
+ continue
70
+ try:
71
+ rows.append(json.loads(line))
72
+ except json.JSONDecodeError as exc:
73
+ raise click.UsageError(
74
+ f"--sitelinks-from-file line {line_number}: invalid JSON: {exc.msg}"
75
+ )
76
+ return rows
77
+
78
+
79
+ @click.group()
80
+ def sitelinks():
81
+ """Manage sitelinks"""
82
+
83
+
84
+ @sitelinks.command()
85
+ @click.option("--ids", help="Comma-separated sitelink IDs")
86
+ @click.option("--limit", type=int, help="Limit number of results")
87
+ @click.option("--fetch-all", is_flag=True, help="Fetch all pages")
88
+ @click.option("--format", "output_format", default="json", help="Output format")
89
+ @click.option("--output", help="Output file")
90
+ @click.option("--fields", help="Comma-separated field names")
91
+ @click.option("--dry-run", is_flag=True, help="Show request without sending")
92
+ @click.pass_context
93
+ def get(ctx, ids, limit, fetch_all, output_format, output, fields, dry_run):
94
+ """Get sitelinks"""
95
+ try:
96
+ client = create_client(
97
+ token=ctx.obj.get("token"),
98
+ login=ctx.obj.get("login"),
99
+ sandbox=ctx.obj.get("sandbox"),
100
+ )
101
+
102
+ field_names = fields.split(",") if fields else get_default_fields("sitelinks")
103
+
104
+ criteria = {}
105
+ if ids:
106
+ criteria["Ids"] = parse_ids(ids)
107
+
108
+ params = {"FieldNames": field_names}
109
+ if criteria:
110
+ params["SelectionCriteria"] = criteria
111
+
112
+ if limit:
113
+ params["Page"] = {"Limit": limit}
114
+
115
+ body = {"method": "get", "params": params}
116
+
117
+ if dry_run:
118
+ format_output(body, "json", None)
119
+ return
120
+
121
+ result = client.sitelinks().post(data=body)
122
+
123
+ if fetch_all:
124
+ items = []
125
+ for item in result().iter_items():
126
+ items.append(item)
127
+ format_output(items, output_format, output)
128
+ else:
129
+ data = result().extract()
130
+ format_output(data, output_format, output)
131
+
132
+ except Exception as e:
133
+ print_error(str(e))
134
+ raise click.Abort()
135
+
136
+
137
+ @sitelinks.command()
138
+ @click.option(
139
+ "--sitelink",
140
+ "sitelinks_specs",
141
+ multiple=True,
142
+ help="Sitelink spec: TITLE|HREF[|DESCRIPTION]. Escape literal '|' as '\\|'.",
143
+ )
144
+ @click.option(
145
+ "--sitelink-json",
146
+ "sitelinks_json",
147
+ help="Inline JSON array of sitelink objects: "
148
+ '[{"Title":"...","Href":"...","Description":"..."}]',
149
+ )
150
+ @click.option(
151
+ "--sitelinks-from-file",
152
+ "sitelinks_from_file",
153
+ type=click.Path(exists=True, dir_okay=False, readable=True),
154
+ help="JSONL file with one sitelink object per line",
155
+ )
156
+ @click.option("--dry-run", is_flag=True, help="Show request without sending")
157
+ @click.pass_context
158
+ def add(ctx, sitelinks_specs, sitelinks_json, sitelinks_from_file, dry_run):
159
+ """Add sitelinks set.
160
+
161
+ Provide exactly one source: --sitelink (repeatable), --sitelink-json,
162
+ or --sitelinks-from-file.
163
+ """
164
+ sources_used = (
165
+ (1 if sitelinks_specs else 0)
166
+ + (1 if sitelinks_json is not None else 0)
167
+ + (1 if sitelinks_from_file is not None else 0)
168
+ )
169
+ if sources_used == 0:
170
+ raise click.UsageError(
171
+ "Provide exactly one of: --sitelink (repeatable), "
172
+ "--sitelink-json (inline JSON array), or --sitelinks-from-file (JSONL)."
173
+ )
174
+ if sources_used > 1:
175
+ raise click.UsageError(
176
+ "--sitelink, --sitelink-json, and --sitelinks-from-file are "
177
+ "mutually exclusive — provide exactly one."
178
+ )
179
+
180
+ try:
181
+ if sitelinks_specs:
182
+ try:
183
+ sitelinks_payload = parse_sitelink_specs(list(sitelinks_specs))
184
+ except ValueError as exc:
185
+ raise click.UsageError(str(exc))
186
+ else:
187
+ if sitelinks_json is not None:
188
+ raw_rows = _load_sitelinks_from_inline(sitelinks_json)
189
+ else:
190
+ raw_rows = _load_sitelinks_from_file(sitelinks_from_file)
191
+
192
+ if not raw_rows:
193
+ raise click.UsageError("Input contains no sitelink rows.")
194
+
195
+ sitelinks_payload = [
196
+ _normalize_sitelink_row(row, idx)
197
+ for idx, row in enumerate(raw_rows, start=1)
198
+ ]
199
+
200
+ body = {
201
+ "method": "add",
202
+ "params": {"SitelinksSets": [{"Sitelinks": sitelinks_payload}]},
203
+ }
204
+
205
+ if dry_run:
206
+ format_output(body, "json", None)
207
+ return
208
+
209
+ client = create_client(
210
+ token=ctx.obj.get("token"),
211
+ login=ctx.obj.get("login"),
212
+ sandbox=ctx.obj.get("sandbox"),
213
+ )
214
+
215
+ result = client.sitelinks().post(data=body)
216
+ format_output(result().extract(), "json", None)
217
+
218
+ except click.UsageError:
219
+ raise
220
+ except Exception as e:
221
+ print_error(str(e))
222
+ raise click.Abort()
223
+
224
+
225
+ @sitelinks.command()
226
+ @click.option("--id", "set_id", required=True, type=int, help="Sitelinks set ID")
227
+ @click.option("--dry-run", is_flag=True, help="Show request without sending")
228
+ @click.pass_context
229
+ def delete(ctx, set_id, dry_run):
230
+ """Delete sitelinks set"""
231
+ try:
232
+ body = {"method": "delete", "params": {"SelectionCriteria": {"Ids": [set_id]}}}
233
+
234
+ if dry_run:
235
+ format_output(body, "json", None)
236
+ return
237
+
238
+ client = create_client(
239
+ token=ctx.obj.get("token"),
240
+ login=ctx.obj.get("login"),
241
+ sandbox=ctx.obj.get("sandbox"),
242
+ )
243
+
244
+ result = client.sitelinks().post(data=body)
245
+ format_output(result().extract(), "json", None)
246
+
247
+ except Exception as e:
248
+ print_error(str(e))
249
+ raise click.Abort()
@@ -457,18 +457,48 @@ def parse_retargeting_rule_specs(
457
457
  return rules
458
458
 
459
459
 
460
+ def _split_sitelink_spec(spec: str) -> List[str]:
461
+ """Split a sitelink spec by '|', treating '\\|' as a literal pipe.
462
+
463
+ UTM templates in Yandex Direct use literal '|' inside URLs
464
+ (e.g. cid|{campaign_id}|gid|{gbid}); allow users to escape it as '\\|'.
465
+ """
466
+ parts: List[str] = []
467
+ current: List[str] = []
468
+ i = 0
469
+ while i < len(spec):
470
+ ch = spec[i]
471
+ if ch == "\\" and i + 1 < len(spec) and spec[i + 1] == "|":
472
+ current.append("|")
473
+ i += 2
474
+ continue
475
+ if ch == "|":
476
+ parts.append("".join(current))
477
+ current = []
478
+ i += 1
479
+ continue
480
+ current.append(ch)
481
+ i += 1
482
+ parts.append("".join(current))
483
+ return parts
484
+
485
+
460
486
  def parse_sitelink_specs(specs: Optional[List[str]]) -> Optional[List[Dict[str, str]]]:
461
- """Parse repeated TITLE|HREF[|DESCRIPTION] sitelink specs."""
487
+ """Parse repeated TITLE|HREF[|DESCRIPTION] sitelink specs.
488
+
489
+ Literal '|' characters inside any field can be escaped as '\\|'.
490
+ """
462
491
  if not specs:
463
492
  return None
464
493
 
465
494
  sitelinks = []
466
495
  for spec in specs:
467
- parts = [part.strip() for part in spec.split("|")]
496
+ parts = [part.strip() for part in _split_sitelink_spec(spec)]
468
497
  if len(parts) not in (2, 3):
469
498
  raise ValueError(
470
499
  "Invalid sitelink: "
471
- f"'{spec}'. Expected format: TITLE|HREF[|DESCRIPTION]"
500
+ f"'{spec}'. Expected format: TITLE|HREF[|DESCRIPTION]. "
501
+ "Escape a literal '|' inside a field as '\\|'."
472
502
  )
473
503
 
474
504
  sitelink = {"Title": parts[0], "Href": parts[1]}
@@ -0,0 +1,57 @@
1
+ """Shared helpers for Yandex Direct v4 Live commands."""
2
+
3
+ from typing import Any, Optional
4
+
5
+ import click
6
+
7
+ from direct_cli.v4_contracts import (
8
+ PARAM_UNDOCUMENTED_SHAPE_MSG,
9
+ SAFETY_DANGEROUS,
10
+ SAFETY_WRITE,
11
+ get_v4_contract,
12
+ validate_v4_body_shape,
13
+ )
14
+
15
+ _UNSAFE_SAFETY_LEVELS = frozenset({SAFETY_WRITE, SAFETY_DANGEROUS})
16
+
17
+
18
+ def build_v4_body(method: str, param: Optional[Any] = None) -> dict:
19
+ """Build a v4 Live request body."""
20
+ body = {"method": method}
21
+ if param is not None:
22
+ body["param"] = param
23
+ return body
24
+
25
+
26
+ def call_v4(client: Any, method: str, param: Optional[Any] = None) -> Any:
27
+ """Call one v4 Live method and return the extracted response payload."""
28
+ body = build_v4_body(method, param)
29
+
30
+ errors = validate_v4_body_shape(method, body)
31
+ # Hard errors (shape mismatch, method mismatch) always block.
32
+ hard_errors = [e for e in errors if PARAM_UNDOCUMENTED_SHAPE_MSG not in e]
33
+ if hard_errors:
34
+ raise click.UsageError("; ".join(hard_errors))
35
+
36
+ if errors:
37
+ # Only undocumented-shape errors remain. Fail-closed when the
38
+ # contract is write/dangerous — we will not blindly post a
39
+ # financial or write operation whose payload shape we cannot
40
+ # verify. For read-class undocumented methods (e.g.
41
+ # GetKeywordsSuggestion) a soft warning is acceptable.
42
+ safety = get_v4_contract(method).safety
43
+ if safety in _UNSAFE_SAFETY_LEVELS:
44
+ raise click.UsageError(
45
+ f"refusing to send v4 method {method!r}: param shape is "
46
+ f"undocumented and safety is {safety!r}. "
47
+ "Add a documented param_shape to V4_METHOD_CONTRACTS "
48
+ "before exposing this method through the CLI."
49
+ )
50
+ click.echo(
51
+ f"warning: v4 method {method!r} has an undocumented param "
52
+ "shape; sending request as-is.",
53
+ err=True,
54
+ )
55
+
56
+ result = client.v4live().post(data=body)
57
+ return result().extract()