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.
- {direct_cli-0.3.9 → direct_cli-0.3.10}/CHANGELOG.md +60 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/PKG-INFO +1 -1
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/auth.py +7 -1
- direct_cli-0.3.10/direct_cli/commands/changes.py +175 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/keywords.py +16 -3
- direct_cli-0.3.10/direct_cli/commands/sitelinks.py +249 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/utils.py +33 -3
- direct_cli-0.3.10/direct_cli/v4/__init__.py +57 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/v4_contracts.py +33 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli.egg-info/PKG-INFO +1 -1
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli.egg-info/SOURCES.txt +3 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/pyproject.toml +1 -1
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/api_coverage_payloads.py +23 -0
- direct_cli-0.3.10/tests/test_auth_write_json.py +96 -0
- direct_cli-0.3.10/tests/test_changes.py +179 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_cli.py +186 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_dry_run.py +208 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_low_coverage_payloads.py +2 -2
- direct_cli-0.3.10/tests/test_v4_runtime_shape.py +188 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_wsdl_parity_gate.py +166 -0
- direct_cli-0.3.9/direct_cli/commands/changes.py +0 -104
- direct_cli-0.3.9/direct_cli/commands/sitelinks.py +0 -134
- direct_cli-0.3.9/direct_cli/v4/__init__.py +0 -17
- {direct_cli-0.3.9 → direct_cli-0.3.10}/.env.example +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/.github/copilot-instructions.md +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/.github/workflows/api-coverage.yml +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/.github/workflows/claude.yml +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/.github/workflows/quality.yml +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/.gitignore +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/AGENTS.md +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/CLAUDE.md +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/MANIFEST.in +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/README.md +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/__init__.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/_deprecated.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/_smoke_probes.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/_vendor/__init__.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/_vendor/tapi_yandex_direct/__init__.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/_vendor/tapi_yandex_direct/endpoints.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/_vendor/tapi_yandex_direct/exceptions.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/_vendor/tapi_yandex_direct/resource_mapping.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/_vendor/tapi_yandex_direct/tapi_yandex_direct.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/_vendor/tapi_yandex_direct/tapi_yandex_direct.pyi +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/_vendor/tapi_yandex_direct/v4/__init__.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/_vendor/tapi_yandex_direct/v4/adapter.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/_vendor/tapi_yandex_direct/v4/adapter.pyi +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/_vendor/tapi_yandex_direct/v4/resource_mapping.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/api.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/cli.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/__init__.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/adextensions.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/adgroups.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/adimages.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/ads.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/advideos.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/agencyclients.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/audiencetargets.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/auth.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/balance.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/bidmodifiers.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/bids.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/businesses.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/campaigns.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/clients.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/creatives.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/dictionaries.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/dynamicads.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/dynamicfeedadtargets.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/feeds.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/keywordbids.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/keywordsresearch.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/leads.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/negativekeywordsharedsets.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/reports.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/retargeting.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/smartadtargets.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/strategies.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/turbopages.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/v4account.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/v4events.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/v4finance.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/v4forecast.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/v4goals.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/v4shells.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/v4tags.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/v4wordstat.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/commands/vcards.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/output.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/reports_coverage.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/smoke_matrix.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/v4/money.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli/wsdl_coverage.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli.egg-info/dependency_links.txt +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli.egg-info/entry_points.txt +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli.egg-info/requires.txt +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/direct_cli.egg-info/top_level.txt +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/docs/audits/issue-198-mutating-wsdl-audit.md +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/docs/superpowers/plans/2026-04-12-issue-32-completion.md +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/docs/superpowers/specs/2026-04-23-vendor-tapi-yandex-direct-design.md +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/scripts/anonymize_cassettes.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/scripts/build_api_coverage_checklist.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/scripts/build_api_coverage_report.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/scripts/check_reports_drift.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/scripts/check_wsdl_drift.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/scripts/patch_vendor_imports.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/scripts/refresh_reports_cache.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/scripts/refresh_wsdl_cache.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/scripts/release_pypi.sh +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/scripts/sandbox_write_audit.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/scripts/sandbox_write_live.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/scripts/test_dangerous_commands.sh +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/scripts/test_safe_commands.sh +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/scripts/test_sandbox_write.sh +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/scripts/update_vendor.sh +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/setup.cfg +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/setup.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/API_COVERAGE.md +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/API_ISSUE_AUDIT.md +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/MANUAL_COVERAGE.md +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/__init__.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/_orphan_store.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_integration_write/TestWriteAdExtensions.test_add_delete.yaml +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_integration_write/TestWriteAdGroups.test_add_update_delete.yaml +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_integration_write/TestWriteAdImages.test_add_delete.yaml +0 -0
- {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
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_integration_write/TestWriteAudienceTargets.test_add_delete.yaml +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_integration_write/TestWriteBidModifiersAdd.test_add_delete_mobile.yaml +0 -0
- {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
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_integration_write/TestWriteBids.test_set_bid.yaml +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_integration_write/TestWriteBidsRead.test_bids_get.yaml +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_integration_write/TestWriteBidsRead.test_bids_set_auto.yaml +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_integration_write/TestWriteCampaignDraftLifecycle.test_draft_create_get_delete.yaml +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_integration_write/TestWriteCampaigns.test_campaign_lifecycle.yaml +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_integration_write/TestWriteDynamicAds.test_add_update_delete.yaml +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_integration_write/TestWriteFeeds.test_add_update_delete.yaml +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_integration_write/TestWriteKeywordBids.test_set_keyword_bid.yaml +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_integration_write/TestWriteKeywords.test_add_update_delete.yaml +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_integration_write/TestWriteNegativeKeywordSharedSets.test_add_update_delete.yaml +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_integration_write/TestWriteRetargeting.test_add_delete.yaml +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_integration_write/TestWriteRetargetingUpdate.test_retargeting_update.yaml +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_integration_write/TestWriteSitelinks.test_add_delete.yaml +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_integration_write/TestWriteSmartAdTargets.test_add_update_delete.yaml +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_integration_write/TestWriteStrategies.test_strategies_lifecycle.yaml +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/cassettes/test_integration_write/TestWriteVCards.test_add_delete.yaml +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/conftest.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/fixtures/test-video.mp4 +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/reports_cache/raw/fields-list.html +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/reports_cache/raw/headers.html +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/reports_cache/raw/period.html +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/reports_cache/raw/spec.html +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/reports_cache/raw/type.html +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/reports_cache/spec.json +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_api_coverage.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_auth_bw.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_auth_oauth.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_auth_op.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_balance.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_cli_contract.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_comprehensive.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_integration.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_integration_write.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_reports_drift.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_reports_parsing.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_sandbox_write_audit.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_smoke_matrix.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_transport_contract.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_v4_contracts.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_v4_foundation.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_v4_live_contracts.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_v4_safety.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_v4account.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_v4events.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_v4finance_money.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_v4finance_read.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_v4forecast.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_v4goals.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_v4tags.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_v4wordstat.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_v5_live_write.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/test_vendor_imports.py +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/adextensions.xml +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/adgroups.xml +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/adimages.xml +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/ads.xml +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/advideos.xml +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/agencyclients.xml +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/audiencetargets.xml +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/bidmodifiers.xml +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/bids.xml +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/businesses.xml +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/campaigns.xml +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/changes.xml +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/clients.xml +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/creatives.xml +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/dictionaries.xml +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/dynamicfeedadtargets.xml +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/dynamictextadtargets.xml +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/feeds.xml +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/imports/adextensiontypes.xsd +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/imports/general.xsd +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/imports/generalclients.xsd +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/keywordbids.xml +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/keywords.xml +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/keywordsresearch.xml +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/leads.xml +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/negativekeywordsharedsets.xml +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/retargetinglists.xml +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/sitelinks.xml +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/smartadtargets.xml +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/strategies.xml +0 -0
- {direct_cli-0.3.9 → direct_cli-0.3.10}/tests/wsdl_cache/turbopages.xml +0 -0
- {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:**
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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()
|