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,1103 @@
|
|
|
1
|
+
"""Workspace service - business logic for workspace lifecycle management.
|
|
2
|
+
|
|
3
|
+
Orchestrates workspace CRUD, table loading, SQL query execution via Query Service,
|
|
4
|
+
and high-level from-transformation workflow. Provides multi-project list and
|
|
5
|
+
single-project operations.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import csv
|
|
9
|
+
import io
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from cryptography.hazmat.primitives import serialization
|
|
16
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
17
|
+
|
|
18
|
+
from ..client import _collect_inline_results
|
|
19
|
+
from ..constants import (
|
|
20
|
+
BIGQUERY_WORKSPACE_LOGIN_TYPE,
|
|
21
|
+
QUERY_RESULTS_DEFAULT_LIMIT,
|
|
22
|
+
QUERY_SERVICE_COMPATIBLE_LOGIN_TYPES,
|
|
23
|
+
QUERY_SERVICE_COMPATIBLE_LOGIN_TYPES_BIGQUERY,
|
|
24
|
+
SNOWFLAKE_WORKSPACE_LOGIN_TYPE,
|
|
25
|
+
)
|
|
26
|
+
from ..errors import ConfigError, ErrorCode, KeboolaApiError
|
|
27
|
+
from ..models import ProjectConfig
|
|
28
|
+
from .base import BaseService
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class SnowflakeWorkspaceKeyPair:
|
|
35
|
+
"""PEM key material for Snowflake key-pair workspace authentication."""
|
|
36
|
+
|
|
37
|
+
private_pem: str
|
|
38
|
+
public_pem: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _classify_qs_compatibility(login_type: str, backend: str) -> bool:
|
|
42
|
+
"""Map a workspace ``(loginType, backend)`` pair to Query-Service compat.
|
|
43
|
+
|
|
44
|
+
Compatibility is keyed by BOTH backend and loginType because the same
|
|
45
|
+
``default`` string means opposite things per backend: a BigQuery workspace's
|
|
46
|
+
``default`` loginType IS Query-Service-compatible (verified against project
|
|
47
|
+
9621 on connection.keboola.com), whereas a Snowflake legacy ``default``
|
|
48
|
+
workspace is NOT ('JWT token is invalid'). See the two whitelists in
|
|
49
|
+
``constants`` for the empirical rationale.
|
|
50
|
+
|
|
51
|
+
Conservative whitelist semantics: returns True only for ``loginType``s
|
|
52
|
+
confirmed to work with POST /v2/storage/branch/{ID}/workspaces/{WS}/query.
|
|
53
|
+
Unknown backends fall through to the Snowflake whitelist (false negatives
|
|
54
|
+
over false positives).
|
|
55
|
+
"""
|
|
56
|
+
if backend.lower() == "bigquery":
|
|
57
|
+
return login_type in QUERY_SERVICE_COMPATIBLE_LOGIN_TYPES_BIGQUERY
|
|
58
|
+
return login_type in QUERY_SERVICE_COMPATIBLE_LOGIN_TYPES
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _workspace_login_type_for_backend(backend: str) -> str | None:
|
|
62
|
+
"""Return the loginType kbagent should request for newly created workspaces."""
|
|
63
|
+
normalized = backend.lower()
|
|
64
|
+
if normalized == "snowflake":
|
|
65
|
+
return SNOWFLAKE_WORKSPACE_LOGIN_TYPE
|
|
66
|
+
if normalized == "bigquery":
|
|
67
|
+
# BigQuery's Query-Service-compatible loginType. Omitting it lets the
|
|
68
|
+
# backend default to the same value, but requesting it explicitly keeps
|
|
69
|
+
# parity with keboola-mcp-server and is robust to a server-side change
|
|
70
|
+
# of the implicit default.
|
|
71
|
+
return BIGQUERY_WORKSPACE_LOGIN_TYPE
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _generate_snowflake_workspace_key_pair() -> SnowflakeWorkspaceKeyPair:
|
|
76
|
+
"""Generate the unencrypted PEM key pair required by Snowflake workspaces."""
|
|
77
|
+
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
78
|
+
private_key_pem = private_key.private_bytes(
|
|
79
|
+
encoding=serialization.Encoding.PEM,
|
|
80
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
81
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
82
|
+
).decode("ascii")
|
|
83
|
+
public_key_pem = (
|
|
84
|
+
private_key.public_key()
|
|
85
|
+
.public_bytes(
|
|
86
|
+
encoding=serialization.Encoding.PEM,
|
|
87
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
88
|
+
)
|
|
89
|
+
.decode("ascii")
|
|
90
|
+
)
|
|
91
|
+
return SnowflakeWorkspaceKeyPair(private_pem=private_key_pem, public_pem=public_key_pem)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _workspace_key_pair_for_backend(backend: str) -> SnowflakeWorkspaceKeyPair | None:
|
|
95
|
+
"""Return private/public key material for backends that require it."""
|
|
96
|
+
if backend.lower() == "snowflake":
|
|
97
|
+
return _generate_snowflake_workspace_key_pair()
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def find_storage_workspace_for_sandbox_config(
|
|
102
|
+
workspaces: list[dict[str, Any]],
|
|
103
|
+
config_id: str,
|
|
104
|
+
) -> int | None:
|
|
105
|
+
"""Pure-function lookup: find the Storage workspace that backs a sandbox config.
|
|
106
|
+
|
|
107
|
+
A ``keboola.sandboxes`` configuration's ``parameters.id`` is the
|
|
108
|
+
sandbox-service internal ID, not a Storage workspace ID -- passing it to
|
|
109
|
+
``GET /v2/storage/workspaces/{ID}`` returns 404 (issue #304). The real
|
|
110
|
+
relation goes the other direction: each Storage workspace exposes
|
|
111
|
+
``configurationId`` pointing back at its sandbox config.
|
|
112
|
+
|
|
113
|
+
Extracted from ``WorkspaceService.resolve_sandbox_workspace_id`` so
|
|
114
|
+
``ConfigService.get_config_detail`` can call it with a workspace list it
|
|
115
|
+
already has (avoiding a circular ``ConfigService -> WorkspaceService``
|
|
116
|
+
dependency and the extra HTTP round-trip that would otherwise pile up
|
|
117
|
+
in HTTP and web-UI consumers -- see issue #312).
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
workspaces: Raw output of ``KeboolaClient.list_workspaces()`` -- each
|
|
121
|
+
entry is the Storage API workspace dict (not the normalised CLI
|
|
122
|
+
shape).
|
|
123
|
+
config_id: ``keboola.sandboxes`` configuration ID.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
Storage workspace ID (int), or None if no workspace currently backs
|
|
127
|
+
this config (orphan sandbox, or workspace deleted but config kept
|
|
128
|
+
around).
|
|
129
|
+
"""
|
|
130
|
+
for ws in workspaces:
|
|
131
|
+
if ws.get("component") == "keboola.sandboxes" and str(ws.get("configurationId", "")) == str(
|
|
132
|
+
config_id
|
|
133
|
+
):
|
|
134
|
+
ws_id = ws.get("id")
|
|
135
|
+
if isinstance(ws_id, int):
|
|
136
|
+
return ws_id
|
|
137
|
+
try:
|
|
138
|
+
return int(ws_id) if ws_id is not None else None
|
|
139
|
+
except (TypeError, ValueError):
|
|
140
|
+
return None
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _is_orphaned_workspace(ws: dict[str, Any], config_names: dict[str, str]) -> bool:
|
|
145
|
+
"""Return True if a workspace has no backing keboola.sandboxes config.
|
|
146
|
+
|
|
147
|
+
A workspace is orphaned when it is tied to keboola.sandboxes (the normal
|
|
148
|
+
kbagent creation path) but the sandbox config no longer exists — either it
|
|
149
|
+
was deleted separately or was never created.
|
|
150
|
+
"""
|
|
151
|
+
component_id = ws.get("component_id", "")
|
|
152
|
+
config_id = str(ws.get("config_id", ""))
|
|
153
|
+
if component_id != "keboola.sandboxes":
|
|
154
|
+
return False
|
|
155
|
+
# config_names keys are sandbox config IDs; absence means orphan
|
|
156
|
+
return not config_id or config_id not in config_names
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _csv_cell(value: Any) -> Any:
|
|
160
|
+
"""Coerce one `/results` JSON cell to its CSV representation.
|
|
161
|
+
|
|
162
|
+
``None`` -> empty field (matches the warehouse CSV export). VARIANT/ARRAY/
|
|
163
|
+
OBJECT (Snowflake) and STRUCT/ARRAY (BigQuery) columns arrive as native
|
|
164
|
+
Python ``dict``/``list``; ``csv.writer`` would otherwise emit their Python
|
|
165
|
+
``repr`` (``{'k': 'v'}``), so we serialize them as compact JSON
|
|
166
|
+
(``{"k":"v"}``) to match the warehouse's CSV serialization. Scalars pass
|
|
167
|
+
through and are stringified by ``csv.writer``.
|
|
168
|
+
"""
|
|
169
|
+
if value is None:
|
|
170
|
+
return ""
|
|
171
|
+
if isinstance(value, (dict, list)):
|
|
172
|
+
return json.dumps(value, separators=(",", ":"), ensure_ascii=False)
|
|
173
|
+
return value
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _rows_to_csv(columns: list[dict[str, Any]], rows: list[list[Any]]) -> str:
|
|
177
|
+
"""Render structured columns+rows as an RFC-4180 CSV string.
|
|
178
|
+
|
|
179
|
+
Synthesized so the inline `/results` payload stays drop-in compatible with
|
|
180
|
+
consumers that still read ``csv_data`` (CLI preview, web UI table + export
|
|
181
|
+
buttons, REST). ``csv.writer`` handles quoting/escaping.
|
|
182
|
+
"""
|
|
183
|
+
buffer = io.StringIO()
|
|
184
|
+
writer = csv.writer(buffer, lineterminator="\n")
|
|
185
|
+
writer.writerow([col.get("name", "") for col in columns])
|
|
186
|
+
for row in rows:
|
|
187
|
+
writer.writerow([_csv_cell(value) for value in row])
|
|
188
|
+
return buffer.getvalue()
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class WorkspaceService(BaseService):
|
|
192
|
+
"""Business logic for managing Keboola workspaces.
|
|
193
|
+
|
|
194
|
+
Supports:
|
|
195
|
+
- Workspace CRUD (create, list, detail, delete, password reset)
|
|
196
|
+
- Table loading into workspaces
|
|
197
|
+
- SQL query execution via Query Service
|
|
198
|
+
- High-level from-transformation workflow
|
|
199
|
+
|
|
200
|
+
Uses dependency injection for config_store and client_factory.
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
def _resolve_branch_id(self, alias: str, project: ProjectConfig) -> int:
|
|
204
|
+
"""Resolve the effective branch ID for a project.
|
|
205
|
+
|
|
206
|
+
Uses active_branch_id if set, otherwise fetches main branch from API.
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
Branch ID (int).
|
|
210
|
+
"""
|
|
211
|
+
if project.active_branch_id is not None:
|
|
212
|
+
return project.active_branch_id
|
|
213
|
+
|
|
214
|
+
client = self._client_factory(project.stack_url, project.token)
|
|
215
|
+
try:
|
|
216
|
+
branches = client.list_dev_branches()
|
|
217
|
+
for branch in branches:
|
|
218
|
+
if branch.get("isDefault", False):
|
|
219
|
+
return int(branch["id"])
|
|
220
|
+
raise ConfigError(
|
|
221
|
+
f"No default branch found for project '{alias}'. "
|
|
222
|
+
"Set an active branch with 'kbagent branch use'."
|
|
223
|
+
)
|
|
224
|
+
finally:
|
|
225
|
+
client.close()
|
|
226
|
+
|
|
227
|
+
def _fetch_sandbox_config_names(
|
|
228
|
+
self, client: Any, branch_id: int | None = None
|
|
229
|
+
) -> dict[str, str]:
|
|
230
|
+
"""Fetch sandbox configuration names for workspace name resolution.
|
|
231
|
+
|
|
232
|
+
The workspace API returns internal names (WORKSPACE_xxxxx). The user-given
|
|
233
|
+
name lives on the keboola.sandboxes configuration. This method fetches those
|
|
234
|
+
configs and builds a config_id -> name mapping.
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
Dict mapping config_id (str) to config name.
|
|
238
|
+
"""
|
|
239
|
+
try:
|
|
240
|
+
configs = client.list_component_configs("keboola.sandboxes", branch_id=branch_id)
|
|
241
|
+
return {str(cfg.get("id", "")): cfg.get("name", "") for cfg in configs}
|
|
242
|
+
except Exception:
|
|
243
|
+
# Non-critical: fall back to internal workspace names
|
|
244
|
+
return {}
|
|
245
|
+
|
|
246
|
+
def _detect_backend(self, client: Any) -> str:
|
|
247
|
+
"""Detect the project's default backend via token verification.
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
Backend string (e.g. 'snowflake', 'bigquery').
|
|
251
|
+
"""
|
|
252
|
+
token_info = client.verify_token()
|
|
253
|
+
return token_info.default_backend
|
|
254
|
+
|
|
255
|
+
def create_workspace(
|
|
256
|
+
self,
|
|
257
|
+
alias: str,
|
|
258
|
+
name: str = "",
|
|
259
|
+
backend: str | None = None,
|
|
260
|
+
read_only: bool = True,
|
|
261
|
+
ui_mode: bool = False,
|
|
262
|
+
) -> dict[str, Any]:
|
|
263
|
+
"""Create a new workspace.
|
|
264
|
+
|
|
265
|
+
Two modes:
|
|
266
|
+
- Default (headless): fast (~1s) via Storage API. Not visible in Keboola UI.
|
|
267
|
+
- UI mode (--ui): slower (~15s) via Queue job. Visible in UI Workspaces tab.
|
|
268
|
+
|
|
269
|
+
IMPORTANT: Credentials are only available on creation (headless mode).
|
|
270
|
+
Snowflake returns a generated private key; password-based workspaces
|
|
271
|
+
return a password. In UI mode, password must be retrieved via
|
|
272
|
+
'workspace password' command.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
alias: Project alias.
|
|
276
|
+
name: Human-readable name for the workspace.
|
|
277
|
+
backend: Workspace backend. Auto-detected from project if None.
|
|
278
|
+
read_only: Whether the workspace has read-only storage access.
|
|
279
|
+
ui_mode: If True, create via Queue job (visible in Keboola UI).
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
Dict with workspace details including connection credentials.
|
|
283
|
+
"""
|
|
284
|
+
projects = self.resolve_projects([alias])
|
|
285
|
+
project = projects[alias]
|
|
286
|
+
branch_id = self._resolve_branch_id(alias, project)
|
|
287
|
+
effective_name = name or f"kbagent-{alias}"
|
|
288
|
+
|
|
289
|
+
client = self._client_factory(project.stack_url, project.token)
|
|
290
|
+
try:
|
|
291
|
+
effective_backend = backend or self._detect_backend(client)
|
|
292
|
+
# Step 1: Create keboola.sandboxes config (in the correct branch)
|
|
293
|
+
sandbox_config = client.create_sandbox_config(
|
|
294
|
+
name=effective_name,
|
|
295
|
+
description="Created by kbagent CLI",
|
|
296
|
+
branch_id=branch_id,
|
|
297
|
+
)
|
|
298
|
+
config_id = sandbox_config.get("id", "")
|
|
299
|
+
|
|
300
|
+
if ui_mode:
|
|
301
|
+
return self._create_workspace_via_job(
|
|
302
|
+
client,
|
|
303
|
+
alias,
|
|
304
|
+
effective_name,
|
|
305
|
+
config_id,
|
|
306
|
+
effective_backend,
|
|
307
|
+
)
|
|
308
|
+
else:
|
|
309
|
+
return self._create_workspace_direct(
|
|
310
|
+
client,
|
|
311
|
+
alias,
|
|
312
|
+
effective_name,
|
|
313
|
+
config_id,
|
|
314
|
+
branch_id,
|
|
315
|
+
effective_backend,
|
|
316
|
+
read_only,
|
|
317
|
+
)
|
|
318
|
+
finally:
|
|
319
|
+
client.close()
|
|
320
|
+
|
|
321
|
+
def _create_workspace_direct(
|
|
322
|
+
self,
|
|
323
|
+
client: Any,
|
|
324
|
+
alias: str,
|
|
325
|
+
name: str,
|
|
326
|
+
config_id: str,
|
|
327
|
+
branch_id: int,
|
|
328
|
+
backend: str,
|
|
329
|
+
read_only: bool,
|
|
330
|
+
) -> dict[str, Any]:
|
|
331
|
+
"""Create workspace via Storage API (fast, headless)."""
|
|
332
|
+
key_pair = _workspace_key_pair_for_backend(backend)
|
|
333
|
+
ws_data = client.create_config_workspace(
|
|
334
|
+
branch_id=branch_id,
|
|
335
|
+
component_id="keboola.sandboxes",
|
|
336
|
+
config_id=config_id,
|
|
337
|
+
backend=backend,
|
|
338
|
+
login_type=_workspace_login_type_for_backend(backend),
|
|
339
|
+
public_key=key_pair.public_pem if key_pair else None,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
connection = ws_data.get("connection", {})
|
|
343
|
+
credential_label = "private key" if key_pair else "password"
|
|
344
|
+
result = {
|
|
345
|
+
"project_alias": alias,
|
|
346
|
+
"workspace_id": ws_data.get("id"),
|
|
347
|
+
"name": name,
|
|
348
|
+
"config_id": config_id,
|
|
349
|
+
"backend": connection.get("backend", backend),
|
|
350
|
+
"host": connection.get("host", ""),
|
|
351
|
+
"warehouse": connection.get("warehouse", ""),
|
|
352
|
+
"database": connection.get("database", ""),
|
|
353
|
+
"schema": connection.get("schema", ""),
|
|
354
|
+
"user": connection.get("user", ""),
|
|
355
|
+
"password": connection.get("password", ""),
|
|
356
|
+
"read_only": read_only,
|
|
357
|
+
"ui_mode": False,
|
|
358
|
+
"message": f"Workspace '{name}' created in project '{alias}'. "
|
|
359
|
+
f"Save the {credential_label} -- it cannot be retrieved later!",
|
|
360
|
+
}
|
|
361
|
+
if key_pair is not None:
|
|
362
|
+
result["private_key"] = key_pair.private_pem
|
|
363
|
+
return result
|
|
364
|
+
|
|
365
|
+
def _create_workspace_via_job(
|
|
366
|
+
self,
|
|
367
|
+
client: Any,
|
|
368
|
+
alias: str,
|
|
369
|
+
name: str,
|
|
370
|
+
config_id: str,
|
|
371
|
+
backend: str,
|
|
372
|
+
) -> dict[str, Any]:
|
|
373
|
+
"""Create workspace via Queue job (slower, visible in UI)."""
|
|
374
|
+
# The Queue job path does not expose a publicKey/loginType input. Keep
|
|
375
|
+
# returning a reset password here; headless Snowflake creates use the
|
|
376
|
+
# key-pair path in _create_workspace_direct().
|
|
377
|
+
job = client.create_job(
|
|
378
|
+
component_id="keboola.sandboxes",
|
|
379
|
+
config_id=config_id,
|
|
380
|
+
config_data={
|
|
381
|
+
"parameters": {
|
|
382
|
+
"task": "create",
|
|
383
|
+
"type": backend,
|
|
384
|
+
"shared": False,
|
|
385
|
+
},
|
|
386
|
+
},
|
|
387
|
+
)
|
|
388
|
+
job_id = str(job.get("id", ""))
|
|
389
|
+
|
|
390
|
+
# Wait for the job to complete
|
|
391
|
+
client.wait_for_queue_job(job_id)
|
|
392
|
+
|
|
393
|
+
# Find the workspace created by the job
|
|
394
|
+
workspaces = client.list_config_workspaces(
|
|
395
|
+
branch_id=int(job.get("branchId", 0)),
|
|
396
|
+
component_id="keboola.sandboxes",
|
|
397
|
+
config_id=config_id,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
if not workspaces:
|
|
401
|
+
raise KeboolaApiError(
|
|
402
|
+
message=f"Sandbox job completed but no workspace found for config {config_id}",
|
|
403
|
+
status_code=500,
|
|
404
|
+
error_code=ErrorCode.WORKSPACE_NOT_FOUND,
|
|
405
|
+
retryable=False,
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
ws_data = workspaces[0]
|
|
409
|
+
connection = ws_data.get("connection", {})
|
|
410
|
+
workspace_id = ws_data.get("id")
|
|
411
|
+
|
|
412
|
+
# Reset password so we can return it (job doesn't expose the initial password)
|
|
413
|
+
password = ""
|
|
414
|
+
try:
|
|
415
|
+
pw_data = client.reset_workspace_password(workspace_id)
|
|
416
|
+
password = pw_data.get("password", "")
|
|
417
|
+
except KeboolaApiError:
|
|
418
|
+
logger.debug("Could not reset password for workspace %s", workspace_id)
|
|
419
|
+
|
|
420
|
+
return {
|
|
421
|
+
"project_alias": alias,
|
|
422
|
+
"workspace_id": workspace_id,
|
|
423
|
+
"name": name,
|
|
424
|
+
"config_id": config_id,
|
|
425
|
+
"backend": connection.get("backend", backend),
|
|
426
|
+
"host": connection.get("host", ""),
|
|
427
|
+
"warehouse": connection.get("warehouse", ""),
|
|
428
|
+
"database": connection.get("database", ""),
|
|
429
|
+
"schema": connection.get("schema", ""),
|
|
430
|
+
"user": connection.get("user", ""),
|
|
431
|
+
"password": password,
|
|
432
|
+
"read_only": True,
|
|
433
|
+
"ui_mode": True,
|
|
434
|
+
"message": (
|
|
435
|
+
f"Workspace '{name}' ({workspace_id}) created in project '{alias}' (visible in UI). "
|
|
436
|
+
"Save the password -- it cannot be retrieved later!"
|
|
437
|
+
),
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
def resolve_sandbox_workspace_id(
|
|
441
|
+
self,
|
|
442
|
+
alias: str,
|
|
443
|
+
config_id: str,
|
|
444
|
+
branch_id: int | None = None,
|
|
445
|
+
) -> int | None:
|
|
446
|
+
"""Map a ``keboola.sandboxes`` config ID to its Storage workspace ID.
|
|
447
|
+
|
|
448
|
+
The sandbox config's ``parameters.id`` field looks like a Storage
|
|
449
|
+
workspace ID but is actually a sandbox-service-internal handle --
|
|
450
|
+
passing it to ``GET /v2/storage/workspaces/{ID}`` returns 404 (issue
|
|
451
|
+
#304). The real mapping lives the other way around: each Storage
|
|
452
|
+
workspace exposes ``configurationId`` pointing back at its sandbox
|
|
453
|
+
config. This helper walks the workspace list to find the matching
|
|
454
|
+
one.
|
|
455
|
+
|
|
456
|
+
Args:
|
|
457
|
+
alias: Project alias.
|
|
458
|
+
config_id: ``keboola.sandboxes`` configuration ID.
|
|
459
|
+
branch_id: Branch to query. When None, uses the project's
|
|
460
|
+
resolved branch (production fallback).
|
|
461
|
+
|
|
462
|
+
Returns:
|
|
463
|
+
Storage workspace ID, or None if no workspace is currently
|
|
464
|
+
backed by this config (orphan sandbox, or workspace deleted but
|
|
465
|
+
config kept around).
|
|
466
|
+
"""
|
|
467
|
+
projects = self.resolve_projects([alias])
|
|
468
|
+
project = projects[alias]
|
|
469
|
+
effective_branch = (
|
|
470
|
+
branch_id if branch_id is not None else self._resolve_branch_id(alias, project)
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
client = self._client_factory(project.stack_url, project.token)
|
|
474
|
+
try:
|
|
475
|
+
workspaces = client.list_workspaces(branch_id=effective_branch)
|
|
476
|
+
finally:
|
|
477
|
+
client.close()
|
|
478
|
+
|
|
479
|
+
return find_storage_workspace_for_sandbox_config(workspaces, config_id)
|
|
480
|
+
|
|
481
|
+
def list_workspaces(
|
|
482
|
+
self,
|
|
483
|
+
aliases: list[str] | None = None,
|
|
484
|
+
orphaned_only: bool = False,
|
|
485
|
+
branch_id: int | None = None,
|
|
486
|
+
qs_compatible_only: bool = False,
|
|
487
|
+
) -> dict[str, Any]:
|
|
488
|
+
"""List workspaces across one or multiple projects.
|
|
489
|
+
|
|
490
|
+
Args:
|
|
491
|
+
aliases: Project aliases to query. None means all projects.
|
|
492
|
+
orphaned_only: If True, return only orphaned workspaces — those
|
|
493
|
+
whose keboola.sandboxes config no longer exists.
|
|
494
|
+
branch_id: When set, list workspaces from this specific dev branch
|
|
495
|
+
(`/v2/storage/branch/{ID}/workspaces`). When None, the
|
|
496
|
+
production endpoint is used; callers wanting to honour the
|
|
497
|
+
alias's pinned branch should resolve it via
|
|
498
|
+
``resolve_branch()`` in the command layer before calling.
|
|
499
|
+
Only valid with a single alias (mirrors storage commands).
|
|
500
|
+
qs_compatible_only: If True, return only workspaces whose
|
|
501
|
+
``login_type`` is in ``QUERY_SERVICE_COMPATIBLE_LOGIN_TYPES``
|
|
502
|
+
AND that are read-only -- the canonical shape for a Streamlit
|
|
503
|
+
/ Quix data-app reading via the Query Service.
|
|
504
|
+
|
|
505
|
+
Returns:
|
|
506
|
+
Dict with "workspaces" and "errors" lists. Each workspace entry
|
|
507
|
+
carries ``login_type``, ``read_only``, ``qs_compatible``,
|
|
508
|
+
``database`` and ``warehouse`` so callers can pick a
|
|
509
|
+
data-app-compatible workspace without spawning a probe query
|
|
510
|
+
(closes #304).
|
|
511
|
+
"""
|
|
512
|
+
if branch_id is not None and (aliases is None or len(aliases) != 1):
|
|
513
|
+
raise ConfigError(
|
|
514
|
+
"branch_id requires exactly one alias (a branch ID is scoped to a single project)."
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
projects = self.resolve_projects(aliases)
|
|
518
|
+
|
|
519
|
+
def worker(
|
|
520
|
+
alias: str, project: ProjectConfig
|
|
521
|
+
) -> tuple[str, list[dict[str, Any]], bool] | tuple[str, dict[str, str]]:
|
|
522
|
+
client = self._client_factory(project.stack_url, project.token)
|
|
523
|
+
try:
|
|
524
|
+
effective_branch = (
|
|
525
|
+
branch_id if branch_id is not None else self._resolve_branch_id(alias, project)
|
|
526
|
+
)
|
|
527
|
+
raw_workspaces = client.list_workspaces(branch_id=effective_branch)
|
|
528
|
+
|
|
529
|
+
# Fetch sandbox configs to resolve user-given names
|
|
530
|
+
config_names = self._fetch_sandbox_config_names(client, effective_branch)
|
|
531
|
+
|
|
532
|
+
workspaces: list[dict[str, Any]] = []
|
|
533
|
+
for ws in raw_workspaces:
|
|
534
|
+
connection = ws.get("connection", {})
|
|
535
|
+
config_id = ws.get("configurationId") or ""
|
|
536
|
+
component_id = ws.get("component") or ""
|
|
537
|
+
login_type = connection.get("loginType", "") or ""
|
|
538
|
+
backend = connection.get("backend", "") or ""
|
|
539
|
+
read_only = bool(ws.get("readOnlyStorageAccess", False))
|
|
540
|
+
entry = {
|
|
541
|
+
"project_alias": alias,
|
|
542
|
+
"id": ws.get("id"),
|
|
543
|
+
"name": config_names.get(str(config_id), ws.get("name", "")),
|
|
544
|
+
"backend": backend,
|
|
545
|
+
"host": connection.get("host", ""),
|
|
546
|
+
"database": connection.get("database", ""),
|
|
547
|
+
"warehouse": connection.get("warehouse", ""),
|
|
548
|
+
"schema": connection.get("schema", ""),
|
|
549
|
+
"user": connection.get("user", ""),
|
|
550
|
+
"created": ws.get("created", ""),
|
|
551
|
+
"component_id": component_id,
|
|
552
|
+
"config_id": config_id,
|
|
553
|
+
"login_type": login_type,
|
|
554
|
+
"read_only": read_only,
|
|
555
|
+
"qs_compatible": _classify_qs_compatibility(login_type, backend),
|
|
556
|
+
}
|
|
557
|
+
if orphaned_only:
|
|
558
|
+
if _is_orphaned_workspace(entry, config_names):
|
|
559
|
+
workspaces.append(entry)
|
|
560
|
+
elif qs_compatible_only:
|
|
561
|
+
if entry["qs_compatible"] and entry["read_only"]:
|
|
562
|
+
workspaces.append(entry)
|
|
563
|
+
else:
|
|
564
|
+
workspaces.append(entry)
|
|
565
|
+
return (alias, workspaces, True)
|
|
566
|
+
except KeboolaApiError as exc:
|
|
567
|
+
return (
|
|
568
|
+
alias,
|
|
569
|
+
{
|
|
570
|
+
"project_alias": alias,
|
|
571
|
+
"error_code": exc.error_code,
|
|
572
|
+
"message": exc.message,
|
|
573
|
+
},
|
|
574
|
+
)
|
|
575
|
+
except Exception as exc:
|
|
576
|
+
return (
|
|
577
|
+
alias,
|
|
578
|
+
{
|
|
579
|
+
"project_alias": alias,
|
|
580
|
+
"error_code": "UNEXPECTED_ERROR",
|
|
581
|
+
"message": str(exc),
|
|
582
|
+
},
|
|
583
|
+
)
|
|
584
|
+
finally:
|
|
585
|
+
client.close()
|
|
586
|
+
|
|
587
|
+
successes, errors = self._run_parallel(projects, worker)
|
|
588
|
+
|
|
589
|
+
all_workspaces: list[dict[str, Any]] = []
|
|
590
|
+
for _alias, workspaces, _ok in successes:
|
|
591
|
+
all_workspaces.extend(workspaces)
|
|
592
|
+
|
|
593
|
+
all_workspaces.sort(key=lambda w: (w["project_alias"], w.get("id", 0)))
|
|
594
|
+
errors.sort(key=lambda e: e.get("project_alias", ""))
|
|
595
|
+
|
|
596
|
+
return {
|
|
597
|
+
"workspaces": all_workspaces,
|
|
598
|
+
"errors": errors,
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
def gc_workspaces(
|
|
602
|
+
self,
|
|
603
|
+
aliases: list[str] | None = None,
|
|
604
|
+
dry_run: bool = False,
|
|
605
|
+
) -> dict[str, Any]:
|
|
606
|
+
"""Delete all orphaned workspaces (workspace GC).
|
|
607
|
+
|
|
608
|
+
An orphan is a keboola.sandboxes-backed workspace whose config no longer
|
|
609
|
+
exists. Reuses delete_workspace for each orphan so the sandbox config
|
|
610
|
+
cleanup path is also exercised.
|
|
611
|
+
|
|
612
|
+
Args:
|
|
613
|
+
aliases: Project aliases to query. None means all projects.
|
|
614
|
+
dry_run: If True, list orphans without deleting.
|
|
615
|
+
|
|
616
|
+
Returns:
|
|
617
|
+
Dict with dry_run flag, would_delete/deleted list, errors, count.
|
|
618
|
+
"""
|
|
619
|
+
orphan_result = self.list_workspaces(aliases=aliases, orphaned_only=True)
|
|
620
|
+
orphans = orphan_result["workspaces"]
|
|
621
|
+
list_errors = orphan_result["errors"]
|
|
622
|
+
|
|
623
|
+
if dry_run:
|
|
624
|
+
return {
|
|
625
|
+
"dry_run": True,
|
|
626
|
+
"would_delete": orphans,
|
|
627
|
+
"count": len(orphans),
|
|
628
|
+
"errors": list_errors,
|
|
629
|
+
"message": (
|
|
630
|
+
f"DRY RUN: {len(orphans)} orphaned workspace(s) would be deleted."
|
|
631
|
+
+ (" No errors." if not list_errors else f" {len(list_errors)} list error(s).")
|
|
632
|
+
),
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
deleted: list[dict[str, Any]] = []
|
|
636
|
+
delete_errors: list[dict[str, Any]] = []
|
|
637
|
+
for ws in orphans:
|
|
638
|
+
try:
|
|
639
|
+
self.delete_workspace(alias=ws["project_alias"], workspace_id=ws["id"])
|
|
640
|
+
deleted.append(ws)
|
|
641
|
+
except Exception as exc:
|
|
642
|
+
# Full traceback goes to the logger (observability for unexpected
|
|
643
|
+
# errors like AttributeError); user-facing flow is unchanged.
|
|
644
|
+
logger.exception(
|
|
645
|
+
"Failed to delete orphaned workspace %s in project %s",
|
|
646
|
+
ws["id"],
|
|
647
|
+
ws["project_alias"],
|
|
648
|
+
)
|
|
649
|
+
delete_errors.append(
|
|
650
|
+
{
|
|
651
|
+
"workspace_id": ws["id"],
|
|
652
|
+
"project_alias": ws["project_alias"],
|
|
653
|
+
"error": str(exc),
|
|
654
|
+
}
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
all_errors = list_errors + delete_errors
|
|
658
|
+
return {
|
|
659
|
+
"dry_run": False,
|
|
660
|
+
"deleted": deleted,
|
|
661
|
+
"errors": all_errors,
|
|
662
|
+
"count_deleted": len(deleted),
|
|
663
|
+
"count_errors": len(all_errors),
|
|
664
|
+
"message": (
|
|
665
|
+
f"GC complete: {len(deleted)} orphaned workspace(s) deleted"
|
|
666
|
+
+ (f", {len(all_errors)} error(s)." if all_errors else ".")
|
|
667
|
+
),
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
def get_workspace(
|
|
671
|
+
self,
|
|
672
|
+
alias: str,
|
|
673
|
+
workspace_id: int,
|
|
674
|
+
branch_id: int | None = None,
|
|
675
|
+
) -> dict[str, Any]:
|
|
676
|
+
"""Get workspace details (password NOT included).
|
|
677
|
+
|
|
678
|
+
Args:
|
|
679
|
+
alias: Project alias.
|
|
680
|
+
workspace_id: Workspace ID.
|
|
681
|
+
branch_id: When set, query the branch-scoped endpoint
|
|
682
|
+
``/v2/storage/branch/{ID}/workspaces/{WS}``. When None, falls
|
|
683
|
+
back to the project's active branch (or the production
|
|
684
|
+
endpoint if no active branch is pinned). Explicit None vs.
|
|
685
|
+
resolved value lets the command layer surface a "production
|
|
686
|
+
branch used" notice without changing the service signature.
|
|
687
|
+
|
|
688
|
+
Returns:
|
|
689
|
+
Dict with workspace details including ``login_type``,
|
|
690
|
+
``read_only`` and ``qs_compatible`` so callers can pick a
|
|
691
|
+
Query-Service-compatible workspace without firing a probe query
|
|
692
|
+
(closes #304).
|
|
693
|
+
"""
|
|
694
|
+
projects = self.resolve_projects([alias])
|
|
695
|
+
project = projects[alias]
|
|
696
|
+
effective_branch = (
|
|
697
|
+
branch_id if branch_id is not None else self._resolve_branch_id(alias, project)
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
client = self._client_factory(project.stack_url, project.token)
|
|
701
|
+
try:
|
|
702
|
+
ws_data = client.get_workspace(workspace_id, branch_id=effective_branch)
|
|
703
|
+
finally:
|
|
704
|
+
client.close()
|
|
705
|
+
|
|
706
|
+
connection = ws_data.get("connection", {})
|
|
707
|
+
login_type = connection.get("loginType", "") or ""
|
|
708
|
+
backend = connection.get("backend", "") or ""
|
|
709
|
+
return {
|
|
710
|
+
"project_alias": alias,
|
|
711
|
+
"workspace_id": ws_data.get("id"),
|
|
712
|
+
"backend": backend,
|
|
713
|
+
"host": connection.get("host", ""),
|
|
714
|
+
"warehouse": connection.get("warehouse", ""),
|
|
715
|
+
"database": connection.get("database", ""),
|
|
716
|
+
"schema": connection.get("schema", ""),
|
|
717
|
+
"user": connection.get("user", ""),
|
|
718
|
+
"created": ws_data.get("created", ""),
|
|
719
|
+
"login_type": login_type,
|
|
720
|
+
"read_only": bool(ws_data.get("readOnlyStorageAccess", False)),
|
|
721
|
+
"qs_compatible": _classify_qs_compatibility(login_type, backend),
|
|
722
|
+
"component_id": ws_data.get("component", "") or "",
|
|
723
|
+
"config_id": ws_data.get("configurationId", "") or "",
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
def delete_workspace(self, alias: str, workspace_id: int) -> dict[str, Any]:
|
|
727
|
+
"""Delete a workspace and its associated sandboxes config (if any).
|
|
728
|
+
|
|
729
|
+
Args:
|
|
730
|
+
alias: Project alias.
|
|
731
|
+
workspace_id: Workspace ID.
|
|
732
|
+
|
|
733
|
+
Returns:
|
|
734
|
+
Dict confirming the deletion.
|
|
735
|
+
"""
|
|
736
|
+
projects = self.resolve_projects([alias])
|
|
737
|
+
project = projects[alias]
|
|
738
|
+
branch_id = self._resolve_branch_id(alias, project)
|
|
739
|
+
|
|
740
|
+
client = self._client_factory(project.stack_url, project.token)
|
|
741
|
+
try:
|
|
742
|
+
# Get workspace details to find associated config
|
|
743
|
+
config_id = None
|
|
744
|
+
component = None
|
|
745
|
+
try:
|
|
746
|
+
ws_data = client.get_workspace(workspace_id, branch_id=branch_id)
|
|
747
|
+
component = ws_data.get("component")
|
|
748
|
+
config_id = ws_data.get("configurationId")
|
|
749
|
+
except KeboolaApiError:
|
|
750
|
+
pass # Workspace might not exist, proceed with delete
|
|
751
|
+
|
|
752
|
+
# Delete the workspace
|
|
753
|
+
client.delete_workspace(workspace_id, branch_id=branch_id)
|
|
754
|
+
|
|
755
|
+
# Clean up associated sandboxes config (in the correct branch)
|
|
756
|
+
if config_id and component == "keboola.sandboxes":
|
|
757
|
+
try:
|
|
758
|
+
client.delete_config("keboola.sandboxes", config_id, branch_id=branch_id)
|
|
759
|
+
except KeboolaApiError:
|
|
760
|
+
logger.debug("Could not delete sandbox config %s", config_id)
|
|
761
|
+
finally:
|
|
762
|
+
client.close()
|
|
763
|
+
|
|
764
|
+
return {
|
|
765
|
+
"project_alias": alias,
|
|
766
|
+
"workspace_id": workspace_id,
|
|
767
|
+
"message": f"Workspace {workspace_id} deleted from project '{alias}'.",
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
def reset_password(self, alias: str, workspace_id: int) -> dict[str, Any]:
|
|
771
|
+
"""Reset workspace password.
|
|
772
|
+
|
|
773
|
+
Args:
|
|
774
|
+
alias: Project alias.
|
|
775
|
+
workspace_id: Workspace ID.
|
|
776
|
+
|
|
777
|
+
Returns:
|
|
778
|
+
Dict with the new password.
|
|
779
|
+
"""
|
|
780
|
+
projects = self.resolve_projects([alias])
|
|
781
|
+
project = projects[alias]
|
|
782
|
+
branch_id = self._resolve_branch_id(alias, project)
|
|
783
|
+
|
|
784
|
+
client = self._client_factory(project.stack_url, project.token)
|
|
785
|
+
try:
|
|
786
|
+
result = client.reset_workspace_password(workspace_id, branch_id=branch_id)
|
|
787
|
+
finally:
|
|
788
|
+
client.close()
|
|
789
|
+
|
|
790
|
+
return {
|
|
791
|
+
"project_alias": alias,
|
|
792
|
+
"workspace_id": workspace_id,
|
|
793
|
+
"password": result.get("password", ""),
|
|
794
|
+
"message": (
|
|
795
|
+
f"Password reset for workspace {workspace_id} in project '{alias}'. "
|
|
796
|
+
"Save the new password -- it cannot be retrieved later!"
|
|
797
|
+
),
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
def load_tables(
|
|
801
|
+
self,
|
|
802
|
+
alias: str,
|
|
803
|
+
workspace_id: int,
|
|
804
|
+
tables: list[str],
|
|
805
|
+
preserve: bool = False,
|
|
806
|
+
) -> dict[str, Any]:
|
|
807
|
+
"""Load tables into a workspace.
|
|
808
|
+
|
|
809
|
+
Builds table mapping from table IDs, using the last segment as the
|
|
810
|
+
destination name. Waits for the async storage job to complete.
|
|
811
|
+
|
|
812
|
+
Args:
|
|
813
|
+
alias: Project alias.
|
|
814
|
+
workspace_id: Workspace ID.
|
|
815
|
+
tables: List of table IDs (e.g. "in.c-bucket.table-name").
|
|
816
|
+
preserve: If True, keep existing tables in the workspace.
|
|
817
|
+
|
|
818
|
+
Returns:
|
|
819
|
+
Dict with load job results.
|
|
820
|
+
"""
|
|
821
|
+
projects = self.resolve_projects([alias])
|
|
822
|
+
project = projects[alias]
|
|
823
|
+
branch_id = self._resolve_branch_id(alias, project)
|
|
824
|
+
|
|
825
|
+
# Build table load definitions
|
|
826
|
+
table_defs: list[dict[str, Any]] = []
|
|
827
|
+
for table_id in tables:
|
|
828
|
+
# Use last part of table ID as destination name
|
|
829
|
+
parts = table_id.split(".")
|
|
830
|
+
destination = parts[-1] if parts else table_id
|
|
831
|
+
table_defs.append(
|
|
832
|
+
{
|
|
833
|
+
"source": table_id,
|
|
834
|
+
"destination": destination,
|
|
835
|
+
}
|
|
836
|
+
)
|
|
837
|
+
|
|
838
|
+
client = self._client_factory(project.stack_url, project.token)
|
|
839
|
+
try:
|
|
840
|
+
job_result = client.load_workspace_tables(
|
|
841
|
+
workspace_id, table_defs, branch_id=branch_id, preserve=preserve
|
|
842
|
+
)
|
|
843
|
+
finally:
|
|
844
|
+
client.close()
|
|
845
|
+
|
|
846
|
+
return {
|
|
847
|
+
"project_alias": alias,
|
|
848
|
+
"workspace_id": workspace_id,
|
|
849
|
+
"tables_loaded": len(tables),
|
|
850
|
+
"table_ids": tables,
|
|
851
|
+
"job_id": job_result.get("id"),
|
|
852
|
+
"job_status": job_result.get("status", ""),
|
|
853
|
+
"message": f"Loaded {len(tables)} table(s) into workspace {workspace_id}.",
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
def execute_query(
|
|
857
|
+
self,
|
|
858
|
+
alias: str,
|
|
859
|
+
workspace_id: int,
|
|
860
|
+
sql: str,
|
|
861
|
+
transactional: bool = False,
|
|
862
|
+
full: bool = False,
|
|
863
|
+
limit: int = QUERY_RESULTS_DEFAULT_LIMIT,
|
|
864
|
+
) -> dict[str, Any]:
|
|
865
|
+
"""Execute SQL query in a workspace via Query Service.
|
|
866
|
+
|
|
867
|
+
Submits the query, polls until complete, and fetches results for each
|
|
868
|
+
statement. Two retrieval paths:
|
|
869
|
+
|
|
870
|
+
* Default (``full=False``): the fast inline ``GET .../results`` path.
|
|
871
|
+
Reads the already-computed result set as JSON (no warehouse UNLOAD /
|
|
872
|
+
CSV-file materialization), paginated up to ``limit`` rows. Each
|
|
873
|
+
statement carries structured ``columns`` + ``rows`` and a synthesized
|
|
874
|
+
``csv_data`` (drop-in for the legacy CSV-string consumers).
|
|
875
|
+
* ``full=True``: the legacy ``GET .../export?fileType=csv`` path, which
|
|
876
|
+
materializes the *complete* result set as a CSV file. Slower, but not
|
|
877
|
+
capped at ``limit`` -- use it when you need every row.
|
|
878
|
+
|
|
879
|
+
Args:
|
|
880
|
+
alias: Project alias.
|
|
881
|
+
workspace_id: Workspace ID.
|
|
882
|
+
sql: SQL statement(s) to execute.
|
|
883
|
+
transactional: Whether to wrap in a transaction.
|
|
884
|
+
full: Fetch the complete result set via the CSV export path instead
|
|
885
|
+
of the fast inline path.
|
|
886
|
+
limit: Max rows to fetch via the fast inline path (ignored when
|
|
887
|
+
``full`` is True).
|
|
888
|
+
|
|
889
|
+
Returns:
|
|
890
|
+
Dict with query results.
|
|
891
|
+
"""
|
|
892
|
+
projects = self.resolve_projects([alias])
|
|
893
|
+
project = projects[alias]
|
|
894
|
+
branch_id = self._resolve_branch_id(alias, project)
|
|
895
|
+
|
|
896
|
+
client = self._client_factory(project.stack_url, project.token)
|
|
897
|
+
try:
|
|
898
|
+
# Submit query
|
|
899
|
+
query_job = client.submit_query(
|
|
900
|
+
branch_id=branch_id,
|
|
901
|
+
workspace_id=workspace_id,
|
|
902
|
+
statements=[sql],
|
|
903
|
+
transactional=transactional,
|
|
904
|
+
)
|
|
905
|
+
|
|
906
|
+
query_job_id = str(query_job.get("queryJobId", query_job.get("id", "")))
|
|
907
|
+
|
|
908
|
+
# Wait for completion
|
|
909
|
+
completed_job = client.wait_for_query_job(query_job_id)
|
|
910
|
+
|
|
911
|
+
# Fetch results for each statement
|
|
912
|
+
results: list[dict[str, Any]] = []
|
|
913
|
+
statements = completed_job.get("statements", [])
|
|
914
|
+
for stmt in statements:
|
|
915
|
+
stmt_id = str(stmt.get("id", ""))
|
|
916
|
+
status = stmt.get("status", "")
|
|
917
|
+
num_rows = stmt.get("numberOfRows", stmt.get("resultRows", 0))
|
|
918
|
+
result_entry: dict[str, Any] = {
|
|
919
|
+
"statement_id": stmt_id,
|
|
920
|
+
"status": status,
|
|
921
|
+
"rows_affected": num_rows,
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
# Only statements that produced a result set carry rows.
|
|
925
|
+
if status == "completed" and num_rows > 0:
|
|
926
|
+
self._attach_statement_results(
|
|
927
|
+
result_entry, client, query_job_id, stmt_id, full=full, limit=limit
|
|
928
|
+
)
|
|
929
|
+
|
|
930
|
+
results.append(result_entry)
|
|
931
|
+
|
|
932
|
+
return {
|
|
933
|
+
"project_alias": alias,
|
|
934
|
+
"workspace_id": workspace_id,
|
|
935
|
+
"branch_id": branch_id,
|
|
936
|
+
"query_job_id": query_job_id,
|
|
937
|
+
"status": completed_job.get("status", ""),
|
|
938
|
+
"statements": results,
|
|
939
|
+
"message": f"Query executed in workspace {workspace_id}.",
|
|
940
|
+
}
|
|
941
|
+
finally:
|
|
942
|
+
client.close()
|
|
943
|
+
|
|
944
|
+
@staticmethod
|
|
945
|
+
def _attach_statement_results(
|
|
946
|
+
result_entry: dict[str, Any],
|
|
947
|
+
client: Any,
|
|
948
|
+
query_job_id: str,
|
|
949
|
+
stmt_id: str,
|
|
950
|
+
*,
|
|
951
|
+
full: bool,
|
|
952
|
+
limit: int,
|
|
953
|
+
) -> None:
|
|
954
|
+
"""Populate a statement's result entry, fast inline path or full export.
|
|
955
|
+
|
|
956
|
+
Failures are swallowed (debug-logged): a result-fetch error must not
|
|
957
|
+
sink an otherwise-successful query -- the statement still reports its
|
|
958
|
+
status and row count, just without the data payload.
|
|
959
|
+
"""
|
|
960
|
+
try:
|
|
961
|
+
if full:
|
|
962
|
+
result_entry["csv_data"] = client.export_query_results(query_job_id, stmt_id)
|
|
963
|
+
return
|
|
964
|
+
inline = _collect_inline_results(client, query_job_id, stmt_id, limit)
|
|
965
|
+
result_entry["columns"] = inline.columns
|
|
966
|
+
result_entry["rows"] = inline.rows
|
|
967
|
+
result_entry["row_count"] = len(inline.rows)
|
|
968
|
+
result_entry["total_rows"] = inline.total_rows
|
|
969
|
+
result_entry["truncated"] = inline.truncated
|
|
970
|
+
# Synthesize csv_data so legacy consumers (web UI table/export, CLI
|
|
971
|
+
# preview) keep working without a format change.
|
|
972
|
+
result_entry["csv_data"] = _rows_to_csv(inline.columns, inline.rows)
|
|
973
|
+
except KeboolaApiError:
|
|
974
|
+
logger.debug("Could not fetch results for statement %s", stmt_id)
|
|
975
|
+
|
|
976
|
+
def create_from_transformation(
|
|
977
|
+
self,
|
|
978
|
+
alias: str,
|
|
979
|
+
component_id: str,
|
|
980
|
+
config_id: str,
|
|
981
|
+
row_id: str | None = None,
|
|
982
|
+
backend: str | None = None,
|
|
983
|
+
preserve: bool = False,
|
|
984
|
+
) -> dict[str, Any]:
|
|
985
|
+
"""Create a workspace from a transformation config.
|
|
986
|
+
|
|
987
|
+
Reads the transformation configuration, extracts input table mappings,
|
|
988
|
+
creates a config-tied workspace, and loads the input tables.
|
|
989
|
+
|
|
990
|
+
Args:
|
|
991
|
+
alias: Project alias.
|
|
992
|
+
component_id: Transformation component ID (e.g. keboola.snowflake-transformation).
|
|
993
|
+
config_id: Configuration ID.
|
|
994
|
+
row_id: Optional row ID for row-based transformations.
|
|
995
|
+
backend: Workspace backend. Auto-detected from project if None.
|
|
996
|
+
preserve: If True, keep existing tables in the workspace during load.
|
|
997
|
+
|
|
998
|
+
Returns:
|
|
999
|
+
Dict with workspace details and loaded tables.
|
|
1000
|
+
"""
|
|
1001
|
+
projects = self.resolve_projects([alias])
|
|
1002
|
+
project = projects[alias]
|
|
1003
|
+
branch_id = self._resolve_branch_id(alias, project)
|
|
1004
|
+
|
|
1005
|
+
client = self._client_factory(project.stack_url, project.token)
|
|
1006
|
+
try:
|
|
1007
|
+
effective_backend = backend or self._detect_backend(client)
|
|
1008
|
+
|
|
1009
|
+
# Read the transformation config
|
|
1010
|
+
config_data = client.get_config_detail(component_id, config_id)
|
|
1011
|
+
|
|
1012
|
+
# Extract input mapping from configuration
|
|
1013
|
+
configuration = config_data.get("configuration", {})
|
|
1014
|
+
|
|
1015
|
+
# If row_id specified, find the row
|
|
1016
|
+
if row_id:
|
|
1017
|
+
rows = config_data.get("rows", [])
|
|
1018
|
+
target_row = None
|
|
1019
|
+
for row in rows:
|
|
1020
|
+
if str(row.get("id", "")) == str(row_id):
|
|
1021
|
+
target_row = row
|
|
1022
|
+
break
|
|
1023
|
+
if target_row is None:
|
|
1024
|
+
raise ConfigError(
|
|
1025
|
+
f"Row '{row_id}' not found in config '{config_id}' "
|
|
1026
|
+
f"of component '{component_id}'."
|
|
1027
|
+
)
|
|
1028
|
+
configuration = target_row.get("configuration", {})
|
|
1029
|
+
|
|
1030
|
+
storage = configuration.get("storage", {})
|
|
1031
|
+
input_tables = storage.get("input", {}).get("tables", [])
|
|
1032
|
+
|
|
1033
|
+
if not input_tables:
|
|
1034
|
+
raise ConfigError(
|
|
1035
|
+
f"No input tables found in transformation config '{config_id}'. "
|
|
1036
|
+
"The configuration may not have input mapping defined."
|
|
1037
|
+
)
|
|
1038
|
+
|
|
1039
|
+
# Create config-tied workspace
|
|
1040
|
+
key_pair = _workspace_key_pair_for_backend(effective_backend)
|
|
1041
|
+
ws_data = client.create_config_workspace(
|
|
1042
|
+
branch_id=branch_id,
|
|
1043
|
+
component_id=component_id,
|
|
1044
|
+
config_id=config_id,
|
|
1045
|
+
backend=effective_backend,
|
|
1046
|
+
login_type=_workspace_login_type_for_backend(effective_backend),
|
|
1047
|
+
public_key=key_pair.public_pem if key_pair else None,
|
|
1048
|
+
)
|
|
1049
|
+
|
|
1050
|
+
workspace_id = ws_data.get("id")
|
|
1051
|
+
# create_config_workspace always returns an id for the new workspace
|
|
1052
|
+
assert workspace_id is not None
|
|
1053
|
+
connection = ws_data.get("connection", {})
|
|
1054
|
+
|
|
1055
|
+
# Build table load definitions from input mapping
|
|
1056
|
+
table_defs: list[dict[str, Any]] = []
|
|
1057
|
+
source_tables: list[str] = []
|
|
1058
|
+
for table in input_tables:
|
|
1059
|
+
source = table.get("source", "")
|
|
1060
|
+
destination = table.get("destination", "")
|
|
1061
|
+
if source:
|
|
1062
|
+
source_tables.append(source)
|
|
1063
|
+
entry: dict[str, Any] = {"source": source, "destination": destination}
|
|
1064
|
+
# Pass through columns, where_column, where_values if present
|
|
1065
|
+
if table.get("columns"):
|
|
1066
|
+
entry["columns"] = table["columns"]
|
|
1067
|
+
if table.get("where_column"):
|
|
1068
|
+
entry["where_column"] = table["where_column"]
|
|
1069
|
+
if table.get("where_values"):
|
|
1070
|
+
entry["where_values"] = table["where_values"]
|
|
1071
|
+
table_defs.append(entry)
|
|
1072
|
+
|
|
1073
|
+
# Load tables into workspace
|
|
1074
|
+
if table_defs:
|
|
1075
|
+
client.load_workspace_tables(
|
|
1076
|
+
workspace_id, table_defs, branch_id=branch_id, preserve=preserve
|
|
1077
|
+
)
|
|
1078
|
+
|
|
1079
|
+
credential_label = "private key" if key_pair else "password"
|
|
1080
|
+
result = {
|
|
1081
|
+
"project_alias": alias,
|
|
1082
|
+
"workspace_id": workspace_id,
|
|
1083
|
+
"branch_id": branch_id,
|
|
1084
|
+
"component_id": component_id,
|
|
1085
|
+
"config_id": config_id,
|
|
1086
|
+
"row_id": row_id,
|
|
1087
|
+
"backend": connection.get("backend", backend),
|
|
1088
|
+
"host": connection.get("host", ""),
|
|
1089
|
+
"warehouse": connection.get("warehouse", ""),
|
|
1090
|
+
"database": connection.get("database", ""),
|
|
1091
|
+
"schema": connection.get("schema", ""),
|
|
1092
|
+
"user": connection.get("user", ""),
|
|
1093
|
+
"password": connection.get("password", ""),
|
|
1094
|
+
"tables_loaded": source_tables,
|
|
1095
|
+
"message": f"Workspace {workspace_id} created from transformation "
|
|
1096
|
+
f"'{config_id}' with {len(source_tables)} table(s) loaded. "
|
|
1097
|
+
f"Save the {credential_label} -- it cannot be retrieved later!",
|
|
1098
|
+
}
|
|
1099
|
+
if key_pair is not None:
|
|
1100
|
+
result["private_key"] = key_pair.private_pem
|
|
1101
|
+
return result
|
|
1102
|
+
finally:
|
|
1103
|
+
client.close()
|