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,1497 @@
|
|
|
1
|
+
"""Semantic-layer service — business logic for ``kbagent semantic-layer``.
|
|
2
|
+
|
|
3
|
+
Composes :class:`MetastoreClient` primitives into the high-level operations
|
|
4
|
+
exposed by the CLI: model resolution, show/validate/export/diff (read),
|
|
5
|
+
add/edit/import/promote/build (write), remove (destructive), and the
|
|
6
|
+
token-encryption helper.
|
|
7
|
+
|
|
8
|
+
All API calls go through this service; the command layer only formats inputs
|
|
9
|
+
and outputs. Following the project's BaseService pattern, the metastore
|
|
10
|
+
client is created via an injected ``metastore_client_factory`` so unit tests
|
|
11
|
+
can swap in a :class:`unittest.mock.MagicMock`.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import re
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, ClassVar
|
|
23
|
+
|
|
24
|
+
from ..config_store import ConfigStore
|
|
25
|
+
from ..errors import ConfigError, ErrorCode, KeboolaApiError
|
|
26
|
+
from ..metastore_client import MetastoreClient, SemanticType
|
|
27
|
+
from ..models import ProjectConfig
|
|
28
|
+
from . import _semantic_layer_reference_data as _refdata
|
|
29
|
+
from ._semantic_layer_cascade import cascade_delete_model as _cascade_delete_model_impl
|
|
30
|
+
from ._semantic_layer_crud import REMOVE_KINDS as _REMOVE_KINDS_HELPER
|
|
31
|
+
from ._semantic_layer_crud import code_metric as _code_metric_helper
|
|
32
|
+
from ._semantic_layer_crud import delete_then_post as _delete_then_post_helper
|
|
33
|
+
from ._semantic_layer_crud import edit_metric_with_cascade as _edit_metric_helper
|
|
34
|
+
from ._semantic_layer_crud import edit_simple as _edit_simple_helper
|
|
35
|
+
from ._semantic_layer_crud import find_target_for_remove as _find_target_for_remove
|
|
36
|
+
from ._semantic_layer_crud import scan_orphan_constraints as _scan_orphan_constraints
|
|
37
|
+
from ._semantic_layer_crud import validate_constraint_attrs as _validate_constraint_attrs
|
|
38
|
+
from ._semantic_layer_internals import build_export_snapshot as _build_export_snapshot
|
|
39
|
+
from ._semantic_layer_internals import collect_side_from_file
|
|
40
|
+
from ._semantic_layer_internals import default_export_path as _default_export_path
|
|
41
|
+
from ._semantic_layer_internals import diff_one_type as _diff_one_type_helper
|
|
42
|
+
from ._semantic_layer_internals import fetch_table_schemas as _fetch_table_schemas
|
|
43
|
+
from ._semantic_layer_internals import heuristic_generate_model as _heuristic_generate_helper
|
|
44
|
+
from ._semantic_layer_internals import push_built_model as _push_built_model
|
|
45
|
+
from ._semantic_layer_internals import resolve_model_uuid as _resolve_model_uuid
|
|
46
|
+
from ._semantic_layer_internals import run_import_loop as _run_import_loop
|
|
47
|
+
from ._semantic_layer_internals import run_promote_loop as _run_promote_loop
|
|
48
|
+
from ._semantic_layer_internals import (
|
|
49
|
+
synthesize_role_classified_fields as _synthesize_role_classified_fields,
|
|
50
|
+
)
|
|
51
|
+
from ._semantic_layer_internals import unpack_attrs_with_id as _unpack_attrs_with_id
|
|
52
|
+
from ._semantic_layer_internals import unpack_children_by_plural as _unpack_children_by_plural
|
|
53
|
+
from ._semantic_layer_internals import validate_basic as _validate_basic_helper
|
|
54
|
+
from ._semantic_layer_internals import validate_deep as _validate_deep_helper
|
|
55
|
+
from ._semantic_layer_internals import write_snapshot_to_file as _write_snapshot_to_file
|
|
56
|
+
from ._semantic_layer_lookup import run_get_context as _run_get_context_helper
|
|
57
|
+
from ._semantic_layer_lookup import run_search_context as _run_search_context_helper
|
|
58
|
+
from .base import BaseService, ClientFactory
|
|
59
|
+
from .encrypt_service import EncryptService
|
|
60
|
+
from .storage_service import StorageService
|
|
61
|
+
|
|
62
|
+
# Constraint name regex enforced by the metastore server.
|
|
63
|
+
CONSTRAINT_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*$")
|
|
64
|
+
|
|
65
|
+
# Constraint type enum (closed set on the server).
|
|
66
|
+
CONSTRAINT_TYPES: tuple[str, ...] = (
|
|
67
|
+
"inequality",
|
|
68
|
+
"equality",
|
|
69
|
+
"range",
|
|
70
|
+
"composition",
|
|
71
|
+
"exclusion",
|
|
72
|
+
"temporal",
|
|
73
|
+
"conditional",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Constraint severity enum (3 values; the 4-band health suffix lives in
|
|
77
|
+
# the name, not severity).
|
|
78
|
+
CONSTRAINT_SEVERITIES: tuple[str, ...] = ("error", "warning", "info")
|
|
79
|
+
|
|
80
|
+
# Role heuristics for `add dataset --deep-fields`.
|
|
81
|
+
_KEY_PREFIXES = ("PK_", "FK_")
|
|
82
|
+
_TIMESTAMP_NAMES = ("_DATE", "DATE_", "INS_DT", "UPD_DT")
|
|
83
|
+
_MEASURE_TOKENS = (
|
|
84
|
+
"AMOUNT",
|
|
85
|
+
"VALUE",
|
|
86
|
+
"TOTAL",
|
|
87
|
+
"REVENUE",
|
|
88
|
+
"COST",
|
|
89
|
+
"PRICE",
|
|
90
|
+
"RATE",
|
|
91
|
+
"PCT",
|
|
92
|
+
"PERCENT",
|
|
93
|
+
"COUNT",
|
|
94
|
+
"QTY",
|
|
95
|
+
"QUANTITY",
|
|
96
|
+
)
|
|
97
|
+
_NUMERIC_TYPES = ("NUMBER", "DECIMAL", "FLOAT", "INTEGER", "INT")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _derive_fqn(table_id: str) -> str:
|
|
101
|
+
"""Compute ``"KEBOOLA"."<schema>"."<table>"`` from a Keboola tableId.
|
|
102
|
+
|
|
103
|
+
The Keboola Snowflake mapping is ``"KEBOOLA"."<bucket-path>"."<table>"``;
|
|
104
|
+
we split off the last segment as the table and quote both remaining
|
|
105
|
+
pieces as a single schema string.
|
|
106
|
+
"""
|
|
107
|
+
if '"' in table_id:
|
|
108
|
+
# Reject double-quotes in tableIds at the service boundary: the FQN
|
|
109
|
+
# is stored verbatim in the metastore and pasted into Snowflake SQL by
|
|
110
|
+
# downstream consumers. A `"` inside a segment would terminate a quoted
|
|
111
|
+
# identifier early and let an attacker steer parsing — defense in depth
|
|
112
|
+
# even though Keboola Storage would reject the bucket/table at creation.
|
|
113
|
+
raise KeboolaApiError(
|
|
114
|
+
message=(
|
|
115
|
+
f"tableId {table_id!r} contains a double-quote, which is "
|
|
116
|
+
"rejected at the FQN-derivation boundary because the FQN is "
|
|
117
|
+
"pasted into Snowflake SQL by downstream consumers."
|
|
118
|
+
),
|
|
119
|
+
error_code=ErrorCode.VALIDATION_ERROR,
|
|
120
|
+
)
|
|
121
|
+
parts = table_id.split(".")
|
|
122
|
+
if len(parts) < 2:
|
|
123
|
+
raise KeboolaApiError(
|
|
124
|
+
message=f"tableId {table_id!r} must contain at least one dot.",
|
|
125
|
+
error_code=ErrorCode.VALIDATION_ERROR,
|
|
126
|
+
)
|
|
127
|
+
schema = ".".join(parts[:-1])
|
|
128
|
+
table = parts[-1]
|
|
129
|
+
return f'"KEBOOLA"."{schema}"."{table}"'
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _classify_field_role(name: str, basetype: str) -> str:
|
|
133
|
+
"""Apply the documented role heuristic to (column name, basetype)."""
|
|
134
|
+
upper = name.upper()
|
|
135
|
+
if any(upper.startswith(prefix) for prefix in _KEY_PREFIXES):
|
|
136
|
+
return "key"
|
|
137
|
+
if any(tok in upper for tok in _TIMESTAMP_NAMES):
|
|
138
|
+
return "timestamp"
|
|
139
|
+
if basetype.upper() in _NUMERIC_TYPES and any(tok in upper for tok in _MEASURE_TOKENS):
|
|
140
|
+
return "measure"
|
|
141
|
+
return "dimension"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# Mapping from CLI singular ``--type`` filter to the wire-level
|
|
145
|
+
# ``semantic-<type>`` slug. Centralized so commands stay free of magic strings.
|
|
146
|
+
TYPE_ALIAS: dict[str, SemanticType] = {
|
|
147
|
+
"dataset": "semantic-dataset",
|
|
148
|
+
"metric": "semantic-metric",
|
|
149
|
+
"relationship": "semantic-relationship",
|
|
150
|
+
"constraint": "semantic-constraint",
|
|
151
|
+
"glossary": "semantic-glossary",
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
# Child-types order used everywhere we fan out per-type fetches.
|
|
155
|
+
CHILD_TYPES: tuple[SemanticType, ...] = (
|
|
156
|
+
"semantic-dataset",
|
|
157
|
+
"semantic-metric",
|
|
158
|
+
"semantic-relationship",
|
|
159
|
+
"semantic-constraint",
|
|
160
|
+
"semantic-glossary",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# Plural keys used in snapshot envelopes and accepted by ``--types`` filters
|
|
164
|
+
# on ``import`` and ``promote``. Must mirror the keys produced by
|
|
165
|
+
# :meth:`SemanticLayerService.export`.
|
|
166
|
+
PLURAL_TYPES: tuple[str, ...] = (
|
|
167
|
+
"datasets",
|
|
168
|
+
"metrics",
|
|
169
|
+
"relationships",
|
|
170
|
+
"constraints",
|
|
171
|
+
"glossary",
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _validate_types_filter(types: list[str] | None) -> set[str] | None:
|
|
176
|
+
"""Validate ``--types`` against the known plural list, return a set or None.
|
|
177
|
+
|
|
178
|
+
Closes the silent no-op trap where a typo like ``--types BOGUS`` would
|
|
179
|
+
filter every type out and emit zero imports without an error.
|
|
180
|
+
"""
|
|
181
|
+
if not types:
|
|
182
|
+
return None
|
|
183
|
+
unknown = [t for t in types if t not in PLURAL_TYPES]
|
|
184
|
+
if unknown:
|
|
185
|
+
raise KeboolaApiError(
|
|
186
|
+
message=(
|
|
187
|
+
f"--types {unknown!r} not recognised. Must be a subset of {list(PLURAL_TYPES)}."
|
|
188
|
+
),
|
|
189
|
+
error_code=ErrorCode.VALIDATION_ERROR,
|
|
190
|
+
)
|
|
191
|
+
return set(types)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
logger = logging.getLogger(__name__)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
MetastoreClientFactory = Callable[[str, str], MetastoreClient]
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def default_metastore_client_factory(stack_url: str, token: str) -> MetastoreClient:
|
|
201
|
+
"""Build a :class:`MetastoreClient` for the given project."""
|
|
202
|
+
return MetastoreClient(stack_url=stack_url, token=token)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class SemanticLayerService(BaseService):
|
|
206
|
+
"""Business logic for the semantic-layer command group.
|
|
207
|
+
|
|
208
|
+
Inherits multi-project resolution (``resolve_projects``) and the
|
|
209
|
+
parallel worker scaffold (``_run_parallel``) from :class:`BaseService`.
|
|
210
|
+
Adds a dedicated :class:`MetastoreClient` factory so command-layer
|
|
211
|
+
operations can target the metastore without polluting the Storage API
|
|
212
|
+
client.
|
|
213
|
+
|
|
214
|
+
Cross-project operations (``promote``) hold **two** clients in a
|
|
215
|
+
``try/finally`` and close both even on failure.
|
|
216
|
+
"""
|
|
217
|
+
|
|
218
|
+
def __init__(
|
|
219
|
+
self,
|
|
220
|
+
config_store: ConfigStore,
|
|
221
|
+
client_factory: ClientFactory | None = None,
|
|
222
|
+
metastore_client_factory: MetastoreClientFactory | None = None,
|
|
223
|
+
) -> None:
|
|
224
|
+
super().__init__(config_store=config_store, client_factory=client_factory)
|
|
225
|
+
self._metastore_factory: MetastoreClientFactory = (
|
|
226
|
+
metastore_client_factory or default_metastore_client_factory
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
# Helpers (used by every subcommand).
|
|
230
|
+
def _resolve_one_project(self, alias: str) -> ProjectConfig:
|
|
231
|
+
"""Resolve a single project alias to its ``ProjectConfig`` or raise.
|
|
232
|
+
|
|
233
|
+
:meth:`BaseService.resolve_projects` already raises ``ConfigError`` for
|
|
234
|
+
missing aliases, so we just unwrap.
|
|
235
|
+
"""
|
|
236
|
+
return self.resolve_projects([alias])[alias]
|
|
237
|
+
|
|
238
|
+
def _new_metastore_client(self, project: ProjectConfig) -> MetastoreClient:
|
|
239
|
+
"""Build a fresh metastore client. Caller is responsible for ``close()``."""
|
|
240
|
+
return self._metastore_factory(project.stack_url, project.token)
|
|
241
|
+
|
|
242
|
+
def _resolve_model(
|
|
243
|
+
self, client: MetastoreClient, model_name_or_uuid: str | None
|
|
244
|
+
) -> tuple[str, dict[str, Any]]:
|
|
245
|
+
"""Resolve a model selector via :func:`._semantic_layer_internals.resolve_model_uuid`."""
|
|
246
|
+
return _resolve_model_uuid(client, model_name_or_uuid)
|
|
247
|
+
|
|
248
|
+
# Phase 3 — Read commands.
|
|
249
|
+
def list_models(self, alias: str) -> dict[str, Any]:
|
|
250
|
+
"""List all semantic-layer models for a project.
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
Dict with ``project`` and ``models`` (list of
|
|
254
|
+
``{id, name, description, sql_dialect}``).
|
|
255
|
+
"""
|
|
256
|
+
project = self._resolve_one_project(alias)
|
|
257
|
+
with self._new_metastore_client(project) as client:
|
|
258
|
+
raw = client.list_items("semantic-model")
|
|
259
|
+
|
|
260
|
+
models: list[dict[str, Any]] = []
|
|
261
|
+
for item in raw:
|
|
262
|
+
attrs = item.get("attributes") or {}
|
|
263
|
+
models.append(
|
|
264
|
+
{
|
|
265
|
+
"id": item.get("id", ""),
|
|
266
|
+
"name": attrs.get("name", ""),
|
|
267
|
+
"description": attrs.get("description", ""),
|
|
268
|
+
"sql_dialect": attrs.get("sql_dialect", ""),
|
|
269
|
+
}
|
|
270
|
+
)
|
|
271
|
+
return {"project": alias, "models": models}
|
|
272
|
+
|
|
273
|
+
def search_context(
|
|
274
|
+
self,
|
|
275
|
+
alias: str,
|
|
276
|
+
patterns: list[str] | None = None,
|
|
277
|
+
type_filter: str | None = None,
|
|
278
|
+
limit: int | None = None,
|
|
279
|
+
) -> dict[str, Any]:
|
|
280
|
+
"""Project-wide glob search; see :func:`_semantic_layer_lookup.run_search_context`."""
|
|
281
|
+
return _run_search_context_helper(
|
|
282
|
+
open_client=lambda: self._new_metastore_client(self._resolve_one_project(alias)),
|
|
283
|
+
alias=alias,
|
|
284
|
+
child_types=CHILD_TYPES,
|
|
285
|
+
type_alias=TYPE_ALIAS,
|
|
286
|
+
patterns=patterns,
|
|
287
|
+
type_filter=type_filter,
|
|
288
|
+
limit=limit,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
def get_context(self, alias: str, context_id: str) -> dict[str, Any]:
|
|
292
|
+
"""Single-id lookup; see :func:`_semantic_layer_lookup.run_get_context`."""
|
|
293
|
+
return _run_get_context_helper(
|
|
294
|
+
open_client=lambda: self._new_metastore_client(self._resolve_one_project(alias)),
|
|
295
|
+
alias=alias,
|
|
296
|
+
child_types=CHILD_TYPES,
|
|
297
|
+
context_id=context_id,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
# Internal helpers (model-scoped fetches).
|
|
301
|
+
|
|
302
|
+
@staticmethod
|
|
303
|
+
def _fetch_children_parallel(
|
|
304
|
+
client: MetastoreClient,
|
|
305
|
+
model_uuid: str,
|
|
306
|
+
) -> dict[SemanticType, list[dict[str, Any]]]:
|
|
307
|
+
"""Fetch all child entity types in parallel, filtered to one model.
|
|
308
|
+
|
|
309
|
+
Each result is the raw item list (full ``{type, id, attributes, meta}``).
|
|
310
|
+
Errors propagate to the caller (we re-raise the first encountered).
|
|
311
|
+
"""
|
|
312
|
+
results: dict[SemanticType, list[dict[str, Any]]] = {}
|
|
313
|
+
with ThreadPoolExecutor(max_workers=5) as pool:
|
|
314
|
+
future_to_type = {pool.submit(client.list_items, t, model_uuid): t for t in CHILD_TYPES}
|
|
315
|
+
errors: list[Exception] = []
|
|
316
|
+
for future in future_to_type:
|
|
317
|
+
try:
|
|
318
|
+
results[future_to_type[future]] = future.result()
|
|
319
|
+
# Future.result() re-raises arbitrary worker exceptions
|
|
320
|
+
# (KeboolaApiError, httpx errors, etc.); collect and
|
|
321
|
+
# surface the first one rather than masking the others.
|
|
322
|
+
except Exception as exc:
|
|
323
|
+
errors.append(exc)
|
|
324
|
+
if errors:
|
|
325
|
+
raise errors[0]
|
|
326
|
+
return results
|
|
327
|
+
|
|
328
|
+
# ------------------------------------------------------------------
|
|
329
|
+
# Phase 3 — show
|
|
330
|
+
# ------------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
# Plural-key alias used by show_model's --type filter.
|
|
333
|
+
_PLURAL_BY_TYPE: ClassVar[dict[str, str]] = {
|
|
334
|
+
"dataset": "datasets",
|
|
335
|
+
"metric": "metrics",
|
|
336
|
+
"relationship": "relationships",
|
|
337
|
+
"constraint": "constraints",
|
|
338
|
+
"glossary": "glossary",
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
def show_model(
|
|
342
|
+
self,
|
|
343
|
+
alias: str,
|
|
344
|
+
model_name_or_uuid: str | None = None,
|
|
345
|
+
type_filter: str | None = None,
|
|
346
|
+
) -> dict[str, Any]:
|
|
347
|
+
"""Return the entities in a model. See ``--type`` for filtering."""
|
|
348
|
+
if type_filter is not None and type_filter not in TYPE_ALIAS:
|
|
349
|
+
raise KeboolaApiError(
|
|
350
|
+
message=(
|
|
351
|
+
f"Invalid --type {type_filter!r}. Must be one of: "
|
|
352
|
+
f"{', '.join(sorted(TYPE_ALIAS))}."
|
|
353
|
+
),
|
|
354
|
+
error_code=ErrorCode.VALIDATION_ERROR,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
project = self._resolve_one_project(alias)
|
|
358
|
+
with self._new_metastore_client(project) as client:
|
|
359
|
+
model_uuid, model_attrs = self._resolve_model(client, model_name_or_uuid)
|
|
360
|
+
raw_by_type = self._fetch_children_parallel(client, model_uuid)
|
|
361
|
+
|
|
362
|
+
result: dict[str, Any] = {
|
|
363
|
+
"project": alias,
|
|
364
|
+
"model": {"id": model_uuid, "name": model_attrs.get("name", "")},
|
|
365
|
+
**_unpack_children_by_plural(raw_by_type),
|
|
366
|
+
}
|
|
367
|
+
if type_filter is not None:
|
|
368
|
+
plural = self._PLURAL_BY_TYPE[type_filter]
|
|
369
|
+
for k in set(self._PLURAL_BY_TYPE.values()) - {plural}:
|
|
370
|
+
result.pop(k, None)
|
|
371
|
+
return result
|
|
372
|
+
|
|
373
|
+
# ------------------------------------------------------------------
|
|
374
|
+
# Phase 3 — validate (+ --deep)
|
|
375
|
+
# ------------------------------------------------------------------
|
|
376
|
+
|
|
377
|
+
def validate_model(
|
|
378
|
+
self,
|
|
379
|
+
alias: str,
|
|
380
|
+
model_name_or_uuid: str | None = None,
|
|
381
|
+
deep: bool = False,
|
|
382
|
+
) -> dict[str, Any]:
|
|
383
|
+
"""Validate a semantic-layer model (basic + optional --deep).
|
|
384
|
+
|
|
385
|
+
Returns ``{errors, warnings, deep, valid, model, project}``. See
|
|
386
|
+
:func:`._semantic_layer_internals.validate_basic` and
|
|
387
|
+
:func:`._semantic_layer_internals.validate_deep` for the full
|
|
388
|
+
check inventory.
|
|
389
|
+
"""
|
|
390
|
+
project = self._resolve_one_project(alias)
|
|
391
|
+
with self._new_metastore_client(project) as client:
|
|
392
|
+
model_uuid, model_attrs = self._resolve_model(client, model_name_or_uuid)
|
|
393
|
+
raw_by_type = self._fetch_children_parallel(client, model_uuid)
|
|
394
|
+
|
|
395
|
+
datasets = _unpack_attrs_with_id(raw_by_type.get("semantic-dataset", []))
|
|
396
|
+
metrics = _unpack_attrs_with_id(raw_by_type.get("semantic-metric", []))
|
|
397
|
+
relationships = _unpack_attrs_with_id(raw_by_type.get("semantic-relationship", []))
|
|
398
|
+
constraints = _unpack_attrs_with_id(raw_by_type.get("semantic-constraint", []))
|
|
399
|
+
glossary = _unpack_attrs_with_id(raw_by_type.get("semantic-glossary", []))
|
|
400
|
+
|
|
401
|
+
errors: list[dict[str, str]] = []
|
|
402
|
+
warnings: list[dict[str, str]] = []
|
|
403
|
+
_validate_basic_helper(
|
|
404
|
+
datasets=datasets,
|
|
405
|
+
metrics=metrics,
|
|
406
|
+
relationships=relationships,
|
|
407
|
+
constraints=constraints,
|
|
408
|
+
glossary=glossary,
|
|
409
|
+
errors=errors,
|
|
410
|
+
warnings=warnings,
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
if deep:
|
|
414
|
+
self._validate_deep(
|
|
415
|
+
alias=alias,
|
|
416
|
+
datasets=datasets,
|
|
417
|
+
metrics=metrics,
|
|
418
|
+
errors=errors,
|
|
419
|
+
warnings=warnings,
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
return {
|
|
423
|
+
"project": alias,
|
|
424
|
+
"model": {"id": model_uuid, "name": model_attrs.get("name", "")},
|
|
425
|
+
"errors": errors,
|
|
426
|
+
"warnings": warnings,
|
|
427
|
+
"deep": deep,
|
|
428
|
+
"valid": len(errors) == 0,
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
# -- validate internals --------------------------------------------
|
|
432
|
+
|
|
433
|
+
# Pure in-memory validation lives in
|
|
434
|
+
# :func:`._semantic_layer_internals.validate_basic` (imported above as
|
|
435
|
+
# ``_validate_basic_helper``); call it directly from ``validate_model``
|
|
436
|
+
# / ``build_model`` rather than via a thin wrapper.
|
|
437
|
+
|
|
438
|
+
def _validate_deep(
|
|
439
|
+
self,
|
|
440
|
+
*,
|
|
441
|
+
alias: str,
|
|
442
|
+
datasets: list[dict[str, Any]],
|
|
443
|
+
metrics: list[dict[str, Any]],
|
|
444
|
+
errors: list[dict[str, str]],
|
|
445
|
+
warnings: list[dict[str, str]],
|
|
446
|
+
) -> None:
|
|
447
|
+
"""Add deep checks that require a Snowflake schema fetch per dataset.
|
|
448
|
+
|
|
449
|
+
Thin delegate to :func:`._semantic_layer_internals.validate_deep`.
|
|
450
|
+
"""
|
|
451
|
+
storage = StorageService(
|
|
452
|
+
config_store=self._config_store, client_factory=self._client_factory
|
|
453
|
+
)
|
|
454
|
+
_validate_deep_helper(
|
|
455
|
+
alias=alias,
|
|
456
|
+
storage=storage,
|
|
457
|
+
datasets=datasets,
|
|
458
|
+
metrics=metrics,
|
|
459
|
+
errors=errors,
|
|
460
|
+
warnings=warnings,
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
# ------------------------------------------------------------------
|
|
464
|
+
# Phase 3 — export
|
|
465
|
+
# ------------------------------------------------------------------
|
|
466
|
+
|
|
467
|
+
def export_model(
|
|
468
|
+
self,
|
|
469
|
+
alias: str,
|
|
470
|
+
model_name_or_uuid: str | None = None,
|
|
471
|
+
output_path: Path | None = None,
|
|
472
|
+
) -> dict[str, Any]:
|
|
473
|
+
"""Export a model + every child entity to a self-describing JSON file.
|
|
474
|
+
|
|
475
|
+
Each child item is stored in its full server shape
|
|
476
|
+
(``{type, id, attributes, meta}``) so the file is replayable by
|
|
477
|
+
``import`` and ``promote`` without further negotiation.
|
|
478
|
+
|
|
479
|
+
Args:
|
|
480
|
+
alias: Source project alias.
|
|
481
|
+
model_name_or_uuid: Selector for the source model.
|
|
482
|
+
output_path: Where to write the snapshot. Defaults to
|
|
483
|
+
``./sl_export_{model_name}_{YYYYMMDD_HHMMSS}.json``.
|
|
484
|
+
|
|
485
|
+
Returns:
|
|
486
|
+
``{path, exported_at, project, model, datasets, metrics,
|
|
487
|
+
relationships, constraints, glossary, counts}``.
|
|
488
|
+
"""
|
|
489
|
+
project = self._resolve_one_project(alias)
|
|
490
|
+
with self._new_metastore_client(project) as client:
|
|
491
|
+
model_uuid, model_attrs = self._resolve_model(client, model_name_or_uuid)
|
|
492
|
+
raw_by_type = self._fetch_children_parallel(client, model_uuid)
|
|
493
|
+
|
|
494
|
+
snapshot = _build_export_snapshot(
|
|
495
|
+
alias=alias,
|
|
496
|
+
model_uuid=model_uuid,
|
|
497
|
+
model_attrs=model_attrs,
|
|
498
|
+
raw_by_type=raw_by_type,
|
|
499
|
+
)
|
|
500
|
+
if output_path is None:
|
|
501
|
+
output_path = _default_export_path(str(model_attrs.get("name", "model")))
|
|
502
|
+
_write_snapshot_to_file(snapshot, output_path)
|
|
503
|
+
|
|
504
|
+
result = dict(snapshot)
|
|
505
|
+
result["path"] = str(output_path)
|
|
506
|
+
result["counts"] = {
|
|
507
|
+
"datasets": len(snapshot["datasets"]),
|
|
508
|
+
"metrics": len(snapshot["metrics"]),
|
|
509
|
+
"relationships": len(snapshot["relationships"]),
|
|
510
|
+
"constraints": len(snapshot["constraints"]),
|
|
511
|
+
"glossary": len(snapshot["glossary"]),
|
|
512
|
+
}
|
|
513
|
+
return result
|
|
514
|
+
|
|
515
|
+
# ------------------------------------------------------------------
|
|
516
|
+
# Phase 3 — diff
|
|
517
|
+
# ------------------------------------------------------------------
|
|
518
|
+
|
|
519
|
+
# Diff helpers live in ._semantic_layer_internals (DIFF_IGNORED_KEYS,
|
|
520
|
+
# compare_attrs, diff_one_type, collect_side_from_file) -- imported
|
|
521
|
+
# there so this orchestrator file fits the CONTRIBUTING.md budget.
|
|
522
|
+
|
|
523
|
+
def diff(
|
|
524
|
+
self,
|
|
525
|
+
*,
|
|
526
|
+
project_a: str | None = None,
|
|
527
|
+
project_b: str | None = None,
|
|
528
|
+
model_a: str | None = None,
|
|
529
|
+
model_b: str | None = None,
|
|
530
|
+
file_a: Path | None = None,
|
|
531
|
+
file_b: Path | None = None,
|
|
532
|
+
) -> dict[str, Any]:
|
|
533
|
+
"""Diff two semantic-layer snapshots.
|
|
534
|
+
|
|
535
|
+
Each "side" is either a live project (resolve + fetch) or a file
|
|
536
|
+
produced by :meth:`export_model`. The diff is structural and
|
|
537
|
+
per-entity-type: ``added | removed | changed`` lists. The
|
|
538
|
+
identification key is ``name`` for every type except glossary,
|
|
539
|
+
which uses ``term``.
|
|
540
|
+
|
|
541
|
+
Returns:
|
|
542
|
+
``{left, right, datasets, metrics, relationships, constraints,
|
|
543
|
+
glossary}`` where each per-type entry is
|
|
544
|
+
``{added: [name], removed: [name], changed: [{name, diff_keys}]}``.
|
|
545
|
+
"""
|
|
546
|
+
left = self._collect_side(project=project_a, model=model_a, file=file_a)
|
|
547
|
+
right = self._collect_side(project=project_b, model=model_b, file=file_b)
|
|
548
|
+
|
|
549
|
+
result: dict[str, Any] = {"left": left["ref"], "right": right["ref"]}
|
|
550
|
+
for type_key, id_key in (
|
|
551
|
+
("datasets", "name"),
|
|
552
|
+
("metrics", "name"),
|
|
553
|
+
("relationships", "name"),
|
|
554
|
+
("constraints", "name"),
|
|
555
|
+
("glossary", "term"),
|
|
556
|
+
):
|
|
557
|
+
result[type_key] = _diff_one_type_helper(
|
|
558
|
+
left["data"].get(type_key, []),
|
|
559
|
+
right["data"].get(type_key, []),
|
|
560
|
+
id_key=id_key,
|
|
561
|
+
)
|
|
562
|
+
return result
|
|
563
|
+
|
|
564
|
+
def _collect_side(
|
|
565
|
+
self,
|
|
566
|
+
*,
|
|
567
|
+
project: str | None,
|
|
568
|
+
model: str | None,
|
|
569
|
+
file: Path | None,
|
|
570
|
+
) -> dict[str, Any]:
|
|
571
|
+
"""Resolve one side of a diff to bare ``attributes`` lists.
|
|
572
|
+
|
|
573
|
+
Live-project path stays here (needs ``self.show_model``); the
|
|
574
|
+
file-path branch delegates to
|
|
575
|
+
:func:`._semantic_layer_internals.collect_side_from_file`.
|
|
576
|
+
"""
|
|
577
|
+
if project is not None:
|
|
578
|
+
data = self.show_model(alias=project, model_name_or_uuid=model)
|
|
579
|
+
return {
|
|
580
|
+
"ref": {"source": "project", "ref": project, "model": data.get("model", {})},
|
|
581
|
+
"data": data,
|
|
582
|
+
}
|
|
583
|
+
if file is None:
|
|
584
|
+
raise KeboolaApiError(
|
|
585
|
+
message="Internal: diff side has no project and no file.",
|
|
586
|
+
error_code=ErrorCode.VALIDATION_ERROR,
|
|
587
|
+
)
|
|
588
|
+
return collect_side_from_file(file)
|
|
589
|
+
|
|
590
|
+
# Per-type diff lives in :func:`._semantic_layer_internals.diff_one_type`
|
|
591
|
+
# (imported above as ``_diff_one_type_helper``); call it directly.
|
|
592
|
+
# ``compare_attrs`` is used inside that helper and not separately here.
|
|
593
|
+
|
|
594
|
+
# ------------------------------------------------------------------
|
|
595
|
+
# Phase 4 — Model lifecycle (create / delete)
|
|
596
|
+
# ------------------------------------------------------------------
|
|
597
|
+
|
|
598
|
+
def create_model(
|
|
599
|
+
self,
|
|
600
|
+
alias: str,
|
|
601
|
+
name: str,
|
|
602
|
+
description: str = "",
|
|
603
|
+
sql_dialect: str = "Snowflake",
|
|
604
|
+
) -> dict[str, Any]:
|
|
605
|
+
"""Create a semantic-layer model and return the server-stored item."""
|
|
606
|
+
project = self._resolve_one_project(alias)
|
|
607
|
+
with self._new_metastore_client(project) as client:
|
|
608
|
+
data: dict[str, Any] = {"name": name, "sql_dialect": sql_dialect}
|
|
609
|
+
if description:
|
|
610
|
+
data["description"] = description
|
|
611
|
+
created = client.post_item("semantic-model", name=name, data=data)
|
|
612
|
+
return {"project": alias, "model": created}
|
|
613
|
+
|
|
614
|
+
def delete_model(
|
|
615
|
+
self,
|
|
616
|
+
alias: str,
|
|
617
|
+
model_name_or_uuid: str,
|
|
618
|
+
) -> dict[str, Any]:
|
|
619
|
+
"""Delete a semantic-layer model and cascade-delete its children.
|
|
620
|
+
|
|
621
|
+
Thin orchestrator: resolve project + model, fetch children, and
|
|
622
|
+
forward to :func:`_cascade_delete_model_impl`. Cascade semantics
|
|
623
|
+
(reverse :data:`PUSH_ORDER`, per-child try/except, parent
|
|
624
|
+
preserved on any failure with ``details.cascade`` envelope) live
|
|
625
|
+
in the helper to keep this file under the 1500 LOC ceiling.
|
|
626
|
+
"""
|
|
627
|
+
project = self._resolve_one_project(alias)
|
|
628
|
+
with self._new_metastore_client(project) as client:
|
|
629
|
+
model_uuid, model_attrs = self._resolve_model(client, model_name_or_uuid)
|
|
630
|
+
children = self._fetch_children_parallel(client, model_uuid)
|
|
631
|
+
return _cascade_delete_model_impl(
|
|
632
|
+
client,
|
|
633
|
+
alias=alias,
|
|
634
|
+
model_uuid=model_uuid,
|
|
635
|
+
model_attrs=model_attrs,
|
|
636
|
+
children=children,
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
# ------------------------------------------------------------------
|
|
640
|
+
# Phase 4 — add subcommands
|
|
641
|
+
# ------------------------------------------------------------------
|
|
642
|
+
|
|
643
|
+
def add_metric(
|
|
644
|
+
self,
|
|
645
|
+
alias: str,
|
|
646
|
+
model_name_or_uuid: str | None,
|
|
647
|
+
*,
|
|
648
|
+
name: str,
|
|
649
|
+
sql: str,
|
|
650
|
+
dataset: str,
|
|
651
|
+
description: str = "",
|
|
652
|
+
assume_yes: bool = False,
|
|
653
|
+
is_tty: bool = False,
|
|
654
|
+
confirm_cb: Callable[[str], bool] | None = None,
|
|
655
|
+
) -> dict[str, Any]:
|
|
656
|
+
"""Create a metric. The ``dataset`` argument is a tableId.
|
|
657
|
+
|
|
658
|
+
If the tableId is not in the model's datasets, warn and require
|
|
659
|
+
an interactive confirmation. ``--yes`` (assume_yes) skips the
|
|
660
|
+
prompt. In non-TTY contexts we refuse with VALIDATION_ERROR
|
|
661
|
+
rather than silently push a broken metric.
|
|
662
|
+
"""
|
|
663
|
+
project = self._resolve_one_project(alias)
|
|
664
|
+
with self._new_metastore_client(project) as client:
|
|
665
|
+
model_uuid, _ = self._resolve_model(client, model_name_or_uuid)
|
|
666
|
+
datasets = client.list_items("semantic-dataset", model_uuid)
|
|
667
|
+
ds_tids = {(d.get("attributes") or {}).get("tableId", "") for d in datasets}
|
|
668
|
+
|
|
669
|
+
if dataset not in ds_tids:
|
|
670
|
+
msg = (
|
|
671
|
+
f"Metric dataset {dataset!r} is not a tableId in this model "
|
|
672
|
+
f"(known: {sorted(t for t in ds_tids if t)})."
|
|
673
|
+
)
|
|
674
|
+
if not assume_yes:
|
|
675
|
+
if not is_tty:
|
|
676
|
+
raise KeboolaApiError(
|
|
677
|
+
message=msg + " Pass --yes to bypass.",
|
|
678
|
+
error_code=ErrorCode.VALIDATION_ERROR,
|
|
679
|
+
)
|
|
680
|
+
if confirm_cb is None or not confirm_cb(msg + " Create anyway?"):
|
|
681
|
+
raise KeboolaApiError(
|
|
682
|
+
message="Aborted by user.",
|
|
683
|
+
error_code=ErrorCode.VALIDATION_ERROR,
|
|
684
|
+
)
|
|
685
|
+
|
|
686
|
+
data: dict[str, Any] = {
|
|
687
|
+
"name": name,
|
|
688
|
+
"sql": sql,
|
|
689
|
+
"dataset": dataset,
|
|
690
|
+
"modelUUID": model_uuid,
|
|
691
|
+
}
|
|
692
|
+
if description:
|
|
693
|
+
data["description"] = description
|
|
694
|
+
return client.post_item("semantic-metric", name=name, data=data)
|
|
695
|
+
|
|
696
|
+
def add_dataset(
|
|
697
|
+
self,
|
|
698
|
+
alias: str,
|
|
699
|
+
model_name_or_uuid: str | None,
|
|
700
|
+
*,
|
|
701
|
+
name: str,
|
|
702
|
+
table_id: str,
|
|
703
|
+
description: str = "",
|
|
704
|
+
grain: str = "",
|
|
705
|
+
primary_key: list[str] | None = None,
|
|
706
|
+
deep_fields: bool = False,
|
|
707
|
+
) -> dict[str, Any]:
|
|
708
|
+
"""Create a dataset, auto-deriving ``fqn`` from the tableId.
|
|
709
|
+
|
|
710
|
+
With ``deep_fields=True``, fetches the storage column schema and
|
|
711
|
+
synthesises a ``fields[]`` array with role heuristics.
|
|
712
|
+
"""
|
|
713
|
+
project = self._resolve_one_project(alias)
|
|
714
|
+
with self._new_metastore_client(project) as client:
|
|
715
|
+
model_uuid, _ = self._resolve_model(client, model_name_or_uuid)
|
|
716
|
+
data: dict[str, Any] = {
|
|
717
|
+
"name": name,
|
|
718
|
+
"tableId": table_id,
|
|
719
|
+
"fqn": _derive_fqn(table_id),
|
|
720
|
+
"modelUUID": model_uuid,
|
|
721
|
+
}
|
|
722
|
+
if description:
|
|
723
|
+
data["description"] = description
|
|
724
|
+
if grain:
|
|
725
|
+
data["grain"] = grain
|
|
726
|
+
if primary_key:
|
|
727
|
+
data["primaryKey"] = list(primary_key)
|
|
728
|
+
if deep_fields:
|
|
729
|
+
storage = StorageService(
|
|
730
|
+
config_store=self._config_store,
|
|
731
|
+
client_factory=self._client_factory,
|
|
732
|
+
)
|
|
733
|
+
fields = _synthesize_role_classified_fields(
|
|
734
|
+
storage, alias, table_id, _classify_field_role
|
|
735
|
+
)
|
|
736
|
+
if fields:
|
|
737
|
+
data["fields"] = fields
|
|
738
|
+
return client.post_item("semantic-dataset", name=name, data=data)
|
|
739
|
+
|
|
740
|
+
def add_relationship(
|
|
741
|
+
self,
|
|
742
|
+
alias: str,
|
|
743
|
+
model_name_or_uuid: str | None,
|
|
744
|
+
*,
|
|
745
|
+
name: str,
|
|
746
|
+
from_: str,
|
|
747
|
+
to: str,
|
|
748
|
+
on: str,
|
|
749
|
+
type_: str,
|
|
750
|
+
) -> dict[str, Any]:
|
|
751
|
+
"""Create a relationship. ``from``/``to`` are tableIds; ``type``='left'|'inner'."""
|
|
752
|
+
if type_ not in ("left", "inner"):
|
|
753
|
+
raise KeboolaApiError(
|
|
754
|
+
message=f"--type must be 'left' or 'inner', got {type_!r}.",
|
|
755
|
+
error_code=ErrorCode.VALIDATION_ERROR,
|
|
756
|
+
)
|
|
757
|
+
project = self._resolve_one_project(alias)
|
|
758
|
+
with self._new_metastore_client(project) as client:
|
|
759
|
+
model_uuid, _ = self._resolve_model(client, model_name_or_uuid)
|
|
760
|
+
data = {
|
|
761
|
+
"name": name,
|
|
762
|
+
"from": from_,
|
|
763
|
+
"to": to,
|
|
764
|
+
"on": on,
|
|
765
|
+
"type": type_,
|
|
766
|
+
"modelUUID": model_uuid,
|
|
767
|
+
}
|
|
768
|
+
return client.post_item("semantic-relationship", name=name, data=data)
|
|
769
|
+
|
|
770
|
+
def add_constraint(
|
|
771
|
+
self,
|
|
772
|
+
alias: str,
|
|
773
|
+
model_name_or_uuid: str | None,
|
|
774
|
+
*,
|
|
775
|
+
name: str,
|
|
776
|
+
constraint_type: str,
|
|
777
|
+
rule: str,
|
|
778
|
+
metrics: list[str],
|
|
779
|
+
severity: str = "warning",
|
|
780
|
+
) -> dict[str, Any]:
|
|
781
|
+
"""Create a constraint after validating every field locally."""
|
|
782
|
+
_validate_constraint_attrs(
|
|
783
|
+
name_re=CONSTRAINT_NAME_RE,
|
|
784
|
+
constraint_types=CONSTRAINT_TYPES,
|
|
785
|
+
severities=CONSTRAINT_SEVERITIES,
|
|
786
|
+
name=name,
|
|
787
|
+
constraint_type=constraint_type,
|
|
788
|
+
severity=severity,
|
|
789
|
+
)
|
|
790
|
+
|
|
791
|
+
# METRICS exist in model
|
|
792
|
+
project = self._resolve_one_project(alias)
|
|
793
|
+
with self._new_metastore_client(project) as client:
|
|
794
|
+
model_uuid, _ = self._resolve_model(client, model_name_or_uuid)
|
|
795
|
+
existing = client.list_items("semantic-metric", model_uuid)
|
|
796
|
+
existing_names = {(m.get("attributes") or {}).get("name", "") for m in existing}
|
|
797
|
+
missing = [m for m in metrics if m not in existing_names]
|
|
798
|
+
if missing:
|
|
799
|
+
raise KeboolaApiError(
|
|
800
|
+
message=(
|
|
801
|
+
f"Constraint references metric(s) not in model: {missing}. "
|
|
802
|
+
f"Known metrics: {sorted(existing_names - {''})}."
|
|
803
|
+
),
|
|
804
|
+
error_code=ErrorCode.VALIDATION_ERROR,
|
|
805
|
+
)
|
|
806
|
+
data = {
|
|
807
|
+
"name": name,
|
|
808
|
+
"constraintType": constraint_type,
|
|
809
|
+
"rule": rule,
|
|
810
|
+
"metrics": list(metrics),
|
|
811
|
+
"severity": severity,
|
|
812
|
+
"modelUUID": model_uuid,
|
|
813
|
+
}
|
|
814
|
+
return client.post_item("semantic-constraint", name=name, data=data)
|
|
815
|
+
|
|
816
|
+
def add_glossary(
|
|
817
|
+
self,
|
|
818
|
+
alias: str,
|
|
819
|
+
model_name_or_uuid: str | None,
|
|
820
|
+
*,
|
|
821
|
+
term: str,
|
|
822
|
+
definition: str = "",
|
|
823
|
+
) -> dict[str, Any]:
|
|
824
|
+
"""Create a glossary term. Outer envelope ``name`` must equal ``term``."""
|
|
825
|
+
project = self._resolve_one_project(alias)
|
|
826
|
+
with self._new_metastore_client(project) as client:
|
|
827
|
+
model_uuid, _ = self._resolve_model(client, model_name_or_uuid)
|
|
828
|
+
data: dict[str, Any] = {"term": term, "modelUUID": model_uuid}
|
|
829
|
+
if definition:
|
|
830
|
+
data["definition"] = definition
|
|
831
|
+
return client.post_item("semantic-glossary", name=term, data=data)
|
|
832
|
+
|
|
833
|
+
# ------------------------------------------------------------------
|
|
834
|
+
# Reference data — dimension-member records (e.g. a Chart of Accounts).
|
|
835
|
+
# Thin delegators; logic lives in :mod:`._semantic_layer_reference_data`.
|
|
836
|
+
# ------------------------------------------------------------------
|
|
837
|
+
|
|
838
|
+
def list_reference_data(
|
|
839
|
+
self,
|
|
840
|
+
alias: str,
|
|
841
|
+
model_name_or_uuid: str | None = None,
|
|
842
|
+
) -> dict[str, Any]:
|
|
843
|
+
"""List reference-data records (optionally scoped to one model)."""
|
|
844
|
+
return _refdata.run_list(
|
|
845
|
+
lambda: self._new_metastore_client(self._resolve_one_project(alias)),
|
|
846
|
+
alias,
|
|
847
|
+
model_name_or_uuid,
|
|
848
|
+
)
|
|
849
|
+
|
|
850
|
+
def get_reference_data(
|
|
851
|
+
self,
|
|
852
|
+
alias: str,
|
|
853
|
+
*,
|
|
854
|
+
record_id: str | None = None,
|
|
855
|
+
dimension: str | None = None,
|
|
856
|
+
) -> dict[str, Any]:
|
|
857
|
+
"""Fetch one reference-data record by ``record_id`` or by ``dimension``.
|
|
858
|
+
|
|
859
|
+
``dimension`` is the project-unique key, so no model is needed.
|
|
860
|
+
"""
|
|
861
|
+
return _refdata.run_get(
|
|
862
|
+
lambda: self._new_metastore_client(self._resolve_one_project(alias)),
|
|
863
|
+
alias,
|
|
864
|
+
record_id=record_id,
|
|
865
|
+
dimension=dimension,
|
|
866
|
+
)
|
|
867
|
+
|
|
868
|
+
def set_reference_data(
|
|
869
|
+
self,
|
|
870
|
+
alias: str,
|
|
871
|
+
model_name_or_uuid: str | None,
|
|
872
|
+
*,
|
|
873
|
+
dimension: str,
|
|
874
|
+
members: list[dict[str, Any]],
|
|
875
|
+
dataset_id: str | None = None,
|
|
876
|
+
description: str | None = None,
|
|
877
|
+
) -> dict[str, Any]:
|
|
878
|
+
"""Create or replace (by model + dimension) a reference-data record."""
|
|
879
|
+
return _refdata.run_set(
|
|
880
|
+
lambda: self._new_metastore_client(self._resolve_one_project(alias)),
|
|
881
|
+
alias,
|
|
882
|
+
model_name_or_uuid,
|
|
883
|
+
dimension=dimension,
|
|
884
|
+
members=members,
|
|
885
|
+
dataset_id=dataset_id,
|
|
886
|
+
description=description,
|
|
887
|
+
)
|
|
888
|
+
|
|
889
|
+
def delete_reference_data(self, alias: str, record_id: str) -> dict[str, Any]:
|
|
890
|
+
"""Delete a reference-data record by UUID (server-side soft-delete)."""
|
|
891
|
+
return _refdata.run_delete(
|
|
892
|
+
lambda: self._new_metastore_client(self._resolve_one_project(alias)),
|
|
893
|
+
alias,
|
|
894
|
+
record_id,
|
|
895
|
+
)
|
|
896
|
+
|
|
897
|
+
# ------------------------------------------------------------------
|
|
898
|
+
# Phase 4 — edit (DELETE-then-POST with rollback + rename cascade)
|
|
899
|
+
# ------------------------------------------------------------------
|
|
900
|
+
|
|
901
|
+
# Thin delegates to ._semantic_layer_crud helpers -- bodies live
|
|
902
|
+
# there so this orchestrator stays under the services budget.
|
|
903
|
+
_code_metric = staticmethod(_code_metric_helper)
|
|
904
|
+
_delete_then_post = staticmethod(_delete_then_post_helper)
|
|
905
|
+
|
|
906
|
+
def edit_metric(
|
|
907
|
+
self,
|
|
908
|
+
alias: str,
|
|
909
|
+
model_name_or_uuid: str | None,
|
|
910
|
+
*,
|
|
911
|
+
current_name: str,
|
|
912
|
+
new_name: str | None = None,
|
|
913
|
+
new_sql: str | None = None,
|
|
914
|
+
new_dataset: str | None = None,
|
|
915
|
+
new_description: str | None = None,
|
|
916
|
+
assume_yes: bool = False,
|
|
917
|
+
is_tty: bool = False,
|
|
918
|
+
confirm_cb: Callable[[str], bool] | None = None,
|
|
919
|
+
) -> dict[str, Any]:
|
|
920
|
+
"""Edit a metric via DELETE+POST with rename-cascade on constraints.
|
|
921
|
+
|
|
922
|
+
Returns:
|
|
923
|
+
``{updated: item, cascaded_constraints: [...], rollback: None|{...}}``.
|
|
924
|
+
|
|
925
|
+
The cascade body lives in
|
|
926
|
+
:func:`._semantic_layer_crud.edit_metric_with_cascade` -- this
|
|
927
|
+
method just resolves credentials + the model UUID and
|
|
928
|
+
delegates.
|
|
929
|
+
"""
|
|
930
|
+
project = self._resolve_one_project(alias)
|
|
931
|
+
with self._new_metastore_client(project) as client:
|
|
932
|
+
model_uuid, _ = self._resolve_model(client, model_name_or_uuid)
|
|
933
|
+
return _edit_metric_helper(
|
|
934
|
+
client,
|
|
935
|
+
model_uuid=model_uuid,
|
|
936
|
+
current_name=current_name,
|
|
937
|
+
new_name=new_name,
|
|
938
|
+
new_sql=new_sql,
|
|
939
|
+
new_dataset=new_dataset,
|
|
940
|
+
new_description=new_description,
|
|
941
|
+
assume_yes=assume_yes,
|
|
942
|
+
is_tty=is_tty,
|
|
943
|
+
confirm_cb=confirm_cb,
|
|
944
|
+
)
|
|
945
|
+
|
|
946
|
+
def edit_dataset(
|
|
947
|
+
self,
|
|
948
|
+
alias: str,
|
|
949
|
+
model_name_or_uuid: str | None,
|
|
950
|
+
*,
|
|
951
|
+
current_name: str,
|
|
952
|
+
new_name: str | None = None,
|
|
953
|
+
new_description: str | None = None,
|
|
954
|
+
new_grain: str | None = None,
|
|
955
|
+
) -> dict[str, Any]:
|
|
956
|
+
"""Edit a dataset (DELETE+POST). Renames do NOT cascade for datasets."""
|
|
957
|
+
project = self._resolve_one_project(alias)
|
|
958
|
+
with self._new_metastore_client(project) as client:
|
|
959
|
+
model_uuid, _ = self._resolve_model(client, model_name_or_uuid)
|
|
960
|
+
return _edit_simple_helper(
|
|
961
|
+
client,
|
|
962
|
+
"semantic-dataset",
|
|
963
|
+
items=client.list_items("semantic-dataset", model_uuid),
|
|
964
|
+
id_key="name",
|
|
965
|
+
current_key=current_name,
|
|
966
|
+
overrides={
|
|
967
|
+
"name": new_name,
|
|
968
|
+
"description": new_description,
|
|
969
|
+
"grain": new_grain,
|
|
970
|
+
},
|
|
971
|
+
not_found_label="Dataset",
|
|
972
|
+
)
|
|
973
|
+
|
|
974
|
+
def edit_constraint(
|
|
975
|
+
self,
|
|
976
|
+
alias: str,
|
|
977
|
+
model_name_or_uuid: str | None,
|
|
978
|
+
*,
|
|
979
|
+
current_name: str,
|
|
980
|
+
new_name: str | None = None,
|
|
981
|
+
new_rule: str | None = None,
|
|
982
|
+
new_constraint_type: str | None = None,
|
|
983
|
+
new_severity: str | None = None,
|
|
984
|
+
new_metrics: list[str] | None = None,
|
|
985
|
+
) -> dict[str, Any]:
|
|
986
|
+
"""Edit a constraint (DELETE+POST). Validates new attrs locally first."""
|
|
987
|
+
_validate_constraint_attrs(
|
|
988
|
+
name_re=CONSTRAINT_NAME_RE,
|
|
989
|
+
constraint_types=CONSTRAINT_TYPES,
|
|
990
|
+
severities=CONSTRAINT_SEVERITIES,
|
|
991
|
+
name=new_name,
|
|
992
|
+
constraint_type=new_constraint_type,
|
|
993
|
+
severity=new_severity,
|
|
994
|
+
)
|
|
995
|
+
|
|
996
|
+
project = self._resolve_one_project(alias)
|
|
997
|
+
with self._new_metastore_client(project) as client:
|
|
998
|
+
model_uuid, _ = self._resolve_model(client, model_name_or_uuid)
|
|
999
|
+
if new_metrics is not None:
|
|
1000
|
+
existing = client.list_items("semantic-metric", model_uuid)
|
|
1001
|
+
existing_names = {(m.get("attributes") or {}).get("name", "") for m in existing}
|
|
1002
|
+
missing = [m for m in new_metrics if m not in existing_names]
|
|
1003
|
+
if missing:
|
|
1004
|
+
raise KeboolaApiError(
|
|
1005
|
+
message=(f"--new-metrics references metric(s) not in model: {missing}."),
|
|
1006
|
+
error_code=ErrorCode.VALIDATION_ERROR,
|
|
1007
|
+
)
|
|
1008
|
+
return _edit_simple_helper(
|
|
1009
|
+
client,
|
|
1010
|
+
"semantic-constraint",
|
|
1011
|
+
items=client.list_items("semantic-constraint", model_uuid),
|
|
1012
|
+
id_key="name",
|
|
1013
|
+
current_key=current_name,
|
|
1014
|
+
overrides={
|
|
1015
|
+
"name": new_name,
|
|
1016
|
+
"rule": new_rule,
|
|
1017
|
+
"constraintType": new_constraint_type,
|
|
1018
|
+
"severity": new_severity,
|
|
1019
|
+
"metrics": list(new_metrics) if new_metrics is not None else None,
|
|
1020
|
+
},
|
|
1021
|
+
not_found_label="Constraint",
|
|
1022
|
+
)
|
|
1023
|
+
|
|
1024
|
+
def edit_relationship(
|
|
1025
|
+
self,
|
|
1026
|
+
alias: str,
|
|
1027
|
+
model_name_or_uuid: str | None,
|
|
1028
|
+
*,
|
|
1029
|
+
current_name: str,
|
|
1030
|
+
new_name: str | None = None,
|
|
1031
|
+
new_from: str | None = None,
|
|
1032
|
+
new_to: str | None = None,
|
|
1033
|
+
new_on: str | None = None,
|
|
1034
|
+
new_type: str | None = None,
|
|
1035
|
+
) -> dict[str, Any]:
|
|
1036
|
+
"""Edit a relationship (DELETE+POST). Validates ``--new-type`` locally.
|
|
1037
|
+
|
|
1038
|
+
Relationships are not referenced by any other entity, so no
|
|
1039
|
+
cascade is needed -- the result is shaped identically to
|
|
1040
|
+
:meth:`edit_dataset` for consistency.
|
|
1041
|
+
"""
|
|
1042
|
+
if new_type is not None and new_type not in ("left", "inner"):
|
|
1043
|
+
raise KeboolaApiError(
|
|
1044
|
+
message=f"--new-type must be 'left' or 'inner', got {new_type!r}.",
|
|
1045
|
+
error_code=ErrorCode.VALIDATION_ERROR,
|
|
1046
|
+
)
|
|
1047
|
+
project = self._resolve_one_project(alias)
|
|
1048
|
+
with self._new_metastore_client(project) as client:
|
|
1049
|
+
model_uuid, _ = self._resolve_model(client, model_name_or_uuid)
|
|
1050
|
+
return _edit_simple_helper(
|
|
1051
|
+
client,
|
|
1052
|
+
"semantic-relationship",
|
|
1053
|
+
items=client.list_items("semantic-relationship", model_uuid),
|
|
1054
|
+
id_key="name",
|
|
1055
|
+
current_key=current_name,
|
|
1056
|
+
overrides={
|
|
1057
|
+
"name": new_name,
|
|
1058
|
+
"from": new_from,
|
|
1059
|
+
"to": new_to,
|
|
1060
|
+
"on": new_on,
|
|
1061
|
+
"type": new_type,
|
|
1062
|
+
},
|
|
1063
|
+
not_found_label="Relationship",
|
|
1064
|
+
)
|
|
1065
|
+
|
|
1066
|
+
def edit_glossary(
|
|
1067
|
+
self,
|
|
1068
|
+
alias: str,
|
|
1069
|
+
model_name_or_uuid: str | None,
|
|
1070
|
+
*,
|
|
1071
|
+
current_term: str,
|
|
1072
|
+
new_term: str | None = None,
|
|
1073
|
+
new_definition: str | None = None,
|
|
1074
|
+
) -> dict[str, Any]:
|
|
1075
|
+
"""Edit a glossary term (DELETE+POST).
|
|
1076
|
+
|
|
1077
|
+
Renaming via ``--new-term`` is destructive for downstream
|
|
1078
|
+
consumers that join on the literal term string (the term IS the
|
|
1079
|
+
identity for a glossary entry). The CLI layer warns + gates
|
|
1080
|
+
behind ``--yes``; this method just executes.
|
|
1081
|
+
"""
|
|
1082
|
+
project = self._resolve_one_project(alias)
|
|
1083
|
+
with self._new_metastore_client(project) as client:
|
|
1084
|
+
model_uuid, _ = self._resolve_model(client, model_name_or_uuid)
|
|
1085
|
+
return _edit_simple_helper(
|
|
1086
|
+
client,
|
|
1087
|
+
"semantic-glossary",
|
|
1088
|
+
items=client.list_items("semantic-glossary", model_uuid),
|
|
1089
|
+
id_key="term",
|
|
1090
|
+
current_key=current_term,
|
|
1091
|
+
overrides={"term": new_term, "definition": new_definition},
|
|
1092
|
+
not_found_label="Glossary term",
|
|
1093
|
+
)
|
|
1094
|
+
|
|
1095
|
+
# ------------------------------------------------------------------
|
|
1096
|
+
# Phase 5 — remove (destructive, orphan-warning before delete)
|
|
1097
|
+
# ------------------------------------------------------------------
|
|
1098
|
+
|
|
1099
|
+
# REMOVE_KINDS lives in ._semantic_layer_crud; re-bound here so
|
|
1100
|
+
# subclasses can override the accepted-kinds set without forking
|
|
1101
|
+
# the helper.
|
|
1102
|
+
_REMOVE_KINDS = _REMOVE_KINDS_HELPER
|
|
1103
|
+
|
|
1104
|
+
def preview_remove(
|
|
1105
|
+
self,
|
|
1106
|
+
alias: str,
|
|
1107
|
+
model_name_or_uuid: str | None,
|
|
1108
|
+
*,
|
|
1109
|
+
kind: str,
|
|
1110
|
+
name: str,
|
|
1111
|
+
) -> dict[str, Any]:
|
|
1112
|
+
"""Return what `remove` would do (orphan list) without deleting.
|
|
1113
|
+
|
|
1114
|
+
Always called before the actual delete so the command layer can
|
|
1115
|
+
echo the warning even when --yes skips the prompt.
|
|
1116
|
+
"""
|
|
1117
|
+
if kind not in self._REMOVE_KINDS:
|
|
1118
|
+
raise KeboolaApiError(
|
|
1119
|
+
message=(
|
|
1120
|
+
f"remove kind must be one of {'|'.join(self._REMOVE_KINDS)}, got {kind!r}."
|
|
1121
|
+
),
|
|
1122
|
+
error_code=ErrorCode.VALIDATION_ERROR,
|
|
1123
|
+
)
|
|
1124
|
+
project = self._resolve_one_project(alias)
|
|
1125
|
+
with self._new_metastore_client(project) as client:
|
|
1126
|
+
model_uuid, _ = self._resolve_model(client, model_name_or_uuid)
|
|
1127
|
+
target, _, _ = _find_target_for_remove(
|
|
1128
|
+
client,
|
|
1129
|
+
kind=kind,
|
|
1130
|
+
model_uuid=model_uuid,
|
|
1131
|
+
name=name,
|
|
1132
|
+
type_alias=TYPE_ALIAS,
|
|
1133
|
+
)
|
|
1134
|
+
orphan_constraints = (
|
|
1135
|
+
_scan_orphan_constraints(client, model_uuid=model_uuid, metric_name=name)
|
|
1136
|
+
if kind == "metric"
|
|
1137
|
+
else []
|
|
1138
|
+
)
|
|
1139
|
+
return {
|
|
1140
|
+
"kind": kind,
|
|
1141
|
+
"id": target["id"],
|
|
1142
|
+
"name": name,
|
|
1143
|
+
"orphaned_constraints": orphan_constraints,
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
def remove_item(
|
|
1147
|
+
self,
|
|
1148
|
+
alias: str,
|
|
1149
|
+
model_name_or_uuid: str | None,
|
|
1150
|
+
*,
|
|
1151
|
+
kind: str,
|
|
1152
|
+
name: str,
|
|
1153
|
+
) -> dict[str, Any]:
|
|
1154
|
+
"""Delete a single child entity. Returns the removed item descriptor."""
|
|
1155
|
+
if kind not in self._REMOVE_KINDS:
|
|
1156
|
+
raise KeboolaApiError(
|
|
1157
|
+
message=(
|
|
1158
|
+
f"remove kind must be one of {'|'.join(self._REMOVE_KINDS)}, got {kind!r}."
|
|
1159
|
+
),
|
|
1160
|
+
error_code=ErrorCode.VALIDATION_ERROR,
|
|
1161
|
+
)
|
|
1162
|
+
project = self._resolve_one_project(alias)
|
|
1163
|
+
with self._new_metastore_client(project) as client:
|
|
1164
|
+
model_uuid, _ = self._resolve_model(client, model_name_or_uuid)
|
|
1165
|
+
target, type_slug, _ = _find_target_for_remove(
|
|
1166
|
+
client,
|
|
1167
|
+
kind=kind,
|
|
1168
|
+
model_uuid=model_uuid,
|
|
1169
|
+
name=name,
|
|
1170
|
+
type_alias=TYPE_ALIAS,
|
|
1171
|
+
)
|
|
1172
|
+
orphan_constraints = (
|
|
1173
|
+
_scan_orphan_constraints(client, model_uuid=model_uuid, metric_name=name)
|
|
1174
|
+
if kind == "metric"
|
|
1175
|
+
else []
|
|
1176
|
+
)
|
|
1177
|
+
client.delete_item(type_slug, target["id"])
|
|
1178
|
+
return {
|
|
1179
|
+
"removed": {"type": type_slug, "id": target["id"], "name": name},
|
|
1180
|
+
"orphaned_constraints": orphan_constraints,
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
# ------------------------------------------------------------------
|
|
1184
|
+
# Phase 6 — import (replay a snapshot, optionally overwrite)
|
|
1185
|
+
# ------------------------------------------------------------------
|
|
1186
|
+
|
|
1187
|
+
# Push order for both `import` and `promote` lives in
|
|
1188
|
+
# ._semantic_layer_internals.PUSH_ORDER.
|
|
1189
|
+
|
|
1190
|
+
def import_snapshot(
|
|
1191
|
+
self,
|
|
1192
|
+
alias: str,
|
|
1193
|
+
file: Path,
|
|
1194
|
+
*,
|
|
1195
|
+
model_name_or_uuid: str | None = None,
|
|
1196
|
+
types: list[str] | None = None,
|
|
1197
|
+
dry_run: bool = False,
|
|
1198
|
+
overwrite: bool = False,
|
|
1199
|
+
) -> dict[str, Any]:
|
|
1200
|
+
"""Replay a snapshot produced by :meth:`export_model` into a project.
|
|
1201
|
+
|
|
1202
|
+
Args:
|
|
1203
|
+
alias: Target project alias.
|
|
1204
|
+
file: Snapshot JSON file path.
|
|
1205
|
+
model_name_or_uuid: Target model selector. If different from the
|
|
1206
|
+
snapshot's model, every item's ``modelUUID`` is rewritten.
|
|
1207
|
+
types: Filter to a subset of types. ``None`` = all types.
|
|
1208
|
+
dry_run: When True, plan and return the action counts without
|
|
1209
|
+
hitting any write API.
|
|
1210
|
+
overwrite: When True, DELETE+POST conflicting items by name.
|
|
1211
|
+
Default (False) skips conflicts.
|
|
1212
|
+
|
|
1213
|
+
Returns:
|
|
1214
|
+
``{imported: {<type>: {created, skipped, overwritten, failed}}}``.
|
|
1215
|
+
"""
|
|
1216
|
+
try:
|
|
1217
|
+
snapshot = json.loads(file.read_text(encoding="utf-8"))
|
|
1218
|
+
except OSError as exc:
|
|
1219
|
+
raise KeboolaApiError(
|
|
1220
|
+
message=f"Cannot read --file {file}: {exc}",
|
|
1221
|
+
error_code=ErrorCode.READ_ERROR,
|
|
1222
|
+
) from exc
|
|
1223
|
+
except json.JSONDecodeError as exc:
|
|
1224
|
+
raise KeboolaApiError(
|
|
1225
|
+
message=f"File {file} is not valid JSON: {exc}",
|
|
1226
|
+
error_code=ErrorCode.INVALID_FORMAT,
|
|
1227
|
+
) from exc
|
|
1228
|
+
|
|
1229
|
+
return self.import_snapshot_from_dict(
|
|
1230
|
+
alias,
|
|
1231
|
+
snapshot=snapshot,
|
|
1232
|
+
model_name_or_uuid=model_name_or_uuid,
|
|
1233
|
+
types=types,
|
|
1234
|
+
dry_run=dry_run,
|
|
1235
|
+
overwrite=overwrite,
|
|
1236
|
+
)
|
|
1237
|
+
|
|
1238
|
+
def import_snapshot_from_dict(
|
|
1239
|
+
self,
|
|
1240
|
+
alias: str,
|
|
1241
|
+
*,
|
|
1242
|
+
snapshot: dict[str, Any],
|
|
1243
|
+
model_name_or_uuid: str | None = None,
|
|
1244
|
+
types: list[str] | None = None,
|
|
1245
|
+
dry_run: bool = False,
|
|
1246
|
+
overwrite: bool = False,
|
|
1247
|
+
) -> dict[str, Any]:
|
|
1248
|
+
"""Replay an in-memory snapshot dict (sibling of :meth:`import_snapshot`).
|
|
1249
|
+
|
|
1250
|
+
Used by the REST router ``POST /semantic-layer/import``, where the
|
|
1251
|
+
snapshot arrives inline in the JSON body — there is no file to
|
|
1252
|
+
read. The wire-level shape must match what :meth:`export_model`
|
|
1253
|
+
produces (``{datasets, metrics, relationships, constraints,
|
|
1254
|
+
glossary}`` with full server item envelopes).
|
|
1255
|
+
"""
|
|
1256
|
+
if not isinstance(snapshot, dict):
|
|
1257
|
+
raise KeboolaApiError(
|
|
1258
|
+
message="Snapshot must be a JSON object.",
|
|
1259
|
+
error_code=ErrorCode.VALIDATION_ERROR,
|
|
1260
|
+
)
|
|
1261
|
+
|
|
1262
|
+
type_filter = _validate_types_filter(types)
|
|
1263
|
+
|
|
1264
|
+
project = self._resolve_one_project(alias)
|
|
1265
|
+
with self._new_metastore_client(project) as client:
|
|
1266
|
+
model_uuid, _ = self._resolve_model(client, model_name_or_uuid)
|
|
1267
|
+
existing_by_type = self._fetch_children_parallel(client, model_uuid)
|
|
1268
|
+
|
|
1269
|
+
imported = _run_import_loop(
|
|
1270
|
+
client,
|
|
1271
|
+
snapshot=snapshot,
|
|
1272
|
+
target_model_uuid=model_uuid,
|
|
1273
|
+
existing_by_type=existing_by_type,
|
|
1274
|
+
type_filter=type_filter,
|
|
1275
|
+
dry_run=dry_run,
|
|
1276
|
+
overwrite=overwrite,
|
|
1277
|
+
)
|
|
1278
|
+
return {
|
|
1279
|
+
"target_project": alias,
|
|
1280
|
+
"target_model": model_uuid,
|
|
1281
|
+
"source_model": (snapshot.get("model") or {}).get("id", ""),
|
|
1282
|
+
"dry_run": dry_run,
|
|
1283
|
+
"overwrite": overwrite,
|
|
1284
|
+
"imported": imported,
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
# ------------------------------------------------------------------
|
|
1288
|
+
# Phase 6 — promote (cross-project copy)
|
|
1289
|
+
# ------------------------------------------------------------------
|
|
1290
|
+
|
|
1291
|
+
def promote_model(
|
|
1292
|
+
self,
|
|
1293
|
+
*,
|
|
1294
|
+
from_project: str,
|
|
1295
|
+
to_project: str,
|
|
1296
|
+
from_model: str | None = None,
|
|
1297
|
+
to_model: str | None = None,
|
|
1298
|
+
types: list[str] | None = None,
|
|
1299
|
+
dry_run: bool = False,
|
|
1300
|
+
) -> dict[str, Any]:
|
|
1301
|
+
"""Promote a model's entities from one project to another.
|
|
1302
|
+
|
|
1303
|
+
Holds two metastore clients in a try/finally and closes both on
|
|
1304
|
+
exit even on error. Items are classified as NEW (not in target),
|
|
1305
|
+
IDENTICAL (all attributes equal after stripping modelUUID), or
|
|
1306
|
+
CHANGED (differs). Default behaviour: import NEW + overwrite
|
|
1307
|
+
CHANGED. IDENTICAL items are skipped. Target items absent from
|
|
1308
|
+
source are NEVER deleted.
|
|
1309
|
+
|
|
1310
|
+
Returns:
|
|
1311
|
+
``{from_project, to_project, dry_run, datasets: {new,
|
|
1312
|
+
overwritten, identical, failed}, metrics: {...}, ...}``.
|
|
1313
|
+
"""
|
|
1314
|
+
projects = self.resolve_projects([from_project, to_project])
|
|
1315
|
+
if from_project not in projects:
|
|
1316
|
+
raise ConfigError(f"Source project '{from_project}' not found.")
|
|
1317
|
+
if to_project not in projects:
|
|
1318
|
+
raise ConfigError(f"Target project '{to_project}' not found.")
|
|
1319
|
+
|
|
1320
|
+
type_filter = _validate_types_filter(types)
|
|
1321
|
+
|
|
1322
|
+
with (
|
|
1323
|
+
self._new_metastore_client(projects[from_project]) as src_client,
|
|
1324
|
+
self._new_metastore_client(projects[to_project]) as tgt_client,
|
|
1325
|
+
):
|
|
1326
|
+
src_uuid, _ = self._resolve_model(src_client, from_model)
|
|
1327
|
+
tgt_uuid, _ = self._resolve_model(tgt_client, to_model)
|
|
1328
|
+
|
|
1329
|
+
src_children = self._fetch_children_parallel(src_client, src_uuid)
|
|
1330
|
+
tgt_children = self._fetch_children_parallel(tgt_client, tgt_uuid)
|
|
1331
|
+
|
|
1332
|
+
per_type_stats = _run_promote_loop(
|
|
1333
|
+
tgt_client,
|
|
1334
|
+
src_children=src_children,
|
|
1335
|
+
tgt_children=tgt_children,
|
|
1336
|
+
target_model_uuid=tgt_uuid,
|
|
1337
|
+
type_filter=type_filter,
|
|
1338
|
+
dry_run=dry_run,
|
|
1339
|
+
)
|
|
1340
|
+
return {
|
|
1341
|
+
"from_project": from_project,
|
|
1342
|
+
"to_project": to_project,
|
|
1343
|
+
"from_model": src_uuid,
|
|
1344
|
+
"to_model": tgt_uuid,
|
|
1345
|
+
"dry_run": dry_run,
|
|
1346
|
+
**per_type_stats,
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
# ------------------------------------------------------------------
|
|
1350
|
+
# Phase 7 — build (AI-assisted / heuristic greenfield)
|
|
1351
|
+
# ------------------------------------------------------------------
|
|
1352
|
+
|
|
1353
|
+
def build_model(
|
|
1354
|
+
self,
|
|
1355
|
+
alias: str,
|
|
1356
|
+
*,
|
|
1357
|
+
table_ids: list[str],
|
|
1358
|
+
model_name: str | None = None,
|
|
1359
|
+
model_name_or_uuid: str | None = None,
|
|
1360
|
+
dry_run: bool = False,
|
|
1361
|
+
keep_on_failure: bool = False,
|
|
1362
|
+
output_path: Path | None = None,
|
|
1363
|
+
) -> dict[str, Any]:
|
|
1364
|
+
"""Build (or update) a semantic-layer model from a list of tableIds.
|
|
1365
|
+
|
|
1366
|
+
Implementation note: the AI Service client (`ai_client.py`) currently
|
|
1367
|
+
exposes only `suggest_components` for natural-language component
|
|
1368
|
+
search; there is no endpoint that returns arbitrary structured JSON.
|
|
1369
|
+
Rather than degrade silently, this method falls back to a
|
|
1370
|
+
DETERMINISTIC HEURISTIC builder: it fetches the storage schema for
|
|
1371
|
+
every tableId in parallel, synthesises a dataset per table with
|
|
1372
|
+
role-classified fields, suggests one COUNT(*)-style metric per
|
|
1373
|
+
table, and seeds a small glossary. The intended behaviour is "best
|
|
1374
|
+
starting point, then iterate via `add`/`edit`" — not "ready-to-ship
|
|
1375
|
+
model". This fallback is documented in gotchas.md.
|
|
1376
|
+
|
|
1377
|
+
Validation runs locally against the generated model BEFORE any POST.
|
|
1378
|
+
If validation surfaces any errors, the operation is refused with
|
|
1379
|
+
VALIDATION_ERROR + the full error list in details so callers can
|
|
1380
|
+
iterate.
|
|
1381
|
+
|
|
1382
|
+
On push failure, ``push_built_model`` rolls back every successfully
|
|
1383
|
+
POSTed child in reverse PUSH_ORDER and deletes the model itself if
|
|
1384
|
+
we created it during this call (issue #295). Pass
|
|
1385
|
+
``keep_on_failure=True`` to skip cleanup and preserve the partial
|
|
1386
|
+
state for forensic inspection (mirrors ``data-app create``).
|
|
1387
|
+
"""
|
|
1388
|
+
if not table_ids:
|
|
1389
|
+
raise KeboolaApiError(
|
|
1390
|
+
message="--tables must contain at least one tableId.",
|
|
1391
|
+
error_code=ErrorCode.VALIDATION_ERROR,
|
|
1392
|
+
)
|
|
1393
|
+
|
|
1394
|
+
# Fetch schemas in parallel via in-process StorageService.
|
|
1395
|
+
storage = StorageService(
|
|
1396
|
+
config_store=self._config_store, client_factory=self._client_factory
|
|
1397
|
+
)
|
|
1398
|
+
schemas_by_tid, fetch_errors = _fetch_table_schemas(storage, alias, table_ids)
|
|
1399
|
+
|
|
1400
|
+
# Generate model JSON (heuristic). The internals helper takes the
|
|
1401
|
+
# FQN-derivation and role-classification callables as kwargs so its
|
|
1402
|
+
# module stays free of import-time coupling to this module.
|
|
1403
|
+
generated = _heuristic_generate_helper(
|
|
1404
|
+
schemas=schemas_by_tid,
|
|
1405
|
+
model_name=model_name or "kbagent_build_model",
|
|
1406
|
+
derive_fqn=_derive_fqn,
|
|
1407
|
+
classify_role=_classify_field_role,
|
|
1408
|
+
)
|
|
1409
|
+
|
|
1410
|
+
# Validate locally.
|
|
1411
|
+
errors: list[dict[str, str]] = []
|
|
1412
|
+
warnings: list[dict[str, str]] = []
|
|
1413
|
+
_validate_basic_helper(
|
|
1414
|
+
datasets=generated["datasets"],
|
|
1415
|
+
metrics=generated["metrics"],
|
|
1416
|
+
relationships=generated["relationships"],
|
|
1417
|
+
constraints=generated["constraints"],
|
|
1418
|
+
glossary=generated["glossary"],
|
|
1419
|
+
errors=errors,
|
|
1420
|
+
warnings=warnings,
|
|
1421
|
+
)
|
|
1422
|
+
|
|
1423
|
+
# Write output file (if requested).
|
|
1424
|
+
if output_path is not None:
|
|
1425
|
+
_write_snapshot_to_file(generated, output_path)
|
|
1426
|
+
|
|
1427
|
+
result: dict[str, Any] = {
|
|
1428
|
+
"project": alias,
|
|
1429
|
+
"dry_run": dry_run,
|
|
1430
|
+
"keep_on_failure": keep_on_failure,
|
|
1431
|
+
"fallback_used": "heuristic", # no AI endpoint shipped yet
|
|
1432
|
+
"fetch_errors": fetch_errors,
|
|
1433
|
+
"generated": generated,
|
|
1434
|
+
"validation": {"errors": errors, "warnings": warnings},
|
|
1435
|
+
"validated": len(errors) == 0,
|
|
1436
|
+
}
|
|
1437
|
+
if output_path is not None:
|
|
1438
|
+
result["output_path"] = str(output_path)
|
|
1439
|
+
|
|
1440
|
+
if dry_run:
|
|
1441
|
+
return result
|
|
1442
|
+
|
|
1443
|
+
if errors:
|
|
1444
|
+
raise KeboolaApiError(
|
|
1445
|
+
message=(
|
|
1446
|
+
f"Generated model failed local validation ({len(errors)} errors). "
|
|
1447
|
+
"Refusing to push. Inspect with --dry-run + --output to iterate."
|
|
1448
|
+
),
|
|
1449
|
+
error_code=ErrorCode.VALIDATION_ERROR,
|
|
1450
|
+
details={"validation": result["validation"]},
|
|
1451
|
+
)
|
|
1452
|
+
|
|
1453
|
+
# Push to the metastore in dependency order.
|
|
1454
|
+
project = self._resolve_one_project(alias)
|
|
1455
|
+
with self._new_metastore_client(project) as client:
|
|
1456
|
+
counts, model_uuid, model_item = _push_built_model(
|
|
1457
|
+
client,
|
|
1458
|
+
generated=generated,
|
|
1459
|
+
model_name_or_uuid=model_name_or_uuid,
|
|
1460
|
+
resolve_model_fn=self._resolve_model,
|
|
1461
|
+
keep_on_failure=keep_on_failure,
|
|
1462
|
+
)
|
|
1463
|
+
result["model"] = {"id": model_uuid, "item": model_item}
|
|
1464
|
+
result["created"] = counts
|
|
1465
|
+
return result
|
|
1466
|
+
|
|
1467
|
+
# ------------------------------------------------------------------
|
|
1468
|
+
# Phase 8 — token --encrypt
|
|
1469
|
+
# ------------------------------------------------------------------
|
|
1470
|
+
|
|
1471
|
+
def encrypt_token(self, alias: str, component_id: str) -> dict[str, Any]:
|
|
1472
|
+
"""Encrypt the project's storage token for `user_properties`.
|
|
1473
|
+
|
|
1474
|
+
Reads the project's own token from ``ProjectConfig`` (no config.json
|
|
1475
|
+
digging) and passes it through the existing :class:`EncryptService`
|
|
1476
|
+
as ``{"#metastore_token": token}``.
|
|
1477
|
+
|
|
1478
|
+
Returns:
|
|
1479
|
+
``{"encrypted": {"#metastore_token": "KBC::ProjectSecure..."},
|
|
1480
|
+
"component_id": C, "project": alias}``.
|
|
1481
|
+
"""
|
|
1482
|
+
project = self._resolve_one_project(alias)
|
|
1483
|
+
# Reuse the production EncryptService factory so the token never
|
|
1484
|
+
# leaves the in-process Storage API path.
|
|
1485
|
+
encrypt = EncryptService(
|
|
1486
|
+
config_store=self._config_store, client_factory=self._client_factory
|
|
1487
|
+
)
|
|
1488
|
+
encrypted = encrypt.encrypt(
|
|
1489
|
+
alias=alias,
|
|
1490
|
+
component_id=component_id,
|
|
1491
|
+
input_data={"#metastore_token": project.token},
|
|
1492
|
+
)
|
|
1493
|
+
return {
|
|
1494
|
+
"project": alias,
|
|
1495
|
+
"component_id": component_id,
|
|
1496
|
+
"encrypted": encrypted,
|
|
1497
|
+
}
|