databay-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 (403) hide show
  1. databay/__init__.py +5 -0
  2. databay/__main__.py +7 -0
  3. databay/agent_skill.py +184 -0
  4. databay/api.py +626 -0
  5. databay/cli/__init__.py +0 -0
  6. databay/cli/auth.py +377 -0
  7. databay/cli/build.py +89 -0
  8. databay/cli/commands/__init__.py +0 -0
  9. databay/cli/commands/alerting_webhook.py +19 -0
  10. databay/cli/commands/alerting_webhook_tag.py +14 -0
  11. databay/cli/commands/alerting_webhook_type.py +14 -0
  12. databay/cli/commands/alerting_webhook_undelivered.py +15 -0
  13. databay/cli/commands/announcement.py +17 -0
  14. databay/cli/commands/api_key.py +18 -0
  15. databay/cli/commands/api_key_project.py +16 -0
  16. databay/cli/commands/backfill_reason_code.py +14 -0
  17. databay/cli/commands/backfill_run.py +17 -0
  18. databay/cli/commands/canvas.py +16 -0
  19. databay/cli/commands/canvas_graph.py +15 -0
  20. databay/cli/commands/canvas_table_check.py +15 -0
  21. databay/cli/commands/dashboards.py +21 -0
  22. databay/cli/commands/dashboards_card.py +20 -0
  23. databay/cli/commands/dashboards_dataset.py +14 -0
  24. databay/cli/commands/dashboards_folder.py +16 -0
  25. databay/cli/commands/dashboards_home.py +17 -0
  26. databay/cli/commands/dashboards_placement.py +16 -0
  27. databay/cli/commands/db.py +19 -0
  28. databay/cli/commands/db_column.py +14 -0
  29. databay/cli/commands/docs_article.py +16 -0
  30. databay/cli/commands/dq_result.py +14 -0
  31. databay/cli/commands/dq_rule.py +16 -0
  32. databay/cli/commands/feature.py +14 -0
  33. databay/cli/commands/google_ads_source.py +14 -0
  34. databay/cli/commands/google_analytics_source.py +14 -0
  35. databay/cli/commands/instagram_account.py +14 -0
  36. databay/cli/commands/instagram_source.py +14 -0
  37. databay/cli/commands/linkedin_account.py +14 -0
  38. databay/cli/commands/linkedin_source.py +14 -0
  39. databay/cli/commands/me.py +14 -0
  40. databay/cli/commands/meta_ads_source.py +14 -0
  41. databay/cli/commands/metric_models.py +26 -0
  42. databay/cli/commands/metrics.py +14 -0
  43. databay/cli/commands/models.py +16 -0
  44. databay/cli/commands/nlq.py +15 -0
  45. databay/cli/commands/notebook_credential.py +16 -0
  46. databay/cli/commands/org.py +14 -0
  47. databay/cli/commands/org_member.py +17 -0
  48. databay/cli/commands/org_offboarding.py +14 -0
  49. databay/cli/commands/pipe.py +26 -0
  50. databay/cli/commands/pipe_backfill_run.py +14 -0
  51. databay/cli/commands/pipe_config_option.py +14 -0
  52. databay/cli/commands/pipe_destination.py +16 -0
  53. databay/cli/commands/pipe_leftover.py +16 -0
  54. databay/cli/commands/pipe_sink_type.py +14 -0
  55. databay/cli/commands/pipe_source_type.py +14 -0
  56. databay/cli/commands/pipe_status_bucket.py +14 -0
  57. databay/cli/commands/pipe_stuck_delete.py +14 -0
  58. databay/cli/commands/plan.py +14 -0
  59. databay/cli/commands/project.py +21 -0
  60. databay/cli/commands/project_budget.py +15 -0
  61. databay/cli/commands/project_member.py +18 -0
  62. databay/cli/commands/project_privilege.py +16 -0
  63. databay/cli/commands/project_share.py +16 -0
  64. databay/cli/commands/provision.py +14 -0
  65. databay/cli/commands/provisioning.py +14 -0
  66. databay/cli/commands/registry.py +14 -0
  67. databay/cli/commands/resource_home.py +14 -0
  68. databay/cli/commands/schema_change.py +18 -0
  69. databay/cli/commands/schema_history.py +17 -0
  70. databay/cli/commands/schema_version.py +16 -0
  71. databay/cli/commands/shopify_store.py +14 -0
  72. databay/cli/commands/sink_data_type.py +14 -0
  73. databay/cli/commands/source.py +24 -0
  74. databay/cli/commands/source_catalog.py +15 -0
  75. databay/cli/commands/source_check_run.py +17 -0
  76. databay/cli/commands/source_hidden_type.py +14 -0
  77. databay/cli/commands/source_name.py +14 -0
  78. databay/cli/commands/source_pipe.py +14 -0
  79. databay/cli/commands/source_publication.py +16 -0
  80. databay/cli/commands/source_readiness_override.py +15 -0
  81. databay/cli/commands/source_setup_choice.py +16 -0
  82. databay/cli/commands/source_stream.py +15 -0
  83. databay/cli/commands/source_table.py +14 -0
  84. databay/cli/commands/ssbi_job.py +18 -0
  85. databay/cli/commands/transform_type.py +14 -0
  86. databay/cli/commands/usage.py +14 -0
  87. databay/cli/commands/warehouse.py +21 -0
  88. databay/cli/commands/warehouse_access_assignment.py +16 -0
  89. databay/cli/commands/warehouse_access_role.py +22 -0
  90. databay/cli/commands/warehouse_access_settings.py +17 -0
  91. databay/cli/commands/warehouse_login.py +20 -0
  92. databay/cli/commands/warehouse_principal.py +17 -0
  93. databay/cli/commands/warehouse_service_account.py +17 -0
  94. databay/cli/commands/warehouse_service_account_credential.py +18 -0
  95. databay/cli/common.py +93 -0
  96. databay/cli/console.py +121 -0
  97. databay/cli/flags.py +183 -0
  98. databay/cli/handwritten.py +22 -0
  99. databay/cli/main.py +198 -0
  100. databay/cli/metrics.py +171 -0
  101. databay/cli/org.py +193 -0
  102. databay/cli/output.py +134 -0
  103. databay/cli/pipe_wait.py +357 -0
  104. databay/cli/profile.py +230 -0
  105. databay/cli/runner.py +432 -0
  106. databay/cli/skill.py +64 -0
  107. databay/cli/sql.py +84 -0
  108. databay/compat.py +161 -0
  109. databay/credentials.py +217 -0
  110. databay/error_catalog.py +226 -0
  111. databay/error_codes.py +3265 -0
  112. databay/errors.py +222 -0
  113. databay/feature_header.py +40 -0
  114. databay/invoke.py +225 -0
  115. databay/locks.py +41 -0
  116. databay/login_flow.py +245 -0
  117. databay/model_base.py +40 -0
  118. databay/models/__init__.py +0 -0
  119. databay/models/alerting_webhook.py +46 -0
  120. databay/models/alerting_webhook_tag.py +11 -0
  121. databay/models/alerting_webhook_type.py +11 -0
  122. databay/models/alerting_webhook_undelivered.py +23 -0
  123. databay/models/announcement.py +37 -0
  124. databay/models/api_key.py +81 -0
  125. databay/models/api_key_project.py +29 -0
  126. databay/models/backfill_reason_code.py +11 -0
  127. databay/models/backfill_run.py +41 -0
  128. databay/models/canvas.py +68 -0
  129. databay/models/canvas_graph.py +86 -0
  130. databay/models/canvas_table_check.py +181 -0
  131. databay/models/dashboards.py +79 -0
  132. databay/models/dashboards_card.py +37 -0
  133. databay/models/dashboards_dataset.py +27 -0
  134. databay/models/dashboards_folder.py +18 -0
  135. databay/models/dashboards_home.py +28 -0
  136. databay/models/db.py +34 -0
  137. databay/models/db_column.py +11 -0
  138. databay/models/docs_article.py +59 -0
  139. databay/models/dq_result.py +27 -0
  140. databay/models/dq_rule.py +43 -0
  141. databay/models/feature.py +15 -0
  142. databay/models/google_ads_source.py +11 -0
  143. databay/models/google_analytics_source.py +11 -0
  144. databay/models/instagram_source.py +11 -0
  145. databay/models/linkedin_source.py +11 -0
  146. databay/models/me.py +71 -0
  147. databay/models/meta_ads_source.py +11 -0
  148. databay/models/metric_models.py +274 -0
  149. databay/models/metrics.py +158 -0
  150. databay/models/models.py +73 -0
  151. databay/models/nlq.py +92 -0
  152. databay/models/notebook_credential.py +51 -0
  153. databay/models/org.py +44 -0
  154. databay/models/org_member.py +123 -0
  155. databay/models/org_offboarding.py +36 -0
  156. databay/models/pipe.py +340 -0
  157. databay/models/pipe_config_option.py +25 -0
  158. databay/models/pipe_destination.py +128 -0
  159. databay/models/pipe_leftover.py +51 -0
  160. databay/models/pipe_sink_type.py +11 -0
  161. databay/models/pipe_source_type.py +11 -0
  162. databay/models/pipe_status_bucket.py +13 -0
  163. databay/models/pipe_stuck_delete.py +25 -0
  164. databay/models/plan.py +37 -0
  165. databay/models/project.py +154 -0
  166. databay/models/project_budget.py +22 -0
  167. databay/models/project_member.py +67 -0
  168. databay/models/project_privilege.py +21 -0
  169. databay/models/project_share.py +76 -0
  170. databay/models/provision.py +12 -0
  171. databay/models/provisioning.py +20 -0
  172. databay/models/registry.py +18 -0
  173. databay/models/resource_home.py +35 -0
  174. databay/models/schema_change.py +96 -0
  175. databay/models/schema_history.py +32 -0
  176. databay/models/schema_version.py +23 -0
  177. databay/models/shared.py +875 -0
  178. databay/models/shopify_store.py +19 -0
  179. databay/models/sink_data_type.py +11 -0
  180. databay/models/source.py +263 -0
  181. databay/models/source_catalog.py +82 -0
  182. databay/models/source_check_run.py +63 -0
  183. databay/models/source_hidden_type.py +13 -0
  184. databay/models/source_name.py +18 -0
  185. databay/models/source_pipe.py +16 -0
  186. databay/models/source_publication.py +30 -0
  187. databay/models/source_readiness_override.py +13 -0
  188. databay/models/source_setup_choice.py +33 -0
  189. databay/models/source_stream.py +65 -0
  190. databay/models/source_table.py +27 -0
  191. databay/models/sql.py +31 -0
  192. databay/models/ssbi_job.py +97 -0
  193. databay/models/transform_type.py +40 -0
  194. databay/models/usage.py +112 -0
  195. databay/models/warehouse.py +38 -0
  196. databay/models/warehouse_access_assignment.py +70 -0
  197. databay/models/warehouse_access_role.py +148 -0
  198. databay/models/warehouse_access_settings.py +35 -0
  199. databay/models/warehouse_login.py +20 -0
  200. databay/models/warehouse_principal.py +49 -0
  201. databay/models/warehouse_service_account.py +35 -0
  202. databay/models/warehouse_service_account_credential.py +11 -0
  203. databay/oidc.py +205 -0
  204. databay/operation.py +191 -0
  205. databay/operations/__init__.py +0 -0
  206. databay/operations/alerting_webhook.py +317 -0
  207. databay/operations/alerting_webhook_tag.py +35 -0
  208. databay/operations/alerting_webhook_type.py +35 -0
  209. databay/operations/alerting_webhook_undelivered.py +74 -0
  210. databay/operations/announcement.py +135 -0
  211. databay/operations/api_key.py +198 -0
  212. databay/operations/api_key_project.py +130 -0
  213. databay/operations/backfill_reason_code.py +35 -0
  214. databay/operations/backfill_run.py +245 -0
  215. databay/operations/canvas.py +136 -0
  216. databay/operations/canvas_graph.py +102 -0
  217. databay/operations/canvas_table_check.py +101 -0
  218. databay/operations/dashboards.py +441 -0
  219. databay/operations/dashboards_card.py +406 -0
  220. databay/operations/dashboards_dataset.py +50 -0
  221. databay/operations/dashboards_folder.py +146 -0
  222. databay/operations/dashboards_home.py +176 -0
  223. databay/operations/dashboards_placement.py +152 -0
  224. databay/operations/db.py +337 -0
  225. databay/operations/db_column.py +57 -0
  226. databay/operations/docs_article.py +101 -0
  227. databay/operations/dq_result.py +43 -0
  228. databay/operations/dq_rule.py +155 -0
  229. databay/operations/feature.py +35 -0
  230. databay/operations/google_ads_source.py +40 -0
  231. databay/operations/google_analytics_source.py +40 -0
  232. databay/operations/instagram_account.py +47 -0
  233. databay/operations/instagram_source.py +40 -0
  234. databay/operations/linkedin_account.py +47 -0
  235. databay/operations/linkedin_source.py +40 -0
  236. databay/operations/me.py +57 -0
  237. databay/operations/meta_ads_source.py +40 -0
  238. databay/operations/metric_models.py +612 -0
  239. databay/operations/metrics.py +218 -0
  240. databay/operations/models.py +123 -0
  241. databay/operations/nlq.py +100 -0
  242. databay/operations/notebook_credential.py +95 -0
  243. databay/operations/org.py +79 -0
  244. databay/operations/org_member.py +192 -0
  245. databay/operations/org_offboarding.py +43 -0
  246. databay/operations/pipe.py +882 -0
  247. databay/operations/pipe_backfill_run.py +72 -0
  248. databay/operations/pipe_config_option.py +50 -0
  249. databay/operations/pipe_destination.py +178 -0
  250. databay/operations/pipe_leftover.py +110 -0
  251. databay/operations/pipe_sink_type.py +35 -0
  252. databay/operations/pipe_source_type.py +35 -0
  253. databay/operations/pipe_status_bucket.py +35 -0
  254. databay/operations/pipe_stuck_delete.py +52 -0
  255. databay/operations/plan.py +35 -0
  256. databay/operations/project.py +346 -0
  257. databay/operations/project_budget.py +135 -0
  258. databay/operations/project_member.py +226 -0
  259. databay/operations/project_privilege.py +143 -0
  260. databay/operations/project_share.py +160 -0
  261. databay/operations/provision.py +35 -0
  262. databay/operations/provisioning.py +35 -0
  263. databay/operations/registry.py +35 -0
  264. databay/operations/resource_home.py +80 -0
  265. databay/operations/schema_change.py +222 -0
  266. databay/operations/schema_history.py +136 -0
  267. databay/operations/schema_version.py +115 -0
  268. databay/operations/shopify_store.py +40 -0
  269. databay/operations/sink_data_type.py +43 -0
  270. databay/operations/source.py +1207 -0
  271. databay/operations/source_catalog.py +135 -0
  272. databay/operations/source_check_run.py +242 -0
  273. databay/operations/source_hidden_type.py +59 -0
  274. databay/operations/source_name.py +69 -0
  275. databay/operations/source_pipe.py +57 -0
  276. databay/operations/source_publication.py +139 -0
  277. databay/operations/source_readiness_override.py +96 -0
  278. databay/operations/source_setup_choice.py +161 -0
  279. databay/operations/source_stream.py +99 -0
  280. databay/operations/source_table.py +59 -0
  281. databay/operations/sql.py +54 -0
  282. databay/operations/ssbi_job.py +242 -0
  283. databay/operations/transform_type.py +35 -0
  284. databay/operations/usage.py +35 -0
  285. databay/operations/warehouse.py +343 -0
  286. databay/operations/warehouse_access_assignment.py +179 -0
  287. databay/operations/warehouse_access_role.py +473 -0
  288. databay/operations/warehouse_access_settings.py +172 -0
  289. databay/operations/warehouse_login.py +308 -0
  290. databay/operations/warehouse_principal.py +174 -0
  291. databay/operations/warehouse_service_account.py +206 -0
  292. databay/operations/warehouse_service_account_credential.py +285 -0
  293. databay/paths.py +32 -0
  294. databay/platform_check.py +20 -0
  295. databay/profiles.py +181 -0
  296. databay/redact.py +22 -0
  297. databay/reference.py +218 -0
  298. databay/resource.py +167 -0
  299. databay/resources/__init__.py +0 -0
  300. databay/resources/alerting_webhook.py +84 -0
  301. databay/resources/alerting_webhook_tag.py +21 -0
  302. databay/resources/alerting_webhook_type.py +21 -0
  303. databay/resources/alerting_webhook_undelivered.py +35 -0
  304. databay/resources/announcement.py +45 -0
  305. databay/resources/api_key.py +56 -0
  306. databay/resources/api_key_project.py +47 -0
  307. databay/resources/backfill_reason_code.py +21 -0
  308. databay/resources/backfill_run.py +78 -0
  309. databay/resources/canvas.py +43 -0
  310. databay/resources/canvas_graph.py +38 -0
  311. databay/resources/canvas_table_check.py +38 -0
  312. databay/resources/dashboards.py +105 -0
  313. databay/resources/dashboards_card.py +97 -0
  314. databay/resources/dashboards_dataset.py +27 -0
  315. databay/resources/dashboards_folder.py +47 -0
  316. databay/resources/dashboards_home.py +59 -0
  317. databay/resources/dashboards_placement.py +47 -0
  318. databay/resources/db.py +87 -0
  319. databay/resources/db_column.py +28 -0
  320. databay/resources/docs_article.py +37 -0
  321. databay/resources/dq_result.py +25 -0
  322. databay/resources/dq_rule.py +48 -0
  323. databay/resources/feature.py +21 -0
  324. databay/resources/google_ads_source.py +25 -0
  325. databay/resources/google_analytics_source.py +25 -0
  326. databay/resources/instagram_account.py +27 -0
  327. databay/resources/instagram_source.py +25 -0
  328. databay/resources/linkedin_account.py +27 -0
  329. databay/resources/linkedin_source.py +25 -0
  330. databay/resources/me.py +27 -0
  331. databay/resources/meta_ads_source.py +25 -0
  332. databay/resources/metric_models.py +154 -0
  333. databay/resources/metrics.py +57 -0
  334. databay/resources/models.py +42 -0
  335. databay/resources/nlq.py +34 -0
  336. databay/resources/notebook_credential.py +41 -0
  337. databay/resources/org.py +29 -0
  338. databay/resources/org_member.py +53 -0
  339. databay/resources/org_offboarding.py +25 -0
  340. databay/resources/pipe.py +208 -0
  341. databay/resources/pipe_backfill_run.py +28 -0
  342. databay/resources/pipe_config_option.py +26 -0
  343. databay/resources/pipe_destination.py +49 -0
  344. databay/resources/pipe_leftover.py +42 -0
  345. databay/resources/pipe_sink_type.py +21 -0
  346. databay/resources/pipe_source_type.py +21 -0
  347. databay/resources/pipe_status_bucket.py +21 -0
  348. databay/resources/pipe_stuck_delete.py +27 -0
  349. databay/resources/plan.py +21 -0
  350. databay/resources/project.py +92 -0
  351. databay/resources/project_budget.py +38 -0
  352. databay/resources/project_member.py +68 -0
  353. databay/resources/project_privilege.py +48 -0
  354. databay/resources/project_share.py +47 -0
  355. databay/resources/provision.py +21 -0
  356. databay/resources/provisioning.py +21 -0
  357. databay/resources/registry.py +21 -0
  358. databay/resources/resource_home.py +29 -0
  359. databay/resources/schema_change.py +62 -0
  360. databay/resources/schema_history.py +52 -0
  361. databay/resources/schema_version.py +43 -0
  362. databay/resources/shopify_store.py +25 -0
  363. databay/resources/sink_data_type.py +25 -0
  364. databay/resources/source.py +132 -0
  365. databay/resources/source_catalog.py +34 -0
  366. databay/resources/source_check_run.py +67 -0
  367. databay/resources/source_hidden_type.py +25 -0
  368. databay/resources/source_name.py +27 -0
  369. databay/resources/source_pipe.py +28 -0
  370. databay/resources/source_publication.py +47 -0
  371. databay/resources/source_readiness_override.py +37 -0
  372. databay/resources/source_setup_choice.py +50 -0
  373. databay/resources/source_stream.py +38 -0
  374. databay/resources/source_table.py +27 -0
  375. databay/resources/sql.py +27 -0
  376. databay/resources/ssbi_job.py +69 -0
  377. databay/resources/transform_type.py +21 -0
  378. databay/resources/usage.py +21 -0
  379. databay/resources/warehouse.py +136 -0
  380. databay/resources/warehouse_access_assignment.py +51 -0
  381. databay/resources/warehouse_access_role.py +118 -0
  382. databay/resources/warehouse_access_settings.py +56 -0
  383. databay/resources/warehouse_login.py +81 -0
  384. databay/resources/warehouse_principal.py +51 -0
  385. databay/resources/warehouse_service_account.py +61 -0
  386. databay/resources/warehouse_service_account_credential.py +78 -0
  387. databay/sdk.py +77 -0
  388. databay/settings.py +90 -0
  389. databay/skill/SKILL.md +357 -0
  390. databay/skill/references/commands.md +7025 -0
  391. databay/skill/references/errors.md +727 -0
  392. databay/spec.py +362 -0
  393. databay/stored_token.py +134 -0
  394. databay/tokens.py +108 -0
  395. databay/transport.py +357 -0
  396. databay/update_check.py +151 -0
  397. databay/version.py +6 -0
  398. databay/waits.py +330 -0
  399. databay_cli-0.1.0.dist-info/METADATA +42 -0
  400. databay_cli-0.1.0.dist-info/RECORD +403 -0
  401. databay_cli-0.1.0.dist-info/WHEEL +4 -0
  402. databay_cli-0.1.0.dist-info/entry_points.txt +2 -0
  403. databay_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
