vidbyte-cli 0.1.0__py3-none-any.whl

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 (269) hide show
  1. vidbyte_cli/__init__.py +11 -0
  2. vidbyte_cli/__main__.py +6 -0
  3. vidbyte_cli/cli.py +18 -0
  4. vidbyte_cli/commands/__init__.py +86 -0
  5. vidbyte_cli/commands/agent_options.py +53 -0
  6. vidbyte_cli/commands/agents/__init__.py +31 -0
  7. vidbyte_cli/commands/agents/suggestion/__init__.py +38 -0
  8. vidbyte_cli/commands/agents/suggestion/prompts/__init__.py +3 -0
  9. vidbyte_cli/commands/agents/suggestion/prompts/all_categories.md +5 -0
  10. vidbyte_cli/commands/agents/suggestion/prompts/approaches.md +100 -0
  11. vidbyte_cli/commands/agents/suggestion/prompts/avoid.md +100 -0
  12. vidbyte_cli/commands/agents/suggestion/prompts/blockers.md +100 -0
  13. vidbyte_cli/commands/agents/suggestion/prompts/capability.md +100 -0
  14. vidbyte_cli/commands/agents/suggestion/prompts/categories.md +5 -0
  15. vidbyte_cli/commands/agents/suggestion/prompts/category.md +5 -0
  16. vidbyte_cli/commands/agents/suggestion/prompts/completed.md +100 -0
  17. vidbyte_cli/commands/agents/suggestion/prompts/constraint.md +100 -0
  18. vidbyte_cli/commands/agents/suggestion/prompts/context.md +100 -0
  19. vidbyte_cli/commands/agents/suggestion/prompts/count.md +5 -0
  20. vidbyte_cli/commands/agents/suggestion/prompts/critic_model.md +100 -0
  21. vidbyte_cli/commands/agents/suggestion/prompts/decision.md +100 -0
  22. vidbyte_cli/commands/agents/suggestion/prompts/dry_run.md +5 -0
  23. vidbyte_cli/commands/agents/suggestion/prompts/extra_compute.md +5 -0
  24. vidbyte_cli/commands/agents/suggestion/prompts/feedback_accept.md +7 -0
  25. vidbyte_cli/commands/agents/suggestion/prompts/feedback_group.md +7 -0
  26. vidbyte_cli/commands/agents/suggestion/prompts/feedback_project.md +100 -0
  27. vidbyte_cli/commands/agents/suggestion/prompts/feedback_reason.md +98 -0
  28. vidbyte_cli/commands/agents/suggestion/prompts/feedback_reject.md +7 -0
  29. vidbyte_cli/commands/agents/suggestion/prompts/feedback_suggestion.md +101 -0
  30. vidbyte_cli/commands/agents/suggestion/prompts/files.md +100 -0
  31. vidbyte_cli/commands/agents/suggestion/prompts/forbidden.md +100 -0
  32. vidbyte_cli/commands/agents/suggestion/prompts/goal.md +100 -0
  33. vidbyte_cli/commands/agents/suggestion/prompts/handoff.md +7 -0
  34. vidbyte_cli/commands/agents/suggestion/prompts/horizon.md +5 -0
  35. vidbyte_cli/commands/agents/suggestion/prompts/hypotheses.md +100 -0
  36. vidbyte_cli/commands/agents/suggestion/prompts/idea.md +100 -0
  37. vidbyte_cli/commands/agents/suggestion/prompts/in_progress.md +100 -0
  38. vidbyte_cli/commands/agents/suggestion/prompts/input.md +100 -0
  39. vidbyte_cli/commands/agents/suggestion/prompts/library.py +29 -0
  40. vidbyte_cli/commands/agents/suggestion/prompts/max_messages.md +5 -0
  41. vidbyte_cli/commands/agents/suggestion/prompts/max_output_tokens.md +5 -0
  42. vidbyte_cli/commands/agents/suggestion/prompts/max_total_tokens.md +5 -0
  43. vidbyte_cli/commands/agents/suggestion/prompts/mistakes.md +100 -0
  44. vidbyte_cli/commands/agents/suggestion/prompts/outcomes.md +100 -0
  45. vidbyte_cli/commands/agents/suggestion/prompts/project.md +100 -0
  46. vidbyte_cli/commands/agents/suggestion/prompts/project_create.md +7 -0
  47. vidbyte_cli/commands/agents/suggestion/prompts/project_description.md +98 -0
  48. vidbyte_cli/commands/agents/suggestion/prompts/project_group.md +7 -0
  49. vidbyte_cli/commands/agents/suggestion/prompts/project_key.md +96 -0
  50. vidbyte_cli/commands/agents/suggestion/prompts/project_list.md +7 -0
  51. vidbyte_cli/commands/agents/suggestion/prompts/project_title.md +99 -0
  52. vidbyte_cli/commands/agents/suggestion/prompts/provider.md +5 -0
  53. vidbyte_cli/commands/agents/suggestion/prompts/question.md +100 -0
  54. vidbyte_cli/commands/agents/suggestion/prompts/risks.md +100 -0
  55. vidbyte_cli/commands/agents/suggestion/prompts/rounds.md +5 -0
  56. vidbyte_cli/commands/agents/suggestion/prompts/run.md +5 -0
  57. vidbyte_cli/commands/agents/suggestion/prompts/success.md +100 -0
  58. vidbyte_cli/commands/agents/suggestion/prompts/timeout.md +5 -0
  59. vidbyte_cli/commands/agents/suggestion/prompts/trajectory.md +100 -0
  60. vidbyte_cli/commands/agents/suggestion/prompts/view.md +5 -0
  61. vidbyte_cli/commands/agents/suggestion/prompts/view_all.md +5 -0
  62. vidbyte_cli/commands/agents/suggestion/render.py +77 -0
  63. vidbyte_cli/commands/agents/suggestion/request_builder.py +240 -0
  64. vidbyte_cli/commands/agents/suggestion/suggest.py +154 -0
  65. vidbyte_cli/commands/agents/suggestion/suggestion_categories.py +77 -0
  66. vidbyte_cli/commands/agents/suggestion/suggestion_feedback.py +85 -0
  67. vidbyte_cli/commands/agents/suggestion/suggestion_handoff.py +67 -0
  68. vidbyte_cli/commands/agents/suggestion/suggestion_projects.py +95 -0
  69. vidbyte_cli/commands/auth/__init__.py +0 -0
  70. vidbyte_cli/commands/auth/login.py +100 -0
  71. vidbyte_cli/commands/auth/logout.py +41 -0
  72. vidbyte_cli/commands/auth/whoami.py +51 -0
  73. vidbyte_cli/commands/billing/__init__.py +20 -0
  74. vidbyte_cli/commands/billing/prompts/__init__.py +1 -0
  75. vidbyte_cli/commands/billing/prompts/confirm.md +16 -0
  76. vidbyte_cli/commands/billing/prompts/group.md +13 -0
  77. vidbyte_cli/commands/billing/prompts/idempotency_key.md +16 -0
  78. vidbyte_cli/commands/billing/prompts/library.py +18 -0
  79. vidbyte_cli/commands/billing/prompts/top_up.md +16 -0
  80. vidbyte_cli/commands/billing/top_up.py +65 -0
  81. vidbyte_cli/commands/config/__init__.py +0 -0
  82. vidbyte_cli/commands/config/get.py +45 -0
  83. vidbyte_cli/commands/config/set.py +39 -0
  84. vidbyte_cli/commands/provider/__init__.py +9 -0
  85. vidbyte_cli/commands/provider/login.py +102 -0
  86. vidbyte_cli/commands/provider/logout.py +45 -0
  87. vidbyte_cli/commands/provider/whoami.py +49 -0
  88. vidbyte_cli/commands/research/__init__.py +5 -0
  89. vidbyte_cli/commands/research/add.py +41 -0
  90. vidbyte_cli/commands/research/options.py +134 -0
  91. vidbyte_cli/commands/research/render.py +157 -0
  92. vidbyte_cli/commands/research/resume.py +37 -0
  93. vidbyte_cli/commands/research/start.py +37 -0
  94. vidbyte_cli/commands/research/status.py +32 -0
  95. vidbyte_cli/commands/research/thread.py +30 -0
  96. vidbyte_cli/commands/research/threads.py +35 -0
  97. vidbyte_cli/commands/research/watch.py +93 -0
  98. vidbyte_cli/commands/runtime/__init__.py +23 -0
  99. vidbyte_cli/commands/runtime/adversarial_team.py +41 -0
  100. vidbyte_cli/commands/runtime/doctor.py +44 -0
  101. vidbyte_cli/commands/runtime/list.py +47 -0
  102. vidbyte_cli/commands/runtime/persistence.py +141 -0
  103. vidbyte_cli/commands/runtime/same_host_ensemble.py +221 -0
  104. vidbyte_cli/commands/runtime/stages.py +553 -0
  105. vidbyte_cli/commands/runtime/task_board.py +1342 -0
  106. vidbyte_cli/commands/setup/__init__.py +0 -0
  107. vidbyte_cli/commands/setup/doctor.py +64 -0
  108. vidbyte_cli/commands/setup/prompts/__init__.py +1 -0
  109. vidbyte_cli/commands/setup/prompts/browser.md +7 -0
  110. vidbyte_cli/commands/setup/prompts/library.py +18 -0
  111. vidbyte_cli/commands/setup/prompts/star.md +13 -0
  112. vidbyte_cli/commands/setup/star.py +132 -0
  113. vidbyte_cli/lib/__init__.py +0 -0
  114. vidbyte_cli/lib/api/__init__.py +0 -0
  115. vidbyte_cli/lib/api/client.py +236 -0
  116. vidbyte_cli/lib/api/endpoints/__init__.py +0 -0
  117. vidbyte_cli/lib/api/endpoints/auth.py +24 -0
  118. vidbyte_cli/lib/api/endpoints/billing.py +36 -0
  119. vidbyte_cli/lib/api/endpoints/research.py +103 -0
  120. vidbyte_cli/lib/api/endpoints/runtime.py +113 -0
  121. vidbyte_cli/lib/api/problem.py +86 -0
  122. vidbyte_cli/lib/api/response.py +101 -0
  123. vidbyte_cli/lib/api/retry.py +116 -0
  124. vidbyte_cli/lib/api/runtime_payment.py +89 -0
  125. vidbyte_cli/lib/auth/__init__.py +16 -0
  126. vidbyte_cli/lib/auth/credentials.py +223 -0
  127. vidbyte_cli/lib/auth/input.py +59 -0
  128. vidbyte_cli/lib/auth/keyring_store.py +126 -0
  129. vidbyte_cli/lib/auth/provider_credentials.py +157 -0
  130. vidbyte_cli/lib/auth/provider_input.py +54 -0
  131. vidbyte_cli/lib/auth/provider_resolver.py +89 -0
  132. vidbyte_cli/lib/auth/provider_store.py +149 -0
  133. vidbyte_cli/lib/auth/provider_verifier.py +219 -0
  134. vidbyte_cli/lib/auth/resolver.py +63 -0
  135. vidbyte_cli/lib/auth/verifier.py +38 -0
  136. vidbyte_cli/lib/config/__init__.py +25 -0
  137. vidbyte_cli/lib/config/atomic.py +47 -0
  138. vidbyte_cli/lib/config/config.py +141 -0
  139. vidbyte_cli/lib/config/migration.py +81 -0
  140. vidbyte_cli/lib/config/models.py +145 -0
  141. vidbyte_cli/lib/config/paths.py +66 -0
  142. vidbyte_cli/lib/config/resolver.py +137 -0
  143. vidbyte_cli/lib/constants/__init__.py +1 -0
  144. vidbyte_cli/lib/constants/runtime.py +186 -0
  145. vidbyte_cli/lib/errors/__init__.py +12 -0
  146. vidbyte_cli/lib/errors/cli_error.py +86 -0
  147. vidbyte_cli/lib/errors/codes.py +43 -0
  148. vidbyte_cli/lib/errors/failures.py +2910 -0
  149. vidbyte_cli/lib/errors/handler.py +72 -0
  150. vidbyte_cli/lib/files/__init__.py +6 -0
  151. vidbyte_cli/lib/files/documents.py +94 -0
  152. vidbyte_cli/lib/files/store.py +156 -0
  153. vidbyte_cli/lib/io/__init__.py +16 -0
  154. vidbyte_cli/lib/io/attachments.py +129 -0
  155. vidbyte_cli/lib/io/codex_attachments.py +47 -0
  156. vidbyte_cli/lib/io/prompt.py +71 -0
  157. vidbyte_cli/lib/io/streams.py +37 -0
  158. vidbyte_cli/lib/io/terminal.py +66 -0
  159. vidbyte_cli/lib/output/__init__.py +7 -0
  160. vidbyte_cli/lib/output/formats.py +29 -0
  161. vidbyte_cli/lib/output/manager.py +95 -0
  162. vidbyte_cli/lib/output/models.py +49 -0
  163. vidbyte_cli/lib/runtime/__init__.py +9 -0
  164. vidbyte_cli/lib/runtime/application.py +134 -0
  165. vidbyte_cli/lib/runtime/context.py +250 -0
  166. vidbyte_cli/lib/runtime/options.py +159 -0
  167. vidbyte_cli/lib/runtime/version.py +30 -0
  168. vidbyte_cli/lib/runtime_primitives/__init__.py +28 -0
  169. vidbyte_cli/lib/runtime_primitives/executor.py +54 -0
  170. vidbyte_cli/lib/runtime_primitives/gate.py +105 -0
  171. vidbyte_cli/lib/runtime_primitives/hosts.py +45 -0
  172. vidbyte_cli/lib/runtime_primitives/planner.py +79 -0
  173. vidbyte_cli/lib/runtime_primitives/stages.py +338 -0
  174. vidbyte_cli/lib/runtime_primitives/task_board.py +982 -0
  175. vidbyte_cli/lib/runtime_primitives/task_board_checkpoints.py +473 -0
  176. vidbyte_cli/lib/runtime_primitives/task_board_system.md +33 -0
  177. vidbyte_cli/lib/runtime_primitives/task_board_tasks.py +111 -0
  178. vidbyte_cli/lib/runtime_primitives/verification.py +68 -0
  179. vidbyte_cli/services/__init__.py +5 -0
  180. vidbyte_cli/services/ensemble/__init__.py +7 -0
  181. vidbyte_cli/services/ensemble/prompts/__init__.py +6 -0
  182. vidbyte_cli/services/ensemble/prompts/implementer_system.md +546 -0
  183. vidbyte_cli/services/ensemble/prompts/implementer_turn.md +49 -0
  184. vidbyte_cli/services/ensemble/prompts/library.py +188 -0
  185. vidbyte_cli/services/ensemble/prompts/planner_system.md +728 -0
  186. vidbyte_cli/services/ensemble/prompts/planner_turn.md +6 -0
  187. vidbyte_cli/services/ensemble/prompts/role_system.md +104 -0
  188. vidbyte_cli/services/ensemble/prompts/role_turn.md +19 -0
  189. vidbyte_cli/services/ensemble/prompts/selector_final_turn.md +19 -0
  190. vidbyte_cli/services/ensemble/prompts/selector_round_turn.md +17 -0
  191. vidbyte_cli/services/ensemble/prompts/selector_system.md +89 -0
  192. vidbyte_cli/services/ensemble/runner.py +30 -0
  193. vidbyte_cli/services/ensemble/sdk.py +134 -0
  194. vidbyte_cli/services/ensemble/service.py +233 -0
  195. vidbyte_cli/services/ensemble/settings.py +60 -0
  196. vidbyte_cli/services/persistence/__init__.py +6 -0
  197. vidbyte_cli/services/persistence/prompts/__init__.py +6 -0
  198. vidbyte_cli/services/persistence/prompts/library.py +46 -0
  199. vidbyte_cli/services/persistence/prompts/persistence_system.md +1 -0
  200. vidbyte_cli/services/persistence/prompts/persistence_turn.md +3 -0
  201. vidbyte_cli/services/persistence/runner.py +35 -0
  202. vidbyte_cli/services/persistence/session.py +116 -0
  203. vidbyte_cli/services/suggestions/__init__.py +7 -0
  204. vidbyte_cli/services/suggestions/categories.py +270 -0
  205. vidbyte_cli/services/suggestions/context.py +222 -0
  206. vidbyte_cli/services/suggestions/extra_compute.py +53 -0
  207. vidbyte_cli/services/suggestions/handoff.py +173 -0
  208. vidbyte_cli/services/suggestions/message_tools.py +47 -0
  209. vidbyte_cli/services/suggestions/project.py +253 -0
  210. vidbyte_cli/services/suggestions/prompts/__init__.py +1 -0
  211. vidbyte_cli/services/suggestions/prompts/categories/__init__.py +3 -0
  212. vidbyte_cli/services/suggestions/prompts/categories/adjacent_opportunity.md +37 -0
  213. vidbyte_cli/services/suggestions/prompts/categories/alternative.md +38 -0
  214. vidbyte_cli/services/suggestions/prompts/categories/big_bets.md +37 -0
  215. vidbyte_cli/services/suggestions/prompts/categories/bottleneck.md +38 -0
  216. vidbyte_cli/services/suggestions/prompts/categories/business_growth.md +38 -0
  217. vidbyte_cli/services/suggestions/prompts/categories/completion.md +37 -0
  218. vidbyte_cli/services/suggestions/prompts/categories/continuation.md +37 -0
  219. vidbyte_cli/services/suggestions/prompts/categories/coordination.md +37 -0
  220. vidbyte_cli/services/suggestions/prompts/categories/creative_exploration.md +37 -0
  221. vidbyte_cli/services/suggestions/prompts/categories/cross_domain.md +37 -0
  222. vidbyte_cli/services/suggestions/prompts/categories/delegation.md +37 -0
  223. vidbyte_cli/services/suggestions/prompts/categories/experiment.md +37 -0
  224. vidbyte_cli/services/suggestions/prompts/categories/feedback.md +37 -0
  225. vidbyte_cli/services/suggestions/prompts/categories/goal_clarification.md +37 -0
  226. vidbyte_cli/services/suggestions/prompts/categories/immediate_next_steps.md +37 -0
  227. vidbyte_cli/services/suggestions/prompts/categories/investigation.md +37 -0
  228. vidbyte_cli/services/suggestions/prompts/categories/learning.md +37 -0
  229. vidbyte_cli/services/suggestions/prompts/categories/leverage.md +37 -0
  230. vidbyte_cli/services/suggestions/prompts/categories/long_term_directions.md +37 -0
  231. vidbyte_cli/services/suggestions/prompts/categories/preparation.md +37 -0
  232. vidbyte_cli/services/suggestions/prompts/categories/prerequisite.md +37 -0
  233. vidbyte_cli/services/suggestions/prompts/categories/prioritization.md +37 -0
  234. vidbyte_cli/services/suggestions/prompts/categories/product_experience.md +37 -0
  235. vidbyte_cli/services/suggestions/prompts/categories/quick_wins.md +37 -0
  236. vidbyte_cli/services/suggestions/prompts/categories/reframing.md +37 -0
  237. vidbyte_cli/services/suggestions/prompts/categories/risk_prevention.md +37 -0
  238. vidbyte_cli/services/suggestions/prompts/categories/simplification.md +37 -0
  239. vidbyte_cli/services/suggestions/prompts/categories/stop_or_defer.md +37 -0
  240. vidbyte_cli/services/suggestions/prompts/categories/strategy.md +37 -0
  241. vidbyte_cli/services/suggestions/prompts/categories/technical_possibilities.md +37 -0
  242. vidbyte_cli/services/suggestions/prompts/categories/verification.md +37 -0
  243. vidbyte_cli/services/suggestions/prompts/critic.md +209 -0
  244. vidbyte_cli/services/suggestions/prompts/critic_message.md +8 -0
  245. vidbyte_cli/services/suggestions/prompts/generator.md +64 -0
  246. vidbyte_cli/services/suggestions/prompts/library.py +72 -0
  247. vidbyte_cli/services/suggestions/prompts/message_generator_argument.md +1 -0
  248. vidbyte_cli/services/suggestions/prompts/message_generator_tool.md +1 -0
  249. vidbyte_cli/services/suggestions/prompts/message_parent_argument.md +1 -0
  250. vidbyte_cli/services/suggestions/prompts/message_parent_tool.md +1 -0
  251. vidbyte_cli/services/suggestions/prompts/message_receipt.md +1 -0
  252. vidbyte_cli/services/suggestions/prompts/revision.md +8 -0
  253. vidbyte_cli/services/suggestions/sdk.py +228 -0
  254. vidbyte_cli/services/suggestions/selection.py +78 -0
  255. vidbyte_cli/services/suggestions/service.py +540 -0
  256. vidbyte_cli/types/__init__.py +0 -0
  257. vidbyte_cli/types/api.py +101 -0
  258. vidbyte_cli/types/attachments.py +79 -0
  259. vidbyte_cli/types/ensemble.py +189 -0
  260. vidbyte_cli/types/provider.py +62 -0
  261. vidbyte_cli/types/research.py +260 -0
  262. vidbyte_cli/types/runtime.py +1098 -0
  263. vidbyte_cli/types/suggestions.py +518 -0
  264. vidbyte_cli-0.1.0.dist-info/METADATA +335 -0
  265. vidbyte_cli-0.1.0.dist-info/RECORD +269 -0
  266. vidbyte_cli-0.1.0.dist-info/WHEEL +5 -0
  267. vidbyte_cli-0.1.0.dist-info/entry_points.txt +2 -0
  268. vidbyte_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  269. vidbyte_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,89 @@
