direct-cli 0.3.2__tar.gz → 0.3.3__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.3/CHANGELOG.md +11 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/PKG-INFO +23 -13
- {direct_cli-0.3.2 → direct_cli-0.3.3}/README.md +22 -12
- direct_cli-0.3.3/direct_cli/_vendor/tapi_yandex_direct/endpoints.py +14 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/_vendor/tapi_yandex_direct/tapi_yandex_direct.py +8 -10
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/_vendor/tapi_yandex_direct/v4/adapter.py +12 -6
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/auth.py +152 -16
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/auth.py +75 -9
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/v4finance.py +176 -26
- direct_cli-0.3.3/direct_cli/v4/money.py +78 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/v4_contracts.py +8 -6
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli.egg-info/PKG-INFO +23 -13
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli.egg-info/SOURCES.txt +2 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/pyproject.toml +1 -1
- {direct_cli-0.3.2 → direct_cli-0.3.3}/scripts/sandbox_write_live.py +1 -1
- {direct_cli-0.3.2 → direct_cli-0.3.3}/scripts/test_dangerous_commands.sh +2 -2
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/API_COVERAGE.md +4 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/MANUAL_COVERAGE.md +3 -2
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_live_write/test_live_draft_adgroups_add_update_delete.yaml +6 -6
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_live_write/test_live_draft_adimages_add_get_delete.yaml +1 -1
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_live_write/test_live_draft_ads_add_update_delete.yaml +8 -8
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_live_write/test_live_draft_ads_suspend_resume_archive_unarchive.yaml +10 -10
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_live_write/test_live_draft_advideos_add_get.yaml +2 -2
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_live_write/test_live_draft_audiencetargets_add_delete.yaml +5 -5
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_live_write/test_live_draft_audiencetargets_suspend_resume.yaml +5 -5
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_live_write/test_live_draft_bids_set.yaml +7 -7
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_live_write/test_live_draft_campaign_create_get_delete.yaml +4 -4
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_live_write/test_live_draft_creatives_chain_advideo_to_creative.yaml +3 -3
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_live_write/test_live_draft_dynamicads_add_delete.yaml +1 -1
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_live_write/test_live_draft_dynamicads_suspend_resume.yaml +1 -1
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_live_write/test_live_draft_keywordbids_set.yaml +7 -7
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_live_write/test_live_draft_keywords_add_update_delete.yaml +7 -7
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_live_write/test_live_draft_keywords_suspend_resume.yaml +8 -8
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_live_write/test_live_draft_sitelinks_add_get_delete.yaml +3 -3
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_live_write/test_live_draft_smartadtargets_add_update_delete.yaml +3 -3
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_live_write/test_live_draft_smartadtargets_suspend_resume.yaml +3 -3
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_write/TestWriteAdExtensions.test_add_delete.yaml +2 -2
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_write/TestWriteAdGroups.test_add_update_delete.yaml +5 -5
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_write/TestWriteAdImages.test_add_delete.yaml +1 -1
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_write/TestWriteAds.test_add_text_ad_update_delete.yaml +5 -5
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_write/TestWriteAudienceTargets.test_add_delete.yaml +7 -7
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_write/TestWriteBidModifiersAdd.test_add_delete_mobile.yaml +4 -4
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_write/TestWriteBidModifiersSet.test_set_without_id_is_rejected.yaml +3 -3
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_write/TestWriteBids.test_set_bid.yaml +5 -5
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_write/TestWriteCampaignDraftLifecycle.test_draft_create_get_delete.yaml +4 -4
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_write/TestWriteCampaigns.test_campaign_lifecycle.yaml +6 -6
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_write/TestWriteDynamicAds.test_add_update_delete.yaml +1 -1
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_write/TestWriteFeeds.test_add_update_delete.yaml +3 -3
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_write/TestWriteKeywordBids.test_set_keyword_bid.yaml +5 -5
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_write/TestWriteKeywords.test_add_update_delete.yaml +5 -5
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_write/TestWriteNegativeKeywordSharedSets.test_add_update_delete.yaml +3 -3
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_write/TestWriteRetargeting.test_add_delete.yaml +2 -2
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_write/TestWriteSitelinks.test_add_delete.yaml +1 -1
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_write/TestWriteSmartAdTargets.test_add_update_delete.yaml +3 -3
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_write/TestWriteVCards.test_add_delete.yaml +4 -4
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/conftest.py +1 -1
- direct_cli-0.3.3/tests/test_auth_oauth.py +657 -0
- direct_cli-0.3.3/tests/test_transport_contract.py +66 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/test_v4_contracts.py +4 -4
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/test_v4finance_money.py +182 -13
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/test_v4finance_read.py +30 -3
- direct_cli-0.3.2/direct_cli/v4/money.py +0 -35
- direct_cli-0.3.2/tests/test_auth_oauth.py +0 -206
- direct_cli-0.3.2/tests/test_transport_contract.py +0 -23
- {direct_cli-0.3.2 → direct_cli-0.3.3}/.env.example +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/.github/copilot-instructions.md +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/.github/workflows/api-coverage.yml +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/.github/workflows/claude-code-review.yml +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/.github/workflows/claude.yml +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/.gitignore +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/AGENTS.md +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/CLAUDE.md +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/MANIFEST.in +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/__init__.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/_deprecated.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/_smoke_probes.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/_vendor/__init__.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/_vendor/tapi_yandex_direct/__init__.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/_vendor/tapi_yandex_direct/exceptions.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/_vendor/tapi_yandex_direct/resource_mapping.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/_vendor/tapi_yandex_direct/tapi_yandex_direct.pyi +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/_vendor/tapi_yandex_direct/v4/__init__.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/_vendor/tapi_yandex_direct/v4/adapter.pyi +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/_vendor/tapi_yandex_direct/v4/resource_mapping.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/api.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/cli.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/__init__.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/adextensions.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/adgroups.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/adimages.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/ads.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/advideos.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/agencyclients.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/audiencetargets.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/balance.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/bidmodifiers.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/bids.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/businesses.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/campaigns.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/changes.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/clients.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/creatives.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/dictionaries.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/dynamicads.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/dynamicfeedadtargets.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/feeds.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/keywordbids.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/keywords.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/keywordsresearch.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/leads.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/negativekeywordsharedsets.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/reports.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/retargeting.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/sitelinks.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/smartadtargets.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/strategies.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/turbopages.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/v4account.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/v4events.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/v4goals.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/v4shells.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/vcards.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/output.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/reports_coverage.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/smoke_matrix.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/utils.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/v4/__init__.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/wsdl_coverage.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli.egg-info/dependency_links.txt +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli.egg-info/entry_points.txt +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli.egg-info/requires.txt +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli.egg-info/top_level.txt +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/docs/superpowers/plans/2026-04-12-issue-32-completion.md +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/docs/superpowers/specs/2026-04-23-vendor-tapi-yandex-direct-design.md +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/scripts/anonymize_cassettes.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/scripts/build_api_coverage_checklist.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/scripts/build_api_coverage_report.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/scripts/check_reports_drift.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/scripts/check_wsdl_drift.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/scripts/patch_vendor_imports.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/scripts/refresh_reports_cache.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/scripts/refresh_wsdl_cache.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/scripts/release_pypi.sh +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/scripts/test_safe_commands.sh +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/scripts/test_sandbox_write.sh +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/scripts/update_vendor.sh +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/setup.cfg +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/setup.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/API_ISSUE_AUDIT.md +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/__init__.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/api_coverage_payloads.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/fixtures/test-video.mp4 +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/reports_cache/raw/fields-list.html +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/reports_cache/raw/headers.html +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/reports_cache/raw/period.html +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/reports_cache/raw/spec.html +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/reports_cache/raw/type.html +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/reports_cache/spec.json +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/test_api_coverage.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/test_auth_bw.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/test_auth_op.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/test_balance.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/test_cli.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/test_comprehensive.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/test_dry_run.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/test_integration.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/test_integration_live_write.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/test_integration_write.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/test_reports_drift.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/test_smoke_matrix.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/test_v4_foundation.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/test_v4_live_contracts.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/test_v4_safety.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/test_v4account.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/test_v4events.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/test_v4goals.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/test_vendor_imports.py +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/adextensions.xml +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/adgroups.xml +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/adimages.xml +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/ads.xml +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/advideos.xml +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/agencyclients.xml +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/audiencetargets.xml +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/bidmodifiers.xml +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/bids.xml +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/businesses.xml +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/campaigns.xml +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/changes.xml +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/clients.xml +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/creatives.xml +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/dictionaries.xml +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/dynamicfeedadtargets.xml +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/dynamictextadtargets.xml +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/feeds.xml +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/imports/adextensiontypes.xsd +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/imports/general.xsd +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/imports/generalclients.xsd +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/keywordbids.xml +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/keywords.xml +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/keywordsresearch.xml +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/leads.xml +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/negativekeywordsharedsets.xml +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/retargetinglists.xml +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/sitelinks.xml +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/smartadtargets.xml +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/strategies.xml +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/turbopages.xml +0 -0
- {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/vcards.xml +0 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.3.3
|
|
4
|
+
|
|
5
|
+
**BREAKING CHANGE:** OAuth profiles created before 0.3.3 (without `refresh_token` and `expires_at`) are no longer accepted. Any such profile will fail immediately with an "incomplete profile" error. Run `direct auth login --profile <name>` to re-authenticate and create a valid 0.3.3 profile.
|
|
6
|
+
|
|
7
|
+
- Added refresh token persistence for OAuth profiles.
|
|
8
|
+
- Added automatic OAuth access token refresh before expiry.
|
|
9
|
+
- Added `expires_in` details to `direct auth status`.
|
|
10
|
+
- Added JSON output for `direct auth status`.
|
|
11
|
+
- Kept `direct auth login --oauth-token` as a manual access-token import without auto-refresh.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: direct-cli
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.3
|
|
4
4
|
Summary: Command-line interface for Yandex Direct API
|
|
5
5
|
Author: axisrow
|
|
6
6
|
License: MIT
|
|
@@ -83,7 +83,6 @@ OAuth and profile commands:
|
|
|
83
83
|
direct auth login
|
|
84
84
|
direct auth login --profile agency1
|
|
85
85
|
direct auth login --code abc123 --profile agency1
|
|
86
|
-
direct auth login --oauth-token y0_example --profile agency1
|
|
87
86
|
direct auth list
|
|
88
87
|
direct auth use --profile agency1
|
|
89
88
|
direct auth status --profile agency1
|
|
@@ -95,6 +94,8 @@ Notes:
|
|
|
95
94
|
- Select credentials with `--profile`.
|
|
96
95
|
- `--login` remains Direct client login.
|
|
97
96
|
- Authorization is performed via `direct auth login`.
|
|
97
|
+
- OAuth profiles store refresh tokens and refresh access tokens automatically.
|
|
98
|
+
- `direct auth login --oauth-token TOKEN` is a manual access-token import and does not auto-refresh.
|
|
98
99
|
- Alias `auth_login` is not supported.
|
|
99
100
|
|
|
100
101
|
Credential resolution priority:
|
|
@@ -156,18 +157,24 @@ direct v4events get-events-log --from 2026-04-14T00:00:00 --to 2026-04-15T00:00:
|
|
|
156
157
|
|
|
157
158
|
### V4 Live Finance
|
|
158
159
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
160
|
+
Finance methods require an extra financial token for money operations. In the
|
|
161
|
+
Yandex Direct web UI, open Tools -> API -> Financial operations, enable the
|
|
162
|
+
financial operations checkbox, click Save, then issue the master token on the
|
|
163
|
+
same Financial operations page and confirm by SMS. Direct CLI can compute the
|
|
164
|
+
per-request token from `--master-token`, `--operation-num`, and
|
|
165
|
+
`--finance-login`; alternatively pass a precomputed token with `--finance-token`.
|
|
166
|
+
Environment variables are
|
|
167
|
+
`YANDEX_DIRECT_MASTER_TOKEN`, `YANDEX_DIRECT_FINANCE_LOGIN`,
|
|
168
|
+
`YANDEX_DIRECT_FINANCE_TOKEN`, and `YANDEX_DIRECT_OPERATION_NUM`. Money mutation
|
|
169
|
+
commands are dry-run-only in this release and always require `--dry-run`; dry-run
|
|
170
|
+
output masks the financial token.
|
|
164
171
|
|
|
165
172
|
```bash
|
|
166
|
-
direct v4finance get-credit-limits --logins client-login --
|
|
173
|
+
direct v4finance get-credit-limits --logins client-login --master-token MASTER_TOKEN --operation-num 123 --finance-login agency-login
|
|
167
174
|
direct v4finance get-credit-limits --logins client-login,other-client --format table
|
|
168
175
|
direct v4finance check-payment --custom-transaction-id A123456789012345678901234567890B
|
|
169
|
-
direct v4finance transfer-money --from-campaign-id 123 --to-campaign-id 456 --amount 100.50 --
|
|
170
|
-
direct v4finance pay-campaigns --campaign-
|
|
176
|
+
direct v4finance transfer-money --from-campaign-id 123 --to-campaign-id 456 --amount 100.50 --currency RUB --master-token MASTER_TOKEN --operation-num 123 --finance-login agency-login --dry-run
|
|
177
|
+
direct v4finance pay-campaigns --campaign-ids 123,456 --amount 100.50 --currency RUB --contract-id CONTRACT_ID --pay-method Bank --master-token MASTER_TOKEN --operation-num 123 --finance-login agency-login --dry-run
|
|
171
178
|
```
|
|
172
179
|
|
|
173
180
|
### V4 Live Shared Account
|
|
@@ -583,7 +590,7 @@ CI runs a scheduled API coverage workflow that:
|
|
|
583
590
|
|
|
584
591
|
`WRITE_SANDBOX` smoke is a live check against the Yandex Direct **sandbox**.
|
|
585
592
|
It does not replay stored HTTP traffic and it does not create new recordings.
|
|
586
|
-
Run it only when you intentionally want to call `api-sandbox.direct.yandex.
|
|
593
|
+
Run it only when you intentionally want to call `api-sandbox.direct.yandex.ru`:
|
|
587
594
|
|
|
588
595
|
```bash
|
|
589
596
|
set -a && source .env && set +a
|
|
@@ -725,13 +732,16 @@ OAuth и profile-команды:
|
|
|
725
732
|
direct auth login
|
|
726
733
|
direct auth login --profile agency1
|
|
727
734
|
direct auth login --code abc123 --profile agency1
|
|
728
|
-
direct auth login --oauth-token y0_example --profile agency1
|
|
729
735
|
direct auth list
|
|
730
736
|
direct auth use --profile agency1
|
|
731
737
|
direct auth status --profile agency1
|
|
732
738
|
direct --profile agency1 campaigns get
|
|
733
739
|
```
|
|
734
740
|
|
|
741
|
+
Примечания:
|
|
742
|
+
- OAuth profiles сохраняют refresh token и автоматически обновляют access token.
|
|
743
|
+
- `direct auth login --oauth-token TOKEN` импортирует access token вручную и не включает auto-refresh.
|
|
744
|
+
|
|
735
745
|
Порядок выбора credentials:
|
|
736
746
|
|
|
737
747
|
| Приоритет | Источник | Пример |
|
|
@@ -1176,7 +1186,7 @@ YANDEX_DIRECT_LIVE_WRITE=1 pytest -m integration_live_write -v --record-mode=rew
|
|
|
1176
1186
|
`WRITE_SANDBOX` smoke — это live-проверка против **sandbox-окружения**
|
|
1177
1187
|
Яндекс Директа. Она не воспроизводит сохранённый HTTP-трафик и не создаёт
|
|
1178
1188
|
новые записи. Запускайте её только когда намеренно хотите обратиться к
|
|
1179
|
-
`api-sandbox.direct.yandex.
|
|
1189
|
+
`api-sandbox.direct.yandex.ru`:
|
|
1180
1190
|
|
|
1181
1191
|
```bash
|
|
1182
1192
|
set -a && source .env && set +a
|
|
@@ -44,7 +44,6 @@ OAuth and profile commands:
|
|
|
44
44
|
direct auth login
|
|
45
45
|
direct auth login --profile agency1
|
|
46
46
|
direct auth login --code abc123 --profile agency1
|
|
47
|
-
direct auth login --oauth-token y0_example --profile agency1
|
|
48
47
|
direct auth list
|
|
49
48
|
direct auth use --profile agency1
|
|
50
49
|
direct auth status --profile agency1
|
|
@@ -56,6 +55,8 @@ Notes:
|
|
|
56
55
|
- Select credentials with `--profile`.
|
|
57
56
|
- `--login` remains Direct client login.
|
|
58
57
|
- Authorization is performed via `direct auth login`.
|
|
58
|
+
- OAuth profiles store refresh tokens and refresh access tokens automatically.
|
|
59
|
+
- `direct auth login --oauth-token TOKEN` is a manual access-token import and does not auto-refresh.
|
|
59
60
|
- Alias `auth_login` is not supported.
|
|
60
61
|
|
|
61
62
|
Credential resolution priority:
|
|
@@ -117,18 +118,24 @@ direct v4events get-events-log --from 2026-04-14T00:00:00 --to 2026-04-15T00:00:
|
|
|
117
118
|
|
|
118
119
|
### V4 Live Finance
|
|
119
120
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
121
|
+
Finance methods require an extra financial token for money operations. In the
|
|
122
|
+
Yandex Direct web UI, open Tools -> API -> Financial operations, enable the
|
|
123
|
+
financial operations checkbox, click Save, then issue the master token on the
|
|
124
|
+
same Financial operations page and confirm by SMS. Direct CLI can compute the
|
|
125
|
+
per-request token from `--master-token`, `--operation-num`, and
|
|
126
|
+
`--finance-login`; alternatively pass a precomputed token with `--finance-token`.
|
|
127
|
+
Environment variables are
|
|
128
|
+
`YANDEX_DIRECT_MASTER_TOKEN`, `YANDEX_DIRECT_FINANCE_LOGIN`,
|
|
129
|
+
`YANDEX_DIRECT_FINANCE_TOKEN`, and `YANDEX_DIRECT_OPERATION_NUM`. Money mutation
|
|
130
|
+
commands are dry-run-only in this release and always require `--dry-run`; dry-run
|
|
131
|
+
output masks the financial token.
|
|
125
132
|
|
|
126
133
|
```bash
|
|
127
|
-
direct v4finance get-credit-limits --logins client-login --
|
|
134
|
+
direct v4finance get-credit-limits --logins client-login --master-token MASTER_TOKEN --operation-num 123 --finance-login agency-login
|
|
128
135
|
direct v4finance get-credit-limits --logins client-login,other-client --format table
|
|
129
136
|
direct v4finance check-payment --custom-transaction-id A123456789012345678901234567890B
|
|
130
|
-
direct v4finance transfer-money --from-campaign-id 123 --to-campaign-id 456 --amount 100.50 --
|
|
131
|
-
direct v4finance pay-campaigns --campaign-
|
|
137
|
+
direct v4finance transfer-money --from-campaign-id 123 --to-campaign-id 456 --amount 100.50 --currency RUB --master-token MASTER_TOKEN --operation-num 123 --finance-login agency-login --dry-run
|
|
138
|
+
direct v4finance pay-campaigns --campaign-ids 123,456 --amount 100.50 --currency RUB --contract-id CONTRACT_ID --pay-method Bank --master-token MASTER_TOKEN --operation-num 123 --finance-login agency-login --dry-run
|
|
132
139
|
```
|
|
133
140
|
|
|
134
141
|
### V4 Live Shared Account
|
|
@@ -544,7 +551,7 @@ CI runs a scheduled API coverage workflow that:
|
|
|
544
551
|
|
|
545
552
|
`WRITE_SANDBOX` smoke is a live check against the Yandex Direct **sandbox**.
|
|
546
553
|
It does not replay stored HTTP traffic and it does not create new recordings.
|
|
547
|
-
Run it only when you intentionally want to call `api-sandbox.direct.yandex.
|
|
554
|
+
Run it only when you intentionally want to call `api-sandbox.direct.yandex.ru`:
|
|
548
555
|
|
|
549
556
|
```bash
|
|
550
557
|
set -a && source .env && set +a
|
|
@@ -686,13 +693,16 @@ OAuth и profile-команды:
|
|
|
686
693
|
direct auth login
|
|
687
694
|
direct auth login --profile agency1
|
|
688
695
|
direct auth login --code abc123 --profile agency1
|
|
689
|
-
direct auth login --oauth-token y0_example --profile agency1
|
|
690
696
|
direct auth list
|
|
691
697
|
direct auth use --profile agency1
|
|
692
698
|
direct auth status --profile agency1
|
|
693
699
|
direct --profile agency1 campaigns get
|
|
694
700
|
```
|
|
695
701
|
|
|
702
|
+
Примечания:
|
|
703
|
+
- OAuth profiles сохраняют refresh token и автоматически обновляют access token.
|
|
704
|
+
- `direct auth login --oauth-token TOKEN` импортирует access token вручную и не включает auto-refresh.
|
|
705
|
+
|
|
696
706
|
Порядок выбора credentials:
|
|
697
707
|
|
|
698
708
|
| Приоритет | Источник | Пример |
|
|
@@ -1137,7 +1147,7 @@ YANDEX_DIRECT_LIVE_WRITE=1 pytest -m integration_live_write -v --record-mode=rew
|
|
|
1137
1147
|
`WRITE_SANDBOX` smoke — это live-проверка против **sandbox-окружения**
|
|
1138
1148
|
Яндекс Директа. Она не воспроизводит сохранённый HTTP-трафик и не создаёт
|
|
1139
1149
|
новые записи. Запускайте её только когда намеренно хотите обратиться к
|
|
1140
|
-
`api-sandbox.direct.yandex.
|
|
1150
|
+
`api-sandbox.direct.yandex.ru`:
|
|
1141
1151
|
|
|
1142
1152
|
```bash
|
|
1143
1153
|
set -a && source .env && set +a
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Runtime endpoints for Yandex Direct API transports."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict
|
|
4
|
+
|
|
5
|
+
DIRECT_API_PRODUCTION_ROOT = "https://api.direct.yandex.ru/"
|
|
6
|
+
DIRECT_API_SANDBOX_ROOT = "https://api-sandbox.direct.yandex.ru/"
|
|
7
|
+
DIRECT_DEBUG_ROOT = "https://"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_direct_api_root(api_params: Dict[str, Any]) -> str:
|
|
11
|
+
"""Return the Direct API root for production or sandbox requests."""
|
|
12
|
+
if api_params.get("is_sandbox"):
|
|
13
|
+
return DIRECT_API_SANDBOX_ROOT
|
|
14
|
+
return DIRECT_API_PRODUCTION_ROOT
|
{direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/_vendor/tapi_yandex_direct/tapi_yandex_direct.py
RENAMED
|
@@ -9,6 +9,7 @@ from tapi2 import TapiAdapter, generate_wrapper_from_adapter, JSONAdapterMixin
|
|
|
9
9
|
from tapi2.exceptions import ResponseProcessException, ClientError, TapiException
|
|
10
10
|
|
|
11
11
|
from . import exceptions
|
|
12
|
+
from .endpoints import DIRECT_DEBUG_ROOT, get_direct_api_root
|
|
12
13
|
from .resource_mapping import RESOURCE_MAPPING_V5
|
|
13
14
|
|
|
14
15
|
logger = logging.getLogger(__name__)
|
|
@@ -72,11 +73,8 @@ class YandexDirectClientAdapter(JSONAdapterMixin, TapiAdapter):
|
|
|
72
73
|
|
|
73
74
|
def get_api_root(self, api_params: dict, resource_name: str) -> str:
|
|
74
75
|
if resource_name == "debugtoken":
|
|
75
|
-
return
|
|
76
|
-
|
|
77
|
-
return "https://api-sandbox.direct.yandex.com/"
|
|
78
|
-
else:
|
|
79
|
-
return "https://api.direct.yandex.com/"
|
|
76
|
+
return DIRECT_DEBUG_ROOT
|
|
77
|
+
return get_direct_api_root(api_params)
|
|
80
78
|
|
|
81
79
|
def get_request_kwargs(self, api_params: dict, *args, **kwargs) -> dict:
|
|
82
80
|
"""Обогащение запроса, параметрами"""
|
|
@@ -154,7 +152,7 @@ class YandexDirectClientAdapter(JSONAdapterMixin, TapiAdapter):
|
|
|
154
152
|
"The report generation time has exceeded the server limit. "
|
|
155
153
|
"Please try to change the request parameters, "
|
|
156
154
|
"reduce the period or the amount of requested data.",
|
|
157
|
-
**kwargs
|
|
155
|
+
**kwargs,
|
|
158
156
|
)
|
|
159
157
|
elif response.status_code == 405:
|
|
160
158
|
raise exceptions.YandexDirectApiError(
|
|
@@ -162,7 +160,7 @@ class YandexDirectClientAdapter(JSONAdapterMixin, TapiAdapter):
|
|
|
162
160
|
"This resource does not support the HTTP method {}\n".format(
|
|
163
161
|
response.request.method
|
|
164
162
|
),
|
|
165
|
-
**kwargs
|
|
163
|
+
**kwargs,
|
|
166
164
|
)
|
|
167
165
|
|
|
168
166
|
data = self.response_to_native(response)
|
|
@@ -190,7 +188,7 @@ class YandexDirectClientAdapter(JSONAdapterMixin, TapiAdapter):
|
|
|
190
188
|
response: Response,
|
|
191
189
|
request_kwargs: dict,
|
|
192
190
|
api_params: dict,
|
|
193
|
-
**kwargs
|
|
191
|
+
**kwargs,
|
|
194
192
|
) -> None:
|
|
195
193
|
if response.status_code in (201, 202):
|
|
196
194
|
pass
|
|
@@ -230,7 +228,7 @@ class YandexDirectClientAdapter(JSONAdapterMixin, TapiAdapter):
|
|
|
230
228
|
response: Response,
|
|
231
229
|
request_kwargs: dict,
|
|
232
230
|
api_params: dict,
|
|
233
|
-
**kwargs
|
|
231
|
+
**kwargs,
|
|
234
232
|
) -> bool:
|
|
235
233
|
status_code = response.status_code
|
|
236
234
|
error_data = error_message.get("error", {})
|
|
@@ -283,7 +281,7 @@ class YandexDirectClientAdapter(JSONAdapterMixin, TapiAdapter):
|
|
|
283
281
|
response: Response,
|
|
284
282
|
request_kwargs: dict,
|
|
285
283
|
api_params: dict,
|
|
286
|
-
**kwargs
|
|
284
|
+
**kwargs,
|
|
287
285
|
) -> Optional[dict]:
|
|
288
286
|
limit = response_data["result"].get("LimitedBy")
|
|
289
287
|
if limit:
|
|
@@ -18,6 +18,7 @@ from tapi2 import JSONAdapterMixin, TapiAdapter, generate_wrapper_from_adapter
|
|
|
18
18
|
from tapi2.exceptions import ClientError, ResponseProcessException, TapiException
|
|
19
19
|
|
|
20
20
|
from .. import exceptions
|
|
21
|
+
from ..endpoints import get_direct_api_root
|
|
21
22
|
from .resource_mapping import (
|
|
22
23
|
RESOURCE_MAPPING_V4_LIVE,
|
|
23
24
|
SUPPORTED_V4_METHODS,
|
|
@@ -33,9 +34,7 @@ class V4LiveClientAdapter(JSONAdapterMixin, TapiAdapter):
|
|
|
33
34
|
super().__init__(*args, **kwargs)
|
|
34
35
|
|
|
35
36
|
def get_api_root(self, api_params: dict, resource_name: str) -> str:
|
|
36
|
-
|
|
37
|
-
return "https://api-sandbox.direct.yandex.ru/"
|
|
38
|
-
return "https://api.direct.yandex.ru/"
|
|
37
|
+
return get_direct_api_root(api_params)
|
|
39
38
|
|
|
40
39
|
def get_request_kwargs(self, api_params: dict, *args, **kwargs) -> dict:
|
|
41
40
|
params = super().get_request_kwargs(api_params, *args, **kwargs)
|
|
@@ -107,7 +106,9 @@ class V4LiveClientAdapter(JSONAdapterMixin, TapiAdapter):
|
|
|
107
106
|
data = None
|
|
108
107
|
return data
|
|
109
108
|
|
|
110
|
-
def process_response(
|
|
109
|
+
def process_response(
|
|
110
|
+
self, response: Response, request_kwargs: dict, **kwargs
|
|
111
|
+
) -> dict:
|
|
111
112
|
# Mirror the v5 behaviour: turn the serialised body back into a dict so
|
|
112
113
|
# downstream hooks (extract, retry) can read it.
|
|
113
114
|
if isinstance(request_kwargs.get("data"), (bytes, bytearray, str)):
|
|
@@ -187,8 +188,13 @@ class V4LiveClientAdapter(JSONAdapterMixin, TapiAdapter):
|
|
|
187
188
|
|
|
188
189
|
return False
|
|
189
190
|
|
|
190
|
-
def extract(
|
|
191
|
-
|
|
191
|
+
def extract(
|
|
192
|
+
self,
|
|
193
|
+
data,
|
|
194
|
+
response: Optional[Response] = None,
|
|
195
|
+
request_kwargs: Optional[dict] = None,
|
|
196
|
+
**kwargs,
|
|
197
|
+
):
|
|
192
198
|
# v4 Live always nests payload under "data". For methods returning a
|
|
193
199
|
# bare scalar (TransferMoney → 1), the scalar comes through unchanged.
|
|
194
200
|
# response / request_kwargs are accepted but unused — they are kept
|
|
@@ -5,12 +5,13 @@ Authentication module for Direct CLI
|
|
|
5
5
|
import base64
|
|
6
6
|
import hashlib
|
|
7
7
|
import json
|
|
8
|
+
import logging
|
|
8
9
|
import os
|
|
9
10
|
import secrets
|
|
10
11
|
import shutil
|
|
11
12
|
import subprocess
|
|
12
13
|
import tempfile
|
|
13
|
-
import
|
|
14
|
+
import time
|
|
14
15
|
import urllib.error
|
|
15
16
|
import urllib.parse
|
|
16
17
|
import urllib.request
|
|
@@ -29,6 +30,7 @@ YANDEX_OAUTH_AUTHORIZE_URL = "https://oauth.yandex.ru/authorize"
|
|
|
29
30
|
YANDEX_OAUTH_TOKEN_URL = "https://oauth.yandex.ru/token"
|
|
30
31
|
DEFAULT_OAUTH_CLIENT_ID = "dcf15d9625f6471d94d6d054d52017ba"
|
|
31
32
|
AUTH_STORE_PATH = Path.home() / ".direct-cli" / "auth.json"
|
|
33
|
+
OAUTH_REFRESH_SKEW_SECONDS = 60
|
|
32
34
|
|
|
33
35
|
|
|
34
36
|
def op_read(ref: str) -> str:
|
|
@@ -175,17 +177,33 @@ def save_oauth_profile(
|
|
|
175
177
|
profile: str,
|
|
176
178
|
token: str,
|
|
177
179
|
login: Optional[str] = None,
|
|
180
|
+
refresh_token: Optional[str] = None,
|
|
181
|
+
expires_at: Optional[float] = None,
|
|
182
|
+
client_id: str = DEFAULT_OAUTH_CLIENT_ID,
|
|
183
|
+
client_secret: Optional[str] = None,
|
|
184
|
+
source: str = "oauth",
|
|
178
185
|
make_active: bool = True,
|
|
179
186
|
path: Optional[Path] = None,
|
|
180
187
|
) -> None:
|
|
181
188
|
"""Save/update one OAuth profile without exposing secret values."""
|
|
182
189
|
store = load_auth_store(path=path)
|
|
183
190
|
profiles = store["profiles"]
|
|
184
|
-
|
|
191
|
+
item: Dict[str, Any] = {
|
|
185
192
|
"token": token,
|
|
186
193
|
"login": login,
|
|
187
|
-
"source":
|
|
194
|
+
"source": source,
|
|
188
195
|
}
|
|
196
|
+
if source == "oauth":
|
|
197
|
+
if not refresh_token:
|
|
198
|
+
raise ValueError("OAuth profile requires refresh_token")
|
|
199
|
+
if expires_at is None:
|
|
200
|
+
raise ValueError("OAuth profile requires expires_at")
|
|
201
|
+
item["refresh_token"] = refresh_token
|
|
202
|
+
item["expires_at"] = float(expires_at)
|
|
203
|
+
item["client_id"] = client_id
|
|
204
|
+
if client_secret:
|
|
205
|
+
item["client_secret"] = client_secret
|
|
206
|
+
profiles[profile] = item
|
|
189
207
|
if make_active:
|
|
190
208
|
store["active_profile"] = profile
|
|
191
209
|
save_auth_store(store, path=path)
|
|
@@ -205,7 +223,7 @@ def get_active_profile(path: Optional[Path] = None) -> Optional[str]:
|
|
|
205
223
|
|
|
206
224
|
def get_oauth_profile(
|
|
207
225
|
profile: str, path: Optional[Path] = None
|
|
208
|
-
) -> Optional[Dict[str,
|
|
226
|
+
) -> Optional[Dict[str, Any]]:
|
|
209
227
|
"""Get OAuth profile by name."""
|
|
210
228
|
store = load_auth_store(path=path)
|
|
211
229
|
item = store["profiles"].get(profile)
|
|
@@ -217,7 +235,13 @@ def get_oauth_profile(
|
|
|
217
235
|
return None
|
|
218
236
|
if login is not None and not isinstance(login, str):
|
|
219
237
|
login = None
|
|
220
|
-
|
|
238
|
+
result = dict(item)
|
|
239
|
+
result["token"] = token
|
|
240
|
+
result["login"] = login
|
|
241
|
+
source = result.get("source")
|
|
242
|
+
if not isinstance(source, str):
|
|
243
|
+
result["source"] = "oauth"
|
|
244
|
+
return result
|
|
221
245
|
|
|
222
246
|
|
|
223
247
|
def get_env_profile(profile: str) -> Tuple[Optional[str], Optional[str]]:
|
|
@@ -245,9 +269,12 @@ def list_profiles(path: Optional[Path] = None) -> List[Dict[str, Any]]:
|
|
|
245
269
|
login = data.get("login")
|
|
246
270
|
if not isinstance(token, str) or not token:
|
|
247
271
|
continue
|
|
272
|
+
source = data.get("source")
|
|
273
|
+
if not isinstance(source, str):
|
|
274
|
+
source = "oauth"
|
|
248
275
|
profiles[profile_name] = {
|
|
249
276
|
"profile": profile_name,
|
|
250
|
-
"source":
|
|
277
|
+
"source": source,
|
|
251
278
|
"has_token": True,
|
|
252
279
|
"has_login": bool(login),
|
|
253
280
|
"login": login,
|
|
@@ -266,7 +293,7 @@ def list_profiles(path: Optional[Path] = None) -> List[Dict[str, Any]]:
|
|
|
266
293
|
login = env.get(f"YANDEX_DIRECT_LOGIN_{suffix}")
|
|
267
294
|
existing = profiles.get(profile_name)
|
|
268
295
|
if existing:
|
|
269
|
-
existing["source"] = "
|
|
296
|
+
existing["source"] = f"{existing['source']}+env"
|
|
270
297
|
existing["has_login"] = bool(existing["has_login"] or login)
|
|
271
298
|
existing["login"] = existing["login"] or login
|
|
272
299
|
continue
|
|
@@ -313,7 +340,7 @@ def exchange_oauth_code(
|
|
|
313
340
|
client_id: str = DEFAULT_OAUTH_CLIENT_ID,
|
|
314
341
|
client_secret: Optional[str] = None,
|
|
315
342
|
code_verifier: Optional[str] = None,
|
|
316
|
-
) -> str:
|
|
343
|
+
) -> Dict[str, Any]:
|
|
317
344
|
"""Exchange OAuth authorization code for access token."""
|
|
318
345
|
payload: Dict[str, str] = {
|
|
319
346
|
"grant_type": "authorization_code",
|
|
@@ -336,22 +363,121 @@ def exchange_oauth_code(
|
|
|
336
363
|
with urllib.request.urlopen(request, timeout=20) as response:
|
|
337
364
|
result = json.loads(response.read().decode("utf-8"))
|
|
338
365
|
except urllib.error.HTTPError as error:
|
|
339
|
-
details = error.read().decode("utf-8", errors="ignore")
|
|
340
|
-
if details:
|
|
341
|
-
raise RuntimeError(f"OAuth token request failed: {details}") from error
|
|
342
366
|
raise RuntimeError(
|
|
343
367
|
f"OAuth token request failed with HTTP {error.code}"
|
|
344
368
|
) from error
|
|
345
369
|
except urllib.error.URLError as error:
|
|
370
|
+
if isinstance(error.reason, TimeoutError):
|
|
371
|
+
raise RuntimeError("OAuth token request timed out") from error
|
|
346
372
|
raise RuntimeError(f"OAuth token request failed: {error.reason}") from error
|
|
347
|
-
except TimeoutError as error:
|
|
348
|
-
raise RuntimeError("OAuth token request timed out") from error
|
|
349
373
|
|
|
350
374
|
access_token = result.get("access_token")
|
|
351
375
|
if not isinstance(access_token, str) or not access_token:
|
|
352
376
|
raise RuntimeError("OAuth token response does not contain access_token")
|
|
353
|
-
|
|
354
|
-
|
|
377
|
+
refresh_token = result.get("refresh_token")
|
|
378
|
+
if not isinstance(refresh_token, str) or not refresh_token:
|
|
379
|
+
raise RuntimeError("OAuth token response does not contain refresh_token")
|
|
380
|
+
expires_in = result.get("expires_in")
|
|
381
|
+
if not isinstance(expires_in, int) or expires_in <= 0:
|
|
382
|
+
raise RuntimeError("OAuth token response does not contain expires_in")
|
|
383
|
+
return {
|
|
384
|
+
"access_token": access_token,
|
|
385
|
+
"refresh_token": refresh_token,
|
|
386
|
+
"expires_in": expires_in,
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _oauth_profile_incomplete_error(profile: str) -> ValueError:
|
|
391
|
+
return ValueError(
|
|
392
|
+
f"OAuth profile '{profile}' is incomplete. "
|
|
393
|
+
f"Run direct auth login --profile {profile} again."
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def validate_oauth_profile(profile: str, data: Dict[str, Any]) -> None:
|
|
398
|
+
refresh_token = data.get("refresh_token")
|
|
399
|
+
expires_at = data.get("expires_at")
|
|
400
|
+
if not isinstance(refresh_token, str) or not refresh_token:
|
|
401
|
+
raise _oauth_profile_incomplete_error(profile)
|
|
402
|
+
if not isinstance(expires_at, (int, float)):
|
|
403
|
+
raise _oauth_profile_incomplete_error(profile)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def refresh_access_token(profile: str, path: Optional[Path] = None) -> Dict[str, Any]:
|
|
407
|
+
"""Refresh and persist an OAuth profile access token."""
|
|
408
|
+
store = load_auth_store(path=path)
|
|
409
|
+
profiles = store["profiles"]
|
|
410
|
+
item = profiles.get(profile)
|
|
411
|
+
if not isinstance(item, dict):
|
|
412
|
+
raise ValueError(f"Profile '{profile}' is not configured.")
|
|
413
|
+
validate_oauth_profile(profile, item)
|
|
414
|
+
|
|
415
|
+
refresh_token = item["refresh_token"]
|
|
416
|
+
client_id = item.get("client_id")
|
|
417
|
+
if not isinstance(client_id, str) or not client_id:
|
|
418
|
+
client_id = DEFAULT_OAUTH_CLIENT_ID
|
|
419
|
+
client_secret = item.get("client_secret")
|
|
420
|
+
|
|
421
|
+
payload: Dict[str, str] = {
|
|
422
|
+
"grant_type": "refresh_token",
|
|
423
|
+
"client_id": client_id,
|
|
424
|
+
"refresh_token": refresh_token,
|
|
425
|
+
}
|
|
426
|
+
if isinstance(client_secret, str) and client_secret:
|
|
427
|
+
payload["client_secret"] = client_secret
|
|
428
|
+
body = urllib.parse.urlencode(payload).encode("utf-8")
|
|
429
|
+
request = urllib.request.Request(
|
|
430
|
+
YANDEX_OAUTH_TOKEN_URL,
|
|
431
|
+
data=body,
|
|
432
|
+
method="POST",
|
|
433
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
434
|
+
)
|
|
435
|
+
try:
|
|
436
|
+
with urllib.request.urlopen(request, timeout=20) as response:
|
|
437
|
+
result = json.loads(response.read().decode("utf-8"))
|
|
438
|
+
except urllib.error.HTTPError as error:
|
|
439
|
+
if error.code in (400, 401):
|
|
440
|
+
# Concurrent-refresh race: another process may have just rotated
|
|
441
|
+
# the token. Re-read the store and, if a fresher access token is
|
|
442
|
+
# already on disk, return it instead of falsely declaring expiry.
|
|
443
|
+
fresh_store = load_auth_store(path=path)
|
|
444
|
+
fresh_item = fresh_store["profiles"].get(profile)
|
|
445
|
+
if isinstance(fresh_item, dict):
|
|
446
|
+
fresh_expires = fresh_item.get("expires_at")
|
|
447
|
+
if (
|
|
448
|
+
isinstance(fresh_expires, (int, float))
|
|
449
|
+
and float(fresh_expires) > time.time() + OAUTH_REFRESH_SKEW_SECONDS
|
|
450
|
+
):
|
|
451
|
+
return fresh_item
|
|
452
|
+
raise RuntimeError(
|
|
453
|
+
f"OAuth refresh token expired. "
|
|
454
|
+
f"Run direct auth login --profile {profile} again."
|
|
455
|
+
) from error
|
|
456
|
+
raise RuntimeError(
|
|
457
|
+
f"OAuth refresh request failed with HTTP {error.code}"
|
|
458
|
+
) from error
|
|
459
|
+
except urllib.error.URLError as error:
|
|
460
|
+
if isinstance(error.reason, TimeoutError):
|
|
461
|
+
raise RuntimeError("OAuth refresh request timed out") from error
|
|
462
|
+
raise RuntimeError(f"OAuth refresh request failed: {error.reason}") from error
|
|
463
|
+
|
|
464
|
+
access_token = result.get("access_token")
|
|
465
|
+
if not isinstance(access_token, str) or not access_token:
|
|
466
|
+
raise RuntimeError("OAuth refresh response does not contain access_token")
|
|
467
|
+
expires_in = result.get("expires_in")
|
|
468
|
+
if not isinstance(expires_in, int) or expires_in <= 0:
|
|
469
|
+
raise RuntimeError("OAuth refresh response does not contain expires_in")
|
|
470
|
+
|
|
471
|
+
item["token"] = access_token
|
|
472
|
+
new_refresh_token = result.get("refresh_token")
|
|
473
|
+
if isinstance(new_refresh_token, str) and new_refresh_token:
|
|
474
|
+
item["refresh_token"] = new_refresh_token
|
|
475
|
+
item["expires_at"] = time.time() + expires_in
|
|
476
|
+
item["client_id"] = client_id
|
|
477
|
+
item["source"] = "oauth"
|
|
478
|
+
profiles[profile] = item
|
|
479
|
+
save_auth_store(store, path=path)
|
|
480
|
+
return item
|
|
355
481
|
|
|
356
482
|
|
|
357
483
|
def resolve_login(token: str) -> Optional[str]:
|
|
@@ -364,7 +490,12 @@ def resolve_login(token: str) -> Optional[str]:
|
|
|
364
490
|
with urllib.request.urlopen(request, timeout=10) as response:
|
|
365
491
|
data = json.loads(response.read().decode("utf-8"))
|
|
366
492
|
return data.get("login")
|
|
367
|
-
except (
|
|
493
|
+
except (
|
|
494
|
+
urllib.error.URLError,
|
|
495
|
+
urllib.error.HTTPError,
|
|
496
|
+
OSError,
|
|
497
|
+
json.JSONDecodeError,
|
|
498
|
+
) as exc:
|
|
368
499
|
logging.debug("resolve_login failed: %s", exc)
|
|
369
500
|
return None
|
|
370
501
|
|
|
@@ -415,6 +546,11 @@ def get_credentials(
|
|
|
415
546
|
if selected_profile and not final_token:
|
|
416
547
|
oauth_profile = get_oauth_profile(selected_profile)
|
|
417
548
|
if oauth_profile:
|
|
549
|
+
if oauth_profile.get("source") == "oauth":
|
|
550
|
+
validate_oauth_profile(selected_profile, oauth_profile)
|
|
551
|
+
expires_at = float(oauth_profile["expires_at"])
|
|
552
|
+
if expires_at <= time.time() + OAUTH_REFRESH_SKEW_SECONDS:
|
|
553
|
+
oauth_profile = refresh_access_token(selected_profile)
|
|
418
554
|
final_token = oauth_profile["token"]
|
|
419
555
|
if not final_login:
|
|
420
556
|
final_login = oauth_profile["login"]
|