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.
Files changed (209) hide show
  1. direct_cli-0.3.3/CHANGELOG.md +11 -0
  2. {direct_cli-0.3.2 → direct_cli-0.3.3}/PKG-INFO +23 -13
  3. {direct_cli-0.3.2 → direct_cli-0.3.3}/README.md +22 -12
  4. direct_cli-0.3.3/direct_cli/_vendor/tapi_yandex_direct/endpoints.py +14 -0
  5. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/_vendor/tapi_yandex_direct/tapi_yandex_direct.py +8 -10
  6. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/_vendor/tapi_yandex_direct/v4/adapter.py +12 -6
  7. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/auth.py +152 -16
  8. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/auth.py +75 -9
  9. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/v4finance.py +176 -26
  10. direct_cli-0.3.3/direct_cli/v4/money.py +78 -0
  11. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/v4_contracts.py +8 -6
  12. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli.egg-info/PKG-INFO +23 -13
  13. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli.egg-info/SOURCES.txt +2 -0
  14. {direct_cli-0.3.2 → direct_cli-0.3.3}/pyproject.toml +1 -1
  15. {direct_cli-0.3.2 → direct_cli-0.3.3}/scripts/sandbox_write_live.py +1 -1
  16. {direct_cli-0.3.2 → direct_cli-0.3.3}/scripts/test_dangerous_commands.sh +2 -2
  17. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/API_COVERAGE.md +4 -0
  18. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/MANUAL_COVERAGE.md +3 -2
  19. {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
  20. {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
  21. {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
  22. {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
  23. {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
  24. {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
  25. {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
  26. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_live_write/test_live_draft_bids_set.yaml +7 -7
  27. {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
  28. {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
  29. {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
  30. {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
  31. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_live_write/test_live_draft_keywordbids_set.yaml +7 -7
  32. {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
  33. {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
  34. {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
  35. {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
  36. {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
  37. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_write/TestWriteAdExtensions.test_add_delete.yaml +2 -2
  38. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_write/TestWriteAdGroups.test_add_update_delete.yaml +5 -5
  39. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_write/TestWriteAdImages.test_add_delete.yaml +1 -1
  40. {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
  41. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_write/TestWriteAudienceTargets.test_add_delete.yaml +7 -7
  42. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_write/TestWriteBidModifiersAdd.test_add_delete_mobile.yaml +4 -4
  43. {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
  44. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_write/TestWriteBids.test_set_bid.yaml +5 -5
  45. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_write/TestWriteCampaignDraftLifecycle.test_draft_create_get_delete.yaml +4 -4
  46. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_write/TestWriteCampaigns.test_campaign_lifecycle.yaml +6 -6
  47. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_write/TestWriteDynamicAds.test_add_update_delete.yaml +1 -1
  48. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_write/TestWriteFeeds.test_add_update_delete.yaml +3 -3
  49. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_write/TestWriteKeywordBids.test_set_keyword_bid.yaml +5 -5
  50. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_write/TestWriteKeywords.test_add_update_delete.yaml +5 -5
  51. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_write/TestWriteNegativeKeywordSharedSets.test_add_update_delete.yaml +3 -3
  52. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_write/TestWriteRetargeting.test_add_delete.yaml +2 -2
  53. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_write/TestWriteSitelinks.test_add_delete.yaml +1 -1
  54. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_write/TestWriteSmartAdTargets.test_add_update_delete.yaml +3 -3
  55. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/cassettes/test_integration_write/TestWriteVCards.test_add_delete.yaml +4 -4
  56. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/conftest.py +1 -1
  57. direct_cli-0.3.3/tests/test_auth_oauth.py +657 -0
  58. direct_cli-0.3.3/tests/test_transport_contract.py +66 -0
  59. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/test_v4_contracts.py +4 -4
  60. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/test_v4finance_money.py +182 -13
  61. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/test_v4finance_read.py +30 -3
  62. direct_cli-0.3.2/direct_cli/v4/money.py +0 -35
  63. direct_cli-0.3.2/tests/test_auth_oauth.py +0 -206
  64. direct_cli-0.3.2/tests/test_transport_contract.py +0 -23
  65. {direct_cli-0.3.2 → direct_cli-0.3.3}/.env.example +0 -0
  66. {direct_cli-0.3.2 → direct_cli-0.3.3}/.github/copilot-instructions.md +0 -0
  67. {direct_cli-0.3.2 → direct_cli-0.3.3}/.github/workflows/api-coverage.yml +0 -0
  68. {direct_cli-0.3.2 → direct_cli-0.3.3}/.github/workflows/claude-code-review.yml +0 -0
  69. {direct_cli-0.3.2 → direct_cli-0.3.3}/.github/workflows/claude.yml +0 -0
  70. {direct_cli-0.3.2 → direct_cli-0.3.3}/.gitignore +0 -0
  71. {direct_cli-0.3.2 → direct_cli-0.3.3}/AGENTS.md +0 -0
  72. {direct_cli-0.3.2 → direct_cli-0.3.3}/CLAUDE.md +0 -0
  73. {direct_cli-0.3.2 → direct_cli-0.3.3}/MANIFEST.in +0 -0
  74. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/__init__.py +0 -0
  75. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/_deprecated.py +0 -0
  76. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/_smoke_probes.py +0 -0
  77. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/_vendor/__init__.py +0 -0
  78. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/_vendor/tapi_yandex_direct/__init__.py +0 -0
  79. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/_vendor/tapi_yandex_direct/exceptions.py +0 -0
  80. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/_vendor/tapi_yandex_direct/resource_mapping.py +0 -0
  81. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/_vendor/tapi_yandex_direct/tapi_yandex_direct.pyi +0 -0
  82. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/_vendor/tapi_yandex_direct/v4/__init__.py +0 -0
  83. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/_vendor/tapi_yandex_direct/v4/adapter.pyi +0 -0
  84. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/_vendor/tapi_yandex_direct/v4/resource_mapping.py +0 -0
  85. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/api.py +0 -0
  86. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/cli.py +0 -0
  87. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/__init__.py +0 -0
  88. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/adextensions.py +0 -0
  89. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/adgroups.py +0 -0
  90. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/adimages.py +0 -0
  91. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/ads.py +0 -0
  92. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/advideos.py +0 -0
  93. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/agencyclients.py +0 -0
  94. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/audiencetargets.py +0 -0
  95. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/balance.py +0 -0
  96. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/bidmodifiers.py +0 -0
  97. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/bids.py +0 -0
  98. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/businesses.py +0 -0
  99. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/campaigns.py +0 -0
  100. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/changes.py +0 -0
  101. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/clients.py +0 -0
  102. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/creatives.py +0 -0
  103. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/dictionaries.py +0 -0
  104. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/dynamicads.py +0 -0
  105. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/dynamicfeedadtargets.py +0 -0
  106. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/feeds.py +0 -0
  107. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/keywordbids.py +0 -0
  108. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/keywords.py +0 -0
  109. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/keywordsresearch.py +0 -0
  110. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/leads.py +0 -0
  111. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/negativekeywordsharedsets.py +0 -0
  112. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/reports.py +0 -0
  113. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/retargeting.py +0 -0
  114. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/sitelinks.py +0 -0
  115. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/smartadtargets.py +0 -0
  116. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/strategies.py +0 -0
  117. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/turbopages.py +0 -0
  118. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/v4account.py +0 -0
  119. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/v4events.py +0 -0
  120. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/v4goals.py +0 -0
  121. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/v4shells.py +0 -0
  122. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/commands/vcards.py +0 -0
  123. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/output.py +0 -0
  124. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/reports_coverage.py +0 -0
  125. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/smoke_matrix.py +0 -0
  126. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/utils.py +0 -0
  127. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/v4/__init__.py +0 -0
  128. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli/wsdl_coverage.py +0 -0
  129. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli.egg-info/dependency_links.txt +0 -0
  130. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli.egg-info/entry_points.txt +0 -0
  131. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli.egg-info/requires.txt +0 -0
  132. {direct_cli-0.3.2 → direct_cli-0.3.3}/direct_cli.egg-info/top_level.txt +0 -0
  133. {direct_cli-0.3.2 → direct_cli-0.3.3}/docs/superpowers/plans/2026-04-12-issue-32-completion.md +0 -0
  134. {direct_cli-0.3.2 → direct_cli-0.3.3}/docs/superpowers/specs/2026-04-23-vendor-tapi-yandex-direct-design.md +0 -0
  135. {direct_cli-0.3.2 → direct_cli-0.3.3}/scripts/anonymize_cassettes.py +0 -0
  136. {direct_cli-0.3.2 → direct_cli-0.3.3}/scripts/build_api_coverage_checklist.py +0 -0
  137. {direct_cli-0.3.2 → direct_cli-0.3.3}/scripts/build_api_coverage_report.py +0 -0
  138. {direct_cli-0.3.2 → direct_cli-0.3.3}/scripts/check_reports_drift.py +0 -0
  139. {direct_cli-0.3.2 → direct_cli-0.3.3}/scripts/check_wsdl_drift.py +0 -0
  140. {direct_cli-0.3.2 → direct_cli-0.3.3}/scripts/patch_vendor_imports.py +0 -0
  141. {direct_cli-0.3.2 → direct_cli-0.3.3}/scripts/refresh_reports_cache.py +0 -0
  142. {direct_cli-0.3.2 → direct_cli-0.3.3}/scripts/refresh_wsdl_cache.py +0 -0
  143. {direct_cli-0.3.2 → direct_cli-0.3.3}/scripts/release_pypi.sh +0 -0
  144. {direct_cli-0.3.2 → direct_cli-0.3.3}/scripts/test_safe_commands.sh +0 -0
  145. {direct_cli-0.3.2 → direct_cli-0.3.3}/scripts/test_sandbox_write.sh +0 -0
  146. {direct_cli-0.3.2 → direct_cli-0.3.3}/scripts/update_vendor.sh +0 -0
  147. {direct_cli-0.3.2 → direct_cli-0.3.3}/setup.cfg +0 -0
  148. {direct_cli-0.3.2 → direct_cli-0.3.3}/setup.py +0 -0
  149. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/API_ISSUE_AUDIT.md +0 -0
  150. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/__init__.py +0 -0
  151. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/api_coverage_payloads.py +0 -0
  152. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/fixtures/test-video.mp4 +0 -0
  153. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/reports_cache/raw/fields-list.html +0 -0
  154. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/reports_cache/raw/headers.html +0 -0
  155. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/reports_cache/raw/period.html +0 -0
  156. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/reports_cache/raw/spec.html +0 -0
  157. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/reports_cache/raw/type.html +0 -0
  158. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/reports_cache/spec.json +0 -0
  159. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/test_api_coverage.py +0 -0
  160. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/test_auth_bw.py +0 -0
  161. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/test_auth_op.py +0 -0
  162. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/test_balance.py +0 -0
  163. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/test_cli.py +0 -0
  164. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/test_comprehensive.py +0 -0
  165. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/test_dry_run.py +0 -0
  166. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/test_integration.py +0 -0
  167. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/test_integration_live_write.py +0 -0
  168. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/test_integration_write.py +0 -0
  169. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/test_reports_drift.py +0 -0
  170. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/test_smoke_matrix.py +0 -0
  171. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/test_v4_foundation.py +0 -0
  172. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/test_v4_live_contracts.py +0 -0
  173. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/test_v4_safety.py +0 -0
  174. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/test_v4account.py +0 -0
  175. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/test_v4events.py +0 -0
  176. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/test_v4goals.py +0 -0
  177. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/test_vendor_imports.py +0 -0
  178. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/adextensions.xml +0 -0
  179. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/adgroups.xml +0 -0
  180. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/adimages.xml +0 -0
  181. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/ads.xml +0 -0
  182. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/advideos.xml +0 -0
  183. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/agencyclients.xml +0 -0
  184. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/audiencetargets.xml +0 -0
  185. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/bidmodifiers.xml +0 -0
  186. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/bids.xml +0 -0
  187. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/businesses.xml +0 -0
  188. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/campaigns.xml +0 -0
  189. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/changes.xml +0 -0
  190. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/clients.xml +0 -0
  191. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/creatives.xml +0 -0
  192. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/dictionaries.xml +0 -0
  193. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/dynamicfeedadtargets.xml +0 -0
  194. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/dynamictextadtargets.xml +0 -0
  195. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/feeds.xml +0 -0
  196. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/imports/adextensiontypes.xsd +0 -0
  197. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/imports/general.xsd +0 -0
  198. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/imports/generalclients.xsd +0 -0
  199. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/keywordbids.xml +0 -0
  200. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/keywords.xml +0 -0
  201. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/keywordsresearch.xml +0 -0
  202. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/leads.xml +0 -0
  203. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/negativekeywordsharedsets.xml +0 -0
  204. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/retargetinglists.xml +0 -0
  205. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/sitelinks.xml +0 -0
  206. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/smartadtargets.xml +0 -0
  207. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/strategies.xml +0 -0
  208. {direct_cli-0.3.2 → direct_cli-0.3.3}/tests/wsdl_cache/turbopages.xml +0 -0
  209. {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.2
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
- `get-credit-limits` requires a financial token and operation number. Pass them
160
- with `--finance-token` and `--operation-num`, or set
161
- `YANDEX_DIRECT_FINANCE_TOKEN` and `YANDEX_DIRECT_OPERATION_NUM`.
162
- Money mutation commands are dry-run-only in this release and always require
163
- `--dry-run`; dry-run output masks the financial token.
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 --finance-token FINANCE_TOKEN --operation-num 123
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 --finance-token FINANCE_TOKEN --operation-num 123 --dry-run
170
- direct v4finance pay-campaigns --campaign-id 123 --amount 100.50 --contract-id CONTRACT_ID --pay-method CREDIT --finance-token FINANCE_TOKEN --operation-num 123 --dry-run
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.com`:
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.com`:
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
- `get-credit-limits` requires a financial token and operation number. Pass them
121
- with `--finance-token` and `--operation-num`, or set
122
- `YANDEX_DIRECT_FINANCE_TOKEN` and `YANDEX_DIRECT_OPERATION_NUM`.
123
- Money mutation commands are dry-run-only in this release and always require
124
- `--dry-run`; dry-run output masks the financial token.
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 --finance-token FINANCE_TOKEN --operation-num 123
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 --finance-token FINANCE_TOKEN --operation-num 123 --dry-run
131
- direct v4finance pay-campaigns --campaign-id 123 --amount 100.50 --contract-id CONTRACT_ID --pay-method CREDIT --finance-token FINANCE_TOKEN --operation-num 123 --dry-run
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.com`:
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.com`:
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
@@ -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 "https://"
76
- elif api_params.get("is_sandbox"):
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
- if api_params.get("is_sandbox"):
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(self, response: Response, request_kwargs: dict, **kwargs) -> dict:
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(self, data, response: Optional[Response] = None,
191
- request_kwargs: Optional[dict] = None, **kwargs):
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 logging
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
- profiles[profile] = {
191
+ item: Dict[str, Any] = {
185
192
  "token": token,
186
193
  "login": login,
187
- "source": "oauth",
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, Optional[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
- return {"token": token, "login": login}
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": "oauth",
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"] = "oauth+env"
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
- # TODO: Persist refresh_token/expires_in and refresh automatically.
354
- return access_token
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 (urllib.error.URLError, urllib.error.HTTPError, OSError, json.JSONDecodeError) as exc:
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"]