1
+ """Which provider credential this invocation uses and where it came from.
2
+
3
+ Order is environment → keyring → restricted file. Secrets get their own precedence
4
+ because a CI job exporting OPENAI_API_KEY must win over whatever was stored locally.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Mapping
10
+ from dataclasses import dataclass
11
+ from enum import StrEnum
12
+
13
+ from ...types.provider import PROVIDER_ENV_VARS, Provider
14
+ from ..errors.failures import InvalidProviderEnvironmentKey
15
+ from .provider_credentials import ProviderCredentials
16
+ from .provider_store import ProviderCredentialStore
17
+
18
+ _MAX_KEY_CHARACTERS = 4096
19
+
20
+
21
+ class ProviderSource(StrEnum):
22
+ """Stable non-secret provenance for a resolved provider credential."""
23
+
24
+ ENVIRONMENT = "environment"
25
+ KEYRING = "keyring"
26
+ RESTRICTED_FILE = "restricted_file"
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class ResolvedProviderCredential:
31
+ """Secret-safe credential plus non-secret provenance."""
32
+
33
+ credentials: ProviderCredentials
34
+ source: ProviderSource
35
+
36
+
37
+ class ProviderResolver:
38
+ """Read one scoped provider credential according to documented precedence."""
39
+
40
+ def __init__(self, store: ProviderCredentialStore, environment: Mapping[str, str]) -> None:
41
+ # Store and environment injected for test control.
42
+ self._store = store
43
+ self._environment = environment
44
+
45
+ def resolve(self, profile: str, provider: Provider) -> ResolvedProviderCredential | None:
46
+ # Env var is highest; set-but-unusable is an error, not a silent miss.
47
+ if provider == Provider.GEMINI:
48
+ return self._resolve_gemini(profile)
49
+ env_var = PROVIDER_ENV_VARS[provider]
50
+ environment_value = self._environment.get(env_var)
51
+ if environment_value is not None:
52
+ token = environment_value.strip()
53
+ if not token or len(token) > _MAX_KEY_CHARACTERS:
54
+ raise InvalidProviderEnvironmentKey(provider.value, env_var)
55
+ if not ProviderCredentials.is_live_format(provider, token):
56
+ raise InvalidProviderEnvironmentKey(provider.value, env_var)
57
+ return ResolvedProviderCredential(
58
+ ProviderCredentials(provider=provider, api_key=token), # type: ignore[arg-type]
59
+ ProviderSource.ENVIRONMENT,
60
+ )
61
+ keyring_value = self._store.keyring.read(profile, provider)
62
+ if keyring_value is not None:
63
+ return ResolvedProviderCredential(keyring_value, ProviderSource.KEYRING)
64
+ file_value = self._store.file.read(profile, provider)
65
+ if file_value is not None:
66
+ return ResolvedProviderCredential(file_value, ProviderSource.RESTRICTED_FILE)
67
+ return None
68
+
69
+ def _resolve_gemini(self, profile: str) -> ResolvedProviderCredential | None:
70
+ # Google supports GEMINI_API_KEY and legacy GOOGLE_API_KEY, with GOOGLE taking precedence.
71
+ for env_var in ("GOOGLE_API_KEY", "GEMINI_API_KEY"):
72
+ environment_value = self._environment.get(env_var)
73
+ if environment_value is not None:
74
+ token = environment_value.strip()
75
+ if not token or len(token) > _MAX_KEY_CHARACTERS:
76
+ raise InvalidProviderEnvironmentKey(Provider.GEMINI.value, env_var)
77
+ if not ProviderCredentials.is_live_format(Provider.GEMINI, token):
78
+ raise InvalidProviderEnvironmentKey(Provider.GEMINI.value, env_var)
79
+ return ResolvedProviderCredential(
80
+ ProviderCredentials(provider=Provider.GEMINI, api_key=token), # type: ignore[arg-type]
81
+ ProviderSource.ENVIRONMENT,
82
+ )
83
+ keyring_value = self._store.keyring.read(profile, Provider.GEMINI)
84
+ if keyring_value is not None:
85
+ return ResolvedProviderCredential(keyring_value, ProviderSource.KEYRING)
86
+ file_value = self._store.file.read(profile, Provider.GEMINI)
87
+ if file_value is not None:
88
+ return ResolvedProviderCredential(file_value, ProviderSource.RESTRICTED_FILE)
89
+ return None
@@ -0,0 +1,149 @@
1
+ """Keyring-first provider credential facade.
2
+
3
+ Mirrors CredentialStore but namespaces to vidbyte-cli-provider and scopes by provider.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import TYPE_CHECKING, Protocol, cast
9
+
10
+ import keyring
11
+ from keyring.errors import KeyringError
12
+ from pydantic import ValidationError
13
+
14
+ from ...types.provider import Provider
15
+ from ..config.paths import VidbytePaths
16
+ from ..errors.failures import NoApprovedProviderStore, ProviderStoreUnavailable
17
+ from .provider_credentials import (
18
+ FileProviderStore,
19
+ ProviderCredentials,
20
+ ProviderScope,
21
+ ProviderStorage,
22
+ )
23
+
24
+ if TYPE_CHECKING:
25
+ pass
26
+
27
+ _PROVIDER_SERVICE = "vidbyte-cli-provider"
28
+ _MINIMUM_BACKEND_PRIORITY = 1
29
+
30
+
31
+ class ProviderKeyringBackend(Protocol):
32
+ """Small structural surface needed from the provider keyring backend."""
33
+
34
+ priority: float
35
+
36
+ def get_password(self, service: str, username: str) -> str | None: ...
37
+
38
+ def set_password(self, service: str, username: str, password: str) -> None: ...
39
+
40
+ def delete_password(self, service: str, username: str) -> None: ...
41
+
42
+
43
+ class KeyringProviderStore:
44
+ """Secret store for provider keys backed by the OS keyring."""
45
+
46
+ def __init__(self, backend: ProviderKeyringBackend | None = None) -> None:
47
+ # Backend injectable for tests; otherwise resolved lazily.
48
+ self._backend = backend
49
+
50
+ def available(self) -> bool:
51
+ # Availability is a question, not a failure.
52
+ try:
53
+ return float(self._get_backend().priority) >= _MINIMUM_BACKEND_PRIORITY
54
+ except (KeyringError, RuntimeError, TypeError, ValueError):
55
+ return False
56
+
57
+ def read(self, profile: str, provider: Provider) -> ProviderCredentials | None:
58
+ # Returns None when unavailable or missing; raises only on backend errors.
59
+ if not self.available():
60
+ return None
61
+ try:
62
+ value = self._get_backend().get_password(
63
+ _PROVIDER_SERVICE,
64
+ ProviderScope(profile, provider).account,
65
+ )
66
+ except KeyringError as error:
67
+ raise ProviderStoreUnavailable(error) from error
68
+ if value is None:
69
+ return None
70
+ try:
71
+ return ProviderCredentials(provider=provider, api_key=value) # type: ignore[arg-type]
72
+ except ValidationError as error:
73
+ raise ProviderStoreUnavailable(error) from error
74
+
75
+ def write(self, credentials: ProviderCredentials, profile: str, provider: Provider) -> None:
76
+ # Verifies write by reading back; a lying backend must not report success.
77
+ if not self.available():
78
+ raise ProviderStoreUnavailable()
79
+ account = ProviderScope(profile, provider).account
80
+ try:
81
+ backend = self._get_backend()
82
+ backend.set_password(_PROVIDER_SERVICE, account, credentials.secret_value())
83
+ read_back = backend.get_password(_PROVIDER_SERVICE, account)
84
+ except KeyringError as error:
85
+ raise ProviderStoreUnavailable(error) from error
86
+ if read_back != credentials.secret_value():
87
+ raise ProviderStoreUnavailable()
88
+
89
+ def clear(self, profile: str, provider: Provider) -> bool:
90
+ # Clears the keyring entry if present.
91
+ if not self.available():
92
+ return False
93
+ account = ProviderScope(profile, provider).account
94
+ try:
95
+ backend = self._get_backend()
96
+ if backend.get_password(_PROVIDER_SERVICE, account) is None:
97
+ return False
98
+ backend.delete_password(_PROVIDER_SERVICE, account)
99
+ return True
100
+ except KeyringError as error:
101
+ raise ProviderStoreUnavailable(error) from error
102
+
103
+ def _get_backend(self) -> ProviderKeyringBackend:
104
+ # Resolved on first use and cached; discovery can open a keychain session.
105
+ if self._backend is None:
106
+ self._backend = cast(ProviderKeyringBackend, keyring.get_keyring())
107
+ return self._backend
108
+
109
+
110
+ class ProviderCredentialStore:
111
+ """Keyring-first durable provider credential facade."""
112
+
113
+ def __init__(
114
+ self,
115
+ keyring_store: KeyringProviderStore | None = None,
116
+ file_store: FileProviderStore | None = None,
117
+ paths: VidbytePaths | None = None,
118
+ ) -> None:
119
+ # Composition mirrors CredentialStore for test injection.
120
+ resolved_paths = paths or VidbytePaths.default()
121
+ self.keyring = keyring_store or KeyringProviderStore()
122
+ self.file = file_store or FileProviderStore(resolved_paths)
123
+
124
+ def read(self, profile: str, provider: Provider) -> ProviderCredentials | None:
125
+ # Keyring outranks file; file is fallback.
126
+ return self.keyring.read(profile, provider) or self.file.read(profile, provider)
127
+
128
+ def write(
129
+ self,
130
+ credentials: ProviderCredentials,
131
+ profile: str,
132
+ provider: Provider,
133
+ *,
134
+ allow_file_fallback: bool = False,
135
+ ) -> ProviderStorage:
136
+ # Writes to keyring when available; otherwise file with consent.
137
+ if self.keyring.available():
138
+ self.keyring.write(credentials, profile, provider)
139
+ return ProviderStorage.KEYRING
140
+ if not allow_file_fallback:
141
+ raise NoApprovedProviderStore(provider.value)
142
+ self.file.write(credentials, profile, provider)
143
+ return ProviderStorage.RESTRICTED_FILE
144
+
145
+ def clear(self, profile: str, provider: Provider) -> bool:
146
+ # Clears both stores; a stale entry in either would stay effective.
147
+ keyring_cleared = self.keyring.clear(profile, provider)
148
+ file_cleared = self.file.clear(profile, provider)
149
+ return keyring_cleared or file_cleared
@@ -0,0 +1,219 @@
1
+ """Live probe verifiers for provider BYOK keys.
2
+
3
+ Verifies OpenAI via GET https://api.openai.com/v1/models with Authorization: Bearer,
4
+ Claude via GET https://api.anthropic.com/v1/models with x-api-key + anthropic-version,
5
+ and Grok/DeepSeek/GLM/Muse via Bearer probes to their native /v1/models endpoints.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from typing import Protocol
12
+
13
+ from pydantic import BaseModel, ConfigDict
14
+
15
+ from ...types.provider import PROVIDER_PROBE_URLS, Provider
16
+ from ..errors.failures import (
17
+ ProviderApiProtocolError,
18
+ ProviderApiUnreachable,
19
+ ProviderCredentialsRejected,
20
+ ProviderRateLimited,
21
+ ProviderRequestRejected,
22
+ )
23
+ from .provider_credentials import ProviderCredentials
24
+
25
+ _MAX_RESPONSE_BYTES = 1_048_576
26
+
27
+
28
+ class ProviderIdentity(BaseModel):
29
+ """Non-secret identity behind a verified provider key."""
30
+
31
+ model_config = ConfigDict(extra="forbid", frozen=True)
32
+
33
+ provider: Provider
34
+ verified: bool = True
35
+
36
+
37
+ class ProviderVerifier(Protocol):
38
+ """Proves a candidate provider key against its native endpoint."""
39
+
40
+ def verify(self, credentials: ProviderCredentials) -> ProviderIdentity: ...
41
+
42
+
43
+ class _BaseProviderVerifier:
44
+ """Shared transport and decoding for one provider probe."""
45
+
46
+ def _headers(self, credentials: ProviderCredentials) -> dict[str, str]:
47
+ # Subclasses define exact auth headers per provider docs.
48
+ raise NotImplementedError
49
+
50
+ def _url(self, provider: Provider) -> str:
51
+ # Canonical probe URL per provider.
52
+ return PROVIDER_PROBE_URLS[provider]
53
+
54
+ def _probe(self, credentials: ProviderCredentials, timeout_seconds: float) -> None:
55
+ # Executes the live GET and classifies the outcome.
56
+ import httpx
57
+
58
+ url = self._url(credentials.provider)
59
+ headers = {"Accept": "application/json", **self._headers(credentials)}
60
+ try:
61
+ with httpx.Client(timeout=timeout_seconds, follow_redirects=False) as client:
62
+ response = client.get(url, headers=headers)
63
+ except httpx.HTTPError as error:
64
+ raise ProviderApiUnreachable(credentials.provider.value, error) from error
65
+ self._classify_status(credentials, response)
66
+ self._validate_body(credentials, response)
67
+
68
+ def _classify_status(self, credentials: ProviderCredentials, response: object) -> None:
69
+ # Maps status to a typed failure before any body is inspected.
70
+ status = int(getattr(response, "status_code", 0))
71
+ headers = getattr(response, "headers", {})
72
+ if status == 429:
73
+ retry = headers.get("Retry-After") if hasattr(headers, "get") else None
74
+ try:
75
+ retry_after = int(retry) if retry is not None else None
76
+ except ValueError:
77
+ retry_after = None
78
+ raise ProviderRateLimited(credentials.provider.value, retry_after)
79
+ if status in (401, 403):
80
+ raise ProviderCredentialsRejected(credentials.provider.value)
81
+ if status in (400, 404, 409, 422):
82
+ raise ProviderRequestRejected(credentials.provider.value)
83
+ if 500 <= status < 600:
84
+ raise ProviderApiUnreachable(credentials.provider.value)
85
+ if not 200 <= status < 300:
86
+ raise ProviderRequestRejected(credentials.provider.value)
87
+
88
+ def _validate_body(self, credentials: ProviderCredentials, response: object) -> None:
89
+ # Bounds, parses, and shape-checks the 2xx body.
90
+ content = getattr(response, "content", b"")
91
+ if not content or len(content) > _MAX_RESPONSE_BYTES:
92
+ raise ProviderApiProtocolError(credentials.provider.value)
93
+ try:
94
+ payload = json.loads(content)
95
+ except (ValueError, json.JSONDecodeError) as error:
96
+ raise ProviderApiProtocolError(credentials.provider.value, error) from error
97
+ if not isinstance(payload, dict):
98
+ raise ProviderApiProtocolError(credentials.provider.value)
99
+ # OpenAI-style uses data; Gemini/Cohere use models.
100
+ data = payload.get("data")
101
+ if isinstance(data, list):
102
+ return
103
+ models = payload.get("models")
104
+ if isinstance(models, list):
105
+ return
106
+ raise ProviderApiProtocolError(credentials.provider.value)
107
+
108
+
109
+ class OpenAIVerifier(_BaseProviderVerifier):
110
+ """Proves an OpenAI key via GET /v1/models with Bearer auth."""
111
+
112
+ def verify(self, credentials: ProviderCredentials) -> ProviderIdentity:
113
+ # Uses a short timeout derived from caller's config where available.
114
+ self._probe(credentials, timeout_seconds=15.0)
115
+ return ProviderIdentity(provider=credentials.provider)
116
+
117
+ def _headers(self, credentials: ProviderCredentials) -> dict[str, str]:
118
+ # Exact string per OpenAI docs: Authorization: Bearer $OPENAI_API_KEY
119
+ return {"Authorization": f"Bearer {credentials.secret_value()}"}
120
+
121
+
122
+ class ClaudeVerifier(_BaseProviderVerifier):
123
+ """Proves a Claude key via GET /v1/models with x-api-key + version."""
124
+
125
+ def verify(self, credentials: ProviderCredentials) -> ProviderIdentity:
126
+ # Anthropic requires anthropic-version header on every request.
127
+ self._probe(credentials, timeout_seconds=15.0)
128
+ return ProviderIdentity(provider=credentials.provider)
129
+
130
+ def _headers(self, credentials: ProviderCredentials) -> dict[str, str]:
131
+ # Per Anthropic docs: x-api-key + anthropic-version: 2023-06-01
132
+ return {
133
+ "x-api-key": credentials.secret_value(),
134
+ "anthropic-version": "2023-06-01",
135
+ }
136
+
137
+
138
+ class GrokVerifier(_BaseProviderVerifier):
139
+ """Proves a Grok key via GET /v1/models with Bearer auth."""
140
+
141
+ def verify(self, credentials: ProviderCredentials) -> ProviderIdentity:
142
+ # xAI uses OpenAI-compatible Bearer on https://api.x.ai/v1/models.
143
+ self._probe(credentials, timeout_seconds=15.0)
144
+ return ProviderIdentity(provider=credentials.provider)
145
+
146
+ def _headers(self, credentials: ProviderCredentials) -> dict[str, str]:
147
+ # Exact string per xAI docs: Authorization: Bearer $XAI_API_KEY
148
+ return {"Authorization": f"Bearer {credentials.secret_value()}"}
149
+
150
+
151
+ class DeepSeekVerifier(_BaseProviderVerifier):
152
+ """Proves a DeepSeek key via GET /v1/models with Bearer auth."""
153
+
154
+ def verify(self, credentials: ProviderCredentials) -> ProviderIdentity:
155
+ # DeepSeek is OpenAI-compatible on https://api.deepseek.com/v1/models.
156
+ self._probe(credentials, timeout_seconds=15.0)
157
+ return ProviderIdentity(provider=credentials.provider)
158
+
159
+ def _headers(self, credentials: ProviderCredentials) -> dict[str, str]:
160
+ # Per DeepSeek docs: Authorization: Bearer $DEEPSEEK_API_KEY
161
+ return {"Authorization": f"Bearer {credentials.secret_value()}"}
162
+
163
+
164
+ class GlmVerifier(_BaseProviderVerifier):
165
+ """Proves a GLM key via GET /models with Bearer auth."""
166
+
167
+ def verify(self, credentials: ProviderCredentials) -> ProviderIdentity:
168
+ # Z.AI is OpenAI-compatible on https://api.z.ai/api/paas/v4/models.
169
+ self._probe(credentials, timeout_seconds=15.0)
170
+ return ProviderIdentity(provider=credentials.provider)
171
+
172
+ def _headers(self, credentials: ProviderCredentials) -> dict[str, str]:
173
+ # Per Z.AI docs: Authorization: Bearer $ZAI_API_KEY
174
+ return {"Authorization": f"Bearer {credentials.secret_value()}"}
175
+
176
+
177
+ class MuseVerifier(_BaseProviderVerifier):
178
+ """Proves a Muse key via GET /v1/models with Bearer auth."""
179
+
180
+ def verify(self, credentials: ProviderCredentials) -> ProviderIdentity:
181
+ # Meta is OpenAI-compatible on https://api.meta.ai/v1/models.
182
+ self._probe(credentials, timeout_seconds=15.0)
183
+ return ProviderIdentity(provider=credentials.provider)
184
+
185
+ def _headers(self, credentials: ProviderCredentials) -> dict[str, str]:
186
+ # Per Meta docs: Authorization: Bearer $MODEL_API_KEY
187
+ return {"Authorization": f"Bearer {credentials.secret_value()}"}
188
+
189
+
190
+ class GeminiVerifier(_BaseProviderVerifier):
191
+ """Proves a Gemini key via GET /v1beta/models with x-goog-api-key."""
192
+
193
+ def verify(self, credentials: ProviderCredentials) -> ProviderIdentity:
194
+ # Google uses x-goog-api-key on generativelanguage.googleapis.com.
195
+ self._probe(credentials, timeout_seconds=15.0)
196
+ return ProviderIdentity(provider=credentials.provider)
197
+
198
+ def _headers(self, credentials: ProviderCredentials) -> dict[str, str]:
199
+ # Per Google docs: x-goog-api-key: $GEMINI_API_KEY
200
+ return {"x-goog-api-key": credentials.secret_value()}
201
+
202
+
203
+ def verifier_for_provider(provider: Provider) -> ProviderVerifier:
204
+ # Factory keeps command branching closed over Provider variants.
205
+ if provider == Provider.OPENAI:
206
+ return OpenAIVerifier()
207
+ if provider == Provider.CLAUDE:
208
+ return ClaudeVerifier()
209
+ if provider == Provider.GROK:
210
+ return GrokVerifier()
211
+ if provider == Provider.DEEPSEEK:
212
+ return DeepSeekVerifier()
213
+ if provider == Provider.GLM:
214
+ return GlmVerifier()
215
+ if provider == Provider.MUSE:
216
+ return MuseVerifier()
217
+ if provider == Provider.GEMINI:
218
+ return GeminiVerifier()
219
+ raise ValueError(f"unsupported provider {provider}")
@@ -0,0 +1,63 @@
1
+ """Which credential this invocation uses, and where it came from.
2
+
3
+ Order is environment → OS keyring → restricted file. Secrets get their own precedence
4
+ because a CI job exporting `VIDBYTE_API_KEY` must win over whatever a developer once stored
5
+ on the same machine, while non-secret config follows the profile layering in `lib/config`.
6
+
7
+ Reading is all this does. Login deliberately does not resolve through here, which is what
8
+ keeps an environment token from being written into durable storage as if it were entered.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Mapping
14
+ from dataclasses import dataclass
15
+ from enum import StrEnum
16
+
17
+ from ..errors.failures import EnvironmentApiKeyNotLive, InvalidEnvironmentApiKey
18
+ from .credentials import Credentials, CredentialStore
19
+
20
+ _MAX_KEY_CHARACTERS = 4096
21
+
22
+
23
+ class CredentialSource(StrEnum):
24
+ """Stable non-secret provenance for a resolved credential."""
25
+
26
+ ENVIRONMENT = "environment"
27
+ KEYRING = "keyring"
28
+ RESTRICTED_FILE = "restricted_file"
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class ResolvedCredential:
33
+ """Secret-safe credential plus non-secret provenance."""
34
+
35
+ credentials: Credentials
36
+ source: CredentialSource
37
+
38
+
39
+ class CredentialResolver:
40
+ """Read one scoped credential according to documented precedence."""
41
+
42
+ def __init__(self, store: CredentialStore, environment: Mapping[str, str]) -> None:
43
+ self._store = store
44
+ self._environment = environment
45
+
46
+ def resolve(self, profile: str, api_url: str) -> ResolvedCredential | None:
47
+ environment_value = self._environment.get("VIDBYTE_API_KEY")
48
+ if environment_value is not None:
49
+ # Set-but-unusable is an error, not a miss: falling through would silently
50
+ # authenticate as whoever is stored locally, which the caller did not ask for.
51
+ token = environment_value.strip()
52
+ if not token or len(token) > _MAX_KEY_CHARACTERS:
53
+ raise InvalidEnvironmentApiKey()
54
+ if not Credentials.is_live_format(token):
55
+ raise EnvironmentApiKeyNotLive()
56
+ return ResolvedCredential(Credentials.from_value(token), CredentialSource.ENVIRONMENT)
57
+ keyring_value = self._store.keyring.read(profile, api_url)
58
+ if keyring_value is not None:
59
+ return ResolvedCredential(keyring_value, CredentialSource.KEYRING)
60
+ file_value = self._store.file.read(profile, api_url)
61
+ if file_value is not None:
62
+ return ResolvedCredential(file_value, CredentialSource.RESTRICTED_FILE)
63
+ return None
@@ -0,0 +1,38 @@
1
+ """The verify-before-persist boundary login must cross before a key reaches storage.
2
+
3
+ Making it a protocol states the invariant in the type system: `CredentialStore.write` is
4
+ only ever reached through a `verify` that returned. A syntactically valid token is not a
5
+ working one, and a stored bad key fails every later command with no obvious cause.
6
+
7
+ `verify` returns the identity rather than nothing so `login` and `whoami` can share one call:
8
+ login discards the result, whoami prints it. Two commands asking the same question of the
9
+ backend cannot then drift into asking it two different ways.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Protocol
15
+
16
+ from ...types.api import KeyIdentity
17
+ from ..api.client import ApiClient
18
+ from ..api.endpoints.auth import AuthEndpoints
19
+ from ..config import ResolvedConfig
20
+ from ..errors.failures import ApiResponseUnsupported
21
+ from .credentials import Credentials
22
+
23
+
24
+ class CredentialVerifier(Protocol):
25
+ def verify(self, credentials: Credentials, config: ResolvedConfig) -> KeyIdentity: ...
26
+
27
+
28
+ class ApiCredentialVerifier:
29
+ """Prove a candidate key against the backend before it may be persisted."""
30
+
31
+ def verify(self, credentials: Credentials, config: ResolvedConfig) -> KeyIdentity:
32
+ # Calls the permission-free liveness check and returns the non-secret identity.
33
+ identity = AuthEndpoints(ApiClient(config, credentials)).validate()
34
+ if not identity.success:
35
+ # A success status that denies success is a contract this CLI cannot act on, and
36
+ # treating it as acceptance would store a key the backend has just refused.
37
+ raise ApiResponseUnsupported()
38
+ return identity
@@ -0,0 +1,25 @@
1
+ """Typed, versioned non-secret CLI configuration contracts."""
2
+
3
+ from .config import ConfigSnapshot, ConfigStore
4
+ from .models import (
5
+ ConfigDocument,
6
+ ConfigField,
7
+ ConfigSource,
8
+ ProfileConfig,
9
+ ResolvedConfig,
10
+ )
11
+ from .paths import VidbytePaths
12
+ from .resolver import ConfigOverrides, ConfigResolver
13
+
14
+ __all__ = [
15
+ "ConfigDocument",
16
+ "ConfigField",
17
+ "ConfigOverrides",
18
+ "ConfigResolver",
19
+ "ConfigSnapshot",
20
+ "ConfigSource",
21
+ "ConfigStore",
22
+ "ProfileConfig",
23
+ "ResolvedConfig",
24
+ "VidbytePaths",
25
+ ]
@@ -0,0 +1,47 @@
1
+ """The one way local CLI state is replaced on disk: owner-only, fsynced, atomic.
2
+
3
+ Config, the fallback credential file, and migrated copies all go through here, so the
4
+ permission and durability rules live in one reviewable place instead of at each store.
5
+
6
+ The write goes to a randomized sibling in the destination directory before `os.replace`,
7
+ because replace is only atomic within a filesystem, and an interrupted write must leave the
8
+ previous file rather than a truncated one. This layer knows nothing about JSON or secrets.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from pathlib import Path
15
+ from uuid import uuid4
16
+
17
+ from ..errors.failures import StateWriteFailed, StateWriteThroughSymlink
18
+
19
+
20
+ class AtomicFileWriter:
21
+ """Write exact files through a flushed sibling and atomic replacement."""
22
+
23
+ def write(self, path: Path, content: bytes, *, mode: int = 0o600) -> None:
24
+ # Replacing a symlink mutates the link entry on some platforms and its target on
25
+ # others. That is not uniform enough to stand as a security boundary for a path
26
+ # that may hold a credential, so refuse rather than pick a behavior.
27
+ if path.is_symlink():
28
+ raise StateWriteThroughSymlink()
29
+ temporary_path = path.with_name(f".{path.name}.{uuid4().hex}.tmp")
30
+ try:
31
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
32
+ # O_EXCL with a random name means this process owns the temporary file outright.
33
+ descriptor = os.open(temporary_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, mode)
34
+ with os.fdopen(descriptor, "wb") as stream:
35
+ stream.write(content)
36
+ stream.flush()
37
+ os.fsync(stream.fileno())
38
+ # Windows ignores the mode passed to os.open, and os.replace preserves the
39
+ # destination's existing permissions, so restate the mode on both sides.
40
+ os.chmod(temporary_path, mode)
41
+ os.replace(temporary_path, path)
42
+ os.chmod(path, mode)
43
+ except OSError as error:
44
+ raise StateWriteFailed(error) from error
45
+ finally:
46
+ # Only this call's randomized sibling is ever removed, never the destination.
47
+ temporary_path.unlink(missing_ok=True)