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.
- keboola_agent_cli/__init__.py +34 -0
- keboola_agent_cli/__main__.py +5 -0
- keboola_agent_cli/_ui_dist/assets/arc-DhFYIddx.js +2 -0
- keboola_agent_cli/_ui_dist/assets/arc-DhFYIddx.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/architecture-7EHR7CIX-hNCijx_H.js +1 -0
- keboola_agent_cli/_ui_dist/assets/architectureDiagram-3BPJPVTR-C6hUlprM.js +37 -0
- keboola_agent_cli/_ui_dist/assets/architectureDiagram-3BPJPVTR-C6hUlprM.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/array-BifhSqXX.js +2 -0
- keboola_agent_cli/_ui_dist/assets/array-BifhSqXX.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/blockDiagram-GPEHLZMM-DC7qY9i4.js +133 -0
- keboola_agent_cli/_ui_dist/assets/blockDiagram-GPEHLZMM-DC7qY9i4.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/c4Diagram-AAUBKEIU-5Lh44evt.js +11 -0
- keboola_agent_cli/_ui_dist/assets/c4Diagram-AAUBKEIU-5Lh44evt.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/channel-DBMrXlxx.js +2 -0
- keboola_agent_cli/_ui_dist/assets/channel-DBMrXlxx.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-2J33WTMH-Coy82EBh.js +2 -0
- keboola_agent_cli/_ui_dist/assets/chunk-2J33WTMH-Coy82EBh.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-3OPIFGDE-BQC5CRHI.js +63 -0
- keboola_agent_cli/_ui_dist/assets/chunk-3OPIFGDE-BQC5CRHI.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-4BX2VUAB-DUuEt70o.js +2 -0
- keboola_agent_cli/_ui_dist/assets/chunk-4BX2VUAB-DUuEt70o.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-55IACEB6-BvR-6chF.js +2 -0
- keboola_agent_cli/_ui_dist/assets/chunk-55IACEB6-BvR-6chF.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-5ZQYHXKU-BjcTN7ul.js +3 -0
- keboola_agent_cli/_ui_dist/assets/chunk-5ZQYHXKU-BjcTN7ul.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-727SXJPM-C0zxqqRN.js +207 -0
- keboola_agent_cli/_ui_dist/assets/chunk-727SXJPM-C0zxqqRN.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-AQP2D5EJ-CXf7rIlZ.js +232 -0
- keboola_agent_cli/_ui_dist/assets/chunk-AQP2D5EJ-CXf7rIlZ.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-BSJP7CBP-Oj_FO9Q7.js +2 -0
- keboola_agent_cli/_ui_dist/assets/chunk-BSJP7CBP-Oj_FO9Q7.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-CSCIHK7Q-CcTsLrFc.js +124 -0
- keboola_agent_cli/_ui_dist/assets/chunk-CSCIHK7Q-CcTsLrFc.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-FMBD7UC4-FH-zLkkW.js +16 -0
- keboola_agent_cli/_ui_dist/assets/chunk-FMBD7UC4-FH-zLkkW.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-L5ZTLDWV-B1Ky_e7O.js +2 -0
- keboola_agent_cli/_ui_dist/assets/chunk-L5ZTLDWV-B1Ky_e7O.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-ND2GUHAM-BHz1rpbm.js +2 -0
- keboola_agent_cli/_ui_dist/assets/chunk-ND2GUHAM-BHz1rpbm.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-NNHCCRGN-DlpIbxXb.js +160 -0
- keboola_agent_cli/_ui_dist/assets/chunk-NNHCCRGN-DlpIbxXb.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-NZK2D7GU-tnrSoegS.js +2 -0
- keboola_agent_cli/_ui_dist/assets/chunk-NZK2D7GU-tnrSoegS.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-O5CBEL6O-DxxqDH0l.js +71 -0
- keboola_agent_cli/_ui_dist/assets/chunk-O5CBEL6O-DxxqDH0l.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-QZHKN3VN-CSjc2gjj.js +2 -0
- keboola_agent_cli/_ui_dist/assets/chunk-QZHKN3VN-CSjc2gjj.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/classDiagram-4FO5ZUOK-BuZcZu85.js +2 -0
- keboola_agent_cli/_ui_dist/assets/classDiagram-4FO5ZUOK-BuZcZu85.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/classDiagram-v2-Q7XG4LA2-BuZcZu85.js +2 -0
- keboola_agent_cli/_ui_dist/assets/classDiagram-v2-Q7XG4LA2-BuZcZu85.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/cose-bilkent-S5V4N54A-Y0L8LDMa.js +2 -0
- keboola_agent_cli/_ui_dist/assets/cose-bilkent-S5V4N54A-Y0L8LDMa.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/cytoscape.esm-C8YCVR3_.js +322 -0
- keboola_agent_cli/_ui_dist/assets/cytoscape.esm-C8YCVR3_.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/dagre-BM42HDAG-UZ-9BTqF.js +5 -0
- keboola_agent_cli/_ui_dist/assets/dagre-BM42HDAG-UZ-9BTqF.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/dagre-Bx709z4p.js +2 -0
- keboola_agent_cli/_ui_dist/assets/dagre-Bx709z4p.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/defaultLocale-C8Fc0cco.js +2 -0
- keboola_agent_cli/_ui_dist/assets/defaultLocale-C8Fc0cco.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/diagram-2AECGRRQ-DoDQ60wi.js +44 -0
- keboola_agent_cli/_ui_dist/assets/diagram-2AECGRRQ-DoDQ60wi.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/diagram-5GNKFQAL-CMGFxpUs.js +11 -0
- keboola_agent_cli/_ui_dist/assets/diagram-5GNKFQAL-CMGFxpUs.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/diagram-KO2AKTUF-1uGDa-Iu.js +4 -0
- keboola_agent_cli/_ui_dist/assets/diagram-KO2AKTUF-1uGDa-Iu.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/diagram-LMA3HP47-XtFH7B51.js +25 -0
- keboola_agent_cli/_ui_dist/assets/diagram-LMA3HP47-XtFH7B51.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/diagram-OG6HWLK6-B4_Te1T5.js +25 -0
- keboola_agent_cli/_ui_dist/assets/diagram-OG6HWLK6-B4_Te1T5.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/dist-Di6zmlv0.js +2 -0
- keboola_agent_cli/_ui_dist/assets/dist-Di6zmlv0.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/erDiagram-TEJ5UH35-NjQkrdFt.js +86 -0
- keboola_agent_cli/_ui_dist/assets/erDiagram-TEJ5UH35-NjQkrdFt.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/eventmodeling-FCH6USID-BrJMIks8.js +1 -0
- keboola_agent_cli/_ui_dist/assets/flowDiagram-I6XJVG4X-CIr8DWl7.js +163 -0
- keboola_agent_cli/_ui_dist/assets/flowDiagram-I6XJVG4X-CIr8DWl7.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/ganttDiagram-6RSMTGT7-C1VY_xbQ.js +293 -0
- keboola_agent_cli/_ui_dist/assets/ganttDiagram-6RSMTGT7-C1VY_xbQ.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/gitGraph-WXDBUCRP-COacYjo-.js +1 -0
- keboola_agent_cli/_ui_dist/assets/gitGraphDiagram-PVQCEYII-DQT8-kg2.js +107 -0
- keboola_agent_cli/_ui_dist/assets/gitGraphDiagram-PVQCEYII-DQT8-kg2.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/graphlib-B8gBHxth.js +2 -0
- keboola_agent_cli/_ui_dist/assets/graphlib-B8gBHxth.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/index-CMq50kkV.css +1 -0
- keboola_agent_cli/_ui_dist/assets/index-D8W97DAz.js +118 -0
- keboola_agent_cli/_ui_dist/assets/index-D8W97DAz.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/info-J43DQDTF-DdCTRIzU.js +1 -0
- keboola_agent_cli/_ui_dist/assets/infoDiagram-5YYISTIA-C77rsoTp.js +3 -0
- keboola_agent_cli/_ui_dist/assets/infoDiagram-5YYISTIA-C77rsoTp.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/init-D6jRqBbL.js +2 -0
- keboola_agent_cli/_ui_dist/assets/init-D6jRqBbL.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/ishikawaDiagram-YF4QCWOH-BcTbXaLy.js +71 -0
- keboola_agent_cli/_ui_dist/assets/ishikawaDiagram-YF4QCWOH-BcTbXaLy.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/journeyDiagram-JHISSGLW-BejeAJQ_.js +140 -0
- keboola_agent_cli/_ui_dist/assets/journeyDiagram-JHISSGLW-BejeAJQ_.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/kanban-definition-UN3LZRKU-BRNz_UrH.js +90 -0
- keboola_agent_cli/_ui_dist/assets/kanban-definition-UN3LZRKU-BRNz_UrH.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/katex-C4eR7coU.js +258 -0
- keboola_agent_cli/_ui_dist/assets/katex-C4eR7coU.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/line-CzAQKFbJ.js +2 -0
- keboola_agent_cli/_ui_dist/assets/line-CzAQKFbJ.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/linear-DUNFFdck.js +2 -0
- keboola_agent_cli/_ui_dist/assets/linear-DUNFFdck.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/mermaid-parser.core-CpuBOkFa.js +5 -0
- keboola_agent_cli/_ui_dist/assets/mermaid-parser.core-CpuBOkFa.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/mindmap-definition-RKZ34NQL-9EJQNjH0.js +97 -0
- keboola_agent_cli/_ui_dist/assets/mindmap-definition-RKZ34NQL-9EJQNjH0.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/ordinal-hYBb2elL.js +2 -0
- keboola_agent_cli/_ui_dist/assets/ordinal-hYBb2elL.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/packet-YPE3B663-DLiiw_B2.js +1 -0
- keboola_agent_cli/_ui_dist/assets/path-BWPyau1x.js +2 -0
- keboola_agent_cli/_ui_dist/assets/path-BWPyau1x.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/pie-LRSECV5Y-CRoO8G1g.js +1 -0
- keboola_agent_cli/_ui_dist/assets/pieDiagram-4H26LBE5-XH4cy6Cb.js +31 -0
- keboola_agent_cli/_ui_dist/assets/pieDiagram-4H26LBE5-XH4cy6Cb.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/quadrantDiagram-W4KKPZXB-fdhc93U8.js +8 -0
- keboola_agent_cli/_ui_dist/assets/quadrantDiagram-W4KKPZXB-fdhc93U8.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/radar-GUYGQ44K-DAlLVJHm.js +1 -0
- keboola_agent_cli/_ui_dist/assets/requirementDiagram-4Y6WPE33-a94eP3R9.js +85 -0
- keboola_agent_cli/_ui_dist/assets/requirementDiagram-4Y6WPE33-a94eP3R9.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/rough.esm-CSKSodPl.js +2 -0
- keboola_agent_cli/_ui_dist/assets/rough.esm-CSKSodPl.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/sankeyDiagram-5OEKKPKP-jcBa02sp.js +41 -0
- keboola_agent_cli/_ui_dist/assets/sankeyDiagram-5OEKKPKP-jcBa02sp.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/sequenceDiagram-3UESZ5HK-A5-GGM-e.js +163 -0
- keboola_agent_cli/_ui_dist/assets/sequenceDiagram-3UESZ5HK-A5-GGM-e.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/src-ZI-V_AF0.js +2 -0
- keboola_agent_cli/_ui_dist/assets/src-ZI-V_AF0.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/stateDiagram-AJRCARHV-BKAA5rqE.js +2 -0
- keboola_agent_cli/_ui_dist/assets/stateDiagram-AJRCARHV-BKAA5rqE.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/stateDiagram-v2-BHNVJYJU-DnJwJBsE.js +2 -0
- keboola_agent_cli/_ui_dist/assets/stateDiagram-v2-BHNVJYJU-DnJwJBsE.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/timeline-definition-PNZ67QCA-Cy39jp8b.js +121 -0
- keboola_agent_cli/_ui_dist/assets/timeline-definition-PNZ67QCA-Cy39jp8b.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/treeView-BLDUP644-DbLYl23-.js +1 -0
- keboola_agent_cli/_ui_dist/assets/treemap-LRROVOQU-Bp0eGlOt.js +1 -0
- keboola_agent_cli/_ui_dist/assets/vennDiagram-CIIHVFJN-BGECKubd.js +35 -0
- keboola_agent_cli/_ui_dist/assets/vennDiagram-CIIHVFJN-BGECKubd.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/wardley-L42UT6IY-D4yH4jqS.js +1 -0
- keboola_agent_cli/_ui_dist/assets/wardleyDiagram-YWT4CUSO-D6XRG3cZ.js +79 -0
- keboola_agent_cli/_ui_dist/assets/wardleyDiagram-YWT4CUSO-D6XRG3cZ.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/xychartDiagram-2RQKCTM6-DRre-pfZ.js +8 -0
- keboola_agent_cli/_ui_dist/assets/xychartDiagram-2RQKCTM6-DRre-pfZ.js.map +1 -0
- keboola_agent_cli/_ui_dist/index.html +50 -0
- keboola_agent_cli/ai_client.py +83 -0
- keboola_agent_cli/auto_update.py +550 -0
- keboola_agent_cli/changelog.py +1198 -0
- keboola_agent_cli/cli.py +448 -0
- keboola_agent_cli/client.py +3422 -0
- keboola_agent_cli/commands/__init__.py +0 -0
- keboola_agent_cli/commands/_data_app_git.py +343 -0
- keboola_agent_cli/commands/_helpers.py +377 -0
- keboola_agent_cli/commands/_metadata_input.py +49 -0
- keboola_agent_cli/commands/_semantic_layer_crud.py +632 -0
- keboola_agent_cli/commands/_semantic_layer_helpers.py +44 -0
- keboola_agent_cli/commands/_semantic_layer_reference_data.py +247 -0
- keboola_agent_cli/commands/agent.py +968 -0
- keboola_agent_cli/commands/branch.py +423 -0
- keboola_agent_cli/commands/changelog.py +168 -0
- keboola_agent_cli/commands/component.py +216 -0
- keboola_agent_cli/commands/config.py +2442 -0
- keboola_agent_cli/commands/context.py +1481 -0
- keboola_agent_cli/commands/data_app.py +1279 -0
- keboola_agent_cli/commands/dev_portal.py +584 -0
- keboola_agent_cli/commands/doctor.py +37 -0
- keboola_agent_cli/commands/encrypt.py +145 -0
- keboola_agent_cli/commands/feature.py +311 -0
- keboola_agent_cli/commands/flow.py +948 -0
- keboola_agent_cli/commands/http_client.py +157 -0
- keboola_agent_cli/commands/init.py +279 -0
- keboola_agent_cli/commands/job.py +661 -0
- keboola_agent_cli/commands/kai.py +301 -0
- keboola_agent_cli/commands/lineage.py +1464 -0
- keboola_agent_cli/commands/org.py +292 -0
- keboola_agent_cli/commands/permissions.py +360 -0
- keboola_agent_cli/commands/project.py +1192 -0
- keboola_agent_cli/commands/repl.py +243 -0
- keboola_agent_cli/commands/schedule.py +340 -0
- keboola_agent_cli/commands/search.py +178 -0
- keboola_agent_cli/commands/semantic_layer.py +939 -0
- keboola_agent_cli/commands/serve.py +272 -0
- keboola_agent_cli/commands/sharing.py +340 -0
- keboola_agent_cli/commands/storage.py +2630 -0
- keboola_agent_cli/commands/stream.py +266 -0
- keboola_agent_cli/commands/sync.py +1277 -0
- keboola_agent_cli/commands/tool.py +206 -0
- keboola_agent_cli/commands/version.py +186 -0
- keboola_agent_cli/commands/workspace.py +635 -0
- keboola_agent_cli/config_store.py +582 -0
- keboola_agent_cli/constants.py +528 -0
- keboola_agent_cli/data_science_client.py +342 -0
- keboola_agent_cli/dev_portal_client.py +323 -0
- keboola_agent_cli/errors.py +248 -0
- keboola_agent_cli/http_base.py +315 -0
- keboola_agent_cli/json_utils.py +126 -0
- keboola_agent_cli/lib.py +536 -0
- keboola_agent_cli/manage_client.py +324 -0
- keboola_agent_cli/metastore_client.py +214 -0
- keboola_agent_cli/models.py +427 -0
- keboola_agent_cli/output.py +1084 -0
- keboola_agent_cli/permissions.py +469 -0
- keboola_agent_cli/py.typed +3 -0
- keboola_agent_cli/result_models.py +271 -0
- keboola_agent_cli/server/__init__.py +34 -0
- keboola_agent_cli/server/agent_runner.py +1289 -0
- keboola_agent_cli/server/agents_store.py +325 -0
- keboola_agent_cli/server/app.py +764 -0
- keboola_agent_cli/server/auth.py +117 -0
- keboola_agent_cli/server/dependencies.py +149 -0
- keboola_agent_cli/server/pricing.py +303 -0
- keboola_agent_cli/server/routers/__init__.py +1 -0
- keboola_agent_cli/server/routers/agents.py +616 -0
- keboola_agent_cli/server/routers/ai_chat.py +129 -0
- keboola_agent_cli/server/routers/branches.py +133 -0
- keboola_agent_cli/server/routers/components.py +48 -0
- keboola_agent_cli/server/routers/configs.py +507 -0
- keboola_agent_cli/server/routers/data_apps.py +384 -0
- keboola_agent_cli/server/routers/dev_portal.py +67 -0
- keboola_agent_cli/server/routers/encrypt.py +35 -0
- keboola_agent_cli/server/routers/feature.py +179 -0
- keboola_agent_cli/server/routers/flows.py +204 -0
- keboola_agent_cli/server/routers/health.py +53 -0
- keboola_agent_cli/server/routers/jobs.py +175 -0
- keboola_agent_cli/server/routers/kai.py +80 -0
- keboola_agent_cli/server/routers/lineage.py +226 -0
- keboola_agent_cli/server/routers/mcp.py +70 -0
- keboola_agent_cli/server/routers/members.py +170 -0
- keboola_agent_cli/server/routers/org.py +96 -0
- keboola_agent_cli/server/routers/projects.py +106 -0
- keboola_agent_cli/server/routers/schedules.py +54 -0
- keboola_agent_cli/server/routers/search.py +30 -0
- keboola_agent_cli/server/routers/semantic_layer.py +650 -0
- keboola_agent_cli/server/routers/sharing.py +86 -0
- keboola_agent_cli/server/routers/storage.py +574 -0
- keboola_agent_cli/server/routers/stream.py +100 -0
- keboola_agent_cli/server/routers/workspaces.py +302 -0
- keboola_agent_cli/server/run_broadcaster.py +329 -0
- keboola_agent_cli/server/sse.py +25 -0
- keboola_agent_cli/services/__init__.py +0 -0
- keboola_agent_cli/services/_encryption.py +217 -0
- keboola_agent_cli/services/_semantic_layer_cascade.py +147 -0
- keboola_agent_cli/services/_semantic_layer_crud.py +382 -0
- keboola_agent_cli/services/_semantic_layer_internals.py +1078 -0
- keboola_agent_cli/services/_semantic_layer_lookup.py +181 -0
- keboola_agent_cli/services/_semantic_layer_reference_data.py +217 -0
- keboola_agent_cli/services/_sync_bindings.py +456 -0
- keboola_agent_cli/services/_sync_branch.py +191 -0
- keboola_agent_cli/services/_sync_bulk.py +228 -0
- keboola_agent_cli/services/_sync_clone.py +163 -0
- keboola_agent_cli/services/_sync_models.py +97 -0
- keboola_agent_cli/services/_sync_push_ops.py +369 -0
- keboola_agent_cli/services/_sync_storage.py +376 -0
- keboola_agent_cli/services/_sync_writeback.py +167 -0
- keboola_agent_cli/services/agent_service.py +458 -0
- keboola_agent_cli/services/base.py +175 -0
- keboola_agent_cli/services/branch_service.py +588 -0
- keboola_agent_cli/services/component_service.py +694 -0
- keboola_agent_cli/services/config_service.py +2099 -0
- keboola_agent_cli/services/data_app_git_service.py +224 -0
- keboola_agent_cli/services/data_app_service.py +2082 -0
- keboola_agent_cli/services/deep_lineage_service.py +1322 -0
- keboola_agent_cli/services/dev_portal_service.py +345 -0
- keboola_agent_cli/services/doctor_service.py +445 -0
- keboola_agent_cli/services/encrypt_service.py +87 -0
- keboola_agent_cli/services/feature_service.py +268 -0
- keboola_agent_cli/services/flow_service.py +769 -0
- keboola_agent_cli/services/flow_validation.py +188 -0
- keboola_agent_cli/services/http_forwarder_service.py +236 -0
- keboola_agent_cli/services/job_idempotency_store.py +285 -0
- keboola_agent_cli/services/job_service.py +797 -0
- keboola_agent_cli/services/kai_service.py +367 -0
- keboola_agent_cli/services/lineage_service.py +274 -0
- keboola_agent_cli/services/mcp_service.py +1498 -0
- keboola_agent_cli/services/mcp_transport.py +259 -0
- keboola_agent_cli/services/member_service.py +593 -0
- keboola_agent_cli/services/org_service.py +619 -0
- keboola_agent_cli/services/project_service.py +947 -0
- keboola_agent_cli/services/repo_validate_service.py +767 -0
- keboola_agent_cli/services/schedule_service.py +731 -0
- keboola_agent_cli/services/search_service.py +331 -0
- keboola_agent_cli/services/semantic_layer_service.py +1497 -0
- keboola_agent_cli/services/sharing_service.py +307 -0
- keboola_agent_cli/services/storage_service.py +2524 -0
- keboola_agent_cli/services/stream_service.py +395 -0
- keboola_agent_cli/services/sync_service.py +2244 -0
- keboola_agent_cli/services/variables_service.py +447 -0
- keboola_agent_cli/services/version_service.py +1038 -0
- keboola_agent_cli/services/workspace_service.py +1103 -0
- keboola_agent_cli/stream_client.py +217 -0
- keboola_agent_cli/sync/__init__.py +1 -0
- keboola_agent_cli/sync/branch_mapping.py +174 -0
- keboola_agent_cli/sync/clone.py +211 -0
- keboola_agent_cli/sync/code_extraction.py +655 -0
- keboola_agent_cli/sync/config_format.py +290 -0
- keboola_agent_cli/sync/diff_engine.py +566 -0
- keboola_agent_cli/sync/git_utils.py +93 -0
- keboola_agent_cli/sync/manifest.py +162 -0
- keboola_agent_cli/sync/naming.py +90 -0
- keboola_agent_cli/sync/secrets.py +62 -0
- keboola_agent_cli/sync/sql_split.py +134 -0
- keboola_cli-0.63.4.dist-info/METADATA +308 -0
- keboola_cli-0.63.4.dist-info/RECORD +306 -0
- keboola_cli-0.63.4.dist-info/WHEEL +4 -0
- 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})")
|