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,947 @@
|
|
|
1
|
+
"""Project management service - business logic for add/remove/edit/list/status.
|
|
2
|
+
|
|
3
|
+
Orchestrates config persistence and API calls without knowing about CLI or HTTP details.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from ..constants import ENV_KBAGENT_PROJECT
|
|
16
|
+
from ..errors import ConfigError, KeboolaApiError, mask_token
|
|
17
|
+
from ..models import ProjectConfig, normalize_stack_url
|
|
18
|
+
from .base import BaseService
|
|
19
|
+
|
|
20
|
+
# Filesystem-safe slug constraint for ``--new-alias``. Aliases land on disk
|
|
21
|
+
# as the nested-sync directory name (``<cwd>/<alias>/``), so they must not
|
|
22
|
+
# contain path separators, NUL bytes, or anything outside the strict slug
|
|
23
|
+
# alphabet. ``..`` is rejected separately to catch ``foo..bar`` cases the
|
|
24
|
+
# regex would otherwise accept (dot is a legal slug character on its own).
|
|
25
|
+
# This is intentionally stricter than ``project add`` accepts today --
|
|
26
|
+
# rename's filesystem interaction is the new pressure point.
|
|
27
|
+
_ALIAS_FORMAT_RE = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_.\-]*")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ProjectService(BaseService):
|
|
31
|
+
"""Business logic for managing Keboola project connections.
|
|
32
|
+
|
|
33
|
+
Uses dependency injection for config_store and client_factory.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def add_project(self, alias: str, stack_url: str, token: str) -> dict[str, Any]:
|
|
37
|
+
"""Add a new project connection after verifying the token.
|
|
38
|
+
|
|
39
|
+
Calls the Keboola API to verify the token and extract project info,
|
|
40
|
+
then saves the project to the config store.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
alias: Human-friendly project name.
|
|
44
|
+
stack_url: Keboola stack URL.
|
|
45
|
+
token: Storage API token.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
Dict with project details (alias, project_name, project_id, stack_url, masked_token).
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
KeboolaApiError: If token verification fails.
|
|
52
|
+
ConfigError: If the alias already exists.
|
|
53
|
+
"""
|
|
54
|
+
# Accept a bare host or a full project deep-link, not just a clean base
|
|
55
|
+
# URL -- normalize before we build the verification client so the token
|
|
56
|
+
# check hits the right host (and the stored value is the clean base).
|
|
57
|
+
stack_url = normalize_stack_url(stack_url)
|
|
58
|
+
client = self._client_factory(stack_url, token)
|
|
59
|
+
try:
|
|
60
|
+
token_info = client.verify_token()
|
|
61
|
+
finally:
|
|
62
|
+
client.close()
|
|
63
|
+
|
|
64
|
+
project = ProjectConfig(
|
|
65
|
+
stack_url=stack_url,
|
|
66
|
+
token=token,
|
|
67
|
+
project_name=token_info.project_name,
|
|
68
|
+
project_id=token_info.project_id,
|
|
69
|
+
org_id=token_info.org_id,
|
|
70
|
+
org_name=token_info.org_name,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
self._config_store.add_project(alias, project)
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
"alias": alias,
|
|
77
|
+
"project_name": token_info.project_name,
|
|
78
|
+
"project_id": token_info.project_id,
|
|
79
|
+
"stack_url": stack_url,
|
|
80
|
+
"token": mask_token(token),
|
|
81
|
+
"org_id": token_info.org_id,
|
|
82
|
+
"org_name": token_info.org_name,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
def remove_project(self, alias: str) -> dict[str, str]:
|
|
86
|
+
"""Remove a project from the configuration.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
alias: The project alias to remove.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Dict confirming the removal.
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
ConfigError: If the alias does not exist.
|
|
96
|
+
"""
|
|
97
|
+
self._config_store.remove_project(alias)
|
|
98
|
+
return {"alias": alias, "message": f"Project '{alias}' removed."}
|
|
99
|
+
|
|
100
|
+
def edit_project(
|
|
101
|
+
self,
|
|
102
|
+
alias: str,
|
|
103
|
+
stack_url: str | None = None,
|
|
104
|
+
token: str | None = None,
|
|
105
|
+
new_alias: str | None = None,
|
|
106
|
+
search_root: Path | None = None,
|
|
107
|
+
dry_run: bool = False,
|
|
108
|
+
) -> dict[str, Any]:
|
|
109
|
+
"""Edit an existing project's configuration.
|
|
110
|
+
|
|
111
|
+
If the token is changed, re-verifies it against the API to update
|
|
112
|
+
project name and ID. If ``new_alias`` is provided and differs from
|
|
113
|
+
``alias``, the rename is applied first (config + nested sync dir);
|
|
114
|
+
any subsequent url/token mutations target the new alias key.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
alias: The project alias to edit.
|
|
118
|
+
stack_url: New stack URL (if changing).
|
|
119
|
+
token: New token (if changing).
|
|
120
|
+
new_alias: Rename target. Skipped when None or equal to ``alias``.
|
|
121
|
+
search_root: Workspace root to scan for a nested sync directory
|
|
122
|
+
``<search_root>/<alias>/.keboola/manifest.json``. Defaults
|
|
123
|
+
to the current working directory at call time. Tests pass
|
|
124
|
+
an explicit ``tmp_path`` to avoid touching the real fs.
|
|
125
|
+
dry_run: If True, validate everything (existence, format,
|
|
126
|
+
collision) and compute the planned cascade WITHOUT
|
|
127
|
+
mutating any state. Result dict carries ``dry_run: True``
|
|
128
|
+
and a ``planned`` sub-dict describing what would happen.
|
|
129
|
+
Validation errors raise the same ``ConfigError``
|
|
130
|
+
exceptions as the live path. Token re-verification is
|
|
131
|
+
also skipped (no API call).
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
Dict with updated project details. When a rename happened,
|
|
135
|
+
includes ``old_alias`` and ``rename`` keys describing the
|
|
136
|
+
cascade outcome. In dry-run mode the dict has
|
|
137
|
+
``dry_run: True`` and a ``planned`` sub-dict; ``alias``
|
|
138
|
+
reports the ORIGINAL alias (no mutation occurred).
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
KeboolaApiError: If token re-verification fails (live mode only).
|
|
142
|
+
ConfigError: If the alias does not exist, no changes provided,
|
|
143
|
+
or ``new_alias`` is invalid / collides.
|
|
144
|
+
"""
|
|
145
|
+
existing = self._config_store.get_project(alias)
|
|
146
|
+
if existing is None:
|
|
147
|
+
raise ConfigError(f"Project '{alias}' not found.")
|
|
148
|
+
|
|
149
|
+
# ``--new-alias`` matching the current alias is treated as no change
|
|
150
|
+
# (rename-to-same-name idempotency). Combined with no url/token, it
|
|
151
|
+
# is the same surface as "user typed the command but specified
|
|
152
|
+
# nothing" -- raise the same error so the UX is consistent.
|
|
153
|
+
new_alias_is_change = new_alias is not None and new_alias != alias
|
|
154
|
+
if stack_url is None and token is None and not new_alias_is_change:
|
|
155
|
+
raise ConfigError(
|
|
156
|
+
"No changes specified. Provide --url, --token, and/or "
|
|
157
|
+
"--new-alias (matching the current alias is a no-op)."
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# Normalize a bare host / full project deep-link to the clean base URL
|
|
161
|
+
# up front so the dry-run preview and the real edit agree on the value.
|
|
162
|
+
if stack_url is not None:
|
|
163
|
+
stack_url = normalize_stack_url(stack_url)
|
|
164
|
+
|
|
165
|
+
# ----- dry-run path: validate everything, mutate nothing ----------
|
|
166
|
+
if dry_run:
|
|
167
|
+
planned_rename: dict[str, Any] | None = None
|
|
168
|
+
if new_alias is not None and new_alias != alias:
|
|
169
|
+
planned_rename = self._plan_project_alias_rename(
|
|
170
|
+
old_alias=alias,
|
|
171
|
+
new_alias=new_alias,
|
|
172
|
+
search_root=search_root if search_root is not None else Path.cwd(),
|
|
173
|
+
)
|
|
174
|
+
return {
|
|
175
|
+
"alias": alias,
|
|
176
|
+
"project_name": existing.project_name,
|
|
177
|
+
"project_id": existing.project_id,
|
|
178
|
+
"stack_url": stack_url if stack_url is not None else existing.stack_url,
|
|
179
|
+
"token": mask_token(existing.token),
|
|
180
|
+
"dry_run": True,
|
|
181
|
+
"planned": {
|
|
182
|
+
"old_alias": alias,
|
|
183
|
+
"new_alias": new_alias if new_alias_is_change else None,
|
|
184
|
+
"stack_url_would_change": stack_url is not None
|
|
185
|
+
and stack_url != existing.stack_url,
|
|
186
|
+
"token_would_change": token is not None,
|
|
187
|
+
"rename": planned_rename,
|
|
188
|
+
},
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
rename_result: dict[str, Any] | None = None
|
|
192
|
+
original_alias = alias
|
|
193
|
+
if new_alias is not None and new_alias != alias:
|
|
194
|
+
rename_result = self._rename_project_alias(
|
|
195
|
+
old_alias=alias,
|
|
196
|
+
new_alias=new_alias,
|
|
197
|
+
search_root=search_root if search_root is not None else Path.cwd(),
|
|
198
|
+
)
|
|
199
|
+
alias = new_alias # subsequent url/token updates target the new key
|
|
200
|
+
|
|
201
|
+
updates: dict[str, str | int] = {}
|
|
202
|
+
|
|
203
|
+
if stack_url is not None:
|
|
204
|
+
updates["stack_url"] = stack_url
|
|
205
|
+
|
|
206
|
+
if token is not None:
|
|
207
|
+
effective_url = stack_url if stack_url is not None else existing.stack_url
|
|
208
|
+
client = self._client_factory(effective_url, token)
|
|
209
|
+
try:
|
|
210
|
+
token_info = client.verify_token()
|
|
211
|
+
finally:
|
|
212
|
+
client.close()
|
|
213
|
+
updates["token"] = token
|
|
214
|
+
updates["project_name"] = token_info.project_name
|
|
215
|
+
if token_info.project_id is not None:
|
|
216
|
+
updates["project_id"] = token_info.project_id
|
|
217
|
+
|
|
218
|
+
if updates:
|
|
219
|
+
self._config_store.edit_project(alias, **updates)
|
|
220
|
+
|
|
221
|
+
updated = self._config_store.get_project(alias)
|
|
222
|
+
if updated is None:
|
|
223
|
+
raise ConfigError(
|
|
224
|
+
f"Project '{alias}' could not be retrieved after editing. "
|
|
225
|
+
"Config store may be in an inconsistent state."
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
result: dict[str, Any] = {
|
|
229
|
+
"alias": alias,
|
|
230
|
+
"project_name": updated.project_name,
|
|
231
|
+
"project_id": updated.project_id,
|
|
232
|
+
"stack_url": updated.stack_url,
|
|
233
|
+
"token": mask_token(updated.token),
|
|
234
|
+
}
|
|
235
|
+
if rename_result is not None:
|
|
236
|
+
result["old_alias"] = original_alias
|
|
237
|
+
result["rename"] = rename_result
|
|
238
|
+
return result
|
|
239
|
+
|
|
240
|
+
def _rename_project_alias(
|
|
241
|
+
self,
|
|
242
|
+
*,
|
|
243
|
+
old_alias: str,
|
|
244
|
+
new_alias: str,
|
|
245
|
+
search_root: Path,
|
|
246
|
+
) -> dict[str, Any]:
|
|
247
|
+
"""Rename a project alias across config.json and nested sync dirs.
|
|
248
|
+
|
|
249
|
+
Order of operations and rollback contract (review iter 2 -- bugs
|
|
250
|
+
S1 + S2):
|
|
251
|
+
|
|
252
|
+
1. Format validation rejects path-traversal characters BEFORE any
|
|
253
|
+
I/O. ``..``, ``/``, ``\\``, NUL, leading ``.`` / ``-``, and
|
|
254
|
+
anything outside ``[A-Za-z0-9_.-]`` are rejected here, so the
|
|
255
|
+
subsequent filesystem path computation cannot escape
|
|
256
|
+
``search_root``.
|
|
257
|
+
2. ``search_root`` is resolved once via ``Path.resolve()`` to
|
|
258
|
+
collapse symlinks; the disk rename uses the resolved path
|
|
259
|
+
throughout, closing the door on a malicious symlink at
|
|
260
|
+
``<cwd>/<new-alias>/`` redirecting the move target.
|
|
261
|
+
3. Config-side rename (`ConfigStore.rename_project`) commits the
|
|
262
|
+
``projects`` dict-key swap and the ``default_project`` cascade
|
|
263
|
+
atomically (one ``save()`` call). On collision it raises
|
|
264
|
+
BEFORE any disk op runs.
|
|
265
|
+
4. The optional disk rename (`_rename_nested_sync_dir`) is wrapped
|
|
266
|
+
in a rollback: any ``OSError`` reverses the config rename via
|
|
267
|
+
a second ``rename_project(new, old)`` call so config and disk
|
|
268
|
+
never end up out of sync. The original exception is re-raised
|
|
269
|
+
wrapped in ``ConfigError`` so the user sees an actionable
|
|
270
|
+
message.
|
|
271
|
+
5. Lineage cache (`*.lineage.json` files in ``search_root``) is
|
|
272
|
+
NOT rewritten -- the cache embeds ``<alias>:<table_id>`` FQNs,
|
|
273
|
+
may live in a sibling git repo, may be committed to disk; a
|
|
274
|
+
partial rewrite is worse than no rewrite. The scan is
|
|
275
|
+
depth-capped at 2 levels to bound cost when ``search_root`` is
|
|
276
|
+
a deep tree (S4).
|
|
277
|
+
|
|
278
|
+
Project alias rename is purely local -- there is no Keboola API
|
|
279
|
+
counterpart to audit, so unlike ``ConfigService.rename_config``
|
|
280
|
+
this method writes no change-description string.
|
|
281
|
+
|
|
282
|
+
Returns a dict describing the cascade:
|
|
283
|
+
{
|
|
284
|
+
"old_alias": str,
|
|
285
|
+
"new_alias": str,
|
|
286
|
+
"default_project_updated": bool,
|
|
287
|
+
"sync_dir": {"old_path": str, "new_path": str, "method": str} | None,
|
|
288
|
+
"lineage_cache_warning": str | None,
|
|
289
|
+
}
|
|
290
|
+
"""
|
|
291
|
+
# 1. Format validation. Rejects path-traversal characters and
|
|
292
|
+
# anything that would make a confusing dict key. Fail-fast,
|
|
293
|
+
# no I/O.
|
|
294
|
+
self._validate_alias_format(new_alias)
|
|
295
|
+
|
|
296
|
+
# 2. Resolve search_root once (collapses symlinks -- defense
|
|
297
|
+
# against a malicious cwd that aliases an unrelated tree).
|
|
298
|
+
try:
|
|
299
|
+
resolved_root = search_root.resolve()
|
|
300
|
+
except (OSError, RuntimeError) as exc: # RuntimeError = symlink loop
|
|
301
|
+
raise ConfigError(f"Cannot resolve search_root '{search_root}': {exc}") from exc
|
|
302
|
+
|
|
303
|
+
# 3. Capture pre-state. config_store.rename_project raises on
|
|
304
|
+
# collision before mutating, so this read is safe.
|
|
305
|
+
pre_config = self._config_store.load()
|
|
306
|
+
default_was_match = pre_config.default_project == old_alias
|
|
307
|
+
|
|
308
|
+
# 4. Atomic config-side rename (collision -> ConfigError, no disk op).
|
|
309
|
+
self._config_store.rename_project(old_alias, new_alias)
|
|
310
|
+
|
|
311
|
+
# 5. Optional disk-side rename, with rollback on OS-level failure.
|
|
312
|
+
try:
|
|
313
|
+
sync_dir_result = self._rename_nested_sync_dir(
|
|
314
|
+
old_alias=old_alias, new_alias=new_alias, search_root=resolved_root
|
|
315
|
+
)
|
|
316
|
+
except OSError as exc:
|
|
317
|
+
# Disk move failed; roll the config rename back so config
|
|
318
|
+
# and disk stay in sync. The rollback's own failure is
|
|
319
|
+
# logged-and-swallowed to surface the original cause.
|
|
320
|
+
import contextlib
|
|
321
|
+
|
|
322
|
+
with contextlib.suppress(ConfigError):
|
|
323
|
+
self._config_store.rename_project(new_alias, old_alias)
|
|
324
|
+
raise ConfigError(
|
|
325
|
+
f"Failed to rename sync directory for '{old_alias}' -> "
|
|
326
|
+
f"'{new_alias}': {exc}. Config rolled back to original alias."
|
|
327
|
+
) from exc
|
|
328
|
+
|
|
329
|
+
# 6. Lineage cache detection -> warn (no rewrite).
|
|
330
|
+
lineage_warning = self._detect_lineage_cache_warning(resolved_root, old_alias)
|
|
331
|
+
|
|
332
|
+
result: dict[str, Any] = {
|
|
333
|
+
"old_alias": old_alias,
|
|
334
|
+
"new_alias": new_alias,
|
|
335
|
+
"default_project_updated": default_was_match,
|
|
336
|
+
"sync_dir": sync_dir_result,
|
|
337
|
+
"lineage_cache_warning": lineage_warning,
|
|
338
|
+
}
|
|
339
|
+
if lineage_warning is not None:
|
|
340
|
+
sys.stderr.write(lineage_warning + "\n")
|
|
341
|
+
return result
|
|
342
|
+
|
|
343
|
+
@staticmethod
|
|
344
|
+
def _validate_alias_format(new_alias: str) -> None:
|
|
345
|
+
"""Reject filesystem-unsafe ``--new-alias`` values.
|
|
346
|
+
|
|
347
|
+
Stricter than the no-op check ``project add`` performs today
|
|
348
|
+
(none) because rename uses ``new_alias`` as a directory name.
|
|
349
|
+
Forbidden inputs:
|
|
350
|
+
|
|
351
|
+
- empty / whitespace-only;
|
|
352
|
+
- any whitespace anywhere;
|
|
353
|
+
- the substring ``..`` (path-traversal in any position);
|
|
354
|
+
- characters outside ``[A-Za-z0-9_.-]`` (catches ``/``, ``\\``,
|
|
355
|
+
NUL, control chars, Unicode letters);
|
|
356
|
+
- leading ``.`` or ``-`` (would surprise CLI parsing or hide as
|
|
357
|
+
a dotfile).
|
|
358
|
+
"""
|
|
359
|
+
if not new_alias or not new_alias.strip():
|
|
360
|
+
raise ConfigError("Invalid --new-alias: must not be empty or whitespace-only.")
|
|
361
|
+
if any(ch.isspace() for ch in new_alias):
|
|
362
|
+
raise ConfigError(f"Invalid --new-alias '{new_alias}': must not contain whitespace.")
|
|
363
|
+
if ".." in new_alias:
|
|
364
|
+
raise ConfigError(
|
|
365
|
+
f"Invalid --new-alias '{new_alias}': must not contain '..' "
|
|
366
|
+
"(path-traversal sequences are rejected because the alias "
|
|
367
|
+
"is used as a filesystem directory name)."
|
|
368
|
+
)
|
|
369
|
+
if not _ALIAS_FORMAT_RE.fullmatch(new_alias):
|
|
370
|
+
raise ConfigError(
|
|
371
|
+
f"Invalid --new-alias '{new_alias}': must match "
|
|
372
|
+
"[A-Za-z0-9_][A-Za-z0-9_.-]* (filesystem-safe slug). "
|
|
373
|
+
"Path separators, NULs, and characters outside the slug "
|
|
374
|
+
"alphabet are rejected because the alias is used as a "
|
|
375
|
+
"nested-sync directory name."
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
@staticmethod
|
|
379
|
+
def _rename_nested_sync_dir(
|
|
380
|
+
*,
|
|
381
|
+
old_alias: str,
|
|
382
|
+
new_alias: str,
|
|
383
|
+
search_root: Path,
|
|
384
|
+
) -> dict[str, str] | None:
|
|
385
|
+
"""Move ``<search_root>/<old_alias>/`` to ``<search_root>/<new_alias>/``.
|
|
386
|
+
|
|
387
|
+
Skips silently when the source directory does not contain a
|
|
388
|
+
``.keboola/manifest.json`` (i.e. it is not a kbagent sync
|
|
389
|
+
workspace). Mirrors the collision-suffix and git-mv-with-fallback
|
|
390
|
+
pattern of :meth:`ConfigService._rename_sync_directory`.
|
|
391
|
+
|
|
392
|
+
Caller is responsible for ``search_root.resolve()``-ing first
|
|
393
|
+
and for validating ``new_alias`` via
|
|
394
|
+
:meth:`_validate_alias_format` -- this helper trusts both inputs.
|
|
395
|
+
"""
|
|
396
|
+
from ..constants import KEBOOLA_DIR_NAME, MANIFEST_FILENAME
|
|
397
|
+
|
|
398
|
+
source_dir = search_root / old_alias
|
|
399
|
+
manifest_path = source_dir / KEBOOLA_DIR_NAME / MANIFEST_FILENAME
|
|
400
|
+
if not manifest_path.exists():
|
|
401
|
+
return None # No nested sync workspace; nothing to rename on disk.
|
|
402
|
+
|
|
403
|
+
# Collision detection: append -2, -3, ... if the target exists or
|
|
404
|
+
# is a symlink (covers both real-dir and symlink-to-elsewhere cases).
|
|
405
|
+
target_dir = search_root / new_alias
|
|
406
|
+
if target_dir.exists() or target_dir.is_symlink():
|
|
407
|
+
counter = 2
|
|
408
|
+
while True:
|
|
409
|
+
candidate = search_root / f"{new_alias}-{counter}"
|
|
410
|
+
if not candidate.exists() and not candidate.is_symlink():
|
|
411
|
+
target_dir = candidate
|
|
412
|
+
break
|
|
413
|
+
counter += 1
|
|
414
|
+
|
|
415
|
+
method = ProjectService._move_directory(source_dir, target_dir)
|
|
416
|
+
return {
|
|
417
|
+
"old_path": str(source_dir),
|
|
418
|
+
"new_path": str(target_dir),
|
|
419
|
+
"method": method,
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
@staticmethod
|
|
423
|
+
def _move_directory(source: Path, target: Path) -> str:
|
|
424
|
+
"""Move ``source`` to ``target``; prefer ``git mv`` for cleaner history.
|
|
425
|
+
|
|
426
|
+
Returns the method string (``"git_mv"`` or ``"shutil_move"``) used.
|
|
427
|
+
Mirrors :meth:`ConfigService._move_directory` -- return strings
|
|
428
|
+
are kept identical so JSON consumers parsing the ``method`` key
|
|
429
|
+
across both rename surfaces see the same vocabulary.
|
|
430
|
+
"""
|
|
431
|
+
try:
|
|
432
|
+
result = subprocess.run(
|
|
433
|
+
["git", "mv", str(source), str(target)],
|
|
434
|
+
capture_output=True,
|
|
435
|
+
text=True,
|
|
436
|
+
cwd=str(source.parent),
|
|
437
|
+
check=False,
|
|
438
|
+
)
|
|
439
|
+
if result.returncode == 0:
|
|
440
|
+
return "git_mv"
|
|
441
|
+
except (FileNotFoundError, OSError):
|
|
442
|
+
pass
|
|
443
|
+
shutil.move(str(source), str(target))
|
|
444
|
+
return "shutil_move"
|
|
445
|
+
|
|
446
|
+
@staticmethod
|
|
447
|
+
def _detect_lineage_cache_warning(search_root: Path, old_alias: str) -> str | None:
|
|
448
|
+
"""Return a stderr-warning string if a ``*.lineage.json`` exists.
|
|
449
|
+
|
|
450
|
+
Depth-capped at 2 levels (top + 2 subdir levels) to bound cost
|
|
451
|
+
when ``search_root`` is a large tree (e.g. ``$HOME``) -- iter 2
|
|
452
|
+
review S4. Lineage caches typically live at the workspace root
|
|
453
|
+
or one level deep next to nested project dirs; deeper scans are
|
|
454
|
+
unbounded and risk symlink loops.
|
|
455
|
+
|
|
456
|
+
We do not parse the JSON or attempt a rewrite -- lineage caches
|
|
457
|
+
embed the alias inside FQN strings, can live anywhere, and may
|
|
458
|
+
be committed to a sibling git repo; partial rewrites are worse
|
|
459
|
+
than no rewrite. Surfacing the manual rebuild step is enough.
|
|
460
|
+
"""
|
|
461
|
+
try:
|
|
462
|
+
if not search_root.is_dir():
|
|
463
|
+
return None
|
|
464
|
+
patterns = ("*.lineage.json", "*/*.lineage.json", "*/*/*.lineage.json")
|
|
465
|
+
for pattern in patterns:
|
|
466
|
+
for p in search_root.glob(pattern):
|
|
467
|
+
# Bail out on first hit; one warning suffices.
|
|
468
|
+
return (
|
|
469
|
+
f"Warning: lineage cache file detected at "
|
|
470
|
+
f"'{p}'. The cache embeds the old alias "
|
|
471
|
+
f"'{old_alias}' in FQN strings and is NOT auto-updated "
|
|
472
|
+
f"by this rename. Run 'kbagent lineage build --output X' "
|
|
473
|
+
f"to rebuild it against the new alias."
|
|
474
|
+
)
|
|
475
|
+
except (OSError, PermissionError):
|
|
476
|
+
return None
|
|
477
|
+
return None
|
|
478
|
+
|
|
479
|
+
def _plan_project_alias_rename(
|
|
480
|
+
self,
|
|
481
|
+
*,
|
|
482
|
+
old_alias: str,
|
|
483
|
+
new_alias: str,
|
|
484
|
+
search_root: Path,
|
|
485
|
+
) -> dict[str, Any]:
|
|
486
|
+
"""Read-only dry-run for ``_rename_project_alias``.
|
|
487
|
+
|
|
488
|
+
Mirrors the live-path validation order (format check, then
|
|
489
|
+
``search_root`` resolve, then collision check via a config load)
|
|
490
|
+
so the same ``ConfigError`` exceptions surface for the same
|
|
491
|
+
inputs. Skips both mutations: no ``rename_project()`` call, no
|
|
492
|
+
``_rename_nested_sync_dir()`` call. Predicts the disk-move shape
|
|
493
|
+
(target dir, collision suffix, ``planned_method``) by inspecting
|
|
494
|
+
the filesystem read-only. The lineage cache warning string is
|
|
495
|
+
included in the result but NOT written to stderr (planning
|
|
496
|
+
shouldn't emit user-facing warnings yet).
|
|
497
|
+
"""
|
|
498
|
+
# 1. Same validators as the live path.
|
|
499
|
+
self._validate_alias_format(new_alias)
|
|
500
|
+
|
|
501
|
+
try:
|
|
502
|
+
resolved_root = search_root.resolve()
|
|
503
|
+
except (OSError, RuntimeError) as exc:
|
|
504
|
+
raise ConfigError(f"Cannot resolve search_root '{search_root}': {exc}") from exc
|
|
505
|
+
|
|
506
|
+
# 2. Collision check via a read-only load (no rename_project mutation).
|
|
507
|
+
config = self._config_store.load()
|
|
508
|
+
if old_alias not in config.projects:
|
|
509
|
+
raise ConfigError(f"Project '{old_alias}' not found.")
|
|
510
|
+
if new_alias in config.projects:
|
|
511
|
+
raise ConfigError(
|
|
512
|
+
f"Cannot rename '{old_alias}' to '{new_alias}': "
|
|
513
|
+
f"alias '{new_alias}' is already in use."
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
default_would_update = config.default_project == old_alias
|
|
517
|
+
|
|
518
|
+
# 3. Sync-dir probe (read-only).
|
|
519
|
+
sync_dir_planned = self._plan_nested_sync_dir(
|
|
520
|
+
old_alias=old_alias, new_alias=new_alias, search_root=resolved_root
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
# 4. Lineage cache scan -- string returned, NOT written to stderr.
|
|
524
|
+
lineage_warning = self._detect_lineage_cache_warning(resolved_root, old_alias)
|
|
525
|
+
|
|
526
|
+
return {
|
|
527
|
+
"old_alias": old_alias,
|
|
528
|
+
"new_alias": new_alias,
|
|
529
|
+
"default_project_would_update": default_would_update,
|
|
530
|
+
"sync_dir_would_move": sync_dir_planned,
|
|
531
|
+
"lineage_cache_warning": lineage_warning,
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
@staticmethod
|
|
535
|
+
def _plan_nested_sync_dir(
|
|
536
|
+
*,
|
|
537
|
+
old_alias: str,
|
|
538
|
+
new_alias: str,
|
|
539
|
+
search_root: Path,
|
|
540
|
+
) -> dict[str, Any] | None:
|
|
541
|
+
"""Read-only counterpart of ``_rename_nested_sync_dir``.
|
|
542
|
+
|
|
543
|
+
Returns the planned move shape (target path, collision suffix,
|
|
544
|
+
method) without touching disk. Returns ``None`` when no nested
|
|
545
|
+
sync workspace exists at ``<search_root>/<old_alias>/``.
|
|
546
|
+
"""
|
|
547
|
+
from ..constants import KEBOOLA_DIR_NAME, MANIFEST_FILENAME
|
|
548
|
+
|
|
549
|
+
source_dir = search_root / old_alias
|
|
550
|
+
manifest_path = source_dir / KEBOOLA_DIR_NAME / MANIFEST_FILENAME
|
|
551
|
+
if not manifest_path.exists():
|
|
552
|
+
return None
|
|
553
|
+
|
|
554
|
+
target_dir = search_root / new_alias
|
|
555
|
+
suffix: str | None = None
|
|
556
|
+
if target_dir.exists() or target_dir.is_symlink():
|
|
557
|
+
counter = 2
|
|
558
|
+
while True:
|
|
559
|
+
candidate = search_root / f"{new_alias}-{counter}"
|
|
560
|
+
if not candidate.exists() and not candidate.is_symlink():
|
|
561
|
+
target_dir = candidate
|
|
562
|
+
suffix = f"-{counter}"
|
|
563
|
+
break
|
|
564
|
+
counter += 1
|
|
565
|
+
|
|
566
|
+
# Predict whether ``git mv`` would succeed: requires (a) ``git`` on
|
|
567
|
+
# PATH and (b) the source dir lives inside a git working tree.
|
|
568
|
+
# Heuristic -- the live path falls back to ``shutil.move`` when
|
|
569
|
+
# ``git mv`` returns nonzero, so the dry-run prediction is best-
|
|
570
|
+
# effort, not a contract.
|
|
571
|
+
planned_method = "shutil_move"
|
|
572
|
+
if shutil.which("git"):
|
|
573
|
+
ancestor: Path | None = source_dir
|
|
574
|
+
while ancestor is not None and ancestor != ancestor.parent:
|
|
575
|
+
if (ancestor / ".git").exists():
|
|
576
|
+
planned_method = "git_mv"
|
|
577
|
+
break
|
|
578
|
+
ancestor = ancestor.parent
|
|
579
|
+
|
|
580
|
+
return {
|
|
581
|
+
"old_path": str(source_dir),
|
|
582
|
+
"planned_new_path": str(target_dir),
|
|
583
|
+
"planned_method": planned_method,
|
|
584
|
+
"collision_suffix": suffix,
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
def list_projects(self) -> list[dict[str, Any]]:
|
|
588
|
+
"""List all configured projects.
|
|
589
|
+
|
|
590
|
+
Returns:
|
|
591
|
+
List of dicts with project details (token masked).
|
|
592
|
+
"""
|
|
593
|
+
config = self._config_store.load()
|
|
594
|
+
result = []
|
|
595
|
+
for alias, project in config.projects.items():
|
|
596
|
+
result.append(
|
|
597
|
+
{
|
|
598
|
+
"alias": alias,
|
|
599
|
+
"project_name": project.project_name,
|
|
600
|
+
"project_id": project.project_id,
|
|
601
|
+
"stack_url": project.stack_url,
|
|
602
|
+
"token": mask_token(project.token),
|
|
603
|
+
"is_default": alias == config.default_project,
|
|
604
|
+
"active_branch_id": project.active_branch_id,
|
|
605
|
+
"org_id": project.org_id,
|
|
606
|
+
"org_name": project.org_name,
|
|
607
|
+
}
|
|
608
|
+
)
|
|
609
|
+
return result
|
|
610
|
+
|
|
611
|
+
def _backfill_org_info(
|
|
612
|
+
self,
|
|
613
|
+
successes: list[tuple[str, dict[str, Any], bool]],
|
|
614
|
+
) -> None:
|
|
615
|
+
"""Persist newly-discovered org info for projects that lack it.
|
|
616
|
+
|
|
617
|
+
Runs serially after the parallel status check so we never write to the
|
|
618
|
+
config JSON from multiple worker threads. A single ``load() -> mutate
|
|
619
|
+
-> save()`` pass updates every alias whose stored org info is empty
|
|
620
|
+
but verify_token just returned a populated organization.
|
|
621
|
+
|
|
622
|
+
No-op when no project needs an update -- common steady-state once the
|
|
623
|
+
backfill has run once.
|
|
624
|
+
"""
|
|
625
|
+
# Build the update set first; skip the file write entirely if empty.
|
|
626
|
+
updates: dict[str, tuple[int | None, str | None]] = {}
|
|
627
|
+
for _alias, status_entry, _flag in successes:
|
|
628
|
+
if status_entry.get("status") != "ok":
|
|
629
|
+
continue
|
|
630
|
+
alias = status_entry["alias"]
|
|
631
|
+
new_id = status_entry.get("org_id")
|
|
632
|
+
new_name = status_entry.get("org_name")
|
|
633
|
+
if new_id is None and not new_name:
|
|
634
|
+
continue # verify_token didn't return org info -- nothing to backfill
|
|
635
|
+
current = self._config_store.get_project(alias)
|
|
636
|
+
if current is None:
|
|
637
|
+
continue
|
|
638
|
+
if current.ephemeral:
|
|
639
|
+
# Env-synthesized __env__ (issue #359): its org info can never
|
|
640
|
+
# be persisted (save() strips it), so backfilling is futile and
|
|
641
|
+
# would trigger a spurious config.json write on disk -- breaking
|
|
642
|
+
# the "no config.json in headless mode" guarantee and repeating
|
|
643
|
+
# on every `project status`. Skip it.
|
|
644
|
+
continue
|
|
645
|
+
if current.org_id is not None and current.org_name:
|
|
646
|
+
continue # already populated; skip
|
|
647
|
+
updates[alias] = (new_id, new_name)
|
|
648
|
+
|
|
649
|
+
if not updates:
|
|
650
|
+
return
|
|
651
|
+
|
|
652
|
+
# Single transactional pass: load once, mutate all in-memory, save once.
|
|
653
|
+
config = self._config_store.load()
|
|
654
|
+
for alias, (new_id, new_name) in updates.items():
|
|
655
|
+
if alias not in config.projects:
|
|
656
|
+
continue
|
|
657
|
+
project = config.projects[alias]
|
|
658
|
+
if project.org_id is None and new_id is not None:
|
|
659
|
+
project.org_id = new_id
|
|
660
|
+
if not project.org_name and new_name:
|
|
661
|
+
project.org_name = new_name
|
|
662
|
+
self._config_store.save(config)
|
|
663
|
+
|
|
664
|
+
def _check_project_status(
|
|
665
|
+
self, alias: str, project: ProjectConfig
|
|
666
|
+
) -> tuple[str, dict[str, Any], bool] | tuple[str, dict[str, str]]:
|
|
667
|
+
"""Check connectivity for a single project (runs in a worker thread).
|
|
668
|
+
|
|
669
|
+
Creates its own KeboolaClient, verifies the token, and measures response time.
|
|
670
|
+
Returns (alias, status_entry) on both success AND KeboolaApiError (since an
|
|
671
|
+
API error still produces a valid status entry with status="error").
|
|
672
|
+
Only truly unexpected exceptions return a 2-tuple error.
|
|
673
|
+
|
|
674
|
+
Note: For this worker, both success and KeboolaApiError return 3-tuples
|
|
675
|
+
(alias, status_entry, True) to distinguish from error 2-tuples in _run_parallel.
|
|
676
|
+
"""
|
|
677
|
+
status_entry: dict[str, Any] = {
|
|
678
|
+
"alias": alias,
|
|
679
|
+
"stack_url": project.stack_url,
|
|
680
|
+
"token": mask_token(project.token),
|
|
681
|
+
"active_branch_id": project.active_branch_id,
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
client = self._client_factory(project.stack_url, project.token)
|
|
685
|
+
start_time = time.monotonic()
|
|
686
|
+
try:
|
|
687
|
+
token_info = client.verify_token()
|
|
688
|
+
elapsed = time.monotonic() - start_time
|
|
689
|
+
status_entry["status"] = "ok"
|
|
690
|
+
status_entry["response_time_ms"] = round(elapsed * 1000)
|
|
691
|
+
status_entry["project_name"] = token_info.project_name
|
|
692
|
+
status_entry["project_id"] = token_info.project_id
|
|
693
|
+
# Carry org info through to the aggregator so get_status() can
|
|
694
|
+
# opportunistically backfill projects registered before owner.
|
|
695
|
+
# organization was being captured (e.g. before #290).
|
|
696
|
+
status_entry["org_id"] = token_info.org_id
|
|
697
|
+
status_entry["org_name"] = token_info.org_name
|
|
698
|
+
return (alias, status_entry, True)
|
|
699
|
+
except KeboolaApiError as exc:
|
|
700
|
+
elapsed = time.monotonic() - start_time
|
|
701
|
+
status_entry["status"] = "error"
|
|
702
|
+
status_entry["response_time_ms"] = round(elapsed * 1000)
|
|
703
|
+
status_entry["error"] = exc.message
|
|
704
|
+
status_entry["error_code"] = exc.error_code
|
|
705
|
+
return (alias, status_entry, True)
|
|
706
|
+
except Exception as exc:
|
|
707
|
+
return (
|
|
708
|
+
alias,
|
|
709
|
+
{
|
|
710
|
+
"project_alias": alias,
|
|
711
|
+
"error_code": "UNEXPECTED_ERROR",
|
|
712
|
+
"message": str(exc),
|
|
713
|
+
},
|
|
714
|
+
)
|
|
715
|
+
finally:
|
|
716
|
+
client.close()
|
|
717
|
+
|
|
718
|
+
def get_status(self, aliases: list[str] | None = None) -> list[dict[str, Any]]:
|
|
719
|
+
"""Check connectivity status for one or more projects.
|
|
720
|
+
|
|
721
|
+
For each project, verifies the token against the API and measures
|
|
722
|
+
response time in parallel using ThreadPoolExecutor.
|
|
723
|
+
|
|
724
|
+
Args:
|
|
725
|
+
aliases: Specific project aliases to check (None = all projects).
|
|
726
|
+
|
|
727
|
+
Returns:
|
|
728
|
+
List of dicts with status, response time, and project details.
|
|
729
|
+
|
|
730
|
+
Raises:
|
|
731
|
+
ConfigError: If a specified alias does not exist.
|
|
732
|
+
"""
|
|
733
|
+
projects = self.resolve_projects(aliases)
|
|
734
|
+
|
|
735
|
+
successes, errors = self._run_parallel(projects, self._check_project_status)
|
|
736
|
+
|
|
737
|
+
# Extract status entries from successes (3-tuples: alias, status_entry, True)
|
|
738
|
+
results: list[dict[str, Any]] = []
|
|
739
|
+
for _alias, status_entry, _flag in successes:
|
|
740
|
+
results.append(status_entry)
|
|
741
|
+
|
|
742
|
+
# Opportunistic org-info backfill: projects registered before #290 have
|
|
743
|
+
# org_id/org_name=None in config.json even though verify_token now
|
|
744
|
+
# returns them. Apply updates serially here -- _run_parallel uses worker
|
|
745
|
+
# threads and concurrent ConfigStore writes would race on the JSON file.
|
|
746
|
+
self._backfill_org_info(successes)
|
|
747
|
+
|
|
748
|
+
# Convert unexpected errors to status entries
|
|
749
|
+
for error in errors:
|
|
750
|
+
results.append(
|
|
751
|
+
{
|
|
752
|
+
"alias": error["project_alias"],
|
|
753
|
+
"stack_url": "",
|
|
754
|
+
"token": "",
|
|
755
|
+
"status": "error",
|
|
756
|
+
"response_time_ms": 0,
|
|
757
|
+
"error": error["message"],
|
|
758
|
+
"error_code": error["error_code"],
|
|
759
|
+
}
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
# Sort for deterministic output
|
|
763
|
+
results.sort(key=lambda r: r.get("alias", ""))
|
|
764
|
+
|
|
765
|
+
return results
|
|
766
|
+
|
|
767
|
+
def use_project(self, alias: str) -> dict[str, Any]:
|
|
768
|
+
"""Pin an alias as the persistent default project.
|
|
769
|
+
|
|
770
|
+
The pin is stored as ``config.default_project`` in config.json.
|
|
771
|
+
It is overridden at runtime by the ``KBAGENT_PROJECT`` env var and by
|
|
772
|
+
explicit ``--project`` flags.
|
|
773
|
+
|
|
774
|
+
Args:
|
|
775
|
+
alias: The project alias to pin.
|
|
776
|
+
|
|
777
|
+
Returns:
|
|
778
|
+
Dict with the new pin, previous pin, and source.
|
|
779
|
+
|
|
780
|
+
Raises:
|
|
781
|
+
ConfigError: If the alias does not exist.
|
|
782
|
+
"""
|
|
783
|
+
config = self._config_store.load()
|
|
784
|
+
if alias not in config.projects:
|
|
785
|
+
raise ConfigError(f"Project '{alias}' not found.")
|
|
786
|
+
|
|
787
|
+
previous = config.default_project or None
|
|
788
|
+
config.default_project = alias
|
|
789
|
+
self._config_store.save(config)
|
|
790
|
+
|
|
791
|
+
env_override = os.environ.get(ENV_KBAGENT_PROJECT)
|
|
792
|
+
return {
|
|
793
|
+
"alias": alias,
|
|
794
|
+
"previous": previous,
|
|
795
|
+
"source": "pin",
|
|
796
|
+
"env_override": env_override or None,
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
def current_project(self) -> dict[str, Any]:
|
|
800
|
+
"""Report the effective default project and its source.
|
|
801
|
+
|
|
802
|
+
Resolution:
|
|
803
|
+
- If ``KBAGENT_PROJECT`` is set, it wins (source=env).
|
|
804
|
+
- Otherwise the persisted pin wins (source=pin).
|
|
805
|
+
- If neither is set, ``alias`` is ``None``.
|
|
806
|
+
|
|
807
|
+
The env override is reported even when it points at a project that is
|
|
808
|
+
not (yet) registered in config.json -- callers get the true effective
|
|
809
|
+
alias plus an ``env_points_to_configured_project`` flag to reason about
|
|
810
|
+
it. This avoids silently masking misconfigurations.
|
|
811
|
+
|
|
812
|
+
Returns:
|
|
813
|
+
Dict with keys: alias, source ('env' | 'pin' | 'none'), pinned,
|
|
814
|
+
env_override, env_points_to_configured_project.
|
|
815
|
+
"""
|
|
816
|
+
config = self._config_store.load()
|
|
817
|
+
pinned = config.default_project or None
|
|
818
|
+
# Treat KBAGENT_PROJECT="" the same as unset (Unix shell convention:
|
|
819
|
+
# empty env is commonly produced by `unset` substitutes / blank
|
|
820
|
+
# exports). Strict rejection would surprise CI users who export it
|
|
821
|
+
# conditionally. Apply consistently in resolve_pinned_alias().
|
|
822
|
+
env_value = os.environ.get(ENV_KBAGENT_PROJECT)
|
|
823
|
+
env_override = env_value if env_value else None
|
|
824
|
+
|
|
825
|
+
if env_override is not None:
|
|
826
|
+
return {
|
|
827
|
+
"alias": env_override,
|
|
828
|
+
"source": "env",
|
|
829
|
+
"pinned": pinned,
|
|
830
|
+
"env_override": env_override,
|
|
831
|
+
"env_points_to_configured_project": env_override in config.projects,
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
return {
|
|
835
|
+
"alias": pinned,
|
|
836
|
+
"source": "pin" if pinned else "none",
|
|
837
|
+
"pinned": pinned,
|
|
838
|
+
"env_override": None,
|
|
839
|
+
"env_points_to_configured_project": None,
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
def get_info(self, alias: str) -> dict[str, Any]:
|
|
843
|
+
"""Return detailed project metadata for a single project.
|
|
844
|
+
|
|
845
|
+
Calls /v2/storage/tokens/verify and formats the full response
|
|
846
|
+
into a structured dict suitable for both JSON and human output.
|
|
847
|
+
|
|
848
|
+
Args:
|
|
849
|
+
alias: The project alias to query.
|
|
850
|
+
|
|
851
|
+
Returns:
|
|
852
|
+
Dict with project_id, project_name, stack_url, default_backend,
|
|
853
|
+
features, limits, metrics, token_id, token_description,
|
|
854
|
+
is_master_token, token_expires, and description fields.
|
|
855
|
+
|
|
856
|
+
Raises:
|
|
857
|
+
ConfigError: If the alias does not exist.
|
|
858
|
+
KeboolaApiError: If the API call fails.
|
|
859
|
+
"""
|
|
860
|
+
project = self._config_store.get_project(alias)
|
|
861
|
+
if project is None:
|
|
862
|
+
raise ConfigError(f"Project '{alias}' not found.")
|
|
863
|
+
|
|
864
|
+
client = self._client_factory(project.stack_url, project.token)
|
|
865
|
+
try:
|
|
866
|
+
raw = client.get_project_info()
|
|
867
|
+
finally:
|
|
868
|
+
client.close()
|
|
869
|
+
|
|
870
|
+
owner = raw.get("owner", {})
|
|
871
|
+
return {
|
|
872
|
+
"alias": alias,
|
|
873
|
+
"project_id": owner.get("id"),
|
|
874
|
+
"project_name": owner.get("name", ""),
|
|
875
|
+
"stack_url": project.stack_url,
|
|
876
|
+
"default_backend": owner.get("defaultBackend", "snowflake"),
|
|
877
|
+
"features": owner.get("features", []),
|
|
878
|
+
"limits": owner.get("limits", {}),
|
|
879
|
+
"metrics": owner.get("metrics", {}),
|
|
880
|
+
"token_id": str(raw.get("id", "")),
|
|
881
|
+
"token_description": raw.get("description", ""),
|
|
882
|
+
"is_master_token": raw.get("isMasterToken", False),
|
|
883
|
+
"token_expires": raw.get("expires"),
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
def resolve_pinned_alias(self, explicit: str | None = None) -> tuple[str, str]:
|
|
887
|
+
"""Resolve the effective project alias for a single-project operation.
|
|
888
|
+
|
|
889
|
+
Precedence (first match wins):
|
|
890
|
+
1. ``explicit`` argument (typically the CLI ``--project`` flag)
|
|
891
|
+
2. ``KBAGENT_PROJECT`` env var
|
|
892
|
+
3. Persisted ``default_project`` pin
|
|
893
|
+
4. If exactly one project is registered, fall back to it (source=sole)
|
|
894
|
+
5. Fail hard with ConfigError
|
|
895
|
+
|
|
896
|
+
This is the single-project analog of ``resolve_projects()`` (which
|
|
897
|
+
fans out to all projects). Use this from write/destructive command
|
|
898
|
+
paths where fan-out would be surprising or unsafe.
|
|
899
|
+
|
|
900
|
+
Args:
|
|
901
|
+
explicit: Explicit alias from a CLI flag, or None.
|
|
902
|
+
|
|
903
|
+
Returns:
|
|
904
|
+
Tuple of (alias, source).
|
|
905
|
+
|
|
906
|
+
Raises:
|
|
907
|
+
ConfigError: If the resolved alias is not registered, or if none
|
|
908
|
+
can be resolved.
|
|
909
|
+
"""
|
|
910
|
+
config = self._config_store.load()
|
|
911
|
+
|
|
912
|
+
if explicit:
|
|
913
|
+
if explicit not in config.projects:
|
|
914
|
+
raise ConfigError(f"Project '{explicit}' not found.")
|
|
915
|
+
return explicit, "explicit"
|
|
916
|
+
|
|
917
|
+
env_value = os.environ.get(ENV_KBAGENT_PROJECT)
|
|
918
|
+
if env_value:
|
|
919
|
+
if env_value not in config.projects:
|
|
920
|
+
raise ConfigError(
|
|
921
|
+
f"{ENV_KBAGENT_PROJECT}='{env_value}' points to a project "
|
|
922
|
+
"that is not registered. Use 'kbagent project add' or "
|
|
923
|
+
"unset the env var."
|
|
924
|
+
)
|
|
925
|
+
return env_value, "env"
|
|
926
|
+
|
|
927
|
+
pinned = config.default_project
|
|
928
|
+
if pinned:
|
|
929
|
+
if pinned not in config.projects:
|
|
930
|
+
raise ConfigError(
|
|
931
|
+
f"Pinned default project '{pinned}' is not registered. "
|
|
932
|
+
"Run 'kbagent project use <alias>' to repair."
|
|
933
|
+
)
|
|
934
|
+
return pinned, "pin"
|
|
935
|
+
|
|
936
|
+
if len(config.projects) == 1:
|
|
937
|
+
(sole,) = config.projects.keys()
|
|
938
|
+
return sole, "sole"
|
|
939
|
+
|
|
940
|
+
if not config.projects:
|
|
941
|
+
raise ConfigError("No projects configured. Run 'kbagent project add' first.")
|
|
942
|
+
|
|
943
|
+
raise ConfigError(
|
|
944
|
+
"Multiple projects configured and no default pinned. "
|
|
945
|
+
"Pass --project <alias>, set KBAGENT_PROJECT, or run "
|
|
946
|
+
"'kbagent project use <alias>'."
|
|
947
|
+
)
|