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,1078 @@
|
|
|
1
|
+
"""Internal helpers for :mod:`semantic_layer_service`.
|
|
2
|
+
|
|
3
|
+
Split out so the orchestrator class stays under the
|
|
4
|
+
CONTRIBUTING.md services hard ceiling (1,500 LOC). Each helper here is
|
|
5
|
+
pure-functional or operates on an externally-provided
|
|
6
|
+
:class:`MetastoreClient` -- there is no in-module state.
|
|
7
|
+
|
|
8
|
+
Helpers grouped by feature:
|
|
9
|
+
|
|
10
|
+
- Validation (basic + deep) :func:`validate_basic`,
|
|
11
|
+
:func:`validate_deep`
|
|
12
|
+
- Diff :func:`collect_side_from_file`,
|
|
13
|
+
:func:`diff_one_type`,
|
|
14
|
+
:func:`compare_attrs`
|
|
15
|
+
- Build (heuristic) :func:`heuristic_generate_model`,
|
|
16
|
+
:func:`fetch_table_schemas`
|
|
17
|
+
- Import / promote :func:`run_import_loop`,
|
|
18
|
+
:func:`run_promote_loop`
|
|
19
|
+
- Export I/O :func:`write_snapshot_to_file`,
|
|
20
|
+
:func:`default_export_path`,
|
|
21
|
+
:func:`build_export_snapshot`
|
|
22
|
+
- Constants reused across modules :data:`DIFF_IGNORED_KEYS`,
|
|
23
|
+
:data:`PUSH_ORDER`
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import logging
|
|
30
|
+
import os
|
|
31
|
+
import re
|
|
32
|
+
from collections.abc import Callable
|
|
33
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from datetime import UTC, datetime
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import TYPE_CHECKING, Any
|
|
38
|
+
|
|
39
|
+
from ..errors import ConfigError, ErrorCode, KeboolaApiError
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
if TYPE_CHECKING:
|
|
44
|
+
from ..metastore_client import SemanticType
|
|
45
|
+
from .storage_service import StorageService
|
|
46
|
+
|
|
47
|
+
# Re-exported from the main module to avoid a circular import; pulled in
|
|
48
|
+
# locally for type hints / runtime constants.
|
|
49
|
+
|
|
50
|
+
# Column reference regex (Snowflake-style ``"SCHEMA"."COLUMN"``).
|
|
51
|
+
_COLUMN_REF_RE = re.compile(r'"[^"]+"\."([^"]+)"')
|
|
52
|
+
|
|
53
|
+
# SUM(... PCT ...) heuristic.
|
|
54
|
+
_SUM_ON_PCT_RE = re.compile(r"\bSUM\s*\(\s*[^)]*\b(PCT|PERCENT|RATE)\b", re.IGNORECASE)
|
|
55
|
+
|
|
56
|
+
# AGG-on-STRING detection for --deep validate.
|
|
57
|
+
_AGG_ON_STRING_FUNCS = ("SUM", "AVG")
|
|
58
|
+
|
|
59
|
+
# Severity-band suffixes recognised by downstream pipelines.
|
|
60
|
+
_SEVERITY_SUFFIXES = ("_critical", "_warning", "_healthy", "_review")
|
|
61
|
+
|
|
62
|
+
# Keys ignored when deep-equality-comparing two items (the modelUUID is
|
|
63
|
+
# rewritten when promoting across projects, and the server-side
|
|
64
|
+
# timestamps drift every fetch).
|
|
65
|
+
DIFF_IGNORED_KEYS: tuple[str, ...] = (
|
|
66
|
+
"modelUUID",
|
|
67
|
+
"createdAt",
|
|
68
|
+
"lastUpdated",
|
|
69
|
+
"revision",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Push order for both `import` and `promote` -- datasets before metrics
|
|
73
|
+
# (metric.dataset references a tableId), constraints last (their
|
|
74
|
+
# `metrics[]` list references metric names).
|
|
75
|
+
PUSH_ORDER: tuple[tuple[str, SemanticType], ...] = (
|
|
76
|
+
("datasets", "semantic-dataset"),
|
|
77
|
+
("metrics", "semantic-metric"),
|
|
78
|
+
("relationships", "semantic-relationship"),
|
|
79
|
+
("glossary", "semantic-glossary"),
|
|
80
|
+
("constraints", "semantic-constraint"),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class WorkerResult:
|
|
86
|
+
"""Outcome of a per-table parallel fetch worker.
|
|
87
|
+
|
|
88
|
+
Shared between the deep-validation pass and the build heuristic.
|
|
89
|
+
Exactly one of ``detail`` / ``error`` is non-None.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
table_id: str
|
|
93
|
+
detail: dict[str, Any] | None
|
|
94
|
+
error: str | None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ── validate (basic + deep) ─────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def validate_basic(
|
|
101
|
+
*,
|
|
102
|
+
datasets: list[dict[str, Any]],
|
|
103
|
+
metrics: list[dict[str, Any]],
|
|
104
|
+
relationships: list[dict[str, Any]],
|
|
105
|
+
constraints: list[dict[str, Any]],
|
|
106
|
+
glossary: list[dict[str, Any]],
|
|
107
|
+
errors: list[dict[str, str]],
|
|
108
|
+
warnings: list[dict[str, str]],
|
|
109
|
+
) -> None:
|
|
110
|
+
"""Pure in-memory validation (no API calls).
|
|
111
|
+
|
|
112
|
+
Mutates ``errors`` and ``warnings`` in place. See the orchestrator's
|
|
113
|
+
docstring for the full list of checks.
|
|
114
|
+
"""
|
|
115
|
+
# DUPLICATES per type (name)
|
|
116
|
+
for label, items, key in (
|
|
117
|
+
("dataset", datasets, "name"),
|
|
118
|
+
("metric", metrics, "name"),
|
|
119
|
+
("relationship", relationships, "name"),
|
|
120
|
+
("constraint", constraints, "name"),
|
|
121
|
+
("glossary", glossary, "term"),
|
|
122
|
+
):
|
|
123
|
+
seen: set[str] = set()
|
|
124
|
+
dups: set[str] = set()
|
|
125
|
+
for it in items:
|
|
126
|
+
name = it.get(key, "")
|
|
127
|
+
if not name:
|
|
128
|
+
continue
|
|
129
|
+
if name in seen:
|
|
130
|
+
dups.add(name)
|
|
131
|
+
seen.add(name)
|
|
132
|
+
for dup in sorted(dups):
|
|
133
|
+
errors.append(
|
|
134
|
+
{"type": "DUPLICATE", "item": f"{label}:{dup}", "detail": f"duplicate {key}"}
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
dataset_tids = {d.get("tableId", "") for d in datasets if d.get("tableId")}
|
|
138
|
+
dataset_names = {d.get("name", "") for d in datasets if d.get("name")}
|
|
139
|
+
|
|
140
|
+
# DANGLING REL: from/to tableId must be in datasets
|
|
141
|
+
for rel in relationships:
|
|
142
|
+
for endpoint in ("from", "to"):
|
|
143
|
+
tid = rel.get(endpoint, "")
|
|
144
|
+
if tid and tid not in dataset_tids and tid not in dataset_names:
|
|
145
|
+
errors.append(
|
|
146
|
+
{
|
|
147
|
+
"type": "DANGLING_RELATIONSHIP",
|
|
148
|
+
"item": rel.get("name", "?"),
|
|
149
|
+
"detail": f"{endpoint}={tid!r} not in model datasets",
|
|
150
|
+
}
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# DANGLING METRIC: metric.dataset is a tableId
|
|
154
|
+
for m in metrics:
|
|
155
|
+
ds = m.get("dataset", "")
|
|
156
|
+
if ds and ds not in dataset_tids and ds not in dataset_names:
|
|
157
|
+
errors.append(
|
|
158
|
+
{
|
|
159
|
+
"type": "DANGLING_METRIC",
|
|
160
|
+
"item": m.get("name", "?"),
|
|
161
|
+
"detail": f"dataset={ds!r} not in model datasets",
|
|
162
|
+
}
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# SUM ON PCT
|
|
166
|
+
for m in metrics:
|
|
167
|
+
sql = m.get("sql", "") or ""
|
|
168
|
+
if _SUM_ON_PCT_RE.search(sql):
|
|
169
|
+
warnings.append(
|
|
170
|
+
{
|
|
171
|
+
"type": "SUM_ON_PCT",
|
|
172
|
+
"item": m.get("name", "?"),
|
|
173
|
+
"detail": "SUM() over a percentage/rate column is usually incorrect",
|
|
174
|
+
}
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# CONSTRAINT ORPHAN + SEVERITY SUFFIX
|
|
178
|
+
metric_names = {m.get("name", "") for m in metrics if m.get("name")}
|
|
179
|
+
for c in constraints:
|
|
180
|
+
cname = c.get("name", "")
|
|
181
|
+
for mname in c.get("metrics", []) or []:
|
|
182
|
+
if mname not in metric_names:
|
|
183
|
+
errors.append(
|
|
184
|
+
{
|
|
185
|
+
"type": "CONSTRAINT_ORPHAN",
|
|
186
|
+
"item": cname or "?",
|
|
187
|
+
"detail": f"references missing metric {mname!r}",
|
|
188
|
+
}
|
|
189
|
+
)
|
|
190
|
+
if cname and not any(cname.endswith(suf) for suf in _SEVERITY_SUFFIXES):
|
|
191
|
+
warnings.append(
|
|
192
|
+
{
|
|
193
|
+
"type": "SEVERITY_SUFFIX",
|
|
194
|
+
"item": cname,
|
|
195
|
+
"detail": (
|
|
196
|
+
"constraint name should end with one of "
|
|
197
|
+
"_critical/_warning/_healthy/_review for downstream parsing"
|
|
198
|
+
),
|
|
199
|
+
}
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def validate_deep(
|
|
204
|
+
*,
|
|
205
|
+
alias: str,
|
|
206
|
+
storage: StorageService,
|
|
207
|
+
datasets: list[dict[str, Any]],
|
|
208
|
+
metrics: list[dict[str, Any]],
|
|
209
|
+
errors: list[dict[str, str]],
|
|
210
|
+
warnings: list[dict[str, str]],
|
|
211
|
+
) -> None:
|
|
212
|
+
"""Add deep checks that require a Snowflake schema fetch per dataset.
|
|
213
|
+
|
|
214
|
+
Mutates ``errors`` and ``warnings`` in place.
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
def _worker(ds: dict[str, Any]) -> WorkerResult:
|
|
218
|
+
tid = ds.get("tableId", "")
|
|
219
|
+
if not tid:
|
|
220
|
+
return WorkerResult(table_id=tid, detail=None, error="missing tableId")
|
|
221
|
+
try:
|
|
222
|
+
detail = storage.get_table_detail(alias, tid)
|
|
223
|
+
return WorkerResult(table_id=tid, detail=detail, error=None)
|
|
224
|
+
except (KeboolaApiError, ConfigError) as exc:
|
|
225
|
+
return WorkerResult(table_id=tid, detail=None, error=str(exc))
|
|
226
|
+
|
|
227
|
+
details_by_tid: dict[str, dict[str, Any]] = {}
|
|
228
|
+
with ThreadPoolExecutor(max_workers=8) as pool:
|
|
229
|
+
for outcome in pool.map(_worker, datasets):
|
|
230
|
+
if outcome.error is not None:
|
|
231
|
+
warnings.append(
|
|
232
|
+
{
|
|
233
|
+
"type": "DEEP_FETCH_FAILED",
|
|
234
|
+
"item": outcome.table_id or "?",
|
|
235
|
+
"detail": outcome.error,
|
|
236
|
+
}
|
|
237
|
+
)
|
|
238
|
+
continue
|
|
239
|
+
if outcome.detail is not None:
|
|
240
|
+
details_by_tid[outcome.table_id] = outcome.detail
|
|
241
|
+
|
|
242
|
+
# PHANTOM FIELD (declared field not in actual columns)
|
|
243
|
+
for ds in datasets:
|
|
244
|
+
tid = ds.get("tableId", "")
|
|
245
|
+
detail = details_by_tid.get(tid)
|
|
246
|
+
if detail is None:
|
|
247
|
+
continue
|
|
248
|
+
actual_columns = set(detail.get("columns", []))
|
|
249
|
+
declared = ds.get("fields", []) or []
|
|
250
|
+
for field in declared:
|
|
251
|
+
fname = field.get("name", "") if isinstance(field, dict) else str(field)
|
|
252
|
+
if fname and fname not in actual_columns:
|
|
253
|
+
errors.append(
|
|
254
|
+
{
|
|
255
|
+
"type": "PHANTOM_FIELD",
|
|
256
|
+
"item": f"{ds.get('name', '?')}.{fname}",
|
|
257
|
+
"detail": f"field {fname!r} not in storage table columns",
|
|
258
|
+
}
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
# METRIC PHANTOM + AGG ON STRING -- build a quick lookup: tableId -> {col_name: basetype}
|
|
262
|
+
col_types_by_tid: dict[str, dict[str, str]] = {}
|
|
263
|
+
for tid, detail in details_by_tid.items():
|
|
264
|
+
cols: dict[str, str] = {}
|
|
265
|
+
for col_info in detail.get("column_details", []) or []:
|
|
266
|
+
cols[col_info.get("name", "")] = (col_info.get("type", "") or "").upper()
|
|
267
|
+
col_types_by_tid[tid] = cols
|
|
268
|
+
|
|
269
|
+
for m in metrics:
|
|
270
|
+
sql = m.get("sql", "") or ""
|
|
271
|
+
mdataset = m.get("dataset", "")
|
|
272
|
+
col_types = col_types_by_tid.get(mdataset, {})
|
|
273
|
+
refs = set(_COLUMN_REF_RE.findall(sql))
|
|
274
|
+
if col_types:
|
|
275
|
+
actual = set(col_types.keys())
|
|
276
|
+
for ref in refs:
|
|
277
|
+
if ref not in actual:
|
|
278
|
+
errors.append(
|
|
279
|
+
{
|
|
280
|
+
"type": "METRIC_PHANTOM",
|
|
281
|
+
"item": m.get("name", "?"),
|
|
282
|
+
"detail": (
|
|
283
|
+
f"column {ref!r} referenced in sql not in dataset {mdataset!r}"
|
|
284
|
+
),
|
|
285
|
+
}
|
|
286
|
+
)
|
|
287
|
+
# AGG ON STRING
|
|
288
|
+
for func in _AGG_ON_STRING_FUNCS:
|
|
289
|
+
pattern = re.compile(
|
|
290
|
+
rf'\b{func}\s*\(\s*[^)]*?"([^"]+)"\."([^"]+)"',
|
|
291
|
+
re.IGNORECASE,
|
|
292
|
+
)
|
|
293
|
+
for match in pattern.finditer(sql):
|
|
294
|
+
col = match.group(2)
|
|
295
|
+
col_type = col_types.get(col, "").upper()
|
|
296
|
+
if col_type == "STRING":
|
|
297
|
+
errors.append(
|
|
298
|
+
{
|
|
299
|
+
"type": "AGG_ON_STRING",
|
|
300
|
+
"item": m.get("name", "?"),
|
|
301
|
+
"detail": f"{func}() on STRING column {col!r}",
|
|
302
|
+
}
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
# ── diff ────────────────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def compare_attrs(a: dict[str, Any], b: dict[str, Any]) -> list[str]:
|
|
310
|
+
"""Return the sorted list of attribute keys that differ between two items."""
|
|
311
|
+
ignored = set(DIFF_IGNORED_KEYS)
|
|
312
|
+
keys = (set(a) | set(b)) - ignored - {"id"}
|
|
313
|
+
diff_keys: list[str] = []
|
|
314
|
+
for key in sorted(keys):
|
|
315
|
+
if a.get(key) != b.get(key):
|
|
316
|
+
diff_keys.append(key)
|
|
317
|
+
return diff_keys
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def diff_one_type(
|
|
321
|
+
left: list[dict[str, Any]],
|
|
322
|
+
right: list[dict[str, Any]],
|
|
323
|
+
*,
|
|
324
|
+
id_key: str,
|
|
325
|
+
) -> dict[str, Any]:
|
|
326
|
+
"""Compute added/removed/changed for one entity type."""
|
|
327
|
+
left_by_key = {item.get(id_key, ""): item for item in left if item.get(id_key)}
|
|
328
|
+
right_by_key = {item.get(id_key, ""): item for item in right if item.get(id_key)}
|
|
329
|
+
|
|
330
|
+
added = sorted(k for k in right_by_key if k not in left_by_key)
|
|
331
|
+
removed = sorted(k for k in left_by_key if k not in right_by_key)
|
|
332
|
+
changed: list[dict[str, Any]] = []
|
|
333
|
+
for key in sorted(set(left_by_key) & set(right_by_key)):
|
|
334
|
+
diff_keys = compare_attrs(left_by_key[key], right_by_key[key])
|
|
335
|
+
if diff_keys:
|
|
336
|
+
changed.append({id_key: key, "diff_keys": diff_keys})
|
|
337
|
+
return {"added": added, "removed": removed, "changed": changed}
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def collect_side_from_file(file: Path) -> dict[str, Any]:
|
|
341
|
+
"""Resolve one diff side from a snapshot file.
|
|
342
|
+
|
|
343
|
+
Returns the shape consumed by the diff orchestrator:
|
|
344
|
+
``{"ref": {"source": "file", "ref": <path>, "model": {...}},
|
|
345
|
+
"data": {<plural>: [attrs_dict, ...]}}``.
|
|
346
|
+
"""
|
|
347
|
+
try:
|
|
348
|
+
payload = json.loads(file.read_text(encoding="utf-8"))
|
|
349
|
+
except OSError as exc:
|
|
350
|
+
raise KeboolaApiError(
|
|
351
|
+
message=f"Cannot read --file {file}: {exc}",
|
|
352
|
+
error_code=ErrorCode.READ_ERROR,
|
|
353
|
+
) from exc
|
|
354
|
+
except json.JSONDecodeError as exc:
|
|
355
|
+
raise KeboolaApiError(
|
|
356
|
+
message=f"File {file} is not valid JSON: {exc}",
|
|
357
|
+
error_code=ErrorCode.INVALID_FORMAT,
|
|
358
|
+
) from exc
|
|
359
|
+
|
|
360
|
+
def _attrs(items: list[Any]) -> list[dict[str, Any]]:
|
|
361
|
+
out: list[dict[str, Any]] = []
|
|
362
|
+
for item in items or []:
|
|
363
|
+
if isinstance(item, dict) and "attributes" in item:
|
|
364
|
+
merged = dict(item.get("attributes") or {})
|
|
365
|
+
merged["id"] = item.get("id", "")
|
|
366
|
+
out.append(merged)
|
|
367
|
+
elif isinstance(item, dict):
|
|
368
|
+
out.append(item)
|
|
369
|
+
return out
|
|
370
|
+
|
|
371
|
+
return {
|
|
372
|
+
"ref": {
|
|
373
|
+
"source": "file",
|
|
374
|
+
"ref": str(file),
|
|
375
|
+
"model": payload.get("model", {}),
|
|
376
|
+
},
|
|
377
|
+
"data": {
|
|
378
|
+
"datasets": _attrs(payload.get("datasets", [])),
|
|
379
|
+
"metrics": _attrs(payload.get("metrics", [])),
|
|
380
|
+
"relationships": _attrs(payload.get("relationships", [])),
|
|
381
|
+
"constraints": _attrs(payload.get("constraints", [])),
|
|
382
|
+
"glossary": _attrs(payload.get("glossary", [])),
|
|
383
|
+
},
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
# ── build (heuristic generator) ─────────────────────────────────────
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def run_import_loop(
|
|
391
|
+
client: Any,
|
|
392
|
+
*,
|
|
393
|
+
snapshot: dict[str, Any],
|
|
394
|
+
target_model_uuid: str,
|
|
395
|
+
existing_by_type: dict[Any, list[dict[str, Any]]],
|
|
396
|
+
type_filter: set[str] | None,
|
|
397
|
+
dry_run: bool,
|
|
398
|
+
overwrite: bool,
|
|
399
|
+
) -> dict[str, Any]:
|
|
400
|
+
"""Replay a snapshot into the target model.
|
|
401
|
+
|
|
402
|
+
Returns the per-type stats dict matching the orchestrator's contract.
|
|
403
|
+
Push order is :data:`PUSH_ORDER`. Errors are accumulated per item;
|
|
404
|
+
one failure does not abort the rest.
|
|
405
|
+
"""
|
|
406
|
+
imported: dict[str, Any] = {}
|
|
407
|
+
for plural, type_slug in PUSH_ORDER:
|
|
408
|
+
if type_filter is not None and plural not in type_filter:
|
|
409
|
+
continue
|
|
410
|
+
source_items = snapshot.get(plural, []) or []
|
|
411
|
+
id_key = "term" if plural == "glossary" else "name"
|
|
412
|
+
existing_by_name: dict[str, dict[str, Any]] = {
|
|
413
|
+
(item.get("attributes") or {}).get(id_key, ""): item
|
|
414
|
+
for item in existing_by_type.get(type_slug, [])
|
|
415
|
+
if (item.get("attributes") or {}).get(id_key)
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
per_type: dict[str, Any] = {
|
|
419
|
+
"created": 0,
|
|
420
|
+
"skipped": 0,
|
|
421
|
+
"overwritten": 0,
|
|
422
|
+
"failed": [],
|
|
423
|
+
}
|
|
424
|
+
for raw_item in source_items:
|
|
425
|
+
if not isinstance(raw_item, dict):
|
|
426
|
+
continue
|
|
427
|
+
attrs = dict(raw_item.get("attributes") or {})
|
|
428
|
+
attrs["modelUUID"] = target_model_uuid # rewrite
|
|
429
|
+
key = attrs.get(id_key, "")
|
|
430
|
+
if not key:
|
|
431
|
+
per_type["failed"].append({"name": "?", "reason": f"missing {id_key} on item"})
|
|
432
|
+
continue
|
|
433
|
+
|
|
434
|
+
if key in existing_by_name:
|
|
435
|
+
if not overwrite:
|
|
436
|
+
per_type["skipped"] += 1
|
|
437
|
+
continue
|
|
438
|
+
if dry_run:
|
|
439
|
+
per_type["overwritten"] += 1
|
|
440
|
+
continue
|
|
441
|
+
try:
|
|
442
|
+
client.delete_item(type_slug, existing_by_name[key]["id"])
|
|
443
|
+
client.post_item(type_slug, name=key, data=attrs)
|
|
444
|
+
per_type["overwritten"] += 1
|
|
445
|
+
except KeboolaApiError as exc:
|
|
446
|
+
per_type["failed"].append({"name": key, "reason": exc.message})
|
|
447
|
+
continue
|
|
448
|
+
|
|
449
|
+
if dry_run:
|
|
450
|
+
per_type["created"] += 1
|
|
451
|
+
continue
|
|
452
|
+
try:
|
|
453
|
+
client.post_item(type_slug, name=key, data=attrs)
|
|
454
|
+
per_type["created"] += 1
|
|
455
|
+
except KeboolaApiError as exc:
|
|
456
|
+
per_type["failed"].append({"name": key, "reason": exc.message})
|
|
457
|
+
|
|
458
|
+
imported[plural] = per_type
|
|
459
|
+
return imported
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def run_promote_loop(
|
|
463
|
+
target_client: Any,
|
|
464
|
+
*,
|
|
465
|
+
src_children: dict[Any, list[dict[str, Any]]],
|
|
466
|
+
tgt_children: dict[Any, list[dict[str, Any]]],
|
|
467
|
+
target_model_uuid: str,
|
|
468
|
+
type_filter: set[str] | None,
|
|
469
|
+
dry_run: bool,
|
|
470
|
+
) -> dict[str, Any]:
|
|
471
|
+
"""Run the additive + overwrite promote loop.
|
|
472
|
+
|
|
473
|
+
NEW: POST to target. CHANGED: DELETE+POST. IDENTICAL: skip.
|
|
474
|
+
Items only in target are never deleted (additive only).
|
|
475
|
+
Returns ``{plural: {new, overwritten, identical, failed, changes}}``.
|
|
476
|
+
"""
|
|
477
|
+
result: dict[str, Any] = {}
|
|
478
|
+
for plural, type_slug in PUSH_ORDER:
|
|
479
|
+
if type_filter is not None and plural not in type_filter:
|
|
480
|
+
continue
|
|
481
|
+
id_key = "term" if plural == "glossary" else "name"
|
|
482
|
+
src_items = src_children.get(type_slug, []) or []
|
|
483
|
+
tgt_by_key: dict[str, dict[str, Any]] = {
|
|
484
|
+
(it.get("attributes") or {}).get(id_key, ""): it
|
|
485
|
+
for it in tgt_children.get(type_slug, [])
|
|
486
|
+
if (it.get("attributes") or {}).get(id_key)
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
stats: dict[str, Any] = {
|
|
490
|
+
"new": 0,
|
|
491
|
+
"overwritten": 0,
|
|
492
|
+
"identical": 0,
|
|
493
|
+
"failed": [],
|
|
494
|
+
"changes": [],
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
for src in src_items:
|
|
498
|
+
src_attrs = dict(src.get("attributes") or {})
|
|
499
|
+
src_attrs["modelUUID"] = target_model_uuid # rewrite
|
|
500
|
+
key = src_attrs.get(id_key, "")
|
|
501
|
+
if not key:
|
|
502
|
+
stats["failed"].append({"name": "?", "reason": f"missing {id_key} on source item"})
|
|
503
|
+
continue
|
|
504
|
+
|
|
505
|
+
if key in tgt_by_key:
|
|
506
|
+
diff_keys = compare_attrs(src_attrs, dict(tgt_by_key[key].get("attributes") or {}))
|
|
507
|
+
if not diff_keys:
|
|
508
|
+
stats["identical"] += 1
|
|
509
|
+
continue
|
|
510
|
+
if dry_run:
|
|
511
|
+
stats["overwritten"] += 1
|
|
512
|
+
stats["changes"].append({id_key: key, "diff_keys": diff_keys})
|
|
513
|
+
continue
|
|
514
|
+
try:
|
|
515
|
+
target_client.delete_item(type_slug, tgt_by_key[key]["id"])
|
|
516
|
+
target_client.post_item(type_slug, name=key, data=src_attrs)
|
|
517
|
+
stats["overwritten"] += 1
|
|
518
|
+
stats["changes"].append({id_key: key, "diff_keys": diff_keys})
|
|
519
|
+
except KeboolaApiError as exc:
|
|
520
|
+
stats["failed"].append({"name": key, "reason": exc.message})
|
|
521
|
+
continue
|
|
522
|
+
|
|
523
|
+
if dry_run:
|
|
524
|
+
stats["new"] += 1
|
|
525
|
+
continue
|
|
526
|
+
try:
|
|
527
|
+
target_client.post_item(type_slug, name=key, data=src_attrs)
|
|
528
|
+
stats["new"] += 1
|
|
529
|
+
except KeboolaApiError as exc:
|
|
530
|
+
stats["failed"].append({"name": key, "reason": exc.message})
|
|
531
|
+
|
|
532
|
+
result[plural] = stats
|
|
533
|
+
return result
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def push_built_model(
|
|
537
|
+
client: Any,
|
|
538
|
+
*,
|
|
539
|
+
generated: dict[str, Any],
|
|
540
|
+
model_name_or_uuid: str | None,
|
|
541
|
+
resolve_model_fn: Callable[[Any, str], tuple[str, dict[str, Any]]],
|
|
542
|
+
keep_on_failure: bool = False,
|
|
543
|
+
) -> tuple[dict[str, int], str, dict[str, Any] | None]:
|
|
544
|
+
"""Push a heuristic-generated model + children to the metastore.
|
|
545
|
+
|
|
546
|
+
Returns ``(counts, model_uuid, model_item)`` where ``model_item``
|
|
547
|
+
is the freshly-created model record (``None`` when updating an
|
|
548
|
+
existing model). ``counts`` maps each plural to the number of
|
|
549
|
+
successfully POSTed children.
|
|
550
|
+
|
|
551
|
+
Rollback semantics (issue #295): on any POST failure during the
|
|
552
|
+
children loop, every successfully-POSTed child is DELETEd in
|
|
553
|
+
reverse order, then the model itself is DELETEd **only when this
|
|
554
|
+
call created it** (i.e. ``model_name_or_uuid`` was None). The
|
|
555
|
+
original exception is re-raised wrapped in a KeboolaApiError that
|
|
556
|
+
carries the rollback summary in ``details``. Pass
|
|
557
|
+
``keep_on_failure=True`` to skip cleanup for forensic inspection.
|
|
558
|
+
"""
|
|
559
|
+
if model_name_or_uuid is None:
|
|
560
|
+
# Create the model.
|
|
561
|
+
model_attrs: dict[str, Any] = {
|
|
562
|
+
"name": generated["name"],
|
|
563
|
+
"sql_dialect": generated.get("sql_dialect", "Snowflake"),
|
|
564
|
+
}
|
|
565
|
+
if generated.get("description"):
|
|
566
|
+
model_attrs["description"] = generated["description"]
|
|
567
|
+
model_item = client.post_item("semantic-model", name=generated["name"], data=model_attrs)
|
|
568
|
+
model_uuid = model_item["id"]
|
|
569
|
+
model_created_here = True
|
|
570
|
+
else:
|
|
571
|
+
model_uuid, _ = resolve_model_fn(client, model_name_or_uuid)
|
|
572
|
+
model_item = None
|
|
573
|
+
model_created_here = False
|
|
574
|
+
|
|
575
|
+
posted_children: list[tuple[SemanticType, str, str]] = []
|
|
576
|
+
counts: dict[str, int] = {plural: 0 for plural, _ in PUSH_ORDER}
|
|
577
|
+
# Sentinel: the except clause references `plural` and `name` to tag the
|
|
578
|
+
# failing row in the wrapped error message; pre-init so they're defined
|
|
579
|
+
# even on the rare path where the loop never started a row (e.g. the
|
|
580
|
+
# first POST raised before either was set inside the loop body).
|
|
581
|
+
plural = ""
|
|
582
|
+
name = ""
|
|
583
|
+
try:
|
|
584
|
+
for plural, type_slug in PUSH_ORDER:
|
|
585
|
+
for item in generated.get(plural, []) or []:
|
|
586
|
+
# Resolve `name` BEFORE `dict(item)` so an exception during
|
|
587
|
+
# the dict-copy (or in the POST itself) tags the wrapped
|
|
588
|
+
# error with the current row, not the previous one.
|
|
589
|
+
name = (
|
|
590
|
+
(item.get("name") if isinstance(item, dict) else "")
|
|
591
|
+
or (item.get("term") if isinstance(item, dict) else "")
|
|
592
|
+
or ""
|
|
593
|
+
)
|
|
594
|
+
if not name:
|
|
595
|
+
continue
|
|
596
|
+
attrs = dict(item)
|
|
597
|
+
attrs["modelUUID"] = model_uuid
|
|
598
|
+
posted = client.post_item(type_slug, name=name, data=attrs)
|
|
599
|
+
posted_id = str(posted.get("id", "") or "") if isinstance(posted, dict) else ""
|
|
600
|
+
if not posted_id:
|
|
601
|
+
# Defensive: the metastore always returns `id` in a
|
|
602
|
+
# dict body, but a proxy / middleware that mangles
|
|
603
|
+
# the response would otherwise leave this child
|
|
604
|
+
# untrackable for rollback. Skip the counts increment
|
|
605
|
+
# so the visible state matches what we can actually
|
|
606
|
+
# clean up.
|
|
607
|
+
logger.warning(
|
|
608
|
+
"POST %s name=%s returned no id (response type=%s); "
|
|
609
|
+
"skipping rollback tracking",
|
|
610
|
+
type_slug,
|
|
611
|
+
name,
|
|
612
|
+
type(posted).__name__,
|
|
613
|
+
)
|
|
614
|
+
continue
|
|
615
|
+
posted_children.append((type_slug, posted_id, name))
|
|
616
|
+
counts[plural] += 1
|
|
617
|
+
except (KeyboardInterrupt, SystemExit):
|
|
618
|
+
raise
|
|
619
|
+
except Exception as exc:
|
|
620
|
+
# Catch broadly: client.post_item can raise httpx.RequestError
|
|
621
|
+
# subclasses (ReadError, ProtocolError, ...) that are NOT
|
|
622
|
+
# KeboolaApiError; without the rollback path on those, the partial
|
|
623
|
+
# state this PR exists to clean up would still leak. Re-raise of
|
|
624
|
+
# KeyboardInterrupt / SystemExit above keeps Ctrl-C honest.
|
|
625
|
+
api_exc = exc if isinstance(exc, KeboolaApiError) else None
|
|
626
|
+
error_code = api_exc.error_code if api_exc else ErrorCode.INTERNAL_ERROR
|
|
627
|
+
status_code = api_exc.status_code if api_exc else 500
|
|
628
|
+
exc_message = api_exc.message if api_exc else str(exc) or type(exc).__name__
|
|
629
|
+
|
|
630
|
+
if keep_on_failure:
|
|
631
|
+
logger.info(
|
|
632
|
+
"build_model push failed for %s/%s; preserving %d posted children + model %s "
|
|
633
|
+
"(--keep-on-failure set)",
|
|
634
|
+
plural,
|
|
635
|
+
name,
|
|
636
|
+
len(posted_children),
|
|
637
|
+
model_uuid,
|
|
638
|
+
)
|
|
639
|
+
raise KeboolaApiError(
|
|
640
|
+
message=(
|
|
641
|
+
f"build_model push failed at {plural}/{name!r}: {exc_message}. "
|
|
642
|
+
f"Partial state preserved (--keep-on-failure): {len(posted_children)} "
|
|
643
|
+
f"children posted, model {model_uuid} kept."
|
|
644
|
+
),
|
|
645
|
+
error_code=error_code,
|
|
646
|
+
status_code=status_code,
|
|
647
|
+
details={
|
|
648
|
+
"rollback": {
|
|
649
|
+
"attempted": False,
|
|
650
|
+
"reason": "keep_on_failure",
|
|
651
|
+
"posted_children": len(posted_children),
|
|
652
|
+
"model_created_here": model_created_here,
|
|
653
|
+
"model_uuid": model_uuid,
|
|
654
|
+
}
|
|
655
|
+
},
|
|
656
|
+
) from exc
|
|
657
|
+
deleted = 0
|
|
658
|
+
failed_deletes: list[dict[str, str]] = []
|
|
659
|
+
for type_slug, child_id, child_name in reversed(posted_children):
|
|
660
|
+
try:
|
|
661
|
+
client.delete_item(type_slug, child_id)
|
|
662
|
+
deleted += 1
|
|
663
|
+
except (KeyboardInterrupt, SystemExit):
|
|
664
|
+
raise
|
|
665
|
+
except Exception as del_exc:
|
|
666
|
+
# Same rationale as the outer broad catch: a non-API
|
|
667
|
+
# exception from delete_item must not abort the cleanup or
|
|
668
|
+
# mask the original POST failure. logger.warning (no
|
|
669
|
+
# traceback) keeps log volume bounded when the metastore
|
|
670
|
+
# is fully down -- 200+ ERROR-level stack traces would
|
|
671
|
+
# otherwise drown out the original POST failure. The
|
|
672
|
+
# consolidated summary line below carries the full count.
|
|
673
|
+
failed_deletes.append(
|
|
674
|
+
{
|
|
675
|
+
"type": type_slug,
|
|
676
|
+
"id": child_id,
|
|
677
|
+
"name": child_name,
|
|
678
|
+
"error": (
|
|
679
|
+
del_exc.message
|
|
680
|
+
if isinstance(del_exc, KeboolaApiError)
|
|
681
|
+
else str(del_exc) or type(del_exc).__name__
|
|
682
|
+
),
|
|
683
|
+
}
|
|
684
|
+
)
|
|
685
|
+
logger.warning(
|
|
686
|
+
"Rollback DELETE failed for %s id=%s name=%s: %s",
|
|
687
|
+
type_slug,
|
|
688
|
+
child_id,
|
|
689
|
+
child_name,
|
|
690
|
+
failed_deletes[-1]["error"],
|
|
691
|
+
)
|
|
692
|
+
model_deleted = False
|
|
693
|
+
model_delete_error: str | None = None
|
|
694
|
+
if model_created_here:
|
|
695
|
+
try:
|
|
696
|
+
client.delete_item("semantic-model", model_uuid)
|
|
697
|
+
model_deleted = True
|
|
698
|
+
except (KeyboardInterrupt, SystemExit):
|
|
699
|
+
raise
|
|
700
|
+
except Exception as del_exc:
|
|
701
|
+
model_delete_error = (
|
|
702
|
+
del_exc.message
|
|
703
|
+
if isinstance(del_exc, KeboolaApiError)
|
|
704
|
+
else str(del_exc) or type(del_exc).__name__
|
|
705
|
+
)
|
|
706
|
+
# logger.exception here (with traceback) because there's
|
|
707
|
+
# only ever ONE model-delete; the volume concern that
|
|
708
|
+
# applies to the per-child loop does not.
|
|
709
|
+
logger.exception(
|
|
710
|
+
"Rollback DELETE of created model %s failed during build_model cleanup",
|
|
711
|
+
model_uuid,
|
|
712
|
+
)
|
|
713
|
+
if failed_deletes:
|
|
714
|
+
logger.error(
|
|
715
|
+
"build_model rollback summary: %d/%d child DELETEs failed during cleanup",
|
|
716
|
+
len(failed_deletes),
|
|
717
|
+
len(posted_children),
|
|
718
|
+
)
|
|
719
|
+
raise KeboolaApiError(
|
|
720
|
+
message=(
|
|
721
|
+
f"build_model push failed at {plural}/{name!r}: {exc_message}. "
|
|
722
|
+
f"Rollback deleted {deleted}/{len(posted_children)} child(ren)"
|
|
723
|
+
+ (
|
|
724
|
+
f" and the model ({model_uuid})."
|
|
725
|
+
if model_deleted
|
|
726
|
+
else (
|
|
727
|
+
f"; model ({model_uuid}) delete failed: {model_delete_error}."
|
|
728
|
+
if model_created_here
|
|
729
|
+
else f"; existing model ({model_uuid}) preserved."
|
|
730
|
+
)
|
|
731
|
+
)
|
|
732
|
+
),
|
|
733
|
+
error_code=error_code,
|
|
734
|
+
status_code=status_code,
|
|
735
|
+
details={
|
|
736
|
+
"rollback": {
|
|
737
|
+
"attempted": True,
|
|
738
|
+
"posted_children": len(posted_children),
|
|
739
|
+
"deleted": deleted,
|
|
740
|
+
"failed_deletes": failed_deletes,
|
|
741
|
+
"model_created_here": model_created_here,
|
|
742
|
+
"model_deleted": model_deleted,
|
|
743
|
+
"model_delete_error": model_delete_error,
|
|
744
|
+
"model_uuid": model_uuid,
|
|
745
|
+
}
|
|
746
|
+
},
|
|
747
|
+
) from exc
|
|
748
|
+
return counts, model_uuid, model_item
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def synthesize_role_classified_fields(
|
|
752
|
+
storage: StorageService,
|
|
753
|
+
alias: str,
|
|
754
|
+
table_id: str,
|
|
755
|
+
classify_role: Callable[[str, str], str],
|
|
756
|
+
) -> list[dict[str, Any]]:
|
|
757
|
+
"""Fetch storage schema for ``table_id`` and synthesise ``fields[]`` with role heuristics.
|
|
758
|
+
|
|
759
|
+
Used by ``add_dataset --deep-fields``. Returns the list of
|
|
760
|
+
``{name, type, role}`` dicts (one per column); empty when the
|
|
761
|
+
storage table has no columns.
|
|
762
|
+
"""
|
|
763
|
+
detail = storage.get_table_detail(alias, table_id)
|
|
764
|
+
fields: list[dict[str, Any]] = []
|
|
765
|
+
for col in detail.get("column_details", []) or []:
|
|
766
|
+
cname = col.get("name", "")
|
|
767
|
+
basetype = col.get("type", "") or col.get("native_type", "")
|
|
768
|
+
fields.append(
|
|
769
|
+
{
|
|
770
|
+
"name": cname,
|
|
771
|
+
"type": basetype,
|
|
772
|
+
"role": classify_role(cname, basetype),
|
|
773
|
+
}
|
|
774
|
+
)
|
|
775
|
+
return fields
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def fetch_table_schemas(
|
|
779
|
+
storage: StorageService,
|
|
780
|
+
alias: str,
|
|
781
|
+
table_ids: list[str],
|
|
782
|
+
) -> tuple[dict[str, dict[str, Any]], list[dict[str, str]]]:
|
|
783
|
+
"""Fetch storage schemas for a list of tableIds in parallel.
|
|
784
|
+
|
|
785
|
+
Returns ``(schemas_by_tid, fetch_errors)`` where ``fetch_errors`` is
|
|
786
|
+
a list of ``{"table_id", "error"}`` dicts -- one per missing /
|
|
787
|
+
failed table. Errors are returned (not raised) so the build pass
|
|
788
|
+
can decide whether to abort.
|
|
789
|
+
"""
|
|
790
|
+
|
|
791
|
+
def _worker(tid: str) -> WorkerResult:
|
|
792
|
+
try:
|
|
793
|
+
detail = storage.get_table_detail(alias, tid)
|
|
794
|
+
return WorkerResult(table_id=tid, detail=detail, error=None)
|
|
795
|
+
except (KeboolaApiError, ConfigError) as exc:
|
|
796
|
+
return WorkerResult(table_id=tid, detail=None, error=str(exc))
|
|
797
|
+
|
|
798
|
+
schemas_by_tid: dict[str, dict[str, Any]] = {}
|
|
799
|
+
fetch_errors: list[dict[str, str]] = []
|
|
800
|
+
with ThreadPoolExecutor(max_workers=8) as pool:
|
|
801
|
+
for outcome in pool.map(_worker, table_ids):
|
|
802
|
+
if outcome.error is not None:
|
|
803
|
+
fetch_errors.append({"table_id": outcome.table_id, "error": outcome.error})
|
|
804
|
+
continue
|
|
805
|
+
if outcome.detail is not None:
|
|
806
|
+
schemas_by_tid[outcome.table_id] = outcome.detail
|
|
807
|
+
return schemas_by_tid, fetch_errors
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def resolve_model_uuid(
|
|
811
|
+
client: Any,
|
|
812
|
+
model_name_or_uuid: str | None,
|
|
813
|
+
) -> tuple[str, dict[str, Any]]:
|
|
814
|
+
"""Resolve a model selector to ``(uuid, attributes_dict)``.
|
|
815
|
+
|
|
816
|
+
See :meth:`SemanticLayerService._resolve_model` for the contract.
|
|
817
|
+
Lives here so the orchestrator class stays under the file-size
|
|
818
|
+
budget; the class method is a one-line delegate.
|
|
819
|
+
|
|
820
|
+
Raises ``ConfigError`` when the selector is ambiguous or missing.
|
|
821
|
+
"""
|
|
822
|
+
models = client.list_items("semantic-model")
|
|
823
|
+
if not models:
|
|
824
|
+
raise ConfigError(
|
|
825
|
+
"Project has no semantic-layer models. Use "
|
|
826
|
+
"'kbagent semantic-layer model create' to create one."
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
if model_name_or_uuid is None:
|
|
830
|
+
if len(models) == 1:
|
|
831
|
+
return models[0]["id"], dict(models[0].get("attributes") or {})
|
|
832
|
+
names = ", ".join(sorted((m.get("attributes") or {}).get("name", "?") for m in models))
|
|
833
|
+
raise ConfigError(f"Project has {len(models)} models — specify --model. Available: {names}")
|
|
834
|
+
|
|
835
|
+
# Try UUID match first (exact ID match)
|
|
836
|
+
for m in models:
|
|
837
|
+
if m.get("id") == model_name_or_uuid:
|
|
838
|
+
return m["id"], dict(m.get("attributes") or {})
|
|
839
|
+
|
|
840
|
+
# Then name match
|
|
841
|
+
name_matches = [
|
|
842
|
+
m for m in models if (m.get("attributes") or {}).get("name") == model_name_or_uuid
|
|
843
|
+
]
|
|
844
|
+
if len(name_matches) == 1:
|
|
845
|
+
return name_matches[0]["id"], dict(name_matches[0].get("attributes") or {})
|
|
846
|
+
if len(name_matches) > 1:
|
|
847
|
+
raise ConfigError(
|
|
848
|
+
f"Multiple models found with name '{model_name_or_uuid}'. Specify the UUID instead."
|
|
849
|
+
)
|
|
850
|
+
|
|
851
|
+
names = ", ".join(sorted((m.get("attributes") or {}).get("name", "?") for m in models))
|
|
852
|
+
raise ConfigError(f"Model '{model_name_or_uuid}' not found. Available: {names}")
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def unpack_attrs_with_id(
|
|
856
|
+
items: list[dict[str, Any]], *, id_field: str = "_id"
|
|
857
|
+
) -> list[dict[str, Any]]:
|
|
858
|
+
"""Flatten ``{type, id, attributes}`` items to attributes dicts + injected id field.
|
|
859
|
+
|
|
860
|
+
Used by validate_model and other callers that need attribute-only
|
|
861
|
+
lists with the server id preserved (under ``_id`` by default so it
|
|
862
|
+
doesn't collide with the attribute namespace).
|
|
863
|
+
"""
|
|
864
|
+
return [dict(i.get("attributes") or {}, **{id_field: i.get("id", "")}) for i in items]
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def unpack_children_by_plural(
|
|
868
|
+
raw_by_type: dict[Any, list[dict[str, Any]]],
|
|
869
|
+
*,
|
|
870
|
+
id_field: str = "id",
|
|
871
|
+
) -> dict[str, list[dict[str, Any]]]:
|
|
872
|
+
"""Convert ``raw_by_type[semantic-X]`` lists to attribute dicts keyed by plural.
|
|
873
|
+
|
|
874
|
+
Returns a dict with keys ``datasets``, ``metrics``, ``relationships``,
|
|
875
|
+
``constraints``, ``glossary`` -- ready to splat into a result envelope.
|
|
876
|
+
"""
|
|
877
|
+
return {
|
|
878
|
+
"datasets": unpack_attrs_with_id(
|
|
879
|
+
raw_by_type.get("semantic-dataset", []), id_field=id_field
|
|
880
|
+
),
|
|
881
|
+
"metrics": unpack_attrs_with_id(raw_by_type.get("semantic-metric", []), id_field=id_field),
|
|
882
|
+
"relationships": unpack_attrs_with_id(
|
|
883
|
+
raw_by_type.get("semantic-relationship", []), id_field=id_field
|
|
884
|
+
),
|
|
885
|
+
"constraints": unpack_attrs_with_id(
|
|
886
|
+
raw_by_type.get("semantic-constraint", []), id_field=id_field
|
|
887
|
+
),
|
|
888
|
+
"glossary": unpack_attrs_with_id(
|
|
889
|
+
raw_by_type.get("semantic-glossary", []), id_field=id_field
|
|
890
|
+
),
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
def default_export_path(model_name: str) -> Path:
|
|
895
|
+
"""Default ``./sl_export_{model_name}_{YYYYMMDD_HHMMSS}.json`` path."""
|
|
896
|
+
ts = datetime.now(tz=UTC).strftime("%Y%m%d_%H%M%S")
|
|
897
|
+
safe_name = re.sub(r"[^A-Za-z0-9._-]+", "_", str(model_name)) or "model"
|
|
898
|
+
return Path.cwd() / f"sl_export_{safe_name}_{ts}.json"
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def build_export_snapshot(
|
|
902
|
+
*,
|
|
903
|
+
alias: str,
|
|
904
|
+
model_uuid: str,
|
|
905
|
+
model_attrs: dict[str, Any],
|
|
906
|
+
raw_by_type: dict[Any, list[dict[str, Any]]],
|
|
907
|
+
) -> dict[str, Any]:
|
|
908
|
+
"""Assemble the self-describing export envelope."""
|
|
909
|
+
exported_at = datetime.now(tz=UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
910
|
+
return {
|
|
911
|
+
"exported_at": exported_at,
|
|
912
|
+
"project": alias,
|
|
913
|
+
"model": dict(model_attrs, id=model_uuid),
|
|
914
|
+
"datasets": raw_by_type.get("semantic-dataset", []),
|
|
915
|
+
"metrics": raw_by_type.get("semantic-metric", []),
|
|
916
|
+
"relationships": raw_by_type.get("semantic-relationship", []),
|
|
917
|
+
"constraints": raw_by_type.get("semantic-constraint", []),
|
|
918
|
+
"glossary": raw_by_type.get("semantic-glossary", []),
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
def write_snapshot_to_file(snapshot: dict[str, Any], output_path: Path) -> None:
|
|
923
|
+
"""Atomic-write a JSON snapshot with 0o644 perms and O_NOFOLLOW.
|
|
924
|
+
|
|
925
|
+
O_NOFOLLOW refuses to follow a pre-existing symlink at the chosen
|
|
926
|
+
path so a malicious --output (or a planted symlink in CWD) cannot
|
|
927
|
+
redirect the write to a sensitive file.
|
|
928
|
+
"""
|
|
929
|
+
payload = json.dumps(snapshot, indent=2).encode("utf-8")
|
|
930
|
+
fd = os.open(
|
|
931
|
+
str(output_path),
|
|
932
|
+
os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW,
|
|
933
|
+
0o644,
|
|
934
|
+
)
|
|
935
|
+
try:
|
|
936
|
+
os.write(fd, payload)
|
|
937
|
+
finally:
|
|
938
|
+
os.close(fd)
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
# Map warehouse-native column types onto the closed set the metastore
|
|
942
|
+
# accepts for `fields[*].type`. Anything not matched falls through to
|
|
943
|
+
# "string" — safest default for legacy untyped Storage tables.
|
|
944
|
+
_FIELD_TYPE_MAP: dict[str, str] = {
|
|
945
|
+
# strings
|
|
946
|
+
"varchar": "string",
|
|
947
|
+
"char": "string",
|
|
948
|
+
"string": "string",
|
|
949
|
+
"text": "string",
|
|
950
|
+
"nvarchar": "string",
|
|
951
|
+
"nchar": "string",
|
|
952
|
+
# integers
|
|
953
|
+
"int": "integer",
|
|
954
|
+
"integer": "integer",
|
|
955
|
+
"bigint": "integer",
|
|
956
|
+
"smallint": "integer",
|
|
957
|
+
"tinyint": "integer",
|
|
958
|
+
# decimals
|
|
959
|
+
"decimal": "decimal",
|
|
960
|
+
"numeric": "decimal",
|
|
961
|
+
"number": "decimal",
|
|
962
|
+
"float": "decimal",
|
|
963
|
+
"double": "decimal",
|
|
964
|
+
"real": "decimal",
|
|
965
|
+
"money": "decimal",
|
|
966
|
+
# booleans
|
|
967
|
+
"boolean": "boolean",
|
|
968
|
+
"bool": "boolean",
|
|
969
|
+
"bit": "boolean",
|
|
970
|
+
# date / datetime
|
|
971
|
+
"date": "date",
|
|
972
|
+
"datetime": "datetime",
|
|
973
|
+
"datetime2": "datetime",
|
|
974
|
+
"timestamp": "datetime",
|
|
975
|
+
"timestamptz": "datetime",
|
|
976
|
+
"timestamp_ntz": "datetime",
|
|
977
|
+
"timestamp_ltz": "datetime",
|
|
978
|
+
"timestamp_tz": "datetime",
|
|
979
|
+
# json
|
|
980
|
+
"json": "json",
|
|
981
|
+
"jsonb": "json",
|
|
982
|
+
"variant": "json",
|
|
983
|
+
"object": "json",
|
|
984
|
+
"array": "json",
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
def _normalize_field_type(basetype: str) -> str:
|
|
989
|
+
"""Coerce a warehouse-native type into the metastore's closed vocabulary."""
|
|
990
|
+
if not basetype:
|
|
991
|
+
return "string"
|
|
992
|
+
# strip parens (e.g. VARCHAR(255), DECIMAL(38, 9))
|
|
993
|
+
head = basetype.split("(", 1)[0].strip().lower()
|
|
994
|
+
return _FIELD_TYPE_MAP.get(head, "string")
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
def heuristic_generate_model(
|
|
998
|
+
*,
|
|
999
|
+
schemas: dict[str, dict[str, Any]],
|
|
1000
|
+
model_name: str,
|
|
1001
|
+
derive_fqn: Callable[[str], str],
|
|
1002
|
+
classify_role: Callable[[str, str], str],
|
|
1003
|
+
) -> dict[str, Any]:
|
|
1004
|
+
"""Deterministic stand-in for the AI generator (see ``build_model``).
|
|
1005
|
+
|
|
1006
|
+
Builds: one dataset per table (with classified fields[]), one
|
|
1007
|
+
COUNT(*) metric per dataset as a placeholder, no relationships
|
|
1008
|
+
(cross-table FKs are not inferrable from columns alone), an empty
|
|
1009
|
+
constraints list, and a glossary entry per dataset.
|
|
1010
|
+
|
|
1011
|
+
Accepts the FQN-derivation and role-classification helpers as
|
|
1012
|
+
callables so the helper module stays free of import-time coupling
|
|
1013
|
+
to the main service module's regex constants.
|
|
1014
|
+
"""
|
|
1015
|
+
datasets: list[dict[str, Any]] = []
|
|
1016
|
+
metrics: list[dict[str, Any]] = []
|
|
1017
|
+
glossary: list[dict[str, Any]] = []
|
|
1018
|
+
|
|
1019
|
+
for tid, detail in schemas.items():
|
|
1020
|
+
ds_name = (
|
|
1021
|
+
(detail.get("display_name") or detail.get("name") or tid.split(".")[-1])
|
|
1022
|
+
.replace(" ", "_")
|
|
1023
|
+
.lower()
|
|
1024
|
+
)
|
|
1025
|
+
fields: list[dict[str, Any]] = []
|
|
1026
|
+
for col in detail.get("column_details", []) or []:
|
|
1027
|
+
cname = col.get("name", "")
|
|
1028
|
+
basetype = col.get("type", "") or col.get("native_type", "")
|
|
1029
|
+
# Metastore validates field types against a closed lowercase set:
|
|
1030
|
+
# {string, integer, decimal, boolean, date, datetime, json}
|
|
1031
|
+
# Storage returns warehouse-native types (VARCHAR, INTEGER, ...)
|
|
1032
|
+
# or empty strings for untyped legacy tables. Map both to the
|
|
1033
|
+
# metastore vocabulary; fall back to "string" for unknown /
|
|
1034
|
+
# empty types so the builder doesn't 422 on legacy buckets.
|
|
1035
|
+
fields.append(
|
|
1036
|
+
{
|
|
1037
|
+
"name": cname,
|
|
1038
|
+
"type": _normalize_field_type(basetype),
|
|
1039
|
+
"role": classify_role(cname, basetype),
|
|
1040
|
+
}
|
|
1041
|
+
)
|
|
1042
|
+
datasets.append(
|
|
1043
|
+
{
|
|
1044
|
+
"name": ds_name,
|
|
1045
|
+
"tableId": tid,
|
|
1046
|
+
"fqn": derive_fqn(tid),
|
|
1047
|
+
"fields": fields,
|
|
1048
|
+
"description": detail.get("description", "") or "",
|
|
1049
|
+
}
|
|
1050
|
+
)
|
|
1051
|
+
metrics.append(
|
|
1052
|
+
{
|
|
1053
|
+
"name": f"{ds_name}_row_count",
|
|
1054
|
+
"sql": f"COUNT(*) FROM {derive_fqn(tid)}",
|
|
1055
|
+
"dataset": tid,
|
|
1056
|
+
"description": f"Row count of {ds_name}.",
|
|
1057
|
+
}
|
|
1058
|
+
)
|
|
1059
|
+
glossary.append(
|
|
1060
|
+
{
|
|
1061
|
+
"term": ds_name,
|
|
1062
|
+
"definition": f"Table {tid}: {detail.get('description', '') or 'auto-generated'}.",
|
|
1063
|
+
}
|
|
1064
|
+
)
|
|
1065
|
+
|
|
1066
|
+
return {
|
|
1067
|
+
"name": model_name,
|
|
1068
|
+
"description": (
|
|
1069
|
+
f"Heuristic-generated model from {len(schemas)} table(s). "
|
|
1070
|
+
"Iterate with `kbagent semantic-layer add/edit`."
|
|
1071
|
+
),
|
|
1072
|
+
"sql_dialect": "Snowflake",
|
|
1073
|
+
"datasets": datasets,
|
|
1074
|
+
"metrics": metrics,
|
|
1075
|
+
"relationships": [],
|
|
1076
|
+
"constraints": [],
|
|
1077
|
+
"glossary": glossary,
|
|
1078
|
+
}
|