keboola-cli 0.63.4__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 (306) hide show
  1. keboola_agent_cli/__init__.py +34 -0
  2. keboola_agent_cli/__main__.py +5 -0
  3. keboola_agent_cli/_ui_dist/assets/arc-DhFYIddx.js +2 -0
  4. keboola_agent_cli/_ui_dist/assets/arc-DhFYIddx.js.map +1 -0
  5. keboola_agent_cli/_ui_dist/assets/architecture-7EHR7CIX-hNCijx_H.js +1 -0
  6. keboola_agent_cli/_ui_dist/assets/architectureDiagram-3BPJPVTR-C6hUlprM.js +37 -0
  7. keboola_agent_cli/_ui_dist/assets/architectureDiagram-3BPJPVTR-C6hUlprM.js.map +1 -0
  8. keboola_agent_cli/_ui_dist/assets/array-BifhSqXX.js +2 -0
  9. keboola_agent_cli/_ui_dist/assets/array-BifhSqXX.js.map +1 -0
  10. keboola_agent_cli/_ui_dist/assets/blockDiagram-GPEHLZMM-DC7qY9i4.js +133 -0
  11. keboola_agent_cli/_ui_dist/assets/blockDiagram-GPEHLZMM-DC7qY9i4.js.map +1 -0
  12. keboola_agent_cli/_ui_dist/assets/c4Diagram-AAUBKEIU-5Lh44evt.js +11 -0
  13. keboola_agent_cli/_ui_dist/assets/c4Diagram-AAUBKEIU-5Lh44evt.js.map +1 -0
  14. keboola_agent_cli/_ui_dist/assets/channel-DBMrXlxx.js +2 -0
  15. keboola_agent_cli/_ui_dist/assets/channel-DBMrXlxx.js.map +1 -0
  16. keboola_agent_cli/_ui_dist/assets/chunk-2J33WTMH-Coy82EBh.js +2 -0
  17. keboola_agent_cli/_ui_dist/assets/chunk-2J33WTMH-Coy82EBh.js.map +1 -0
  18. keboola_agent_cli/_ui_dist/assets/chunk-3OPIFGDE-BQC5CRHI.js +63 -0
  19. keboola_agent_cli/_ui_dist/assets/chunk-3OPIFGDE-BQC5CRHI.js.map +1 -0
  20. keboola_agent_cli/_ui_dist/assets/chunk-4BX2VUAB-DUuEt70o.js +2 -0
  21. keboola_agent_cli/_ui_dist/assets/chunk-4BX2VUAB-DUuEt70o.js.map +1 -0
  22. keboola_agent_cli/_ui_dist/assets/chunk-55IACEB6-BvR-6chF.js +2 -0
  23. keboola_agent_cli/_ui_dist/assets/chunk-55IACEB6-BvR-6chF.js.map +1 -0
  24. keboola_agent_cli/_ui_dist/assets/chunk-5ZQYHXKU-BjcTN7ul.js +3 -0
  25. keboola_agent_cli/_ui_dist/assets/chunk-5ZQYHXKU-BjcTN7ul.js.map +1 -0
  26. keboola_agent_cli/_ui_dist/assets/chunk-727SXJPM-C0zxqqRN.js +207 -0
  27. keboola_agent_cli/_ui_dist/assets/chunk-727SXJPM-C0zxqqRN.js.map +1 -0
  28. keboola_agent_cli/_ui_dist/assets/chunk-AQP2D5EJ-CXf7rIlZ.js +232 -0
  29. keboola_agent_cli/_ui_dist/assets/chunk-AQP2D5EJ-CXf7rIlZ.js.map +1 -0
  30. keboola_agent_cli/_ui_dist/assets/chunk-BSJP7CBP-Oj_FO9Q7.js +2 -0
  31. keboola_agent_cli/_ui_dist/assets/chunk-BSJP7CBP-Oj_FO9Q7.js.map +1 -0
  32. keboola_agent_cli/_ui_dist/assets/chunk-CSCIHK7Q-CcTsLrFc.js +124 -0
  33. keboola_agent_cli/_ui_dist/assets/chunk-CSCIHK7Q-CcTsLrFc.js.map +1 -0
  34. keboola_agent_cli/_ui_dist/assets/chunk-FMBD7UC4-FH-zLkkW.js +16 -0
  35. keboola_agent_cli/_ui_dist/assets/chunk-FMBD7UC4-FH-zLkkW.js.map +1 -0
  36. keboola_agent_cli/_ui_dist/assets/chunk-L5ZTLDWV-B1Ky_e7O.js +2 -0
  37. keboola_agent_cli/_ui_dist/assets/chunk-L5ZTLDWV-B1Ky_e7O.js.map +1 -0
  38. keboola_agent_cli/_ui_dist/assets/chunk-ND2GUHAM-BHz1rpbm.js +2 -0
  39. keboola_agent_cli/_ui_dist/assets/chunk-ND2GUHAM-BHz1rpbm.js.map +1 -0
  40. keboola_agent_cli/_ui_dist/assets/chunk-NNHCCRGN-DlpIbxXb.js +160 -0
  41. keboola_agent_cli/_ui_dist/assets/chunk-NNHCCRGN-DlpIbxXb.js.map +1 -0
  42. keboola_agent_cli/_ui_dist/assets/chunk-NZK2D7GU-tnrSoegS.js +2 -0
  43. keboola_agent_cli/_ui_dist/assets/chunk-NZK2D7GU-tnrSoegS.js.map +1 -0
  44. keboola_agent_cli/_ui_dist/assets/chunk-O5CBEL6O-DxxqDH0l.js +71 -0
  45. keboola_agent_cli/_ui_dist/assets/chunk-O5CBEL6O-DxxqDH0l.js.map +1 -0
  46. keboola_agent_cli/_ui_dist/assets/chunk-QZHKN3VN-CSjc2gjj.js +2 -0
  47. keboola_agent_cli/_ui_dist/assets/chunk-QZHKN3VN-CSjc2gjj.js.map +1 -0
  48. keboola_agent_cli/_ui_dist/assets/classDiagram-4FO5ZUOK-BuZcZu85.js +2 -0
  49. keboola_agent_cli/_ui_dist/assets/classDiagram-4FO5ZUOK-BuZcZu85.js.map +1 -0
  50. keboola_agent_cli/_ui_dist/assets/classDiagram-v2-Q7XG4LA2-BuZcZu85.js +2 -0
  51. keboola_agent_cli/_ui_dist/assets/classDiagram-v2-Q7XG4LA2-BuZcZu85.js.map +1 -0
  52. keboola_agent_cli/_ui_dist/assets/cose-bilkent-S5V4N54A-Y0L8LDMa.js +2 -0
  53. keboola_agent_cli/_ui_dist/assets/cose-bilkent-S5V4N54A-Y0L8LDMa.js.map +1 -0
  54. keboola_agent_cli/_ui_dist/assets/cytoscape.esm-C8YCVR3_.js +322 -0
  55. keboola_agent_cli/_ui_dist/assets/cytoscape.esm-C8YCVR3_.js.map +1 -0
  56. keboola_agent_cli/_ui_dist/assets/dagre-BM42HDAG-UZ-9BTqF.js +5 -0
  57. keboola_agent_cli/_ui_dist/assets/dagre-BM42HDAG-UZ-9BTqF.js.map +1 -0
  58. keboola_agent_cli/_ui_dist/assets/dagre-Bx709z4p.js +2 -0
  59. keboola_agent_cli/_ui_dist/assets/dagre-Bx709z4p.js.map +1 -0
  60. keboola_agent_cli/_ui_dist/assets/defaultLocale-C8Fc0cco.js +2 -0
  61. keboola_agent_cli/_ui_dist/assets/defaultLocale-C8Fc0cco.js.map +1 -0
  62. keboola_agent_cli/_ui_dist/assets/diagram-2AECGRRQ-DoDQ60wi.js +44 -0
  63. keboola_agent_cli/_ui_dist/assets/diagram-2AECGRRQ-DoDQ60wi.js.map +1 -0
  64. keboola_agent_cli/_ui_dist/assets/diagram-5GNKFQAL-CMGFxpUs.js +11 -0
  65. keboola_agent_cli/_ui_dist/assets/diagram-5GNKFQAL-CMGFxpUs.js.map +1 -0
  66. keboola_agent_cli/_ui_dist/assets/diagram-KO2AKTUF-1uGDa-Iu.js +4 -0
  67. keboola_agent_cli/_ui_dist/assets/diagram-KO2AKTUF-1uGDa-Iu.js.map +1 -0
  68. keboola_agent_cli/_ui_dist/assets/diagram-LMA3HP47-XtFH7B51.js +25 -0
  69. keboola_agent_cli/_ui_dist/assets/diagram-LMA3HP47-XtFH7B51.js.map +1 -0
  70. keboola_agent_cli/_ui_dist/assets/diagram-OG6HWLK6-B4_Te1T5.js +25 -0
  71. keboola_agent_cli/_ui_dist/assets/diagram-OG6HWLK6-B4_Te1T5.js.map +1 -0
  72. keboola_agent_cli/_ui_dist/assets/dist-Di6zmlv0.js +2 -0
  73. keboola_agent_cli/_ui_dist/assets/dist-Di6zmlv0.js.map +1 -0
  74. keboola_agent_cli/_ui_dist/assets/erDiagram-TEJ5UH35-NjQkrdFt.js +86 -0
  75. keboola_agent_cli/_ui_dist/assets/erDiagram-TEJ5UH35-NjQkrdFt.js.map +1 -0
  76. keboola_agent_cli/_ui_dist/assets/eventmodeling-FCH6USID-BrJMIks8.js +1 -0
  77. keboola_agent_cli/_ui_dist/assets/flowDiagram-I6XJVG4X-CIr8DWl7.js +163 -0
  78. keboola_agent_cli/_ui_dist/assets/flowDiagram-I6XJVG4X-CIr8DWl7.js.map +1 -0
  79. keboola_agent_cli/_ui_dist/assets/ganttDiagram-6RSMTGT7-C1VY_xbQ.js +293 -0
  80. keboola_agent_cli/_ui_dist/assets/ganttDiagram-6RSMTGT7-C1VY_xbQ.js.map +1 -0
  81. keboola_agent_cli/_ui_dist/assets/gitGraph-WXDBUCRP-COacYjo-.js +1 -0
  82. keboola_agent_cli/_ui_dist/assets/gitGraphDiagram-PVQCEYII-DQT8-kg2.js +107 -0
  83. keboola_agent_cli/_ui_dist/assets/gitGraphDiagram-PVQCEYII-DQT8-kg2.js.map +1 -0
  84. keboola_agent_cli/_ui_dist/assets/graphlib-B8gBHxth.js +2 -0
  85. keboola_agent_cli/_ui_dist/assets/graphlib-B8gBHxth.js.map +1 -0
  86. keboola_agent_cli/_ui_dist/assets/index-CMq50kkV.css +1 -0
  87. keboola_agent_cli/_ui_dist/assets/index-D8W97DAz.js +118 -0
  88. keboola_agent_cli/_ui_dist/assets/index-D8W97DAz.js.map +1 -0
  89. keboola_agent_cli/_ui_dist/assets/info-J43DQDTF-DdCTRIzU.js +1 -0
  90. keboola_agent_cli/_ui_dist/assets/infoDiagram-5YYISTIA-C77rsoTp.js +3 -0
  91. keboola_agent_cli/_ui_dist/assets/infoDiagram-5YYISTIA-C77rsoTp.js.map +1 -0
  92. keboola_agent_cli/_ui_dist/assets/init-D6jRqBbL.js +2 -0
  93. keboola_agent_cli/_ui_dist/assets/init-D6jRqBbL.js.map +1 -0
  94. keboola_agent_cli/_ui_dist/assets/ishikawaDiagram-YF4QCWOH-BcTbXaLy.js +71 -0
  95. keboola_agent_cli/_ui_dist/assets/ishikawaDiagram-YF4QCWOH-BcTbXaLy.js.map +1 -0
  96. keboola_agent_cli/_ui_dist/assets/journeyDiagram-JHISSGLW-BejeAJQ_.js +140 -0
  97. keboola_agent_cli/_ui_dist/assets/journeyDiagram-JHISSGLW-BejeAJQ_.js.map +1 -0
  98. keboola_agent_cli/_ui_dist/assets/kanban-definition-UN3LZRKU-BRNz_UrH.js +90 -0
  99. keboola_agent_cli/_ui_dist/assets/kanban-definition-UN3LZRKU-BRNz_UrH.js.map +1 -0
  100. keboola_agent_cli/_ui_dist/assets/katex-C4eR7coU.js +258 -0
  101. keboola_agent_cli/_ui_dist/assets/katex-C4eR7coU.js.map +1 -0
  102. keboola_agent_cli/_ui_dist/assets/line-CzAQKFbJ.js +2 -0
  103. keboola_agent_cli/_ui_dist/assets/line-CzAQKFbJ.js.map +1 -0
  104. keboola_agent_cli/_ui_dist/assets/linear-DUNFFdck.js +2 -0
  105. keboola_agent_cli/_ui_dist/assets/linear-DUNFFdck.js.map +1 -0
  106. keboola_agent_cli/_ui_dist/assets/mermaid-parser.core-CpuBOkFa.js +5 -0
  107. keboola_agent_cli/_ui_dist/assets/mermaid-parser.core-CpuBOkFa.js.map +1 -0
  108. keboola_agent_cli/_ui_dist/assets/mindmap-definition-RKZ34NQL-9EJQNjH0.js +97 -0
  109. keboola_agent_cli/_ui_dist/assets/mindmap-definition-RKZ34NQL-9EJQNjH0.js.map +1 -0
  110. keboola_agent_cli/_ui_dist/assets/ordinal-hYBb2elL.js +2 -0
  111. keboola_agent_cli/_ui_dist/assets/ordinal-hYBb2elL.js.map +1 -0
  112. keboola_agent_cli/_ui_dist/assets/packet-YPE3B663-DLiiw_B2.js +1 -0
  113. keboola_agent_cli/_ui_dist/assets/path-BWPyau1x.js +2 -0
  114. keboola_agent_cli/_ui_dist/assets/path-BWPyau1x.js.map +1 -0
  115. keboola_agent_cli/_ui_dist/assets/pie-LRSECV5Y-CRoO8G1g.js +1 -0
  116. keboola_agent_cli/_ui_dist/assets/pieDiagram-4H26LBE5-XH4cy6Cb.js +31 -0
  117. keboola_agent_cli/_ui_dist/assets/pieDiagram-4H26LBE5-XH4cy6Cb.js.map +1 -0
  118. keboola_agent_cli/_ui_dist/assets/quadrantDiagram-W4KKPZXB-fdhc93U8.js +8 -0
  119. keboola_agent_cli/_ui_dist/assets/quadrantDiagram-W4KKPZXB-fdhc93U8.js.map +1 -0
  120. keboola_agent_cli/_ui_dist/assets/radar-GUYGQ44K-DAlLVJHm.js +1 -0
  121. keboola_agent_cli/_ui_dist/assets/requirementDiagram-4Y6WPE33-a94eP3R9.js +85 -0
  122. keboola_agent_cli/_ui_dist/assets/requirementDiagram-4Y6WPE33-a94eP3R9.js.map +1 -0
  123. keboola_agent_cli/_ui_dist/assets/rough.esm-CSKSodPl.js +2 -0
  124. keboola_agent_cli/_ui_dist/assets/rough.esm-CSKSodPl.js.map +1 -0
  125. keboola_agent_cli/_ui_dist/assets/sankeyDiagram-5OEKKPKP-jcBa02sp.js +41 -0
  126. keboola_agent_cli/_ui_dist/assets/sankeyDiagram-5OEKKPKP-jcBa02sp.js.map +1 -0
  127. keboola_agent_cli/_ui_dist/assets/sequenceDiagram-3UESZ5HK-A5-GGM-e.js +163 -0
  128. keboola_agent_cli/_ui_dist/assets/sequenceDiagram-3UESZ5HK-A5-GGM-e.js.map +1 -0
  129. keboola_agent_cli/_ui_dist/assets/src-ZI-V_AF0.js +2 -0
  130. keboola_agent_cli/_ui_dist/assets/src-ZI-V_AF0.js.map +1 -0
  131. keboola_agent_cli/_ui_dist/assets/stateDiagram-AJRCARHV-BKAA5rqE.js +2 -0
  132. keboola_agent_cli/_ui_dist/assets/stateDiagram-AJRCARHV-BKAA5rqE.js.map +1 -0
  133. keboola_agent_cli/_ui_dist/assets/stateDiagram-v2-BHNVJYJU-DnJwJBsE.js +2 -0
  134. keboola_agent_cli/_ui_dist/assets/stateDiagram-v2-BHNVJYJU-DnJwJBsE.js.map +1 -0
  135. keboola_agent_cli/_ui_dist/assets/timeline-definition-PNZ67QCA-Cy39jp8b.js +121 -0
  136. keboola_agent_cli/_ui_dist/assets/timeline-definition-PNZ67QCA-Cy39jp8b.js.map +1 -0
  137. keboola_agent_cli/_ui_dist/assets/treeView-BLDUP644-DbLYl23-.js +1 -0
  138. keboola_agent_cli/_ui_dist/assets/treemap-LRROVOQU-Bp0eGlOt.js +1 -0
  139. keboola_agent_cli/_ui_dist/assets/vennDiagram-CIIHVFJN-BGECKubd.js +35 -0
  140. keboola_agent_cli/_ui_dist/assets/vennDiagram-CIIHVFJN-BGECKubd.js.map +1 -0
  141. keboola_agent_cli/_ui_dist/assets/wardley-L42UT6IY-D4yH4jqS.js +1 -0
  142. keboola_agent_cli/_ui_dist/assets/wardleyDiagram-YWT4CUSO-D6XRG3cZ.js +79 -0
  143. keboola_agent_cli/_ui_dist/assets/wardleyDiagram-YWT4CUSO-D6XRG3cZ.js.map +1 -0
  144. keboola_agent_cli/_ui_dist/assets/xychartDiagram-2RQKCTM6-DRre-pfZ.js +8 -0
  145. keboola_agent_cli/_ui_dist/assets/xychartDiagram-2RQKCTM6-DRre-pfZ.js.map +1 -0
  146. keboola_agent_cli/_ui_dist/index.html +50 -0
  147. keboola_agent_cli/ai_client.py +83 -0
  148. keboola_agent_cli/auto_update.py +550 -0
  149. keboola_agent_cli/changelog.py +1198 -0
  150. keboola_agent_cli/cli.py +448 -0
  151. keboola_agent_cli/client.py +3422 -0
  152. keboola_agent_cli/commands/__init__.py +0 -0
  153. keboola_agent_cli/commands/_data_app_git.py +343 -0
  154. keboola_agent_cli/commands/_helpers.py +377 -0
  155. keboola_agent_cli/commands/_metadata_input.py +49 -0
  156. keboola_agent_cli/commands/_semantic_layer_crud.py +632 -0
  157. keboola_agent_cli/commands/_semantic_layer_helpers.py +44 -0
  158. keboola_agent_cli/commands/_semantic_layer_reference_data.py +247 -0
  159. keboola_agent_cli/commands/agent.py +968 -0
  160. keboola_agent_cli/commands/branch.py +423 -0
  161. keboola_agent_cli/commands/changelog.py +168 -0
  162. keboola_agent_cli/commands/component.py +216 -0
  163. keboola_agent_cli/commands/config.py +2442 -0
  164. keboola_agent_cli/commands/context.py +1481 -0
  165. keboola_agent_cli/commands/data_app.py +1279 -0
  166. keboola_agent_cli/commands/dev_portal.py +584 -0
  167. keboola_agent_cli/commands/doctor.py +37 -0
  168. keboola_agent_cli/commands/encrypt.py +145 -0
  169. keboola_agent_cli/commands/feature.py +311 -0
  170. keboola_agent_cli/commands/flow.py +948 -0
  171. keboola_agent_cli/commands/http_client.py +157 -0
  172. keboola_agent_cli/commands/init.py +279 -0
  173. keboola_agent_cli/commands/job.py +661 -0
  174. keboola_agent_cli/commands/kai.py +301 -0
  175. keboola_agent_cli/commands/lineage.py +1464 -0
  176. keboola_agent_cli/commands/org.py +292 -0
  177. keboola_agent_cli/commands/permissions.py +360 -0
  178. keboola_agent_cli/commands/project.py +1192 -0
  179. keboola_agent_cli/commands/repl.py +243 -0
  180. keboola_agent_cli/commands/schedule.py +340 -0
  181. keboola_agent_cli/commands/search.py +178 -0
  182. keboola_agent_cli/commands/semantic_layer.py +939 -0
  183. keboola_agent_cli/commands/serve.py +272 -0
  184. keboola_agent_cli/commands/sharing.py +340 -0
  185. keboola_agent_cli/commands/storage.py +2630 -0
  186. keboola_agent_cli/commands/stream.py +266 -0
  187. keboola_agent_cli/commands/sync.py +1277 -0
  188. keboola_agent_cli/commands/tool.py +206 -0
  189. keboola_agent_cli/commands/version.py +186 -0
  190. keboola_agent_cli/commands/workspace.py +635 -0
  191. keboola_agent_cli/config_store.py +582 -0
  192. keboola_agent_cli/constants.py +528 -0
  193. keboola_agent_cli/data_science_client.py +342 -0
  194. keboola_agent_cli/dev_portal_client.py +323 -0
  195. keboola_agent_cli/errors.py +248 -0
  196. keboola_agent_cli/http_base.py +315 -0
  197. keboola_agent_cli/json_utils.py +126 -0
  198. keboola_agent_cli/lib.py +536 -0
  199. keboola_agent_cli/manage_client.py +324 -0
  200. keboola_agent_cli/metastore_client.py +214 -0
  201. keboola_agent_cli/models.py +427 -0
  202. keboola_agent_cli/output.py +1084 -0
  203. keboola_agent_cli/permissions.py +469 -0
  204. keboola_agent_cli/py.typed +3 -0
  205. keboola_agent_cli/result_models.py +271 -0
  206. keboola_agent_cli/server/__init__.py +34 -0
  207. keboola_agent_cli/server/agent_runner.py +1289 -0
  208. keboola_agent_cli/server/agents_store.py +325 -0
  209. keboola_agent_cli/server/app.py +764 -0
  210. keboola_agent_cli/server/auth.py +117 -0
  211. keboola_agent_cli/server/dependencies.py +149 -0
  212. keboola_agent_cli/server/pricing.py +303 -0
  213. keboola_agent_cli/server/routers/__init__.py +1 -0
  214. keboola_agent_cli/server/routers/agents.py +616 -0
  215. keboola_agent_cli/server/routers/ai_chat.py +129 -0
  216. keboola_agent_cli/server/routers/branches.py +133 -0
  217. keboola_agent_cli/server/routers/components.py +48 -0
  218. keboola_agent_cli/server/routers/configs.py +507 -0
  219. keboola_agent_cli/server/routers/data_apps.py +384 -0
  220. keboola_agent_cli/server/routers/dev_portal.py +67 -0
  221. keboola_agent_cli/server/routers/encrypt.py +35 -0
  222. keboola_agent_cli/server/routers/feature.py +179 -0
  223. keboola_agent_cli/server/routers/flows.py +204 -0
  224. keboola_agent_cli/server/routers/health.py +53 -0
  225. keboola_agent_cli/server/routers/jobs.py +175 -0
  226. keboola_agent_cli/server/routers/kai.py +80 -0
  227. keboola_agent_cli/server/routers/lineage.py +226 -0
  228. keboola_agent_cli/server/routers/mcp.py +70 -0
  229. keboola_agent_cli/server/routers/members.py +170 -0
  230. keboola_agent_cli/server/routers/org.py +96 -0
  231. keboola_agent_cli/server/routers/projects.py +106 -0
  232. keboola_agent_cli/server/routers/schedules.py +54 -0
  233. keboola_agent_cli/server/routers/search.py +30 -0
  234. keboola_agent_cli/server/routers/semantic_layer.py +650 -0
  235. keboola_agent_cli/server/routers/sharing.py +86 -0
  236. keboola_agent_cli/server/routers/storage.py +574 -0
  237. keboola_agent_cli/server/routers/stream.py +100 -0
  238. keboola_agent_cli/server/routers/workspaces.py +302 -0
  239. keboola_agent_cli/server/run_broadcaster.py +329 -0
  240. keboola_agent_cli/server/sse.py +25 -0
  241. keboola_agent_cli/services/__init__.py +0 -0
  242. keboola_agent_cli/services/_encryption.py +217 -0
  243. keboola_agent_cli/services/_semantic_layer_cascade.py +147 -0
  244. keboola_agent_cli/services/_semantic_layer_crud.py +382 -0
  245. keboola_agent_cli/services/_semantic_layer_internals.py +1078 -0
  246. keboola_agent_cli/services/_semantic_layer_lookup.py +181 -0
  247. keboola_agent_cli/services/_semantic_layer_reference_data.py +217 -0
  248. keboola_agent_cli/services/_sync_bindings.py +456 -0
  249. keboola_agent_cli/services/_sync_branch.py +191 -0
  250. keboola_agent_cli/services/_sync_bulk.py +228 -0
  251. keboola_agent_cli/services/_sync_clone.py +163 -0
  252. keboola_agent_cli/services/_sync_models.py +97 -0
  253. keboola_agent_cli/services/_sync_push_ops.py +369 -0
  254. keboola_agent_cli/services/_sync_storage.py +376 -0
  255. keboola_agent_cli/services/_sync_writeback.py +167 -0
  256. keboola_agent_cli/services/agent_service.py +458 -0
  257. keboola_agent_cli/services/base.py +175 -0
  258. keboola_agent_cli/services/branch_service.py +588 -0
  259. keboola_agent_cli/services/component_service.py +694 -0
  260. keboola_agent_cli/services/config_service.py +2099 -0
  261. keboola_agent_cli/services/data_app_git_service.py +224 -0
  262. keboola_agent_cli/services/data_app_service.py +2082 -0
  263. keboola_agent_cli/services/deep_lineage_service.py +1322 -0
  264. keboola_agent_cli/services/dev_portal_service.py +345 -0
  265. keboola_agent_cli/services/doctor_service.py +445 -0
  266. keboola_agent_cli/services/encrypt_service.py +87 -0
  267. keboola_agent_cli/services/feature_service.py +268 -0
  268. keboola_agent_cli/services/flow_service.py +769 -0
  269. keboola_agent_cli/services/flow_validation.py +188 -0
  270. keboola_agent_cli/services/http_forwarder_service.py +236 -0
  271. keboola_agent_cli/services/job_idempotency_store.py +285 -0
  272. keboola_agent_cli/services/job_service.py +797 -0
  273. keboola_agent_cli/services/kai_service.py +367 -0
  274. keboola_agent_cli/services/lineage_service.py +274 -0
  275. keboola_agent_cli/services/mcp_service.py +1498 -0
  276. keboola_agent_cli/services/mcp_transport.py +259 -0
  277. keboola_agent_cli/services/member_service.py +593 -0
  278. keboola_agent_cli/services/org_service.py +619 -0
  279. keboola_agent_cli/services/project_service.py +947 -0
  280. keboola_agent_cli/services/repo_validate_service.py +767 -0
  281. keboola_agent_cli/services/schedule_service.py +731 -0
  282. keboola_agent_cli/services/search_service.py +331 -0
  283. keboola_agent_cli/services/semantic_layer_service.py +1497 -0
  284. keboola_agent_cli/services/sharing_service.py +307 -0
  285. keboola_agent_cli/services/storage_service.py +2524 -0
  286. keboola_agent_cli/services/stream_service.py +395 -0
  287. keboola_agent_cli/services/sync_service.py +2244 -0
  288. keboola_agent_cli/services/variables_service.py +447 -0
  289. keboola_agent_cli/services/version_service.py +1038 -0
  290. keboola_agent_cli/services/workspace_service.py +1103 -0
  291. keboola_agent_cli/stream_client.py +217 -0
  292. keboola_agent_cli/sync/__init__.py +1 -0
  293. keboola_agent_cli/sync/branch_mapping.py +174 -0
  294. keboola_agent_cli/sync/clone.py +211 -0
  295. keboola_agent_cli/sync/code_extraction.py +655 -0
  296. keboola_agent_cli/sync/config_format.py +290 -0
  297. keboola_agent_cli/sync/diff_engine.py +566 -0
  298. keboola_agent_cli/sync/git_utils.py +93 -0
  299. keboola_agent_cli/sync/manifest.py +162 -0
  300. keboola_agent_cli/sync/naming.py +90 -0
  301. keboola_agent_cli/sync/secrets.py +62 -0
  302. keboola_agent_cli/sync/sql_split.py +134 -0
  303. keboola_cli-0.63.4.dist-info/METADATA +308 -0
  304. keboola_cli-0.63.4.dist-info/RECORD +306 -0
  305. keboola_cli-0.63.4.dist-info/WHEEL +4 -0
  306. keboola_cli-0.63.4.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,2630 @@