databay/transport.py ADDED
@@ -0,0 +1,357 @@
1
+ """
2
+ One HTTP exchange with a Databay server: headers, retries, verbose logs, and errors by ``code``.
3
+
4
+ A GET, or a write sent with an ``Idempotency-Key``, is retried on 429/502/503/504 and on connect or
5
+ read failures (``RetryPolicy``); any other write is sent once. Each retry is reported to the event
6
+ sink. An error answer becomes the exception its ``code`` names (``databay.errors``).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import math
13
+ import secrets
14
+ import time
15
+ import uuid
16
+ from dataclasses import dataclass, field
17
+ from email.utils import parsedate_to_datetime
18
+ from typing import TYPE_CHECKING, Any, Protocol
19
+
20
+ from databay import redact
21
+ from databay.error_catalog import UNCODED_404_HINT
22
+ from databay.errors import (
23
+ DatabayError,
24
+ DatabayFeatureDisabled,
25
+ DatabayNetworkError,
26
+ DatabayProfileError,
27
+ DatabayServerUnavailable,
28
+ DatabayUncodedError,
29
+ DatabayUnknownCodeError,
30
+ )
31
+ from databay.settings import SETTINGS, ClientSettings
32
+ from databay.version import CLIENT_VERSION
33
+
34
+ if TYPE_CHECKING:
35
+ import httpx
36
+
37
+ from databay.tokens import TokenProvider
38
+
39
+ CORRELATION_HEADER = "x-correlation-id"
40
+ IDEMPOTENCY_HEADER = "Idempotency-Key"
41
+ PROJECT_HEADER = "X-Databay-Project-ID"
42
+ #: 4xx answers that say "later", not "wrong": request timeout and rate limiting.
43
+ TRANSIENT_4XX = frozenset({408, 429})
44
+
45
+
46
+ def is_transient(status: int) -> bool:
47
+ """Whether an error status says "try later": any 5xx, 408 or 429. A wait polls through these."""
48
+ return status >= 500 or status in TRANSIENT_4XX # noqa: PLR2004
49
+
50
+
51
+ class EventSink(Protocol):
52
+ def __call__(self, event: str, fields: dict[str, Any]) -> None: ...
53
+
54
+
55
+ def no_events(_event: str, _fields: dict[str, Any]) -> None:
56
+ return None
57
+
58
+
59
+ @dataclass(slots=True)
60
+ class Answer:
61
+ status: int
62
+ headers: dict[str, str]
63
+ content: bytes
64
+ request_id: str
65
+ attempts: int
66
+
67
+ @property
68
+ def is_json(self) -> bool:
69
+ return self.headers.get("content-type", "").split(";")[0].strip().endswith("json")
70
+
71
+ def json(self) -> Any: # noqa: ANN401 — any JSON value
72
+ return json.loads(self.content)
73
+
74
+ def text(self) -> str:
75
+ return self.content.decode("utf-8")
76
+
77
+
78
+ @dataclass(slots=True)
79
+ class Request:
80
+ method: str
81
+ path: str
82
+ query: list[tuple[str, str]] = field(default_factory=list)
83
+ headers: dict[str, str] = field(default_factory=dict)
84
+ json_body: Any = None
85
+ has_body: bool = False
86
+ #: Body fields ``--verbose`` replaces with ``<redacted>``.
87
+ secret_fields: frozenset[str] = frozenset()
88
+
89
+ @property
90
+ def retryable(self) -> bool:
91
+ return self.method == "GET" or IDEMPOTENCY_HEADER in self.headers
92
+
93
+
94
+ @dataclass
95
+ class Transport:
96
+ """
97
+ Sends requests to one server as one caller.
98
+
99
+ ``client_kind`` is ``cli`` or ``sdk`` in the ``User-Agent``. ``http`` is the httpx client to
100
+ send with; tests hand one built on ``httpx.MockTransport``.
101
+ """
102
+
103
+ server: str
104
+ tokens: TokenProvider
105
+ client_kind: str
106
+ retries: bool = True
107
+ verbose: bool = False
108
+ events: EventSink = no_events
109
+ settings: ClientSettings = SETTINGS
110
+ http: httpx.Client | None = None
111
+ #: One id for every request this transport sends, echoed by the server as ``x-correlation-id``.
112
+ request_id: str = field(default_factory=lambda: str(uuid.uuid4()))
113
+ sleep: Any = time.sleep
114
+ #: The command a ``login_required`` names as ``next``: the profile's login, when there is a profile.
115
+ login_command: str | None = None
116
+ _owned: httpx.Client | None = field(default=None, init=False, repr=False)
117
+
118
+ def _client(self) -> httpx.Client:
119
+ if self.http is not None:
120
+ return self.http
121
+ if self._owned is None:
122
+ self._owned = new_http_client(self.settings)
123
+ return self._owned
124
+
125
+ def close(self) -> None:
126
+ """Close the client this transport opened; one the caller handed in stays the caller's."""
127
+ if self._owned is not None:
128
+ self._owned.close()
129
+ self._owned = None
130
+
131
+ def _headers(self, request: Request) -> dict[str, str]:
132
+ token = self.tokens.token()
133
+ return {
134
+ **({"Authorization": f"Bearer {token}"} if token is not None else {}),
135
+ "User-Agent": f"databay-python/{CLIENT_VERSION} ({self.client_kind})",
136
+ "Accept": "application/json",
137
+ CORRELATION_HEADER: self.request_id,
138
+ "traceparent": f"00-{secrets.token_hex(16)}-{secrets.token_hex(8)}-01",
139
+ **request.headers,
140
+ }
141
+
142
+ def send(self, request: Request, *, retries: bool | None = None) -> Answer:
143
+ """Send ``request``, retrying it when it is safe and allowed; raise its error answer."""
144
+ answer = self.exchange(request, retries=retries)
145
+ if answer.status >= 400: # noqa: PLR2004
146
+ raise error_from(answer, request)
147
+ return answer
148
+
149
+ def exchange(self, request: Request, *, retries: bool | None = None) -> Answer:
150
+ """
151
+ Send ``request`` and return the last answer, error or not; a network failure raises.
152
+
153
+ A 401 is sent once more after the token source refreshes, when it can; a 401 on the refreshed
154
+ token is ``login_required``.
155
+ """
156
+ answer = self._exchange(request, retries=retries)
157
+ if answer.status == 401 and self.tokens.refreshed() is not None: # noqa: PLR2004
158
+ answer = self._exchange(request, retries=retries)
159
+ if answer.status == 401: # noqa: PLR2004
160
+ raise DatabayProfileError(
161
+ f"{request.method} {request.path}: {self.server} refused the token even after a refresh; "
162
+ "sign in again with databay login",
163
+ code="login_required",
164
+ status=401,
165
+ attempts=answer.attempts,
166
+ next_command=self.login_command,
167
+ )
168
+ return answer
169
+
170
+ def _exchange(self, request: Request, *, retries: bool | None) -> Answer:
171
+ allowed = (self.retries if retries is None else retries) and request.retryable
172
+ policy = self.settings.retry
173
+ attempt = 0
174
+ while True:
175
+ attempt += 1
176
+ failure: DatabayError | None = None
177
+ answer: Answer | None = None
178
+ try:
179
+ answer = self._once(request, attempt)
180
+ except DatabayNetworkError as exc:
181
+ failure = exc
182
+ retryable = failure is not None or (answer is not None and answer.status in policy.statuses)
183
+ if not (allowed and retryable and attempt <= policy.max_retries):
184
+ if failure is not None:
185
+ failure.attempts = attempt
186
+ raise failure
187
+ assert answer is not None
188
+ return answer
189
+ delay = self._delay(answer, attempt)
190
+ self.events(
191
+ "retry",
192
+ {
193
+ "attempt": attempt + 1,
194
+ "status": answer.status if answer else None,
195
+ "error": failure.code if failure else None,
196
+ "delay_s": delay,
197
+ "request_id": self.request_id,
198
+ },
199
+ )
200
+ self.sleep(delay)
201
+
202
+ def _delay(self, answer: Answer | None, attempt: int) -> float:
203
+ policy = self.settings.retry
204
+ backoff = policy.backoff_s[min(attempt, len(policy.backoff_s)) - 1]
205
+ after = retry_after_seconds(answer.headers.get("retry-after") if answer else None)
206
+ return backoff if after is None else min(after, policy.retry_after_cap_s)
207
+
208
+ def _once(self, request: Request, attempt: int) -> Answer:
209
+ import httpx # noqa: PLC0415
210
+
211
+ headers = self._headers(request)
212
+ url = f"{self.server}{request.path}"
213
+ content = json.dumps(request.json_body).encode() if request.has_body else None
214
+ if content is not None:
215
+ headers["Content-Type"] = "application/json"
216
+ started = time.monotonic()
217
+ if self.verbose:
218
+ self._log_request(request, url, headers, attempt)
219
+ try:
220
+ response = self._client().request(
221
+ request.method, url, params=request.query or None, headers=headers, content=content
222
+ )
223
+ except httpx.TimeoutException as exc:
224
+ raise DatabayNetworkError(
225
+ f"{request.method} {request.path}: the server did not answer in time ({type(exc).__name__})"
226
+ ) from exc
227
+ except httpx.TransportError as exc:
228
+ raise DatabayNetworkError(
229
+ f"{request.method} {request.path}: could not reach {self.server} ({type(exc).__name__})"
230
+ ) from exc
231
+ answer = Answer(
232
+ status=response.status_code,
233
+ headers={k.lower(): v for k, v in response.headers.items()},
234
+ content=response.content,
235
+ request_id=response.headers.get(CORRELATION_HEADER, self.request_id),
236
+ attempts=attempt,
237
+ )
238
+ if self.verbose:
239
+ self.events(
240
+ "response",
241
+ {
242
+ "status": answer.status,
243
+ "elapsed_ms": round((time.monotonic() - started) * 1000),
244
+ "bytes": len(answer.content),
245
+ "request_id": answer.request_id,
246
+ },
247
+ )
248
+ return answer
249
+
250
+ def _log_request(self, request: Request, url: str, headers: dict[str, str], attempt: int) -> None:
251
+ fields: dict[str, Any] = {
252
+ "method": request.method,
253
+ "url": url,
254
+ "query": request.query,
255
+ "headers": redact.headers(headers),
256
+ "attempt": attempt,
257
+ }
258
+ if request.has_body:
259
+ fields["body"] = redact.body(request.json_body, request.secret_fields)
260
+ self.events("request", fields)
261
+
262
+
263
+ def new_http_client(settings: ClientSettings) -> httpx.Client:
264
+ """The httpx client requests go through unless a caller hands one in: the settings' timeouts, no redirects."""
265
+ import httpx # noqa: PLC0415 — imported only when a command talks to a server
266
+
267
+ timeout = httpx.Timeout(settings.read_timeout_s, connect=settings.connect_timeout_s)
268
+ return httpx.Client(timeout=timeout, follow_redirects=False)
269
+
270
+
271
+ def retry_after_seconds(value: str | None) -> float | None:
272
+ """
273
+ ``Retry-After`` as seconds, from a number or an HTTP date.
274
+
275
+ ``None`` when the header is absent or unparsable: the caller's own backoff applies then.
276
+ """
277
+ if value is None:
278
+ return None
279
+ try:
280
+ seconds = float(value)
281
+ except ValueError:
282
+ pass
283
+ else:
284
+ return max(seconds, 0.0) if math.isfinite(seconds) else None
285
+ try:
286
+ when = parsedate_to_datetime(value)
287
+ except (TypeError, ValueError):
288
+ return None
289
+ return max(when.timestamp() - time.time(), 0.0)
290
+
291
+
292
+ def _message(detail: Any, status: int, code: str | None) -> str: # noqa: ANN401 — any JSON value
293
+ if isinstance(detail, str):
294
+ return detail
295
+ if isinstance(detail, dict) and isinstance(detail.get("message"), str):
296
+ # The envelope some services answer under ``detail``: ``{"message": ..., ...}``.
297
+ return detail["message"]
298
+ return f"HTTP {status}" + (f" {code}" if code else "")
299
+
300
+
301
+ def error_from(answer: Answer, request: Request) -> DatabayError:
302
+ """The exception an error answer names: by its ``code``, or the named error for a body that does not fit."""
303
+ from databay.error_codes import EXCEPTIONS # noqa: PLC0415 — loaded only on an error
304
+ from databay.feature_header import FEATURE_HEADER, parse_feature_header # noqa: PLC0415 — likewise
305
+
306
+ where = f"{request.method} {request.path}"
307
+ common: dict[str, Any] = {"status": answer.status, "attempts": answer.attempts}
308
+ body: Any = None
309
+ if answer.is_json:
310
+ try:
311
+ body = answer.json()
312
+ except ValueError:
313
+ body = None
314
+ if not isinstance(body, dict):
315
+ if is_transient(answer.status):
316
+ return DatabayServerUnavailable(f"{where}: HTTP {answer.status} without a Databay error body", **common)
317
+ # A 4xx no Databay service answered (a wrong server URL, a proxy's page) is not transient.
318
+ return DatabayError(
319
+ f"{where}: HTTP {answer.status} without a Databay error body; check the profile's server URL",
320
+ code="non_databay_answer",
321
+ **common,
322
+ )
323
+ code = body.get("code")
324
+ detail = body.get("detail")
325
+ extra = {k: v for k, v in body.items() if k not in {"code", "detail"}}
326
+ if code is None:
327
+ is_404 = answer.status == 404 # noqa: PLR2004
328
+ off = parse_feature_header(answer.headers.get(FEATURE_HEADER)) if is_404 else None
329
+ if off is not None:
330
+ return DatabayFeatureDisabled(
331
+ f"{where}: the feature {off.feature} is off (source: {off.source})",
332
+ feature=off.feature,
333
+ source=off.source,
334
+ **common,
335
+ )
336
+ return DatabayUncodedError(
337
+ f"{where}: HTTP {answer.status} without a code: {_message(detail, answer.status, None)}",
338
+ hint=UNCODED_404_HINT if is_404 else None,
339
+ details={**extra, "detail": detail},
340
+ **common,
341
+ )
342
+ if not isinstance(code, str) or code not in EXCEPTIONS:
343
+ return DatabayUnknownCodeError(
344
+ f"{where}: the server answered code {code!r}, which this CLI does not know",
345
+ details={**extra, "server_code": code, "detail": detail},
346
+ **common,
347
+ )
348
+ if not isinstance(detail, str):
349
+ extra["detail"] = detail
350
+ docs = detail.get("docs_url") if isinstance(detail, dict) else None
351
+ return EXCEPTIONS[code](
352
+ _message(detail, answer.status, code),
353
+ details=extra,
354
+ support_ref=body.get("support_ref") if isinstance(body.get("support_ref"), str) else None,
355
+ docs=docs if isinstance(docs, str) else None,
356
+ **common,
357
+ )
@@ -0,0 +1,151 @@
1
+ """
2
+ The update notice: ``databay <new> is available: uv tool upgrade databay-cli`` on stderr.
3
+
4
+ Only on a terminal (stdout and stderr), never under ``$CI``, and off with
5
+ ``DATABAY_NO_UPDATE_CHECK=1``. PyPI is asked at most once per ``update_check_interval_s``, in a
6
+ background thread that runs beside the command; when the command is done it waits for the thread
7
+ at most ``update_check_timeout_s`` more. The answer is cached in the state dir, and the notice is
8
+ printed from that cache at most once per interval. A failed or unfinished check prints nothing,
9
+ except the ``update_check_failed`` event under ``--verbose``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import threading
17
+ import time
18
+ from dataclasses import dataclass, field
19
+ from typing import TYPE_CHECKING, Any, TextIO
20
+
21
+ from databay.paths import state_dir
22
+ from databay.settings import ENV_CI, ENV_NO_UPDATE_CHECK, SETTINGS
23
+ from databay.version import CLIENT_VERSION
24
+
25
+ if TYPE_CHECKING:
26
+ from collections.abc import Callable
27
+ from pathlib import Path
28
+
29
+ PACKAGE = "databay-cli"
30
+ PYPI_URL = f"https://pypi.org/pypi/{PACKAGE}/json"
31
+ UPGRADE_COMMAND = f"uv tool upgrade {PACKAGE}"
32
+ CACHE_NAME = "update-check.json"
33
+ #: A cache file named for the package, which some installs still have; every cache write deletes it.
34
+ RETIRED_CACHE_NAME = "update-check-databay-cli.json"
35
+
36
+
37
+ def enabled(environ: dict[str, str], stdout: TextIO, stderr: TextIO) -> bool:
38
+ if environ.get(ENV_NO_UPDATE_CHECK) == "1" or environ.get(ENV_CI):
39
+ return False
40
+ return stdout.isatty() and stderr.isatty()
41
+
42
+
43
+ def _release(text: str) -> tuple[int, ...]:
44
+ """``1.2.3`` as ``(1, 2, 3)``; a pre-release or anything else non-numeric is a ``ValueError``."""
45
+ return tuple(int(part) for part in text.split("."))
46
+
47
+
48
+ def _cache_file() -> Path:
49
+ return state_dir() / CACHE_NAME
50
+
51
+
52
+ def _read_cache() -> dict[str, Any]:
53
+ """
54
+ The cached answer about ``PACKAGE``; nothing when the cache is about another package.
55
+
56
+ An ``update-check.json`` without ``package`` holds an answer about the unrelated ``databay`` on
57
+ PyPI. It is ignored here, and the next check replaces it.
58
+ """
59
+ try:
60
+ data = json.loads(_cache_file().read_text(encoding="utf-8"))
61
+ except (OSError, ValueError):
62
+ return {}
63
+ return data if isinstance(data, dict) and data.get("package") == PACKAGE else {}
64
+
65
+
66
+ def _write_cache(data: dict[str, Any]) -> None:
67
+ path = _cache_file()
68
+ path.parent.mkdir(parents=True, exist_ok=True)
69
+ tmp = path.with_suffix(f".{os.getpid()}.tmp")
70
+ tmp.write_text(json.dumps({**data, "package": PACKAGE}), encoding="utf-8")
71
+ tmp.replace(path)
72
+ (path.parent / RETIRED_CACHE_NAME).unlink(missing_ok=True)
73
+
74
+
75
+ def fetch_latest() -> str:
76
+ """The newest release on PyPI."""
77
+ import httpx # noqa: PLC0415
78
+
79
+ response = httpx.get(PYPI_URL, timeout=SETTINGS.update_check_timeout_s)
80
+ response.raise_for_status()
81
+ version = response.json()["info"]["version"]
82
+ if not isinstance(version, str):
83
+ raise TypeError("info.version is not a string")
84
+ return version
85
+
86
+
87
+ @dataclass
88
+ class UpdateCheck:
89
+ fetch: Callable[[], str] = fetch_latest
90
+ now: Callable[[], float] = time.time
91
+ #: How long ``finish`` waits for a check still running.
92
+ wait_s: float = SETTINGS.update_check_timeout_s
93
+ #: The exception a check in this process raised, reported under ``--verbose``.
94
+ failure: BaseException | None = None
95
+ _thread: threading.Thread | None = field(default=None, repr=False)
96
+
97
+ def start(self) -> None:
98
+ """Ask PyPI in the background when the cached answer is older than the interval."""
99
+ checked = _read_cache().get("checked_at")
100
+ if isinstance(checked, int | float) and self.now() - checked < SETTINGS.update_check_interval_s:
101
+ return
102
+ self._thread = threading.Thread(target=self._check, name="databay-update-check", daemon=True)
103
+ self._thread.start()
104
+
105
+ def _check(self) -> None:
106
+ latest: str | None = None
107
+ try:
108
+ latest = self.fetch()
109
+ _release(latest)
110
+ except Exception as exc: # noqa: BLE001 — any failure only skips the notice
111
+ self.failure = exc
112
+ latest = None
113
+ # Read just before writing, so a notice shown meanwhile keeps its noticed_at.
114
+ cache = _read_cache()
115
+ if latest is not None:
116
+ cache["latest"] = latest
117
+ cache["checked_at"] = self.now()
118
+ try:
119
+ _write_cache(cache)
120
+ except OSError as exc:
121
+ self.failure = exc
122
+
123
+ def finish(self, stderr: TextIO, event: Callable[[str, dict[str, Any]], None] | None) -> None:
124
+ """Print the notice from the cache when it names a newer release and none was shown this interval."""
125
+ if self._thread is not None:
126
+ self._thread.join(timeout=self.wait_s)
127
+ if self._thread.is_alive() and self.failure is None:
128
+ self.failure = TimeoutError("the PyPI check did not finish")
129
+ self._notice(stderr)
130
+ if self.failure is not None and event is not None:
131
+ event("update_check_failed", {"error": type(self.failure).__name__})
132
+
133
+ def _notice(self, stderr: TextIO) -> None:
134
+ cache = _read_cache()
135
+ latest, noticed = cache.get("latest"), cache.get("noticed_at")
136
+ if not isinstance(latest, str):
137
+ return
138
+ try:
139
+ newer = _release(latest) > _release(CLIENT_VERSION)
140
+ except ValueError:
141
+ return
142
+ if not newer:
143
+ return
144
+ if isinstance(noticed, int | float) and self.now() - noticed < SETTINGS.update_check_interval_s:
145
+ return
146
+ stderr.write(f"databay {latest} is available: {UPGRADE_COMMAND}\n")
147
+ cache["noticed_at"] = self.now()
148
+ try:
149
+ _write_cache(cache)
150
+ except OSError as exc:
151
+ self.failure = exc
databay/version.py ADDED
@@ -0,0 +1,6 @@
1
+ """The package version.
2
+
3
+ Generated by python/tools/gen_client.py from python/pyproject.toml; do not edit.
4
+ """
5
+
6
+ CLIENT_VERSION = "0.1.0"