1
+ """Storage commands - buckets, tables, and direct access path resolution.
2
+
3
+ Provides direct Storage API access including sharing/linked bucket metadata
4
+ that is not available via MCP tools.
5
+ """
6
+
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import typer
11
+
12
+ from ..config_store import ConfigStore
13
+ from ..errors import ConfigError, ErrorCode, KeboolaApiError
14
+ from ._helpers import (
15
+ check_cli_permission,
16
+ emit_project_warnings,
17
+ get_formatter,
18
+ get_service,
19
+ map_error_to_exit_code,
20
+ resolve_branch,
21
+ )
22
+
23
+ storage_app = typer.Typer(help="Browse and manage storage buckets, tables, and files")
24
+
25
+ # Rich help panel names for grouping in --help output
26
+ _BUCKETS = "Buckets"
27
+ _TABLES = "Tables"
28
+ _FILES = "Files"
29
+
30
+ # Surfaced in human mode whenever a branch-aware write completes against a
31
+ # project lacking the `storage-branches` feature. The transformation runner
32
+ # on such projects ignores buckets created via /v2/storage/branch/<id>/buckets
33
+ # and rewrites destinations to `out.c-<branch_id>-*` in the default branch
34
+ # at job time -- so the bucket the user just created here is reachable only
35
+ # from the branch view (and via direct Snowflake) but will NOT receive
36
+ # transformation output. JSON mode surfaces the same signal as the
37
+ # `legacy_branch_storage: true` field on the response.
38
+ _LEGACY_BRANCH_STORAGE_WARNING: str = (
39
+ " [yellow]Warning:[/yellow] this project uses legacy fake-branch storage "
40
+ "(no `storage-branches` feature). The transformation runner will create "
41
+ "a separate `out.c-<branch_id>-...` bucket on its own at job time; the "
42
+ "bucket created here is reachable from the branch view and direct "
43
+ "Snowflake queries, but transformations will not write into it."
44
+ )
45
+
46
+
47
+ @storage_app.callback(invoke_without_command=True)
48
+ def _storage_permission_check(ctx: typer.Context) -> None:
49
+ check_cli_permission(ctx, "storage")
50
+
51
+
52
+ @storage_app.command("buckets", rich_help_panel=_BUCKETS)
53
+ def storage_buckets(
54
+ ctx: typer.Context,
55
+ project: list[str] | None = typer.Option(
56
+ None,
57
+ "--project",
58
+ help="Project alias (can be repeated for multiple projects)",
59
+ ),
60
+ branch: int | None = typer.Option(
61
+ None,
62
+ "--branch",
63
+ help="Dev branch ID (defaults to active branch if set via 'branch use')",
64
+ ),
65
+ ) -> None:
66
+ """List storage buckets with sharing/linked bucket information.
67
+
68
+ Shows which buckets are linked from other projects, including the
69
+ source project ID and name. This information is not available via
70
+ MCP tools.
71
+
72
+ Branch handling: this read command uses the production endpoint by
73
+ default, even when a dev branch is active via `branch use`. The
74
+ Storage API branch-scoped endpoint only returns locally-modified
75
+ buckets, so a fresh dev branch lists nothing. Pass --branch to query
76
+ a dev branch explicitly.
77
+ """
78
+ formatter = get_formatter(ctx)
79
+ service = get_service(ctx, "storage_service")
80
+ config_store: ConfigStore = ctx.obj["config_store"]
81
+
82
+ # --branch requires exactly one --project
83
+ if branch is not None and (not project or len(project) != 1):
84
+ formatter.error(
85
+ message="--branch requires exactly one --project (branch ID is per-project)",
86
+ error_code=ErrorCode.INVALID_ARGUMENT,
87
+ )
88
+ raise typer.Exit(code=2)
89
+
90
+ # Resolve active branch for single-project queries.
91
+ # Storage read commands ignore the implicit active dev branch: the
92
+ # Storage API branch-scoped endpoint returns only locally-modified
93
+ # buckets, which for a freshly created dev branch is an empty set.
94
+ # Explicit --branch still wins.
95
+ effective_branch: int | None = branch
96
+ if branch is None and project and len(project) == 1:
97
+ _, effective_branch = resolve_branch(
98
+ config_store, formatter, project[0], None, ignore_active_branch=True
99
+ )
100
+
101
+ try:
102
+ result = service.list_buckets(aliases=project, branch_id=effective_branch)
103
+ except ConfigError as exc:
104
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
105
+ raise typer.Exit(code=5) from None
106
+
107
+ if formatter.json_mode:
108
+ formatter.output(result)
109
+ else:
110
+ from rich.table import Table
111
+
112
+ buckets = result["buckets"]
113
+ if not buckets:
114
+ formatter.console.print("[dim]No buckets found.[/dim]")
115
+ return
116
+
117
+ # Group by project
118
+ by_project: dict[str, list[dict]] = {}
119
+ for b in buckets:
120
+ alias = b["project_alias"]
121
+ by_project.setdefault(alias, []).append(b)
122
+
123
+ for alias, proj_buckets in by_project.items():
124
+ table = Table(title=f"Buckets - {alias}")
125
+ table.add_column("Bucket ID", style="bold cyan")
126
+ table.add_column("Stage", style="dim")
127
+ table.add_column("Rows", justify="right")
128
+ table.add_column("Linked From", style="yellow")
129
+
130
+ for b in proj_buckets:
131
+ linked = ""
132
+ if b["is_linked"]:
133
+ linked = f"{b['source_project_name']} (#{b['source_project_id']})"
134
+ table.add_row(
135
+ b["id"],
136
+ b["stage"],
137
+ str(b["rows_count"]),
138
+ linked,
139
+ )
140
+
141
+ formatter.console.print(table)
142
+ formatter.console.print()
143
+
144
+ emit_project_warnings(formatter, result)
145
+
146
+
147
+ @storage_app.command("bucket-detail", rich_help_panel=_BUCKETS)
148
+ def storage_bucket_detail(
149
+ ctx: typer.Context,
150
+ project: str = typer.Option(
151
+ ...,
152
+ "--project",
153
+ help="Project alias",
154
+ ),
155
+ bucket_id: str = typer.Option(
156
+ ...,
157
+ "--bucket-id",
158
+ help="Bucket ID (e.g. in.c-db)",
159
+ ),
160
+ branch: int | None = typer.Option(
161
+ None,
162
+ "--branch",
163
+ help="Dev branch ID (defaults to active branch if set via 'branch use')",
164
+ ),
165
+ ) -> None:
166
+ """Show detailed bucket info including backend-native direct access paths.
167
+
168
+ For linked/shared buckets, resolves the correct database/dataset and
169
+ schema from the source project. Each table includes a ready-to-use
170
+ fully-qualified path with dialect-correct quoting:
171
+
172
+ - Snowflake -> ``"DATABASE"."schema"."table"`` (double quotes)
173
+ - BigQuery -> ``\\`project\\`.\\`dataset\\`.\\`table\\``` (backticks);
174
+ ``project`` is omitted when the API does not expose it.
175
+
176
+ Backend-agnostic ``sql_dialect`` and per-table ``sql_path`` keys are
177
+ always present in JSON output.
178
+ """
179
+ formatter = get_formatter(ctx)
180
+ service = get_service(ctx, "storage_service")
181
+ config_store: ConfigStore = ctx.obj["config_store"]
182
+ # Read command: ignore implicit active dev branch (empty listing trap).
183
+ _, effective_branch = resolve_branch(
184
+ config_store, formatter, project, branch, ignore_active_branch=True
185
+ )
186
+
187
+ try:
188
+ result = service.get_bucket_detail(
189
+ alias=project,
190
+ bucket_id=bucket_id,
191
+ branch_id=effective_branch,
192
+ )
193
+ except ConfigError as exc:
194
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
195
+ raise typer.Exit(code=5) from None
196
+ except KeboolaApiError as exc:
197
+ formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable)
198
+ raise typer.Exit(code=map_error_to_exit_code(exc)) from None
199
+
200
+ if formatter.json_mode:
201
+ formatter.output(result)
202
+ else:
203
+ formatter.console.print(f"[bold]Bucket:[/bold] {result['bucket_id']}")
204
+ formatter.console.print(f" Display name: {result['display_name']}")
205
+ formatter.console.print(f" Backend: {result['backend']}")
206
+
207
+ if result["is_linked"]:
208
+ formatter.console.print(
209
+ f" [yellow]Linked from:[/yellow] "
210
+ f"{result['source_project_name']} (#{result['source_project_id']})"
211
+ )
212
+ formatter.console.print(f" Source bucket: {result['source_bucket_id']}")
213
+
214
+ dialect = result.get("sql_dialect", "snowflake")
215
+ if dialect == "bigquery":
216
+ bq_project = result.get("bigquery_project", "")
217
+ if bq_project:
218
+ formatter.console.print(f" BigQuery project: {bq_project}")
219
+ else:
220
+ formatter.console.print(
221
+ " BigQuery project: [dim](not exposed by Storage API "
222
+ "-- supply your GCP project for full FQN)[/dim]"
223
+ )
224
+ formatter.console.print(f" BigQuery dataset: {result.get('bigquery_dataset', '')}")
225
+ else:
226
+ formatter.console.print(f" Snowflake DB: {result['snowflake_database']}")
227
+ formatter.console.print(f" Snowflake schema: {result['snowflake_schema']}")
228
+ formatter.console.print(f" Tables: {result['table_count']}")
229
+
230
+ if result["tables"]:
231
+ formatter.console.print()
232
+ from rich.table import Table
233
+
234
+ path_col = "BigQuery Path" if dialect == "bigquery" else "Snowflake Path"
235
+ table = Table(title=f"Tables with {dialect} paths")
236
+ table.add_column("Table", style="bold")
237
+ table.add_column(path_col, style="green")
238
+ table.add_column("Alias", style="dim")
239
+
240
+ for t in result["tables"][:50]: # limit display
241
+ table.add_row(
242
+ t["name"],
243
+ t.get("sql_path", ""),
244
+ "yes" if t["is_alias"] else "",
245
+ )
246
+
247
+ formatter.console.print(table)
248
+
249
+ if len(result["tables"]) > 50:
250
+ formatter.console.print(
251
+ f" ... and {len(result['tables']) - 50} more (use --json for full list)"
252
+ )
253
+
254
+
255
+ @storage_app.command("tables", rich_help_panel=_TABLES)
256
+ def storage_tables(
257
+ ctx: typer.Context,
258
+ project: list[str] | None = typer.Option(
259
+ None,
260
+ "--project",
261
+ help="Project alias (can be repeated for multiple projects). "
262
+ "Omit to query all connected projects in parallel.",
263
+ ),
264
+ bucket_id: str | None = typer.Option(
265
+ None,
266
+ "--bucket-id",
267
+ help="Filter tables by bucket ID (applied independently per project)",
268
+ ),
269
+ branch: int | None = typer.Option(
270
+ None,
271
+ "--branch",
272
+ help="Dev branch ID (defaults to active branch if set via 'branch use')",
273
+ ),
274
+ ) -> None:
275
+ """List storage tables from one or more projects.
276
+
277
+ Queries all connected projects in parallel by default, matching the
278
+ behaviour of ``storage buckets``, ``config list``, ``job list``, and other
279
+ read commands. Each row in the output is tagged with ``project_alias``
280
+ so results from multiple projects can be distinguished.
281
+
282
+ Branch handling: this read command uses the production endpoint by
283
+ default, even when a dev branch is active via `branch use`. The
284
+ Storage API branch-scoped endpoint only returns tables that were
285
+ locally modified in the dev branch, so a fresh dev branch lists
286
+ nothing. Pass --branch to query a dev branch explicitly.
287
+ """
288
+ formatter = get_formatter(ctx)
289
+ service = get_service(ctx, "storage_service")
290
+ config_store: ConfigStore = ctx.obj["config_store"]
291
+
292
+ # --branch requires exactly one --project (branch ID is per-project).
293
+ # Mirrors the validation used by `storage buckets` and `config list`.
294
+ if branch is not None and (not project or len(project) != 1):
295
+ formatter.error(
296
+ message="--branch requires exactly one --project (branch ID is per-project)",
297
+ error_code=ErrorCode.INVALID_ARGUMENT,
298
+ )
299
+ raise typer.Exit(code=2)
300
+
301
+ # Resolve active branch only for single-project queries; multi-project
302
+ # listing intentionally skips active-branch resolution because branches
303
+ # are per-project state. Read commands use ignore_active_branch=True:
304
+ # Storage API branch endpoint only returns locally modified tables, so
305
+ # auto-scoping to the active branch traps users into an empty listing.
306
+ effective_branch: int | None = branch
307
+ if branch is None and project and len(project) == 1:
308
+ _, effective_branch = resolve_branch(
309
+ config_store, formatter, project[0], None, ignore_active_branch=True
310
+ )
311
+
312
+ try:
313
+ result = service.list_tables(
314
+ aliases=project,
315
+ bucket_id=bucket_id,
316
+ branch_id=effective_branch,
317
+ )
318
+ except ConfigError as exc:
319
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
320
+ raise typer.Exit(code=5) from None
321
+ except KeboolaApiError as exc:
322
+ formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable)
323
+ raise typer.Exit(code=map_error_to_exit_code(exc)) from None
324
+
325
+ if formatter.json_mode:
326
+ formatter.output(result)
327
+ else:
328
+ from rich.table import Table
329
+
330
+ tables = result["tables"]
331
+ if not tables:
332
+ formatter.console.print("[dim]No tables found.[/dim]")
333
+ emit_project_warnings(formatter, result)
334
+ return
335
+
336
+ # Group by project so multi-project output stays readable.
337
+ by_project: dict[str, list[dict]] = {}
338
+ for t in tables:
339
+ alias = t["project_alias"]
340
+ by_project.setdefault(alias, []).append(t)
341
+
342
+ for alias, proj_tables in by_project.items():
343
+ table = Table(title=f"Tables - {alias}")
344
+ table.add_column("Table ID", style="bold cyan")
345
+ table.add_column("Rows", justify="right")
346
+ table.add_column("Size", justify="right", style="dim")
347
+ table.add_column("Last Import", style="dim")
348
+
349
+ for t in proj_tables:
350
+ size_mb = t["data_size_bytes"] / (1024 * 1024) if t["data_size_bytes"] else 0
351
+ last_import = t.get("last_import_date", "")
352
+ if last_import and "T" in last_import:
353
+ last_import = last_import.split("T")[0]
354
+ table.add_row(
355
+ t["id"],
356
+ str(t["rows_count"]),
357
+ f"{size_mb:.1f} MB",
358
+ last_import,
359
+ )
360
+
361
+ formatter.console.print(table)
362
+ formatter.console.print()
363
+
364
+ emit_project_warnings(formatter, result)
365
+
366
+
367
+ @storage_app.command("table-detail", rich_help_panel=_TABLES)
368
+ def storage_table_detail(
369
+ ctx: typer.Context,
370
+ project: str = typer.Option(
371
+ ...,
372
+ "--project",
373
+ help="Project alias",
374
+ ),
375
+ table_id: str = typer.Option(
376
+ ...,
377
+ "--table-id",
378
+ help="Table ID (e.g. 'in.c-my-bucket.my-table')",
379
+ ),
380
+ branch: int | None = typer.Option(
381
+ None,
382
+ "--branch",
383
+ help="Dev branch ID (defaults to active branch if set via 'branch use')",
384
+ ),
385
+ ) -> None:
386
+ """Show detailed table info including columns and types."""
387
+ formatter = get_formatter(ctx)
388
+ service = get_service(ctx, "storage_service")
389
+ config_store: ConfigStore = ctx.obj["config_store"]
390
+ # Read command: ignore implicit active dev branch (empty listing trap).
391
+ _, effective_branch = resolve_branch(
392
+ config_store, formatter, project, branch, ignore_active_branch=True
393
+ )
394
+
395
+ try:
396
+ result = service.get_table_detail(
397
+ alias=project,
398
+ table_id=table_id,
399
+ branch_id=effective_branch,
400
+ )
401
+ except ConfigError as exc:
402
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
403
+ raise typer.Exit(code=5) from None
404
+ except KeboolaApiError as exc:
405
+ formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable)
406
+ raise typer.Exit(code=map_error_to_exit_code(exc)) from None
407
+
408
+ if formatter.json_mode:
409
+ formatter.output(result)
410
+ else:
411
+ formatter.console.print(f"[bold]Table:[/bold] {result['table_id']}")
412
+ formatter.console.print(f" Name: {result['display_name'] or result['name']}")
413
+ formatter.console.print(f" Bucket: {result['bucket_id']}")
414
+ formatter.console.print(f" Rows: {result['rows_count']:,}")
415
+ size_mb = result["data_size_bytes"] / (1024 * 1024)
416
+ formatter.console.print(f" Size: {size_mb:.2f} MB")
417
+ if result["primary_key"]:
418
+ formatter.console.print(f" Primary key: {', '.join(result['primary_key'])}")
419
+ if result["last_import_date"]:
420
+ formatter.console.print(f" Last import: {result['last_import_date']}")
421
+
422
+ if result["column_details"]:
423
+ formatter.console.print()
424
+ from rich.table import Table
425
+
426
+ table = Table(title="Columns")
427
+ table.add_column("Name", style="bold cyan")
428
+ table.add_column("Type", style="dim")
429
+ table.add_column("Nullable", style="dim")
430
+
431
+ for col in result["column_details"]:
432
+ table.add_row(
433
+ col["name"],
434
+ col.get("type", ""),
435
+ "yes" if col.get("nullable") else "",
436
+ )
437
+
438
+ formatter.console.print(table)
439
+
440
+
441
+ @storage_app.command("create-bucket", rich_help_panel=_BUCKETS)
442
+ def storage_create_bucket(
443
+ ctx: typer.Context,
444
+ project: str = typer.Option(
445
+ ...,
446
+ "--project",
447
+ help="Project alias",
448
+ ),
449
+ stage: str = typer.Option(
450
+ ...,
451
+ "--stage",
452
+ help="Bucket stage: 'in' or 'out'",
453
+ ),
454
+ name: str = typer.Option(
455
+ ...,
456
+ "--name",
457
+ help="Bucket name slug (e.g. 'my-bucket')",
458
+ ),
459
+ description: str | None = typer.Option(
460
+ None,
461
+ "--description",
462
+ help="Optional bucket description",
463
+ ),
464
+ backend: str | None = typer.Option(
465
+ None,
466
+ "--backend",
467
+ help="Optional backend type (e.g. 'snowflake', 'bigquery')",
468
+ ),
469
+ branch: int | None = typer.Option(
470
+ None,
471
+ "--branch",
472
+ help="Dev branch ID (defaults to active branch if set via 'branch use')",
473
+ ),
474
+ ) -> None:
475
+ """Create a new storage bucket."""
476
+ formatter = get_formatter(ctx)
477
+ service = get_service(ctx, "storage_service")
478
+ config_store: ConfigStore = ctx.obj["config_store"]
479
+ _, effective_branch = resolve_branch(config_store, formatter, project, branch)
480
+
481
+ try:
482
+ result = service.create_bucket(
483
+ alias=project,
484
+ stage=stage,
485
+ name=name,
486
+ description=description,
487
+ backend=backend,
488
+ branch_id=effective_branch,
489
+ )
490
+ except ValueError as exc:
491
+ formatter.error(message=str(exc), error_code=ErrorCode.INVALID_ARGUMENT)
492
+ raise typer.Exit(code=2) from None
493
+ except ConfigError as exc:
494
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
495
+ raise typer.Exit(code=5) from None
496
+ except KeboolaApiError as exc:
497
+ formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable)
498
+ raise typer.Exit(code=map_error_to_exit_code(exc)) from None
499
+
500
+ if formatter.json_mode:
501
+ formatter.output(result)
502
+ else:
503
+ formatter.console.print(f"[bold green]Created bucket:[/bold green] {result['id']}")
504
+ formatter.console.print(f" Stage: {result['stage']}")
505
+ formatter.console.print(f" Backend: {result['backend']}")
506
+ if result["description"]:
507
+ formatter.console.print(f" Description: {result['description']}")
508
+ if result.get("legacy_branch_storage"):
509
+ formatter.console.print(_LEGACY_BRANCH_STORAGE_WARNING)
510
+
511
+
512
+ @storage_app.command("create-table", rich_help_panel=_TABLES)
513
+ def storage_create_table(
514
+ ctx: typer.Context,
515
+ project: str = typer.Option(
516
+ ...,
517
+ "--project",
518
+ help="Project alias",
519
+ ),
520
+ bucket_id: str = typer.Option(
521
+ ...,
522
+ "--bucket-id",
523
+ help="Target bucket ID (e.g. 'in.c-my-bucket')",
524
+ ),
525
+ name: str = typer.Option(
526
+ ...,
527
+ "--name",
528
+ help="Table name",
529
+ ),
530
+ column: list[str] = typer.Option(
531
+ ...,
532
+ "--column",
533
+ help=(
534
+ "Column as 'name:TYPE' or 'name:TYPE(length)'. Repeatable. Base types: "
535
+ "STRING, INTEGER, NUMERIC, FLOAT, BOOLEAN, DATE, TIMESTAMP. Native types "
536
+ "are passed through to the Storage API (e.g. 'pk:VARCHAR(40)', "
537
+ "'amount:NUMERIC(18,2)', 'ts:TIMESTAMP_TZ', 'meta:VARIANT')."
538
+ ),
539
+ ),
540
+ primary_key: list[str] | None = typer.Option(
541
+ None,
542
+ "--primary-key",
543
+ help="Primary key column name. Can be repeated.",
544
+ ),
545
+ not_null: list[str] | None = typer.Option(
546
+ None,
547
+ "--not-null",
548
+ help="Column name to mark NOT NULL. Can be repeated. Must match a --column name.",
549
+ ),
550
+ default: list[str] | None = typer.Option(
551
+ None,
552
+ "--default",
553
+ help=(
554
+ "Column default as 'name=value'. Can be repeated. Boolean values must be "
555
+ "lowercase ('true'/'false') per Keboola API validation."
556
+ ),
557
+ ),
558
+ branch: int | None = typer.Option(
559
+ None,
560
+ "--branch",
561
+ help="Dev branch ID (defaults to active branch if set via 'branch use')",
562
+ ),
563
+ if_not_exists: bool = typer.Option(
564
+ False,
565
+ "--if-not-exists",
566
+ help=(
567
+ "Treat a duplicate-display-name failure as a successful no-op "
568
+ "when the table already exists at the expected id. Safe for "
569
+ "parallel workers (FIIA scaffold pattern). A different table "
570
+ "with the same display name still surfaces the original error."
571
+ ),
572
+ ),
573
+ ) -> None:
574
+ """Create a new storage table with typed columns.
575
+
576
+ Base types (`STRING`, `INTEGER`, `NUMERIC`, `FLOAT`, `BOOLEAN`, `DATE`,
577
+ `TIMESTAMP`) plus any native backend type (`VARCHAR(n)`, `NUMBER(p,s)`,
578
+ `TIMESTAMP_TZ`, `VARIANT`, etc.) are accepted. Type/length validation
579
+ is delegated to the Keboola Storage API, which has precise per-backend
580
+ rules and returns actionable errors.
581
+
582
+ When `--branch` targets a dev branch and the bucket has not been
583
+ materialized there yet, kbagent auto-creates it (mirrors the official
584
+ Go CLI's `EnsureBucketExists`). The response's `auto_created_bucket`
585
+ flag reports whether this happened.
586
+
587
+ Examples:
588
+ kbagent storage create-table --project p --bucket-id in.c-b --name t \\
589
+ --column id:INTEGER --column name:STRING --primary-key id
590
+
591
+ kbagent storage create-table --project p --bucket-id in.c-b --name sales \\
592
+ --column pk:VARCHAR(40) --column amount:NUMERIC(18,2) \\
593
+ --column ts:TIMESTAMP_TZ --column is_paid:BOOLEAN \\
594
+ --primary-key pk --not-null pk --not-null amount \\
595
+ --default amount=0 --default is_paid=false
596
+ """
597
+ formatter = get_formatter(ctx)
598
+ service = get_service(ctx, "storage_service")
599
+ config_store: ConfigStore = ctx.obj["config_store"]
600
+ _, effective_branch = resolve_branch(config_store, formatter, project, branch)
601
+
602
+ try:
603
+ result = service.create_table(
604
+ alias=project,
605
+ bucket_id=bucket_id,
606
+ name=name,
607
+ columns=column,
608
+ primary_key=primary_key,
609
+ branch_id=effective_branch,
610
+ not_null_columns=not_null,
611
+ defaults=default,
612
+ if_not_exists=if_not_exists,
613
+ )
614
+ except ValueError as exc:
615
+ formatter.error(message=str(exc), error_code=ErrorCode.INVALID_ARGUMENT)
616
+ raise typer.Exit(code=2) from None
617
+ except ConfigError as exc:
618
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
619
+ raise typer.Exit(code=5) from None
620
+ except KeboolaApiError as exc:
621
+ formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable)
622
+ raise typer.Exit(code=map_error_to_exit_code(exc)) from None
623
+
624
+ if formatter.json_mode:
625
+ formatter.output(result)
626
+ else:
627
+ if result.get("action") == "skipped":
628
+ formatter.console.print(
629
+ f"[bold yellow]Skipped[/bold yellow] (already exists): {result['table_id']}"
630
+ )
631
+ reason = result.get("skip_reason")
632
+ if reason:
633
+ formatter.console.print(f" [dim]{reason}[/dim]")
634
+ if result.get("schema_drift"):
635
+ formatter.console.print(
636
+ " [yellow]Warning:[/yellow] the existing table's schema differs "
637
+ "from the requested definition. The fields below show the ACTUAL "
638
+ "existing schema; your requested schema was not applied."
639
+ )
640
+ if result.get("primary_key"):
641
+ formatter.console.print(f" Primary key: {', '.join(result['primary_key'])}")
642
+ if result.get("columns"):
643
+ formatter.console.print(f" Columns: {', '.join(result['columns'])}")
644
+ else:
645
+ formatter.console.print(f"[bold green]Created table:[/bold green] {result['table_id']}")
646
+ if result.get("auto_created_bucket"):
647
+ formatter.console.print(
648
+ f" [yellow]Note:[/yellow] bucket {result['bucket_id']} was "
649
+ f"auto-materialized in this branch."
650
+ )
651
+ if result["primary_key"]:
652
+ formatter.console.print(f" Primary key: {', '.join(result['primary_key'])}")
653
+ formatter.console.print(f" Columns: {', '.join(result['columns'])}")
654
+ if result.get("legacy_branch_storage"):
655
+ formatter.console.print(_LEGACY_BRANCH_STORAGE_WARNING)
656
+
657
+
658
+ @storage_app.command("upload-table", rich_help_panel=_TABLES)
659
+ def storage_upload_table(
660
+ ctx: typer.Context,
661
+ project: str = typer.Option(
662
+ ...,
663
+ "--project",
664
+ help="Project alias",
665
+ ),
666
+ table_id: str = typer.Option(
667
+ ...,
668
+ "--table-id",
669
+ help="Target table ID (e.g. 'in.c-my-bucket.my-table')",
670
+ ),
671
+ file: str = typer.Option(
672
+ ...,
673
+ "--file",
674
+ help="Path to the CSV file to upload",
675
+ ),
676
+ incremental: bool = typer.Option(
677
+ False,
678
+ "--incremental",
679
+ help="Append rows instead of full load (default: full load)",
680
+ ),
681
+ delimiter: str = typer.Option(
682
+ ",",
683
+ "--delimiter",
684
+ help="CSV column delimiter (default: ',')",
685
+ ),
686
+ enclosure: str = typer.Option(
687
+ '"',
688
+ "--enclosure",
689
+ help="CSV value enclosure character (default: '\"')'",
690
+ ),
691
+ auto_create: bool = typer.Option(
692
+ True,
693
+ "--auto-create/--no-auto-create",
694
+ help="Auto-create bucket and table if they don't exist (default: on). "
695
+ "Columns are inferred as STRING from the CSV header row.",
696
+ ),
697
+ branch: int | None = typer.Option(
698
+ None,
699
+ "--branch",
700
+ help="Dev branch ID (defaults to active branch if set via 'branch use')",
701
+ ),
702
+ ) -> None:
703
+ """Upload a CSV file into a storage table.
704
+
705
+ Auto-creates the bucket and table if they don't exist (columns inferred as
706
+ STRING from the CSV header). Use --no-auto-create to require the table to
707
+ already exist.
708
+ """
709
+ formatter = get_formatter(ctx)
710
+ service = get_service(ctx, "storage_service")
711
+ config_store: ConfigStore = ctx.obj["config_store"]
712
+ _, effective_branch = resolve_branch(config_store, formatter, project, branch)
713
+
714
+ p = Path(file)
715
+ if not p.is_file():
716
+ formatter.error(message=f"File not found: {file}", error_code=ErrorCode.FILE_NOT_FOUND)
717
+ raise typer.Exit(code=2) from None
718
+
719
+ if not formatter.json_mode:
720
+ size_mb = p.stat().st_size / (1024 * 1024)
721
+ formatter.console.print(
722
+ f"Uploading [bold]{p.name}[/bold] ({size_mb:.2f} MB) to [cyan]{table_id}[/cyan]..."
723
+ )
724
+
725
+ try:
726
+ result = service.upload_table(
727
+ alias=project,
728
+ table_id=table_id,
729
+ file_path=file,
730
+ incremental=incremental,
731
+ delimiter=delimiter,
732
+ enclosure=enclosure,
733
+ auto_create=auto_create,
734
+ branch_id=effective_branch,
735
+ )
736
+ except ValueError as exc:
737
+ formatter.error(message=str(exc), error_code=ErrorCode.INVALID_ARGUMENT)
738
+ raise typer.Exit(code=2) from None
739
+ except ConfigError as exc:
740
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
741
+ raise typer.Exit(code=5) from None
742
+ except KeboolaApiError as exc:
743
+ formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable)
744
+ raise typer.Exit(code=map_error_to_exit_code(exc)) from None
745
+
746
+ if formatter.json_mode:
747
+ formatter.output(result)
748
+ else:
749
+ parts = result["table_id"].split(".")
750
+ bucket_id = ".".join(parts[:2]) if len(parts) == 3 else ""
751
+ if result.get("auto_created_bucket") and bucket_id:
752
+ formatter.console.print(f"[dim]Created bucket: {bucket_id}[/dim]")
753
+ if result.get("auto_created_table"):
754
+ formatter.console.print(f"[dim]Created table: {result['table_id']}[/dim]")
755
+ load_type = "incremental" if result["incremental"] else "full"
756
+ size_mb = result.get("file_size_bytes", 0) / (1024 * 1024)
757
+ formatter.console.print(
758
+ f"[bold green]Uploaded:[/bold green] {result['table_id']} "
759
+ f"({load_type} load, {size_mb:.2f} MB)"
760
+ )
761
+ if result["imported_rows"] is not None:
762
+ formatter.console.print(f" Rows imported: {result['imported_rows']}")
763
+ if result["warnings"]:
764
+ for w in result["warnings"]:
765
+ formatter.console.print(f" [yellow]Warning:[/yellow] {w}")
766
+
767
+
768
+ @storage_app.command("download-table", rich_help_panel=_TABLES)
769
+ def storage_download_table(
770
+ ctx: typer.Context,
771
+ project: str = typer.Option(
772
+ ...,
773
+ "--project",
774
+ help="Project alias",
775
+ ),
776
+ table_id: str = typer.Option(
777
+ ...,
778
+ "--table-id",
779
+ help="Table ID to export (e.g. 'in.c-my-bucket.my-table')",
780
+ ),
781
+ output: str | None = typer.Option(
782
+ None,
783
+ "--output",
784
+ help=(
785
+ "Output path. Default mode: file path (e.g. table.csv). "
786
+ "With --keep-slices: directory path (default ./{project}/{table_id}.csv/)."
787
+ ),
788
+ ),
789
+ columns: list[str] | None = typer.Option(
790
+ None,
791
+ "--columns",
792
+ help="Column names to export (repeat for multiple: --columns col1 --columns col2)",
793
+ ),
794
+ limit: int | None = typer.Option(
795
+ None,
796
+ "--limit",
797
+ help="Max number of rows to export",
798
+ ),
799
+ branch: int | None = typer.Option(
800
+ None,
801
+ "--branch",
802
+ help="Dev branch ID (defaults to active branch if set via 'branch use')",
803
+ ),
804
+ keep_slices: bool = typer.Option(
805
+ False,
806
+ "--keep-slices",
807
+ help=(
808
+ "Save each slice as its own file under --output (treated as a "
809
+ "directory). Avoids the concat pass, matches the parquet download "
810
+ "layout, and is the analytical-workflow-friendly option for DuckDB, "
811
+ "polars, Spark. A _columns.csv sidecar holds the column order."
812
+ ),
813
+ ),
814
+ where_column: str | None = typer.Option(
815
+ None,
816
+ "--where-column",
817
+ help="Export only rows where this column matches --where-value(s).",
818
+ ),
819
+ where_operator: str = typer.Option(
820
+ "eq",
821
+ "--where-operator",
822
+ help="Filter operator: 'eq' (default) or 'neq'.",
823
+ ),
824
+ where_value: list[str] | None = typer.Option(
825
+ None,
826
+ "--where-value",
827
+ help="Value(s) for --where-column (repeat for multiple: matched as OR).",
828
+ ),
829
+ changed_since: str | None = typer.Option(
830
+ None,
831
+ "--changed-since",
832
+ help="Only rows imported since this time (unix ts or strtotime, e.g. '-2 days').",
833
+ ),
834
+ changed_until: str | None = typer.Option(
835
+ None,
836
+ "--changed-until",
837
+ help="Only rows imported up to this time (unix ts or strtotime).",
838
+ ),
839
+ ) -> None:
840
+ """Export a storage table to a local CSV file.
841
+
842
+ Downloads table data via the async export API. Handles gzip
843
+ decompression transparently. Use --columns to select specific
844
+ columns and --limit to cap row count.
845
+
846
+ Use --keep-slices to write the individual slices into a directory
847
+ instead of concatenating them into a single file.
848
+ """
849
+ formatter = get_formatter(ctx)
850
+ service = get_service(ctx, "storage_service")
851
+ config_store: ConfigStore = ctx.obj["config_store"]
852
+ _, effective_branch = resolve_branch(config_store, formatter, project, branch)
853
+
854
+ if not formatter.json_mode:
855
+ msg = f"Exporting [cyan]{table_id}[/cyan]"
856
+ if columns:
857
+ msg += f" (columns: {', '.join(columns)})"
858
+ if limit:
859
+ msg += f" (limit: {limit})"
860
+ msg += "..."
861
+ formatter.console.print(msg)
862
+
863
+ try:
864
+ result = service.download_table(
865
+ alias=project,
866
+ table_id=table_id,
867
+ output_path=output,
868
+ columns=columns,
869
+ limit=limit,
870
+ branch_id=effective_branch,
871
+ keep_slices=keep_slices,
872
+ where_column=where_column,
873
+ where_operator=where_operator,
874
+ where_values=where_value,
875
+ changed_since=changed_since,
876
+ changed_until=changed_until,
877
+ )
878
+ except ValueError as exc:
879
+ formatter.error(message=str(exc), error_code=ErrorCode.INVALID_ARGUMENT)
880
+ raise typer.Exit(code=2) from None
881
+ except ConfigError as exc:
882
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
883
+ raise typer.Exit(code=5) from None
884
+ except KeboolaApiError as exc:
885
+ formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable)
886
+ raise typer.Exit(code=map_error_to_exit_code(exc)) from None
887
+
888
+ if formatter.json_mode:
889
+ formatter.output(result)
890
+ else:
891
+ size_mb = result["file_size_bytes"] / (1024 * 1024)
892
+ suffix = (
893
+ f", {result['slice_count']} slices"
894
+ if result.get("keep_slices") and result.get("slice_count")
895
+ else ""
896
+ )
897
+ formatter.console.print(
898
+ f"[bold green]Exported:[/bold green] {result['table_id']} -> {result['output_path']} "
899
+ f"({size_mb:.2f} MB{suffix})"
900
+ )
901
+
902
+
903
+ @storage_app.command("delete-table", rich_help_panel=_TABLES)
904
+ def storage_delete_table(
905
+ ctx: typer.Context,
906
+ project: str = typer.Option(
907
+ ...,
908
+ "--project",
909
+ help="Project alias",
910
+ ),
911
+ table_id: list[str] = typer.Option(
912
+ ...,
913
+ "--table-id",
914
+ help="Table ID to delete (e.g. 'in.c-bucket.table'). Can be repeated.",
915
+ ),
916
+ force: bool = typer.Option(
917
+ False,
918
+ "--force",
919
+ help="Force-delete tables that have aliases in other projects (cascade).",
920
+ ),
921
+ dry_run: bool = typer.Option(
922
+ False,
923
+ "--dry-run",
924
+ help="Show what would be deleted without executing",
925
+ ),
926
+ yes: bool = typer.Option(
927
+ False,
928
+ "--yes",
929
+ "-y",
930
+ help="Skip confirmation prompt",
931
+ ),
932
+ branch: int | None = typer.Option(
933
+ None,
934
+ "--branch",
935
+ help="Dev branch ID (defaults to active branch if set via 'branch use')",
936
+ ),
937
+ ) -> None:
938
+ """Delete one or more storage tables.
939
+
940
+ Supports batch deletion with multiple --table-id flags.
941
+ All deletes are async and wait for completion.
942
+
943
+ Use --force to cascade-delete tables that have aliases linked
944
+ into other projects (shared buckets). Without --force, the API
945
+ rejects deletion of aliased tables.
946
+ """
947
+ formatter = get_formatter(ctx)
948
+ service = get_service(ctx, "storage_service")
949
+ config_store: ConfigStore = ctx.obj["config_store"]
950
+ _, effective_branch = resolve_branch(config_store, formatter, project, branch)
951
+
952
+ if dry_run:
953
+ try:
954
+ result = service.delete_tables(
955
+ alias=project,
956
+ table_ids=table_id,
957
+ dry_run=True,
958
+ branch_id=effective_branch,
959
+ )
960
+ except ConfigError as exc:
961
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
962
+ raise typer.Exit(code=5) from None
963
+
964
+ if formatter.json_mode:
965
+ formatter.output(result)
966
+ else:
967
+ for tid in result.get("would_delete", []):
968
+ formatter.console.print(f"[bold blue]Would delete:[/bold blue] {tid}")
969
+ return
970
+
971
+ confirm_msg = f"Delete {len(table_id)} table(s) from project '{project}'?"
972
+ if force:
973
+ confirm_msg = (
974
+ f"FORCE-delete {len(table_id)} table(s) from project '{project}'?"
975
+ " This will also delete all aliases in downstream projects."
976
+ )
977
+ if not yes and not formatter.json_mode and not typer.confirm(confirm_msg):
978
+ formatter.console.print("Aborted.")
979
+ raise typer.Exit(code=0)
980
+
981
+ try:
982
+ result = service.delete_tables(
983
+ alias=project,
984
+ table_ids=table_id,
985
+ force=force,
986
+ branch_id=effective_branch,
987
+ )
988
+ except ConfigError as exc:
989
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
990
+ raise typer.Exit(code=5) from None
991
+
992
+ if formatter.json_mode:
993
+ formatter.output(result)
994
+ else:
995
+ for tid in result["deleted"]:
996
+ formatter.console.print(f"[bold green]Deleted:[/bold green] {tid}")
997
+ for f_item in result["failed"]:
998
+ formatter.console.print(
999
+ f"[bold red]Failed:[/bold red] {f_item['id']}: {f_item['error']}"
1000
+ )
1001
+
1002
+ if result["failed"]:
1003
+ raise typer.Exit(code=1)
1004
+
1005
+
1006
+ @storage_app.command("truncate-table", rich_help_panel=_TABLES)
1007
+ def storage_truncate_table(
1008
+ ctx: typer.Context,
1009
+ project: str = typer.Option(
1010
+ ...,
1011
+ "--project",
1012
+ help="Project alias",
1013
+ ),
1014
+ table_id: list[str] = typer.Option(
1015
+ ...,
1016
+ "--table-id",
1017
+ help="Table ID to truncate (e.g. 'in.c-bucket.table'). Can be repeated.",
1018
+ ),
1019
+ dry_run: bool = typer.Option(
1020
+ False,
1021
+ "--dry-run",
1022
+ help="Show what would be truncated without executing",
1023
+ ),
1024
+ yes: bool = typer.Option(
1025
+ False,
1026
+ "--yes",
1027
+ "-y",
1028
+ help="Skip confirmation prompt",
1029
+ ),
1030
+ branch: int | None = typer.Option(
1031
+ None,
1032
+ "--branch",
1033
+ help="Dev branch ID (defaults to active branch if set via 'branch use')",
1034
+ ),
1035
+ ) -> None:
1036
+ """Truncate (delete all rows from) one or more storage tables.
1037
+
1038
+ Preserves the table definition: columns, types, primary key,
1039
+ descriptions, sharing edges, and dependents are unaffected -- only
1040
+ rows are removed. Idempotent (truncating an empty table is a no-op).
1041
+
1042
+ The Storage API truncate endpoint is asynchronous: it returns a
1043
+ queued storage job which the client polls to completion before
1044
+ surfacing the result. Both production and dev branches behave the
1045
+ same way; the only difference is wall-clock latency (sub-second
1046
+ on production, longer on busy dev branches).
1047
+
1048
+ Use this when re-seeding a table without losing the schema contract.
1049
+ To destroy the table itself, use ``storage delete-table``.
1050
+ """
1051
+ formatter = get_formatter(ctx)
1052
+ service = get_service(ctx, "storage_service")
1053
+ config_store: ConfigStore = ctx.obj["config_store"]
1054
+ _, effective_branch = resolve_branch(config_store, formatter, project, branch)
1055
+
1056
+ if dry_run:
1057
+ try:
1058
+ result = service.truncate_tables(
1059
+ alias=project,
1060
+ table_ids=table_id,
1061
+ dry_run=True,
1062
+ branch_id=effective_branch,
1063
+ )
1064
+ except ConfigError as exc:
1065
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
1066
+ raise typer.Exit(code=5) from None
1067
+
1068
+ if formatter.json_mode:
1069
+ formatter.output(result)
1070
+ else:
1071
+ for entry in result.get("would_truncate", []):
1072
+ formatter.console.print(
1073
+ f"[bold blue]Would truncate:[/bold blue] {entry['table_id']} "
1074
+ f"(rows_before={entry['rows_before']})"
1075
+ )
1076
+ return
1077
+
1078
+ confirm_msg = (
1079
+ f"Truncate {len(table_id)} table(s) in project '{project}'? "
1080
+ "All rows will be deleted; schema and dependents are preserved."
1081
+ )
1082
+ if not yes and not formatter.json_mode and not typer.confirm(confirm_msg):
1083
+ formatter.console.print("Aborted.")
1084
+ raise typer.Exit(code=0)
1085
+
1086
+ try:
1087
+ result = service.truncate_tables(
1088
+ alias=project,
1089
+ table_ids=table_id,
1090
+ branch_id=effective_branch,
1091
+ )
1092
+ except ConfigError as exc:
1093
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
1094
+ raise typer.Exit(code=5) from None
1095
+
1096
+ if formatter.json_mode:
1097
+ formatter.output(result)
1098
+ else:
1099
+ for entry in result["truncated"]:
1100
+ formatter.console.print(
1101
+ f"[bold green]Truncated:[/bold green] {entry['table_id']} "
1102
+ f"({entry['rows_before']} -> 0 rows)"
1103
+ )
1104
+ for f_item in result["failed"]:
1105
+ formatter.console.print(
1106
+ f"[bold red]Failed:[/bold red] {f_item['id']}: {f_item['error']}"
1107
+ )
1108
+
1109
+ if result["failed"]:
1110
+ raise typer.Exit(code=1)
1111
+
1112
+
1113
+ @storage_app.command("add-column", rich_help_panel=_TABLES)
1114
+ def storage_add_column(
1115
+ ctx: typer.Context,
1116
+ project: str = typer.Option(
1117
+ ...,
1118
+ "--project",
1119
+ help="Project alias",
1120
+ ),
1121
+ table_id: str = typer.Option(
1122
+ ...,
1123
+ "--table-id",
1124
+ help="Table ID to add the column to (e.g. 'in.c-bucket.table')",
1125
+ ),
1126
+ column: str = typer.Option(
1127
+ ...,
1128
+ "--column",
1129
+ help=(
1130
+ "Column spec: 'name', 'name:TYPE', or 'name:TYPE(length)' "
1131
+ "(e.g. 'status:VARCHAR(20)', 'amount:NUMBER(18,2)')."
1132
+ ),
1133
+ ),
1134
+ not_null: bool = typer.Option(
1135
+ False,
1136
+ "--not-null",
1137
+ help="Make the new column NOT NULL (needs an empty table or a --default).",
1138
+ ),
1139
+ default: str | None = typer.Option(
1140
+ None,
1141
+ "--default",
1142
+ help="Default value for the new column.",
1143
+ ),
1144
+ branch: int | None = typer.Option(
1145
+ None,
1146
+ "--branch",
1147
+ help="Dev branch ID (defaults to active branch if set via 'branch use')",
1148
+ ),
1149
+ ) -> None:
1150
+ """Add a single column to an existing table (synchronous, typed).
1151
+
1152
+ Mirrors ``create-table --column``: ``name:TYPE(length)`` creates a typed
1153
+ column; a bare ``name`` adds an untyped STRING column. The Storage
1154
+ add-column endpoint is synchronous -- there is no job to wait on.
1155
+ """
1156
+ formatter = get_formatter(ctx)
1157
+ service = get_service(ctx, "storage_service")
1158
+ config_store: ConfigStore = ctx.obj["config_store"]
1159
+ _, effective_branch = resolve_branch(config_store, formatter, project, branch)
1160
+
1161
+ try:
1162
+ result = service.add_column(
1163
+ alias=project,
1164
+ table_id=table_id,
1165
+ column=column,
1166
+ not_null=not_null,
1167
+ default=default,
1168
+ branch_id=effective_branch,
1169
+ )
1170
+ except ValueError as exc:
1171
+ formatter.error(message=str(exc), error_code=ErrorCode.INVALID_ARGUMENT)
1172
+ raise typer.Exit(code=2) from None
1173
+ except ConfigError as exc:
1174
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
1175
+ raise typer.Exit(code=5) from None
1176
+ except KeboolaApiError as exc:
1177
+ formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable)
1178
+ raise typer.Exit(code=map_error_to_exit_code(exc)) from None
1179
+
1180
+ if formatter.json_mode:
1181
+ formatter.output(result)
1182
+ else:
1183
+ col_type = result["definition"].get("type", "STRING")
1184
+ formatter.console.print(
1185
+ f"[bold green]Added column:[/bold green] {result['column']} "
1186
+ f"({col_type}) to {result['table_id']}"
1187
+ )
1188
+
1189
+
1190
+ @storage_app.command("delete-column", rich_help_panel=_TABLES)
1191
+ def storage_delete_column(
1192
+ ctx: typer.Context,
1193
+ project: str = typer.Option(
1194
+ ...,
1195
+ "--project",
1196
+ help="Project alias",
1197
+ ),
1198
+ table_id: str = typer.Option(
1199
+ ...,
1200
+ "--table-id",
1201
+ help="Table ID containing the column(s) (e.g. 'in.c-bucket.table')",
1202
+ ),
1203
+ column: list[str] = typer.Option(
1204
+ ...,
1205
+ "--column",
1206
+ help="Column name to delete. Can be repeated.",
1207
+ ),
1208
+ force: bool = typer.Option(
1209
+ False,
1210
+ "--force",
1211
+ help="Force delete even if column is referenced by table aliases",
1212
+ ),
1213
+ dry_run: bool = typer.Option(
1214
+ False,
1215
+ "--dry-run",
1216
+ help="Show what would be deleted without executing",
1217
+ ),
1218
+ yes: bool = typer.Option(
1219
+ False,
1220
+ "--yes",
1221
+ "-y",
1222
+ help="Skip confirmation prompt",
1223
+ ),
1224
+ branch: int | None = typer.Option(
1225
+ None,
1226
+ "--branch",
1227
+ help="Dev branch ID (defaults to active branch if set via 'branch use')",
1228
+ ),
1229
+ ) -> None:
1230
+ """Delete one or more columns from a storage table.
1231
+
1232
+ Supports batch deletion with multiple --column flags.
1233
+ Use --force when a column is referenced by table aliases.
1234
+ """
1235
+ formatter = get_formatter(ctx)
1236
+ service = get_service(ctx, "storage_service")
1237
+ config_store: ConfigStore = ctx.obj["config_store"]
1238
+ _, effective_branch = resolve_branch(config_store, formatter, project, branch)
1239
+
1240
+ if dry_run:
1241
+ try:
1242
+ result = service.delete_columns(
1243
+ alias=project,
1244
+ table_id=table_id,
1245
+ columns=column,
1246
+ dry_run=True,
1247
+ branch_id=effective_branch,
1248
+ )
1249
+ except ConfigError as exc:
1250
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
1251
+ raise typer.Exit(code=5) from None
1252
+
1253
+ if formatter.json_mode:
1254
+ formatter.output(result)
1255
+ else:
1256
+ for col in result.get("would_delete", []):
1257
+ formatter.console.print(
1258
+ f"[bold blue]Would delete:[/bold blue] {col} from {table_id}"
1259
+ )
1260
+ return
1261
+
1262
+ if (
1263
+ not yes
1264
+ and not formatter.json_mode
1265
+ and not typer.confirm(
1266
+ f"Delete {len(column)} column(s) from table '{table_id}' in project '{project}'?"
1267
+ )
1268
+ ):
1269
+ formatter.console.print("Aborted.")
1270
+ raise typer.Exit(code=0)
1271
+
1272
+ try:
1273
+ result = service.delete_columns(
1274
+ alias=project,
1275
+ table_id=table_id,
1276
+ columns=column,
1277
+ force=force,
1278
+ branch_id=effective_branch,
1279
+ )
1280
+ except ConfigError as exc:
1281
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
1282
+ raise typer.Exit(code=5) from None
1283
+
1284
+ if formatter.json_mode:
1285
+ formatter.output(result)
1286
+ else:
1287
+ for col in result["deleted"]:
1288
+ formatter.console.print(f"[bold green]Deleted:[/bold green] {col} from {table_id}")
1289
+ for f_item in result["failed"]:
1290
+ formatter.console.print(
1291
+ f"[bold red]Failed:[/bold red] {f_item['column']}: {f_item['error']}"
1292
+ )
1293
+
1294
+ if result["failed"]:
1295
+ raise typer.Exit(code=1)
1296
+
1297
+
1298
+ @storage_app.command("swap-tables", rich_help_panel=_TABLES)
1299
+ def storage_swap_tables(
1300
+ ctx: typer.Context,
1301
+ project: str = typer.Option(
1302
+ ...,
1303
+ "--project",
1304
+ help="Project alias",
1305
+ ),
1306
+ table_id: str = typer.Option(
1307
+ ...,
1308
+ "--table-id",
1309
+ help="First table ID (e.g. 'in.c-bucket.table')",
1310
+ ),
1311
+ target_table_id: str = typer.Option(
1312
+ ...,
1313
+ "--target-table-id",
1314
+ help="Second table ID to swap with the first",
1315
+ ),
1316
+ branch: int | None = typer.Option(
1317
+ None,
1318
+ "--branch",
1319
+ help=(
1320
+ "Branch ID. Required; defaults to the active branch set via "
1321
+ "'kbagent branch use'. Any branch works, including the "
1322
+ "default/production branch -- a default-branch swap is how a "
1323
+ "typed rebuild is applied to production."
1324
+ ),
1325
+ ),
1326
+ dry_run: bool = typer.Option(
1327
+ False,
1328
+ "--dry-run",
1329
+ help="Show what would be swapped without executing",
1330
+ ),
1331
+ yes: bool = typer.Option(
1332
+ False,
1333
+ "--yes",
1334
+ "-y",
1335
+ help="Skip confirmation prompt",
1336
+ ),
1337
+ ) -> None:
1338
+ """Swap two storage tables (any branch, including the default/production branch).
1339
+
1340
+ Both tables exchange physical positions. Aliases are NOT transferred --
1341
+ they keep pointing at the same physical position and therefore expose
1342
+ the OTHER table's data after the swap. Use this to promote a typed
1343
+ rebuild ("data_change_log" with proper column types) into the original
1344
+ name ("data") without touching downstream config references.
1345
+
1346
+ \b
1347
+ branch_id is mandatory (the swap is always branch-scoped): the command
1348
+ resolves the active branch from 'kbagent branch use' if --branch is
1349
+ omitted, and exits 5 before any HTTP call if no branch is set in either
1350
+ place. Any branch works, INCLUDING the default/production branch -- a
1351
+ default-branch swap is how a typed rebuild is applied to prod, since a
1352
+ dev-branch merge does not carry storage schema.
1353
+
1354
+ \b
1355
+ Example:
1356
+ kbagent branch use --project P --branch 1234
1357
+ kbagent storage swap-tables --project P \\
1358
+ --table-id in.c-foo.data --target-table-id in.c-foo.data_change_log
1359
+ """
1360
+ formatter = get_formatter(ctx)
1361
+ service = get_service(ctx, "storage_service")
1362
+ config_store: ConfigStore = ctx.obj["config_store"]
1363
+ _, effective_branch = resolve_branch(config_store, formatter, project, branch)
1364
+
1365
+ if dry_run:
1366
+ try:
1367
+ result = service.swap_tables(
1368
+ alias=project,
1369
+ table_id=table_id,
1370
+ target_table_id=target_table_id,
1371
+ branch_id=effective_branch,
1372
+ dry_run=True,
1373
+ )
1374
+ except ConfigError as exc:
1375
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
1376
+ raise typer.Exit(code=5) from None
1377
+
1378
+ if formatter.json_mode:
1379
+ formatter.output(result)
1380
+ else:
1381
+ formatter.console.print(
1382
+ f"[bold blue]Would swap (branch {result['branch_id']}):[/bold blue] "
1383
+ f"{result['table_id']} <-> {result['target_table_id']}"
1384
+ )
1385
+ return
1386
+
1387
+ confirm_msg = (
1388
+ f"Swap '{table_id}' <-> '{target_table_id}' in project '{project}' "
1389
+ f"on branch {effective_branch}? Aliases will continue to point at the "
1390
+ "same physical position (i.e. they will expose the OTHER table's data "
1391
+ "after the swap)."
1392
+ )
1393
+ if not yes and not formatter.json_mode and not typer.confirm(confirm_msg):
1394
+ formatter.console.print("Aborted.")
1395
+ raise typer.Exit(code=0)
1396
+
1397
+ try:
1398
+ result = service.swap_tables(
1399
+ alias=project,
1400
+ table_id=table_id,
1401
+ target_table_id=target_table_id,
1402
+ branch_id=effective_branch,
1403
+ dry_run=False,
1404
+ )
1405
+ except ConfigError as exc:
1406
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
1407
+ raise typer.Exit(code=5) from None
1408
+ except KeboolaApiError as exc:
1409
+ exit_code = map_error_to_exit_code(exc)
1410
+ formatter.error(
1411
+ message=exc.message,
1412
+ error_code=exc.error_code,
1413
+ project=project,
1414
+ retryable=exc.retryable,
1415
+ )
1416
+ raise typer.Exit(code=exit_code) from None
1417
+
1418
+ if formatter.json_mode:
1419
+ formatter.output(result)
1420
+ else:
1421
+ formatter.console.print(
1422
+ f"[bold green]Swapped:[/bold green] {result['table_id']} <-> "
1423
+ f"{result['target_table_id']} (branch {result['branch_id']})"
1424
+ )
1425
+
1426
+
1427
+ @storage_app.command("clone-table", rich_help_panel=_TABLES)
1428
+ def storage_clone_table(
1429
+ ctx: typer.Context,
1430
+ project: str = typer.Option(
1431
+ ...,
1432
+ "--project",
1433
+ help="Project alias",
1434
+ ),
1435
+ table_id: str = typer.Option(
1436
+ ...,
1437
+ "--table-id",
1438
+ help="Table ID to pull into the branch (e.g. 'in.c-bucket.table')",
1439
+ ),
1440
+ branch: int | None = typer.Option(
1441
+ None,
1442
+ "--branch",
1443
+ help=(
1444
+ "Target dev branch ID. Required; defaults to the active branch "
1445
+ "set via 'kbagent branch use'. The pull is one-way: default -> branch."
1446
+ ),
1447
+ ),
1448
+ dry_run: bool = typer.Option(
1449
+ False,
1450
+ "--dry-run",
1451
+ help="Show what would be pulled without executing",
1452
+ ),
1453
+ ) -> None:
1454
+ """Clone (pull) a production table into a development branch.
1455
+
1456
+ On storage-branches projects a dev branch reads production tables
1457
+ transparently until the first write. To mutate a table's schema in the
1458
+ branch -- e.g. 'swap-tables' or dropping a column -- you first need a
1459
+ branch-local copy of the production table; without it the Storage API
1460
+ reports the bucket as "not found" in the branch. This materializes that
1461
+ copy from the default branch (one-way: default -> branch).
1462
+
1463
+ \b
1464
+ Example:
1465
+ kbagent branch use --project P --branch 1234
1466
+ kbagent storage clone-table --project P --table-id in.c-foo.data
1467
+ kbagent storage swap-tables --project P \\
1468
+ --table-id in.c-foo.data --target-table-id in.c-foo.data_typed
1469
+ """
1470
+ formatter = get_formatter(ctx)
1471
+ service = get_service(ctx, "storage_service")
1472
+ config_store: ConfigStore = ctx.obj["config_store"]
1473
+ _, effective_branch = resolve_branch(config_store, formatter, project, branch)
1474
+
1475
+ try:
1476
+ result = service.clone_table(
1477
+ alias=project,
1478
+ table_id=table_id,
1479
+ branch_id=effective_branch,
1480
+ dry_run=dry_run,
1481
+ )
1482
+ except ConfigError as exc:
1483
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
1484
+ raise typer.Exit(code=5) from None
1485
+ except KeboolaApiError as exc:
1486
+ exit_code = map_error_to_exit_code(exc)
1487
+ formatter.error(
1488
+ message=exc.message,
1489
+ error_code=exc.error_code,
1490
+ project=project,
1491
+ retryable=exc.retryable,
1492
+ )
1493
+ raise typer.Exit(code=exit_code) from None
1494
+
1495
+ if dry_run:
1496
+ if formatter.json_mode:
1497
+ formatter.output(result)
1498
+ else:
1499
+ formatter.console.print(
1500
+ f"[bold blue]Would clone (branch {result['branch_id']}):[/bold blue] "
1501
+ f"{result['table_id']} (default -> branch)"
1502
+ )
1503
+ return
1504
+
1505
+ if formatter.json_mode:
1506
+ formatter.output(result)
1507
+ else:
1508
+ formatter.console.print(
1509
+ f"[bold green]Cloned:[/bold green] {result['table_id']} "
1510
+ f"into branch {result['branch_id']}"
1511
+ )
1512
+
1513
+
1514
+ @storage_app.command("delete-bucket", rich_help_panel=_BUCKETS)
1515
+ def storage_delete_bucket(
1516
+ ctx: typer.Context,
1517
+ project: str = typer.Option(
1518
+ ...,
1519
+ "--project",
1520
+ help="Project alias",
1521
+ ),
1522
+ bucket_id: list[str] = typer.Option(
1523
+ ...,
1524
+ "--bucket-id",
1525
+ help="Bucket ID to delete (e.g. 'in.c-my-bucket'). Can be repeated.",
1526
+ ),
1527
+ force: bool = typer.Option(
1528
+ False,
1529
+ "--force",
1530
+ help="Force delete even if bucket contains tables (cascade)",
1531
+ ),
1532
+ dry_run: bool = typer.Option(
1533
+ False,
1534
+ "--dry-run",
1535
+ help="Show what would be deleted without executing",
1536
+ ),
1537
+ yes: bool = typer.Option(
1538
+ False,
1539
+ "--yes",
1540
+ "-y",
1541
+ help="Skip confirmation prompt",
1542
+ ),
1543
+ branch: int | None = typer.Option(
1544
+ None,
1545
+ "--branch",
1546
+ help="Dev branch ID (defaults to active branch if set via 'branch use')",
1547
+ ),
1548
+ ) -> None:
1549
+ """Delete one or more storage buckets.
1550
+
1551
+ Without --force, fails if a bucket contains tables.
1552
+ With --force, cascade-deletes all tables in the bucket.
1553
+ Linked and shared buckets are protected (use sharing unlink/unshare).
1554
+ """
1555
+ formatter = get_formatter(ctx)
1556
+ service = get_service(ctx, "storage_service")
1557
+ config_store: ConfigStore = ctx.obj["config_store"]
1558
+ _, effective_branch = resolve_branch(config_store, formatter, project, branch)
1559
+
1560
+ try:
1561
+ result = service.delete_buckets(
1562
+ alias=project,
1563
+ bucket_ids=bucket_id,
1564
+ force=force,
1565
+ dry_run=dry_run,
1566
+ branch_id=effective_branch,
1567
+ )
1568
+ except ConfigError as exc:
1569
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
1570
+ raise typer.Exit(code=5) from None
1571
+
1572
+ if formatter.json_mode:
1573
+ formatter.output(result)
1574
+ else:
1575
+ if dry_run:
1576
+ for bid in result.get("would_delete", []):
1577
+ force_hint = " [force]" if force else ""
1578
+ formatter.console.print(f"[bold blue]Would delete:[/bold blue] {bid}{force_hint}")
1579
+ else:
1580
+ for bid in result["deleted"]:
1581
+ formatter.console.print(f"[bold green]Deleted:[/bold green] {bid}")
1582
+ for f_item in result["failed"]:
1583
+ formatter.console.print(
1584
+ f"[bold red]Failed:[/bold red] {f_item['id']}: {f_item['error']}"
1585
+ )
1586
+
1587
+ if result["failed"]:
1588
+ raise typer.Exit(code=1)
1589
+
1590
+
1591
+ # ------------------------------------------------------------------
1592
+ # Describe (metadata write) commands
1593
+ # ------------------------------------------------------------------
1594
+
1595
+ _DESCRIBE = "Descriptions"
1596
+
1597
+
1598
+ @storage_app.command("describe-bucket", rich_help_panel=_DESCRIBE)
1599
+ def storage_describe_bucket(
1600
+ ctx: typer.Context,
1601
+ project: str = typer.Option(
1602
+ ...,
1603
+ "--project",
1604
+ help="Project alias",
1605
+ ),
1606
+ bucket_id: str = typer.Option(
1607
+ ...,
1608
+ "--bucket-id",
1609
+ help="Bucket ID (e.g. 'in.c-my-bucket')",
1610
+ ),
1611
+ text: str | None = typer.Option(
1612
+ None,
1613
+ "--text",
1614
+ help="Description text (inline)",
1615
+ ),
1616
+ file: Path | None = typer.Option(
1617
+ None,
1618
+ "--file",
1619
+ help="Path to a file containing the description",
1620
+ ),
1621
+ stdin: bool = typer.Option(
1622
+ False,
1623
+ "--stdin",
1624
+ help="Read description from standard input",
1625
+ ),
1626
+ branch: int | None = typer.Option(
1627
+ None,
1628
+ "--branch",
1629
+ help="Dev branch ID (defaults to active branch if set via 'branch use')",
1630
+ ),
1631
+ ) -> None:
1632
+ """Set the description on a storage bucket.
1633
+
1634
+ Stores the description as KBC.description in bucket metadata (upsert).
1635
+ Provide the text via --text, --file, or --stdin (exactly one required).
1636
+ """
1637
+ formatter = get_formatter(ctx)
1638
+ service = get_service(ctx, "storage_service")
1639
+ config_store: ConfigStore = ctx.obj["config_store"]
1640
+ _, effective_branch = resolve_branch(config_store, formatter, project, branch)
1641
+
1642
+ from ._metadata_input import resolve_text_input
1643
+
1644
+ try:
1645
+ description = resolve_text_input(text=text, file=file, stdin=stdin)
1646
+ except ConfigError as exc:
1647
+ formatter.error(message=exc.message, error_code=ErrorCode.INVALID_ARGUMENT)
1648
+ raise typer.Exit(code=2) from None
1649
+
1650
+ try:
1651
+ result = service.describe_bucket(
1652
+ alias=project,
1653
+ bucket_id=bucket_id,
1654
+ description=description,
1655
+ branch_id=effective_branch,
1656
+ )
1657
+ except ConfigError as exc:
1658
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
1659
+ raise typer.Exit(code=5) from None
1660
+ except KeboolaApiError as exc:
1661
+ formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable)
1662
+ raise typer.Exit(code=map_error_to_exit_code(exc)) from None
1663
+
1664
+ if formatter.json_mode:
1665
+ formatter.output(result)
1666
+ else:
1667
+ formatter.console.print(f"[bold green]Description set:[/bold green] {bucket_id}")
1668
+ formatter.console.print(f" {description[:120]}")
1669
+
1670
+
1671
+ @storage_app.command("describe-table", rich_help_panel=_DESCRIBE)
1672
+ def storage_describe_table(
1673
+ ctx: typer.Context,
1674
+ project: str = typer.Option(
1675
+ ...,
1676
+ "--project",
1677
+ help="Project alias",
1678
+ ),
1679
+ table_id: str = typer.Option(
1680
+ ...,
1681
+ "--table-id",
1682
+ help="Table ID (e.g. 'in.c-my-bucket.my-table')",
1683
+ ),
1684
+ text: str | None = typer.Option(
1685
+ None,
1686
+ "--text",
1687
+ help="Description text (inline)",
1688
+ ),
1689
+ file: Path | None = typer.Option(
1690
+ None,
1691
+ "--file",
1692
+ help="Path to a file containing the description",
1693
+ ),
1694
+ stdin: bool = typer.Option(
1695
+ False,
1696
+ "--stdin",
1697
+ help="Read description from standard input",
1698
+ ),
1699
+ branch: int | None = typer.Option(
1700
+ None,
1701
+ "--branch",
1702
+ help="Dev branch ID (defaults to active branch if set via 'branch use')",
1703
+ ),
1704
+ ) -> None:
1705
+ """Set the description on a storage table.
1706
+
1707
+ Stores the description as KBC.description in table metadata (upsert).
1708
+ Provide the text via --text, --file, or --stdin (exactly one required).
1709
+ """
1710
+ formatter = get_formatter(ctx)
1711
+ service = get_service(ctx, "storage_service")
1712
+ config_store: ConfigStore = ctx.obj["config_store"]
1713
+ _, effective_branch = resolve_branch(config_store, formatter, project, branch)
1714
+
1715
+ from ._metadata_input import resolve_text_input
1716
+
1717
+ try:
1718
+ description = resolve_text_input(text=text, file=file, stdin=stdin)
1719
+ except ConfigError as exc:
1720
+ formatter.error(message=exc.message, error_code=ErrorCode.INVALID_ARGUMENT)
1721
+ raise typer.Exit(code=2) from None
1722
+
1723
+ try:
1724
+ result = service.describe_table(
1725
+ alias=project,
1726
+ table_id=table_id,
1727
+ description=description,
1728
+ branch_id=effective_branch,
1729
+ )
1730
+ except ConfigError as exc:
1731
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
1732
+ raise typer.Exit(code=5) from None
1733
+ except KeboolaApiError as exc:
1734
+ formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable)
1735
+ raise typer.Exit(code=map_error_to_exit_code(exc)) from None
1736
+
1737
+ if formatter.json_mode:
1738
+ formatter.output(result)
1739
+ else:
1740
+ formatter.console.print(f"[bold green]Description set:[/bold green] {table_id}")
1741
+ formatter.console.print(f" {description[:120]}")
1742
+
1743
+
1744
+ @storage_app.command("describe-column", rich_help_panel=_DESCRIBE)
1745
+ def storage_describe_column(
1746
+ ctx: typer.Context,
1747
+ project: str = typer.Option(
1748
+ ...,
1749
+ "--project",
1750
+ help="Project alias",
1751
+ ),
1752
+ table_id: str = typer.Option(
1753
+ ...,
1754
+ "--table-id",
1755
+ help="Table ID (e.g. 'in.c-my-bucket.my-table')",
1756
+ ),
1757
+ column: list[str] = typer.Option(
1758
+ ...,
1759
+ "--column",
1760
+ help="Column description as 'NAME=DESCRIPTION' (can be repeated)",
1761
+ ),
1762
+ branch: int | None = typer.Option(
1763
+ None,
1764
+ "--branch",
1765
+ help="Dev branch ID (defaults to active branch if set via 'branch use')",
1766
+ ),
1767
+ ) -> None:
1768
+ """Set descriptions on one or more columns of a storage table.
1769
+
1770
+ Descriptions are stored as KBC.column.{name}.description keys in table
1771
+ metadata (upsert). Keboola Storage does not expose a user-writable
1772
+ column-level metadata endpoint; this convention lets you annotate columns
1773
+ and read them back via 'storage table-detail'.
1774
+
1775
+ Example:
1776
+
1777
+ kbagent storage describe-column \\
1778
+ --project myproj \\
1779
+ --table-id in.c-bucket.orders \\
1780
+ --column order_id="Unique order identifier" \\
1781
+ --column total="Order total in USD"
1782
+ """
1783
+ formatter = get_formatter(ctx)
1784
+ service = get_service(ctx, "storage_service")
1785
+ config_store: ConfigStore = ctx.obj["config_store"]
1786
+ _, effective_branch = resolve_branch(config_store, formatter, project, branch)
1787
+
1788
+ parsed: dict[str, str] = {}
1789
+ for entry in column:
1790
+ if "=" not in entry:
1791
+ formatter.error(
1792
+ message=f"--column must be NAME=DESCRIPTION, got: {entry!r}",
1793
+ error_code=ErrorCode.INVALID_ARGUMENT,
1794
+ )
1795
+ raise typer.Exit(code=2) from None
1796
+ name, _, desc = entry.partition("=")
1797
+ name = name.strip()
1798
+ if not name:
1799
+ formatter.error(
1800
+ message=f"Column name cannot be empty in: {entry!r}",
1801
+ error_code=ErrorCode.INVALID_ARGUMENT,
1802
+ )
1803
+ raise typer.Exit(code=2) from None
1804
+ parsed[name] = desc
1805
+
1806
+ try:
1807
+ result = service.describe_columns(
1808
+ alias=project,
1809
+ table_id=table_id,
1810
+ columns=parsed,
1811
+ branch_id=effective_branch,
1812
+ )
1813
+ except ValueError as exc:
1814
+ formatter.error(message=str(exc), error_code=ErrorCode.INVALID_ARGUMENT)
1815
+ raise typer.Exit(code=2) from None
1816
+ except ConfigError as exc:
1817
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
1818
+ raise typer.Exit(code=5) from None
1819
+ except KeboolaApiError as exc:
1820
+ formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable)
1821
+ raise typer.Exit(code=map_error_to_exit_code(exc)) from None
1822
+
1823
+ if formatter.json_mode:
1824
+ formatter.output(result)
1825
+ else:
1826
+ formatter.console.print(
1827
+ f"[bold green]Column descriptions set:[/bold green] {table_id} "
1828
+ f"({len(parsed)} column(s))"
1829
+ )
1830
+ for name, desc in parsed.items():
1831
+ formatter.console.print(f" {name}: {desc[:80]}")
1832
+
1833
+
1834
+ @storage_app.command("describe-batch", rich_help_panel=_DESCRIBE)
1835
+ def storage_describe_batch(
1836
+ ctx: typer.Context,
1837
+ project: str = typer.Option(
1838
+ ...,
1839
+ "--project",
1840
+ help="Project alias",
1841
+ ),
1842
+ from_file: Path = typer.Option(
1843
+ ...,
1844
+ "--from-file",
1845
+ help="Path to a YAML file with bucket/table/column descriptions",
1846
+ ),
1847
+ branch: int | None = typer.Option(
1848
+ None,
1849
+ "--branch",
1850
+ help="Dev branch ID (defaults to active branch if set via 'branch use')",
1851
+ ),
1852
+ ) -> None:
1853
+ """Apply descriptions to buckets, tables, and columns from a YAML file.
1854
+
1855
+ YAML schema:
1856
+
1857
+ buckets:
1858
+ in.c-my-bucket: "Bucket description"
1859
+
1860
+ tables:
1861
+ in.c-my-bucket.my-table: "Table description"
1862
+
1863
+ columns:
1864
+ in.c-my-bucket.my-table:
1865
+ col1: "Column 1 description"
1866
+ col2: "Column 2 description"
1867
+
1868
+ All sections are optional. A failure in one item does not abort the
1869
+ rest -- all results are collected and reported.
1870
+ """
1871
+ formatter = get_formatter(ctx)
1872
+ service = get_service(ctx, "storage_service")
1873
+ config_store: ConfigStore = ctx.obj["config_store"]
1874
+ _, effective_branch = resolve_branch(config_store, formatter, project, branch)
1875
+
1876
+ # In human mode, show a live progress indicator so that large batches
1877
+ # (100+ items) do not look frozen. JSON mode must remain silent on stderr
1878
+ # so structured output is the only thing on stdout.
1879
+ progress_cm: Any = None
1880
+ progress_task: Any = None
1881
+ progress_callback = None
1882
+ if not formatter.json_mode:
1883
+ from rich.progress import (
1884
+ BarColumn,
1885
+ MofNCompleteColumn,
1886
+ Progress,
1887
+ SpinnerColumn,
1888
+ TextColumn,
1889
+ TimeElapsedColumn,
1890
+ )
1891
+
1892
+ progress_cm = Progress(
1893
+ SpinnerColumn(),
1894
+ TextColumn("[progress.description]{task.description}"),
1895
+ BarColumn(),
1896
+ MofNCompleteColumn(),
1897
+ TextColumn("•"),
1898
+ TimeElapsedColumn(),
1899
+ console=formatter.console,
1900
+ transient=True,
1901
+ )
1902
+
1903
+ def _on_item(obj_type: str, obj_id: str, current: int, total: int) -> None:
1904
+ # Guard against progress_task/progress_cm not being ready yet.
1905
+ if progress_task is None or progress_cm is None:
1906
+ return
1907
+ # total is known up-front (passed the first time), but re-setting
1908
+ # is a no-op after the first call.
1909
+ progress_cm.update(
1910
+ progress_task,
1911
+ total=total,
1912
+ completed=max(current - 1, 0),
1913
+ description=f"Describing {obj_type} {obj_id}",
1914
+ )
1915
+
1916
+ progress_callback = _on_item
1917
+
1918
+ try:
1919
+ if progress_cm is not None:
1920
+ progress_cm.start()
1921
+ progress_task = progress_cm.add_task("Applying descriptions...", total=None)
1922
+ result = service.describe_batch(
1923
+ alias=project,
1924
+ from_file=from_file,
1925
+ branch_id=effective_branch,
1926
+ progress_callback=progress_callback,
1927
+ )
1928
+ if progress_cm is not None and progress_task is not None:
1929
+ # Mark the task complete so the final render shows N / N.
1930
+ progress_cm.update(
1931
+ progress_task,
1932
+ completed=result["applied_count"] + result["error_count"],
1933
+ )
1934
+ except ValueError as exc:
1935
+ formatter.error(message=str(exc), error_code=ErrorCode.INVALID_ARGUMENT)
1936
+ raise typer.Exit(code=2) from None
1937
+ except ConfigError as exc:
1938
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
1939
+ raise typer.Exit(code=5) from None
1940
+ except KeboolaApiError as exc:
1941
+ formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable)
1942
+ raise typer.Exit(code=map_error_to_exit_code(exc)) from None
1943
+ finally:
1944
+ if progress_cm is not None:
1945
+ # .stop() is idempotent; safe for both happy and error paths.
1946
+ progress_cm.stop()
1947
+
1948
+ if formatter.json_mode:
1949
+ formatter.output(result)
1950
+ else:
1951
+ applied = result["applied_count"]
1952
+ errors = result["error_count"]
1953
+ formatter.console.print(
1954
+ f"[bold green]Batch complete:[/bold green] {applied} applied, {errors} error(s)"
1955
+ )
1956
+ for item in result["applied"]:
1957
+ obj_type = item["type"]
1958
+ obj_id = item["id"]
1959
+ if obj_type == "columns":
1960
+ n = len(item.get("columns", {}))
1961
+ formatter.console.print(f" [green]✓[/green] {obj_type} {obj_id} ({n} cols)")
1962
+ else:
1963
+ formatter.console.print(f" [green]✓[/green] {obj_type} {obj_id}")
1964
+ for item in result["errors"]:
1965
+ formatter.console.print(f" [red]✗[/red] {item['type']} {item['id']}: {item['error']}")
1966
+ if errors:
1967
+ raise typer.Exit(code=1) from None
1968
+
1969
+
1970
+ # ------------------------------------------------------------------
1971
+ # File operations
1972
+ # ------------------------------------------------------------------
1973
+
1974
+
1975
+ def _format_file_size(size_bytes: int | None) -> str:
1976
+ """Format file size in human-readable form."""
1977
+ if size_bytes is None:
1978
+ return "unknown"
1979
+ if size_bytes < 1024:
1980
+ return f"{size_bytes} B"
1981
+ if size_bytes < 1024 * 1024:
1982
+ return f"{size_bytes / 1024:.1f} KB"
1983
+ if size_bytes < 1024 * 1024 * 1024:
1984
+ return f"{size_bytes / (1024 * 1024):.2f} MB"
1985
+ return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB"
1986
+
1987
+
1988
+ @storage_app.command("files", rich_help_panel=_FILES)
1989
+ def storage_file_list(
1990
+ ctx: typer.Context,
1991
+ project: str = typer.Option(
1992
+ ...,
1993
+ "--project",
1994
+ help="Project alias",
1995
+ ),
1996
+ tag: list[str] | None = typer.Option(
1997
+ None,
1998
+ "--tag",
1999
+ help="Filter by tag (repeat for AND logic: --tag a --tag b)",
2000
+ ),
2001
+ limit: int = typer.Option(
2002
+ 20,
2003
+ "--limit",
2004
+ help="Max number of files to return",
2005
+ ),
2006
+ offset: int = typer.Option(
2007
+ 0,
2008
+ "--offset",
2009
+ help="Pagination offset",
2010
+ ),
2011
+ query: str | None = typer.Option(
2012
+ None,
2013
+ "--query",
2014
+ "-q",
2015
+ help="Full-text search on file name",
2016
+ ),
2017
+ branch: int | None = typer.Option(
2018
+ None,
2019
+ "--branch",
2020
+ help="Dev branch ID (defaults to active branch if set via 'branch use')",
2021
+ ),
2022
+ ) -> None:
2023
+ """List Storage Files with optional tag filtering.
2024
+
2025
+ Lists files from the project's Storage Files API. Use --tag to filter
2026
+ by tags (AND logic - all specified tags must match).
2027
+ """
2028
+ formatter = get_formatter(ctx)
2029
+ service = get_service(ctx, "storage_service")
2030
+ config_store: ConfigStore = ctx.obj["config_store"]
2031
+ # Read command: ignore implicit active dev branch (empty listing trap).
2032
+ _, effective_branch = resolve_branch(
2033
+ config_store, formatter, project, branch, ignore_active_branch=True
2034
+ )
2035
+
2036
+ try:
2037
+ result = service.list_files(
2038
+ alias=project,
2039
+ limit=limit,
2040
+ offset=offset,
2041
+ tags=tag,
2042
+ query=query,
2043
+ branch_id=effective_branch,
2044
+ )
2045
+ except ConfigError as exc:
2046
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
2047
+ raise typer.Exit(code=5) from None
2048
+ except KeboolaApiError as exc:
2049
+ formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable)
2050
+ raise typer.Exit(code=map_error_to_exit_code(exc)) from None
2051
+
2052
+ if formatter.json_mode:
2053
+ formatter.output(result)
2054
+ else:
2055
+ files = result["files"]
2056
+ if not files:
2057
+ formatter.console.print("[dim]No files found.[/dim]")
2058
+ return
2059
+
2060
+ from rich.table import Table
2061
+
2062
+ table = Table(title=f"Storage Files ({result['count']} files)")
2063
+ table.add_column("ID", style="cyan")
2064
+ table.add_column("Name")
2065
+ table.add_column("Size", justify="right")
2066
+ table.add_column("Tags")
2067
+ table.add_column("Permanent")
2068
+ table.add_column("Created")
2069
+
2070
+ for f in files:
2071
+ tags_str = ", ".join(f.get("tags", []))
2072
+ permanent = "yes" if f.get("isPermanent") else ""
2073
+ created = f.get("created", "")[:19] if f.get("created") else ""
2074
+ table.add_row(
2075
+ str(f.get("id", "")),
2076
+ f.get("name", ""),
2077
+ _format_file_size(f.get("sizeBytes")),
2078
+ tags_str,
2079
+ permanent,
2080
+ created,
2081
+ )
2082
+
2083
+ formatter.console.print(table)
2084
+
2085
+
2086
+ @storage_app.command("file-detail", rich_help_panel=_FILES)
2087
+ def storage_file_info(
2088
+ ctx: typer.Context,
2089
+ project: str = typer.Option(
2090
+ ...,
2091
+ "--project",
2092
+ help="Project alias",
2093
+ ),
2094
+ file_id: int = typer.Option(
2095
+ ...,
2096
+ "--file-id",
2097
+ help="Storage file ID",
2098
+ ),
2099
+ ) -> None:
2100
+ """Show Storage File metadata (without downloading)."""
2101
+ formatter = get_formatter(ctx)
2102
+ service = get_service(ctx, "storage_service")
2103
+
2104
+ try:
2105
+ result = service.get_file_info(alias=project, file_id=file_id)
2106
+ except ConfigError as exc:
2107
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
2108
+ raise typer.Exit(code=5) from None
2109
+ except KeboolaApiError as exc:
2110
+ formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable)
2111
+ raise typer.Exit(code=map_error_to_exit_code(exc)) from None
2112
+
2113
+ if formatter.json_mode:
2114
+ formatter.output(result)
2115
+ else:
2116
+ formatter.console.print(f"[bold]File ID:[/bold] {result.get('id')}")
2117
+ formatter.console.print(f"[bold]Name:[/bold] {result.get('name')}")
2118
+ formatter.console.print(f"[bold]Size:[/bold] {_format_file_size(result.get('sizeBytes'))}")
2119
+ formatter.console.print(f"[bold]Created:[/bold] {result.get('created', '')}")
2120
+ formatter.console.print(f"[bold]Sliced:[/bold] {'yes' if result.get('isSliced') else 'no'}")
2121
+ formatter.console.print(
2122
+ f"[bold]Permanent:[/bold] {'yes' if result.get('isPermanent') else 'no'}"
2123
+ )
2124
+ tags_str = ", ".join(result.get("tags", []))
2125
+ formatter.console.print(f"[bold]Tags:[/bold] {tags_str or '(none)'}")
2126
+ creator = result.get("creatorToken", {})
2127
+ if isinstance(creator, dict):
2128
+ formatter.console.print(
2129
+ f"[bold]Creator:[/bold] {creator.get('description', 'unknown')}"
2130
+ )
2131
+
2132
+
2133
+ @storage_app.command("file-upload", rich_help_panel=_FILES)
2134
+ def storage_file_upload(
2135
+ ctx: typer.Context,
2136
+ project: str = typer.Option(
2137
+ ...,
2138
+ "--project",
2139
+ help="Project alias",
2140
+ ),
2141
+ file: str = typer.Option(
2142
+ ...,
2143
+ "--file",
2144
+ help="Path to the file to upload",
2145
+ ),
2146
+ name: str | None = typer.Option(
2147
+ None,
2148
+ "--name",
2149
+ help="Custom file name (default: local filename)",
2150
+ ),
2151
+ tag: list[str] | None = typer.Option(
2152
+ None,
2153
+ "--tag",
2154
+ help="Tag to assign (repeat for multiple: --tag a --tag b)",
2155
+ ),
2156
+ permanent: bool = typer.Option(
2157
+ False,
2158
+ "--permanent",
2159
+ help="Make file permanent (not auto-deleted after 15 days)",
2160
+ ),
2161
+ branch: int | None = typer.Option(
2162
+ None,
2163
+ "--branch",
2164
+ help="Dev branch ID (defaults to active branch if set via 'branch use')",
2165
+ ),
2166
+ ) -> None:
2167
+ """Upload a local file to Storage Files.
2168
+
2169
+ Uploads any file (CSV, JSON, ZIP, etc.) to Keboola Storage Files.
2170
+ Use --tag to assign tags and --permanent to prevent auto-deletion.
2171
+ """
2172
+ formatter = get_formatter(ctx)
2173
+ service = get_service(ctx, "storage_service")
2174
+ config_store: ConfigStore = ctx.obj["config_store"]
2175
+ _, effective_branch = resolve_branch(config_store, formatter, project, branch)
2176
+
2177
+ p = Path(file)
2178
+ if not p.is_file():
2179
+ formatter.error(message=f"File not found: {file}", error_code=ErrorCode.FILE_NOT_FOUND)
2180
+ raise typer.Exit(code=2) from None
2181
+
2182
+ if not formatter.json_mode:
2183
+ size_str = _format_file_size(p.stat().st_size)
2184
+ formatter.console.print(f"Uploading [bold]{p.name}[/bold] ({size_str})...")
2185
+
2186
+ try:
2187
+ result = service.upload_file(
2188
+ alias=project,
2189
+ file_path=file,
2190
+ name=name,
2191
+ tags=tag,
2192
+ is_permanent=permanent,
2193
+ branch_id=effective_branch,
2194
+ )
2195
+ except ConfigError as exc:
2196
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
2197
+ raise typer.Exit(code=5) from None
2198
+ except KeboolaApiError as exc:
2199
+ formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable)
2200
+ raise typer.Exit(code=map_error_to_exit_code(exc)) from None
2201
+
2202
+ if formatter.json_mode:
2203
+ formatter.output(result)
2204
+ else:
2205
+ size_str = _format_file_size(result.get("file_size_bytes"))
2206
+ tags_str = ", ".join(result.get("tags", []))
2207
+ formatter.console.print(
2208
+ f"[bold green]Uploaded:[/bold green] file ID {result['id']} "
2209
+ f"({result.get('name', '')}), {size_str}"
2210
+ )
2211
+ if tags_str:
2212
+ formatter.console.print(f" Tags: {tags_str}")
2213
+ if result.get("isPermanent"):
2214
+ formatter.console.print(" Permanent: yes")
2215
+
2216
+
2217
+ @storage_app.command("file-download", rich_help_panel=_FILES)
2218
+ def storage_file_download(
2219
+ ctx: typer.Context,
2220
+ project: str = typer.Option(
2221
+ ...,
2222
+ "--project",
2223
+ help="Project alias",
2224
+ ),
2225
+ file_id: int | None = typer.Option(
2226
+ None,
2227
+ "--file-id",
2228
+ help="Storage file ID to download",
2229
+ ),
2230
+ tag: list[str] | None = typer.Option(
2231
+ None,
2232
+ "--tag",
2233
+ help="Download latest file matching tags (repeat for AND: --tag a --tag b)",
2234
+ ),
2235
+ output: str | None = typer.Option(
2236
+ None,
2237
+ "--output",
2238
+ "-o",
2239
+ help="Output file path (default: original filename)",
2240
+ ),
2241
+ ) -> None:
2242
+ """Download a Storage File to local disk.
2243
+
2244
+ Download by file ID (--file-id) or by tags (--tag, downloads the latest
2245
+ matching file). Handles both sliced and non-sliced files transparently.
2246
+ """
2247
+ formatter = get_formatter(ctx)
2248
+ service = get_service(ctx, "storage_service")
2249
+
2250
+ if not file_id and not tag:
2251
+ formatter.error(
2252
+ message="Either --file-id or --tag must be provided",
2253
+ error_code=ErrorCode.INVALID_ARGUMENT,
2254
+ )
2255
+ raise typer.Exit(code=2) from None
2256
+
2257
+ if not formatter.json_mode:
2258
+ if file_id:
2259
+ formatter.console.print(f"Downloading file ID [cyan]{file_id}[/cyan]...")
2260
+ else:
2261
+ formatter.console.print(f"Downloading latest file with tags: {', '.join(tag or [])}...")
2262
+
2263
+ try:
2264
+ result = service.download_file(
2265
+ alias=project,
2266
+ file_id=file_id,
2267
+ tags=tag,
2268
+ output_path=output,
2269
+ )
2270
+ except ValueError as exc:
2271
+ formatter.error(message=str(exc), error_code=ErrorCode.INVALID_ARGUMENT)
2272
+ raise typer.Exit(code=2) from None
2273
+ except ConfigError as exc:
2274
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
2275
+ raise typer.Exit(code=5) from None
2276
+ except KeboolaApiError as exc:
2277
+ formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable)
2278
+ raise typer.Exit(code=map_error_to_exit_code(exc)) from None
2279
+
2280
+ if formatter.json_mode:
2281
+ formatter.output(result)
2282
+ else:
2283
+ size_str = _format_file_size(result["file_size_bytes"])
2284
+ formatter.console.print(
2285
+ f"[bold green]Downloaded:[/bold green] {result['file_name']} "
2286
+ f"-> {result['output_path']} ({size_str})"
2287
+ )
2288
+
2289
+
2290
+ @storage_app.command("file-tag", rich_help_panel=_FILES)
2291
+ def storage_file_tag(
2292
+ ctx: typer.Context,
2293
+ project: str = typer.Option(
2294
+ ...,
2295
+ "--project",
2296
+ help="Project alias",
2297
+ ),
2298
+ file_id: int = typer.Option(
2299
+ ...,
2300
+ "--file-id",
2301
+ help="Storage file ID",
2302
+ ),
2303
+ add: list[str] | None = typer.Option(
2304
+ None,
2305
+ "--add",
2306
+ help="Tag to add (repeat for multiple: --add a --add b)",
2307
+ ),
2308
+ remove: list[str] | None = typer.Option(
2309
+ None,
2310
+ "--remove",
2311
+ help="Tag to remove (repeat for multiple: --remove a --remove b)",
2312
+ ),
2313
+ ) -> None:
2314
+ """Add and/or remove tags on a Storage File.
2315
+
2316
+ Use --add and --remove to modify tags in a single operation.
2317
+ """
2318
+ formatter = get_formatter(ctx)
2319
+ service = get_service(ctx, "storage_service")
2320
+
2321
+ if not add and not remove:
2322
+ formatter.error(
2323
+ message="At least one of --add or --remove must be provided",
2324
+ error_code=ErrorCode.INVALID_ARGUMENT,
2325
+ )
2326
+ raise typer.Exit(code=2) from None
2327
+
2328
+ try:
2329
+ result = service.tag_file(
2330
+ alias=project,
2331
+ file_id=file_id,
2332
+ add_tags=add,
2333
+ remove_tags=remove,
2334
+ )
2335
+ except ConfigError as exc:
2336
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
2337
+ raise typer.Exit(code=5) from None
2338
+ except KeboolaApiError as exc:
2339
+ formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable)
2340
+ raise typer.Exit(code=map_error_to_exit_code(exc)) from None
2341
+
2342
+ if formatter.json_mode:
2343
+ formatter.output(result)
2344
+ else:
2345
+ for tag_name in result["added"]:
2346
+ formatter.console.print(f"[bold green]Added tag:[/bold green] {tag_name}")
2347
+ for tag_name in result["removed"]:
2348
+ formatter.console.print(f"[bold yellow]Removed tag:[/bold yellow] {tag_name}")
2349
+ for err in result["errors"]:
2350
+ formatter.console.print(
2351
+ f"[bold red]Failed:[/bold red] {err['action']} tag '{err['tag']}': {err['error']}"
2352
+ )
2353
+
2354
+ if result["errors"]:
2355
+ raise typer.Exit(code=1)
2356
+
2357
+
2358
+ @storage_app.command("file-delete", rich_help_panel=_FILES)
2359
+ def storage_file_delete(
2360
+ ctx: typer.Context,
2361
+ project: str = typer.Option(
2362
+ ...,
2363
+ "--project",
2364
+ help="Project alias",
2365
+ ),
2366
+ file_id: list[int] = typer.Option(
2367
+ ...,
2368
+ "--file-id",
2369
+ help="Storage file ID to delete (repeat for multiple)",
2370
+ ),
2371
+ dry_run: bool = typer.Option(
2372
+ False,
2373
+ "--dry-run",
2374
+ help="Show what would be deleted without executing",
2375
+ ),
2376
+ yes: bool = typer.Option(
2377
+ False,
2378
+ "--yes",
2379
+ "-y",
2380
+ help="Skip confirmation prompt",
2381
+ ),
2382
+ ) -> None:
2383
+ """Delete one or more Storage Files."""
2384
+ formatter = get_formatter(ctx)
2385
+ service = get_service(ctx, "storage_service")
2386
+
2387
+ try:
2388
+ result = service.delete_files(
2389
+ alias=project,
2390
+ file_ids=file_id,
2391
+ dry_run=dry_run,
2392
+ )
2393
+ except ConfigError as exc:
2394
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
2395
+ raise typer.Exit(code=5) from None
2396
+ except KeboolaApiError as exc:
2397
+ formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable)
2398
+ raise typer.Exit(code=map_error_to_exit_code(exc)) from None
2399
+
2400
+ if formatter.json_mode:
2401
+ formatter.output(result)
2402
+ else:
2403
+ if dry_run:
2404
+ for fid in result.get("would_delete", []):
2405
+ formatter.console.print(f"[bold blue]Would delete:[/bold blue] file ID {fid}")
2406
+ else:
2407
+ for fid in result["deleted"]:
2408
+ formatter.console.print(f"[bold green]Deleted:[/bold green] file ID {fid}")
2409
+ for f_err in result["failed"]:
2410
+ formatter.console.print(
2411
+ f"[bold red]Failed:[/bold red] file ID {f_err['id']}: {f_err['error']}"
2412
+ )
2413
+
2414
+ if result["failed"]:
2415
+ raise typer.Exit(code=1)
2416
+
2417
+
2418
+ @storage_app.command("load-file", rich_help_panel=_FILES)
2419
+ def storage_load_file(
2420
+ ctx: typer.Context,
2421
+ project: str = typer.Option(
2422
+ ...,
2423
+ "--project",
2424
+ help="Project alias",
2425
+ ),
2426
+ file_id: int = typer.Option(
2427
+ ...,
2428
+ "--file-id",
2429
+ help="Storage file ID to load into a table",
2430
+ ),
2431
+ table_id: str = typer.Option(
2432
+ ...,
2433
+ "--table-id",
2434
+ help="Target table ID (e.g. 'in.c-my-bucket.my-table')",
2435
+ ),
2436
+ incremental: bool = typer.Option(
2437
+ False,
2438
+ "--incremental",
2439
+ help="Append rows instead of full load",
2440
+ ),
2441
+ delimiter: str = typer.Option(
2442
+ ",",
2443
+ "--delimiter",
2444
+ help="CSV column delimiter",
2445
+ ),
2446
+ enclosure: str = typer.Option(
2447
+ '"',
2448
+ "--enclosure",
2449
+ help="CSV value enclosure character",
2450
+ ),
2451
+ branch: int | None = typer.Option(
2452
+ None,
2453
+ "--branch",
2454
+ help="Dev branch ID (defaults to active branch if set via 'branch use')",
2455
+ ),
2456
+ ) -> None:
2457
+ """Load a Storage File into a table.
2458
+
2459
+ Imports an already-uploaded file (from file-upload or component output)
2460
+ into a storage table. Use --incremental to append rows.
2461
+ """
2462
+ formatter = get_formatter(ctx)
2463
+ service = get_service(ctx, "storage_service")
2464
+ config_store: ConfigStore = ctx.obj["config_store"]
2465
+ _, effective_branch = resolve_branch(config_store, formatter, project, branch)
2466
+
2467
+ if not formatter.json_mode:
2468
+ formatter.console.print(
2469
+ f"Loading file ID [cyan]{file_id}[/cyan] into [cyan]{table_id}[/cyan]..."
2470
+ )
2471
+
2472
+ try:
2473
+ result = service.load_file_to_table(
2474
+ alias=project,
2475
+ file_id=file_id,
2476
+ table_id=table_id,
2477
+ incremental=incremental,
2478
+ delimiter=delimiter,
2479
+ enclosure=enclosure,
2480
+ branch_id=effective_branch,
2481
+ )
2482
+ except ConfigError as exc:
2483
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
2484
+ raise typer.Exit(code=5) from None
2485
+ except KeboolaApiError as exc:
2486
+ formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable)
2487
+ raise typer.Exit(code=map_error_to_exit_code(exc)) from None
2488
+
2489
+ if formatter.json_mode:
2490
+ formatter.output(result)
2491
+ else:
2492
+ load_type = "incremental" if result["incremental"] else "full"
2493
+ formatter.console.print(
2494
+ f"[bold green]Loaded:[/bold green] file {result['file_id']} -> "
2495
+ f"{result['table_id']} ({load_type} load)"
2496
+ )
2497
+ if result["imported_rows"] is not None:
2498
+ formatter.console.print(f" Rows imported: {result['imported_rows']}")
2499
+ for w in result.get("warnings", []):
2500
+ formatter.console.print(f" [yellow]Warning:[/yellow] {w}")
2501
+
2502
+
2503
+ @storage_app.command("unload-table", rich_help_panel=_FILES)
2504
+ def storage_unload_table(
2505
+ ctx: typer.Context,
2506
+ project: str = typer.Option(
2507
+ ...,
2508
+ "--project",
2509
+ help="Project alias",
2510
+ ),
2511
+ table_id: str = typer.Option(
2512
+ ...,
2513
+ "--table-id",
2514
+ help="Table ID to export (e.g. 'in.c-my-bucket.my-table')",
2515
+ ),
2516
+ columns: list[str] | None = typer.Option(
2517
+ None,
2518
+ "--columns",
2519
+ help="Column names to export (repeat for multiple)",
2520
+ ),
2521
+ limit: int | None = typer.Option(
2522
+ None,
2523
+ "--limit",
2524
+ help="Max number of rows to export",
2525
+ ),
2526
+ tag: list[str] | None = typer.Option(
2527
+ None,
2528
+ "--tag",
2529
+ help="Tag to apply to the exported file (repeat for multiple)",
2530
+ ),
2531
+ download: bool = typer.Option(
2532
+ False,
2533
+ "--download",
2534
+ help="Also download the exported file locally",
2535
+ ),
2536
+ output: str | None = typer.Option(
2537
+ None,
2538
+ "--output",
2539
+ "-o",
2540
+ help="Output file path (only with --download)",
2541
+ ),
2542
+ branch: int | None = typer.Option(
2543
+ None,
2544
+ "--branch",
2545
+ help="Dev branch ID (defaults to active branch if set via 'branch use')",
2546
+ ),
2547
+ file_type: str = typer.Option(
2548
+ "csv",
2549
+ "--file-type",
2550
+ help="Output format: 'csv' (default) or 'parquet'. Parquet output is "
2551
+ "always sliced; with --download each slice is saved as its own file "
2552
+ "under --output (treated as a directory).",
2553
+ ),
2554
+ keep_slices: bool = typer.Option(
2555
+ False,
2556
+ "--keep-slices",
2557
+ help=(
2558
+ "CSV-only with --download: write each slice as its own file under "
2559
+ "--output (treated as a directory) instead of concatenating into a "
2560
+ "single CSV. Mirrors the parquet download layout. Ignored for "
2561
+ "parquet (always sliced) and for non-sliced exports."
2562
+ ),
2563
+ ),
2564
+ ) -> None:
2565
+ """Export a table to a Storage File.
2566
+
2567
+ Creates a file in Storage that can be downloaded or consumed by other
2568
+ components. Use --tag to tag the output file and --download to also
2569
+ save it locally. Use --file-type parquet to export as Parquet (sliced;
2570
+ --download produces a directory with per-slice .parquet files and a
2571
+ _manifest.json sidecar).
2572
+
2573
+ Default parquet download layout: ./{project}/{table_id}.parquet/
2574
+ Override with --output DIR to choose a different location.
2575
+ """
2576
+ if file_type not in ("csv", "parquet"):
2577
+ formatter = get_formatter(ctx)
2578
+ formatter.error(
2579
+ message=f"--file-type must be 'csv' or 'parquet', got {file_type!r}",
2580
+ error_code=ErrorCode.VALIDATION_ERROR,
2581
+ )
2582
+ raise typer.Exit(code=2) from None
2583
+ formatter = get_formatter(ctx)
2584
+ service = get_service(ctx, "storage_service")
2585
+ config_store: ConfigStore = ctx.obj["config_store"]
2586
+ _, effective_branch = resolve_branch(config_store, formatter, project, branch)
2587
+
2588
+ if not formatter.json_mode:
2589
+ msg = f"Exporting [cyan]{table_id}[/cyan] to Storage File"
2590
+ if download:
2591
+ msg += " (with download)"
2592
+ msg += "..."
2593
+ formatter.console.print(msg)
2594
+
2595
+ try:
2596
+ result = service.unload_table_to_file(
2597
+ alias=project,
2598
+ table_id=table_id,
2599
+ columns=columns,
2600
+ limit=limit,
2601
+ tags=tag,
2602
+ download=download,
2603
+ output_path=output,
2604
+ branch_id=effective_branch,
2605
+ file_type=file_type,
2606
+ keep_slices=keep_slices,
2607
+ )
2608
+ except ConfigError as exc:
2609
+ formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR)
2610
+ raise typer.Exit(code=5) from None
2611
+ except KeboolaApiError as exc:
2612
+ formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable)
2613
+ raise typer.Exit(code=map_error_to_exit_code(exc)) from None
2614
+
2615
+ if formatter.json_mode:
2616
+ formatter.output(result)
2617
+ else:
2618
+ size_str = _format_file_size(result.get("file_size_bytes"))
2619
+ tags_str = ", ".join(result.get("tags", []))
2620
+ formatter.console.print(
2621
+ f"[bold green]Exported:[/bold green] {result['table_id']} -> "
2622
+ f"file ID {result['file_id']} ({size_str}, {result.get('file_type', 'csv')})"
2623
+ )
2624
+ if tags_str:
2625
+ formatter.console.print(f" Tags: {tags_str}")
2626
+ if result.get("downloaded"):
2627
+ dl_size = _format_file_size(result.get("downloaded_bytes"))
2628
+ slice_count = result.get("slice_count")
2629
+ suffix = f", {slice_count} slices" if slice_count else ""
2630
+ formatter.console.print(f" Downloaded to: {result['output_path']} ({dl_size}{suffix})")