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.
Files changed (306) hide show
  1. keboola_agent_cli/__init__.py +34 -0
  2. keboola_agent_cli/__main__.py +5 -0
  3. keboola_agent_cli/_ui_dist/assets/arc-DhFYIddx.js +2 -0
  4. keboola_agent_cli/_ui_dist/assets/arc-DhFYIddx.js.map +1 -0
  5. keboola_agent_cli/_ui_dist/assets/architecture-7EHR7CIX-hNCijx_H.js +1 -0
  6. keboola_agent_cli/_ui_dist/assets/architectureDiagram-3BPJPVTR-C6hUlprM.js +37 -0
  7. keboola_agent_cli/_ui_dist/assets/architectureDiagram-3BPJPVTR-C6hUlprM.js.map +1 -0
  8. keboola_agent_cli/_ui_dist/assets/array-BifhSqXX.js +2 -0
  9. keboola_agent_cli/_ui_dist/assets/array-BifhSqXX.js.map +1 -0
  10. keboola_agent_cli/_ui_dist/assets/blockDiagram-GPEHLZMM-DC7qY9i4.js +133 -0
  11. keboola_agent_cli/_ui_dist/assets/blockDiagram-GPEHLZMM-DC7qY9i4.js.map +1 -0
  12. keboola_agent_cli/_ui_dist/assets/c4Diagram-AAUBKEIU-5Lh44evt.js +11 -0
  13. keboola_agent_cli/_ui_dist/assets/c4Diagram-AAUBKEIU-5Lh44evt.js.map +1 -0
  14. keboola_agent_cli/_ui_dist/assets/channel-DBMrXlxx.js +2 -0
  15. keboola_agent_cli/_ui_dist/assets/channel-DBMrXlxx.js.map +1 -0
  16. keboola_agent_cli/_ui_dist/assets/chunk-2J33WTMH-Coy82EBh.js +2 -0
  17. keboola_agent_cli/_ui_dist/assets/chunk-2J33WTMH-Coy82EBh.js.map +1 -0
  18. keboola_agent_cli/_ui_dist/assets/chunk-3OPIFGDE-BQC5CRHI.js +63 -0
  19. keboola_agent_cli/_ui_dist/assets/chunk-3OPIFGDE-BQC5CRHI.js.map +1 -0
  20. keboola_agent_cli/_ui_dist/assets/chunk-4BX2VUAB-DUuEt70o.js +2 -0
  21. keboola_agent_cli/_ui_dist/assets/chunk-4BX2VUAB-DUuEt70o.js.map +1 -0
  22. keboola_agent_cli/_ui_dist/assets/chunk-55IACEB6-BvR-6chF.js +2 -0
  23. keboola_agent_cli/_ui_dist/assets/chunk-55IACEB6-BvR-6chF.js.map +1 -0
  24. keboola_agent_cli/_ui_dist/assets/chunk-5ZQYHXKU-BjcTN7ul.js +3 -0
  25. keboola_agent_cli/_ui_dist/assets/chunk-5ZQYHXKU-BjcTN7ul.js.map +1 -0
  26. keboola_agent_cli/_ui_dist/assets/chunk-727SXJPM-C0zxqqRN.js +207 -0
  27. keboola_agent_cli/_ui_dist/assets/chunk-727SXJPM-C0zxqqRN.js.map +1 -0
  28. keboola_agent_cli/_ui_dist/assets/chunk-AQP2D5EJ-CXf7rIlZ.js +232 -0
  29. keboola_agent_cli/_ui_dist/assets/chunk-AQP2D5EJ-CXf7rIlZ.js.map +1 -0
  30. keboola_agent_cli/_ui_dist/assets/chunk-BSJP7CBP-Oj_FO9Q7.js +2 -0
  31. keboola_agent_cli/_ui_dist/assets/chunk-BSJP7CBP-Oj_FO9Q7.js.map +1 -0
  32. keboola_agent_cli/_ui_dist/assets/chunk-CSCIHK7Q-CcTsLrFc.js +124 -0
  33. keboola_agent_cli/_ui_dist/assets/chunk-CSCIHK7Q-CcTsLrFc.js.map +1 -0
  34. keboola_agent_cli/_ui_dist/assets/chunk-FMBD7UC4-FH-zLkkW.js +16 -0
  35. keboola_agent_cli/_ui_dist/assets/chunk-FMBD7UC4-FH-zLkkW.js.map +1 -0
  36. keboola_agent_cli/_ui_dist/assets/chunk-L5ZTLDWV-B1Ky_e7O.js +2 -0
  37. keboola_agent_cli/_ui_dist/assets/chunk-L5ZTLDWV-B1Ky_e7O.js.map +1 -0
  38. keboola_agent_cli/_ui_dist/assets/chunk-ND2GUHAM-BHz1rpbm.js +2 -0
  39. keboola_agent_cli/_ui_dist/assets/chunk-ND2GUHAM-BHz1rpbm.js.map +1 -0
  40. keboola_agent_cli/_ui_dist/assets/chunk-NNHCCRGN-DlpIbxXb.js +160 -0
  41. keboola_agent_cli/_ui_dist/assets/chunk-NNHCCRGN-DlpIbxXb.js.map +1 -0
  42. keboola_agent_cli/_ui_dist/assets/chunk-NZK2D7GU-tnrSoegS.js +2 -0
  43. keboola_agent_cli/_ui_dist/assets/chunk-NZK2D7GU-tnrSoegS.js.map +1 -0
  44. keboola_agent_cli/_ui_dist/assets/chunk-O5CBEL6O-DxxqDH0l.js +71 -0
  45. keboola_agent_cli/_ui_dist/assets/chunk-O5CBEL6O-DxxqDH0l.js.map +1 -0
  46. keboola_agent_cli/_ui_dist/assets/chunk-QZHKN3VN-CSjc2gjj.js +2 -0
  47. keboola_agent_cli/_ui_dist/assets/chunk-QZHKN3VN-CSjc2gjj.js.map +1 -0
  48. keboola_agent_cli/_ui_dist/assets/classDiagram-4FO5ZUOK-BuZcZu85.js +2 -0
  49. keboola_agent_cli/_ui_dist/assets/classDiagram-4FO5ZUOK-BuZcZu85.js.map +1 -0
  50. keboola_agent_cli/_ui_dist/assets/classDiagram-v2-Q7XG4LA2-BuZcZu85.js +2 -0
  51. keboola_agent_cli/_ui_dist/assets/classDiagram-v2-Q7XG4LA2-BuZcZu85.js.map +1 -0
  52. keboola_agent_cli/_ui_dist/assets/cose-bilkent-S5V4N54A-Y0L8LDMa.js +2 -0
  53. keboola_agent_cli/_ui_dist/assets/cose-bilkent-S5V4N54A-Y0L8LDMa.js.map +1 -0
  54. keboola_agent_cli/_ui_dist/assets/cytoscape.esm-C8YCVR3_.js +322 -0
  55. keboola_agent_cli/_ui_dist/assets/cytoscape.esm-C8YCVR3_.js.map +1 -0
  56. keboola_agent_cli/_ui_dist/assets/dagre-BM42HDAG-UZ-9BTqF.js +5 -0
  57. keboola_agent_cli/_ui_dist/assets/dagre-BM42HDAG-UZ-9BTqF.js.map +1 -0
  58. keboola_agent_cli/_ui_dist/assets/dagre-Bx709z4p.js +2 -0
  59. keboola_agent_cli/_ui_dist/assets/dagre-Bx709z4p.js.map +1 -0
  60. keboola_agent_cli/_ui_dist/assets/defaultLocale-C8Fc0cco.js +2 -0
  61. keboola_agent_cli/_ui_dist/assets/defaultLocale-C8Fc0cco.js.map +1 -0
  62. keboola_agent_cli/_ui_dist/assets/diagram-2AECGRRQ-DoDQ60wi.js +44 -0
  63. keboola_agent_cli/_ui_dist/assets/diagram-2AECGRRQ-DoDQ60wi.js.map +1 -0
  64. keboola_agent_cli/_ui_dist/assets/diagram-5GNKFQAL-CMGFxpUs.js +11 -0
  65. keboola_agent_cli/_ui_dist/assets/diagram-5GNKFQAL-CMGFxpUs.js.map +1 -0
  66. keboola_agent_cli/_ui_dist/assets/diagram-KO2AKTUF-1uGDa-Iu.js +4 -0
  67. keboola_agent_cli/_ui_dist/assets/diagram-KO2AKTUF-1uGDa-Iu.js.map +1 -0
  68. keboola_agent_cli/_ui_dist/assets/diagram-LMA3HP47-XtFH7B51.js +25 -0
  69. keboola_agent_cli/_ui_dist/assets/diagram-LMA3HP47-XtFH7B51.js.map +1 -0
  70. keboola_agent_cli/_ui_dist/assets/diagram-OG6HWLK6-B4_Te1T5.js +25 -0
  71. keboola_agent_cli/_ui_dist/assets/diagram-OG6HWLK6-B4_Te1T5.js.map +1 -0
  72. keboola_agent_cli/_ui_dist/assets/dist-Di6zmlv0.js +2 -0
  73. keboola_agent_cli/_ui_dist/assets/dist-Di6zmlv0.js.map +1 -0
  74. keboola_agent_cli/_ui_dist/assets/erDiagram-TEJ5UH35-NjQkrdFt.js +86 -0
  75. keboola_agent_cli/_ui_dist/assets/erDiagram-TEJ5UH35-NjQkrdFt.js.map +1 -0
  76. keboola_agent_cli/_ui_dist/assets/eventmodeling-FCH6USID-BrJMIks8.js +1 -0
  77. keboola_agent_cli/_ui_dist/assets/flowDiagram-I6XJVG4X-CIr8DWl7.js +163 -0
  78. keboola_agent_cli/_ui_dist/assets/flowDiagram-I6XJVG4X-CIr8DWl7.js.map +1 -0
  79. keboola_agent_cli/_ui_dist/assets/ganttDiagram-6RSMTGT7-C1VY_xbQ.js +293 -0
  80. keboola_agent_cli/_ui_dist/assets/ganttDiagram-6RSMTGT7-C1VY_xbQ.js.map +1 -0
  81. keboola_agent_cli/_ui_dist/assets/gitGraph-WXDBUCRP-COacYjo-.js +1 -0
  82. keboola_agent_cli/_ui_dist/assets/gitGraphDiagram-PVQCEYII-DQT8-kg2.js +107 -0
  83. keboola_agent_cli/_ui_dist/assets/gitGraphDiagram-PVQCEYII-DQT8-kg2.js.map +1 -0
  84. keboola_agent_cli/_ui_dist/assets/graphlib-B8gBHxth.js +2 -0
  85. keboola_agent_cli/_ui_dist/assets/graphlib-B8gBHxth.js.map +1 -0
  86. keboola_agent_cli/_ui_dist/assets/index-CMq50kkV.css +1 -0
  87. keboola_agent_cli/_ui_dist/assets/index-D8W97DAz.js +118 -0
  88. keboola_agent_cli/_ui_dist/assets/index-D8W97DAz.js.map +1 -0
  89. keboola_agent_cli/_ui_dist/assets/info-J43DQDTF-DdCTRIzU.js +1 -0
  90. keboola_agent_cli/_ui_dist/assets/infoDiagram-5YYISTIA-C77rsoTp.js +3 -0
  91. keboola_agent_cli/_ui_dist/assets/infoDiagram-5YYISTIA-C77rsoTp.js.map +1 -0
  92. keboola_agent_cli/_ui_dist/assets/init-D6jRqBbL.js +2 -0
  93. keboola_agent_cli/_ui_dist/assets/init-D6jRqBbL.js.map +1 -0
  94. keboola_agent_cli/_ui_dist/assets/ishikawaDiagram-YF4QCWOH-BcTbXaLy.js +71 -0
  95. keboola_agent_cli/_ui_dist/assets/ishikawaDiagram-YF4QCWOH-BcTbXaLy.js.map +1 -0
  96. keboola_agent_cli/_ui_dist/assets/journeyDiagram-JHISSGLW-BejeAJQ_.js +140 -0
  97. keboola_agent_cli/_ui_dist/assets/journeyDiagram-JHISSGLW-BejeAJQ_.js.map +1 -0
  98. keboola_agent_cli/_ui_dist/assets/kanban-definition-UN3LZRKU-BRNz_UrH.js +90 -0
  99. keboola_agent_cli/_ui_dist/assets/kanban-definition-UN3LZRKU-BRNz_UrH.js.map +1 -0
  100. keboola_agent_cli/_ui_dist/assets/katex-C4eR7coU.js +258 -0
  101. keboola_agent_cli/_ui_dist/assets/katex-C4eR7coU.js.map +1 -0
  102. keboola_agent_cli/_ui_dist/assets/line-CzAQKFbJ.js +2 -0
  103. keboola_agent_cli/_ui_dist/assets/line-CzAQKFbJ.js.map +1 -0
  104. keboola_agent_cli/_ui_dist/assets/linear-DUNFFdck.js +2 -0
  105. keboola_agent_cli/_ui_dist/assets/linear-DUNFFdck.js.map +1 -0
  106. keboola_agent_cli/_ui_dist/assets/mermaid-parser.core-CpuBOkFa.js +5 -0
  107. keboola_agent_cli/_ui_dist/assets/mermaid-parser.core-CpuBOkFa.js.map +1 -0
  108. keboola_agent_cli/_ui_dist/assets/mindmap-definition-RKZ34NQL-9EJQNjH0.js +97 -0
  109. keboola_agent_cli/_ui_dist/assets/mindmap-definition-RKZ34NQL-9EJQNjH0.js.map +1 -0
  110. keboola_agent_cli/_ui_dist/assets/ordinal-hYBb2elL.js +2 -0
  111. keboola_agent_cli/_ui_dist/assets/ordinal-hYBb2elL.js.map +1 -0
  112. keboola_agent_cli/_ui_dist/assets/packet-YPE3B663-DLiiw_B2.js +1 -0
  113. keboola_agent_cli/_ui_dist/assets/path-BWPyau1x.js +2 -0
  114. keboola_agent_cli/_ui_dist/assets/path-BWPyau1x.js.map +1 -0
  115. keboola_agent_cli/_ui_dist/assets/pie-LRSECV5Y-CRoO8G1g.js +1 -0
  116. keboola_agent_cli/_ui_dist/assets/pieDiagram-4H26LBE5-XH4cy6Cb.js +31 -0
  117. keboola_agent_cli/_ui_dist/assets/pieDiagram-4H26LBE5-XH4cy6Cb.js.map +1 -0
  118. keboola_agent_cli/_ui_dist/assets/quadrantDiagram-W4KKPZXB-fdhc93U8.js +8 -0
  119. keboola_agent_cli/_ui_dist/assets/quadrantDiagram-W4KKPZXB-fdhc93U8.js.map +1 -0
  120. keboola_agent_cli/_ui_dist/assets/radar-GUYGQ44K-DAlLVJHm.js +1 -0
  121. keboola_agent_cli/_ui_dist/assets/requirementDiagram-4Y6WPE33-a94eP3R9.js +85 -0
  122. keboola_agent_cli/_ui_dist/assets/requirementDiagram-4Y6WPE33-a94eP3R9.js.map +1 -0
  123. keboola_agent_cli/_ui_dist/assets/rough.esm-CSKSodPl.js +2 -0
  124. keboola_agent_cli/_ui_dist/assets/rough.esm-CSKSodPl.js.map +1 -0
  125. keboola_agent_cli/_ui_dist/assets/sankeyDiagram-5OEKKPKP-jcBa02sp.js +41 -0
  126. keboola_agent_cli/_ui_dist/assets/sankeyDiagram-5OEKKPKP-jcBa02sp.js.map +1 -0
  127. keboola_agent_cli/_ui_dist/assets/sequenceDiagram-3UESZ5HK-A5-GGM-e.js +163 -0
  128. keboola_agent_cli/_ui_dist/assets/sequenceDiagram-3UESZ5HK-A5-GGM-e.js.map +1 -0
  129. keboola_agent_cli/_ui_dist/assets/src-ZI-V_AF0.js +2 -0
  130. keboola_agent_cli/_ui_dist/assets/src-ZI-V_AF0.js.map +1 -0
  131. keboola_agent_cli/_ui_dist/assets/stateDiagram-AJRCARHV-BKAA5rqE.js +2 -0
  132. keboola_agent_cli/_ui_dist/assets/stateDiagram-AJRCARHV-BKAA5rqE.js.map +1 -0
  133. keboola_agent_cli/_ui_dist/assets/stateDiagram-v2-BHNVJYJU-DnJwJBsE.js +2 -0
  134. keboola_agent_cli/_ui_dist/assets/stateDiagram-v2-BHNVJYJU-DnJwJBsE.js.map +1 -0
  135. keboola_agent_cli/_ui_dist/assets/timeline-definition-PNZ67QCA-Cy39jp8b.js +121 -0
  136. keboola_agent_cli/_ui_dist/assets/timeline-definition-PNZ67QCA-Cy39jp8b.js.map +1 -0
  137. keboola_agent_cli/_ui_dist/assets/treeView-BLDUP644-DbLYl23-.js +1 -0
  138. keboola_agent_cli/_ui_dist/assets/treemap-LRROVOQU-Bp0eGlOt.js +1 -0
  139. keboola_agent_cli/_ui_dist/assets/vennDiagram-CIIHVFJN-BGECKubd.js +35 -0
  140. keboola_agent_cli/_ui_dist/assets/vennDiagram-CIIHVFJN-BGECKubd.js.map +1 -0
  141. keboola_agent_cli/_ui_dist/assets/wardley-L42UT6IY-D4yH4jqS.js +1 -0
  142. keboola_agent_cli/_ui_dist/assets/wardleyDiagram-YWT4CUSO-D6XRG3cZ.js +79 -0
  143. keboola_agent_cli/_ui_dist/assets/wardleyDiagram-YWT4CUSO-D6XRG3cZ.js.map +1 -0
  144. keboola_agent_cli/_ui_dist/assets/xychartDiagram-2RQKCTM6-DRre-pfZ.js +8 -0
  145. keboola_agent_cli/_ui_dist/assets/xychartDiagram-2RQKCTM6-DRre-pfZ.js.map +1 -0
  146. keboola_agent_cli/_ui_dist/index.html +50 -0
  147. keboola_agent_cli/ai_client.py +83 -0
  148. keboola_agent_cli/auto_update.py +550 -0
  149. keboola_agent_cli/changelog.py +1198 -0
  150. keboola_agent_cli/cli.py +448 -0
  151. keboola_agent_cli/client.py +3422 -0
  152. keboola_agent_cli/commands/__init__.py +0 -0
  153. keboola_agent_cli/commands/_data_app_git.py +343 -0
  154. keboola_agent_cli/commands/_helpers.py +377 -0
  155. keboola_agent_cli/commands/_metadata_input.py +49 -0
  156. keboola_agent_cli/commands/_semantic_layer_crud.py +632 -0
  157. keboola_agent_cli/commands/_semantic_layer_helpers.py +44 -0
  158. keboola_agent_cli/commands/_semantic_layer_reference_data.py +247 -0
  159. keboola_agent_cli/commands/agent.py +968 -0
  160. keboola_agent_cli/commands/branch.py +423 -0
  161. keboola_agent_cli/commands/changelog.py +168 -0
  162. keboola_agent_cli/commands/component.py +216 -0
  163. keboola_agent_cli/commands/config.py +2442 -0
  164. keboola_agent_cli/commands/context.py +1481 -0
  165. keboola_agent_cli/commands/data_app.py +1279 -0
  166. keboola_agent_cli/commands/dev_portal.py +584 -0
  167. keboola_agent_cli/commands/doctor.py +37 -0
  168. keboola_agent_cli/commands/encrypt.py +145 -0
  169. keboola_agent_cli/commands/feature.py +311 -0
  170. keboola_agent_cli/commands/flow.py +948 -0
  171. keboola_agent_cli/commands/http_client.py +157 -0
  172. keboola_agent_cli/commands/init.py +279 -0
  173. keboola_agent_cli/commands/job.py +661 -0
  174. keboola_agent_cli/commands/kai.py +301 -0
  175. keboola_agent_cli/commands/lineage.py +1464 -0
  176. keboola_agent_cli/commands/org.py +292 -0
  177. keboola_agent_cli/commands/permissions.py +360 -0
  178. keboola_agent_cli/commands/project.py +1192 -0
  179. keboola_agent_cli/commands/repl.py +243 -0
  180. keboola_agent_cli/commands/schedule.py +340 -0
  181. keboola_agent_cli/commands/search.py +178 -0
  182. keboola_agent_cli/commands/semantic_layer.py +939 -0
  183. keboola_agent_cli/commands/serve.py +272 -0
  184. keboola_agent_cli/commands/sharing.py +340 -0
  185. keboola_agent_cli/commands/storage.py +2630 -0
  186. keboola_agent_cli/commands/stream.py +266 -0
  187. keboola_agent_cli/commands/sync.py +1277 -0
  188. keboola_agent_cli/commands/tool.py +206 -0
  189. keboola_agent_cli/commands/version.py +186 -0
  190. keboola_agent_cli/commands/workspace.py +635 -0
  191. keboola_agent_cli/config_store.py +582 -0
  192. keboola_agent_cli/constants.py +528 -0
  193. keboola_agent_cli/data_science_client.py +342 -0
  194. keboola_agent_cli/dev_portal_client.py +323 -0
  195. keboola_agent_cli/errors.py +248 -0
  196. keboola_agent_cli/http_base.py +315 -0
  197. keboola_agent_cli/json_utils.py +126 -0
  198. keboola_agent_cli/lib.py +536 -0
  199. keboola_agent_cli/manage_client.py +324 -0
  200. keboola_agent_cli/metastore_client.py +214 -0
  201. keboola_agent_cli/models.py +427 -0
  202. keboola_agent_cli/output.py +1084 -0
  203. keboola_agent_cli/permissions.py +469 -0
  204. keboola_agent_cli/py.typed +3 -0
  205. keboola_agent_cli/result_models.py +271 -0
  206. keboola_agent_cli/server/__init__.py +34 -0
  207. keboola_agent_cli/server/agent_runner.py +1289 -0
  208. keboola_agent_cli/server/agents_store.py +325 -0
  209. keboola_agent_cli/server/app.py +764 -0
  210. keboola_agent_cli/server/auth.py +117 -0
  211. keboola_agent_cli/server/dependencies.py +149 -0
  212. keboola_agent_cli/server/pricing.py +303 -0
  213. keboola_agent_cli/server/routers/__init__.py +1 -0
  214. keboola_agent_cli/server/routers/agents.py +616 -0
  215. keboola_agent_cli/server/routers/ai_chat.py +129 -0
  216. keboola_agent_cli/server/routers/branches.py +133 -0
  217. keboola_agent_cli/server/routers/components.py +48 -0
  218. keboola_agent_cli/server/routers/configs.py +507 -0
  219. keboola_agent_cli/server/routers/data_apps.py +384 -0
  220. keboola_agent_cli/server/routers/dev_portal.py +67 -0
  221. keboola_agent_cli/server/routers/encrypt.py +35 -0
  222. keboola_agent_cli/server/routers/feature.py +179 -0
  223. keboola_agent_cli/server/routers/flows.py +204 -0
  224. keboola_agent_cli/server/routers/health.py +53 -0
  225. keboola_agent_cli/server/routers/jobs.py +175 -0
  226. keboola_agent_cli/server/routers/kai.py +80 -0
  227. keboola_agent_cli/server/routers/lineage.py +226 -0
  228. keboola_agent_cli/server/routers/mcp.py +70 -0
  229. keboola_agent_cli/server/routers/members.py +170 -0
  230. keboola_agent_cli/server/routers/org.py +96 -0
  231. keboola_agent_cli/server/routers/projects.py +106 -0
  232. keboola_agent_cli/server/routers/schedules.py +54 -0
  233. keboola_agent_cli/server/routers/search.py +30 -0
  234. keboola_agent_cli/server/routers/semantic_layer.py +650 -0
  235. keboola_agent_cli/server/routers/sharing.py +86 -0
  236. keboola_agent_cli/server/routers/storage.py +574 -0
  237. keboola_agent_cli/server/routers/stream.py +100 -0
  238. keboola_agent_cli/server/routers/workspaces.py +302 -0
  239. keboola_agent_cli/server/run_broadcaster.py +329 -0
  240. keboola_agent_cli/server/sse.py +25 -0
  241. keboola_agent_cli/services/__init__.py +0 -0
  242. keboola_agent_cli/services/_encryption.py +217 -0
  243. keboola_agent_cli/services/_semantic_layer_cascade.py +147 -0
  244. keboola_agent_cli/services/_semantic_layer_crud.py +382 -0
  245. keboola_agent_cli/services/_semantic_layer_internals.py +1078 -0
  246. keboola_agent_cli/services/_semantic_layer_lookup.py +181 -0
  247. keboola_agent_cli/services/_semantic_layer_reference_data.py +217 -0
  248. keboola_agent_cli/services/_sync_bindings.py +456 -0
  249. keboola_agent_cli/services/_sync_branch.py +191 -0
  250. keboola_agent_cli/services/_sync_bulk.py +228 -0
  251. keboola_agent_cli/services/_sync_clone.py +163 -0
  252. keboola_agent_cli/services/_sync_models.py +97 -0
  253. keboola_agent_cli/services/_sync_push_ops.py +369 -0
  254. keboola_agent_cli/services/_sync_storage.py +376 -0
  255. keboola_agent_cli/services/_sync_writeback.py +167 -0
  256. keboola_agent_cli/services/agent_service.py +458 -0
  257. keboola_agent_cli/services/base.py +175 -0
  258. keboola_agent_cli/services/branch_service.py +588 -0
  259. keboola_agent_cli/services/component_service.py +694 -0
  260. keboola_agent_cli/services/config_service.py +2099 -0
  261. keboola_agent_cli/services/data_app_git_service.py +224 -0
  262. keboola_agent_cli/services/data_app_service.py +2082 -0
  263. keboola_agent_cli/services/deep_lineage_service.py +1322 -0
  264. keboola_agent_cli/services/dev_portal_service.py +345 -0
  265. keboola_agent_cli/services/doctor_service.py +445 -0
  266. keboola_agent_cli/services/encrypt_service.py +87 -0
  267. keboola_agent_cli/services/feature_service.py +268 -0
  268. keboola_agent_cli/services/flow_service.py +769 -0
  269. keboola_agent_cli/services/flow_validation.py +188 -0
  270. keboola_agent_cli/services/http_forwarder_service.py +236 -0
  271. keboola_agent_cli/services/job_idempotency_store.py +285 -0
  272. keboola_agent_cli/services/job_service.py +797 -0
  273. keboola_agent_cli/services/kai_service.py +367 -0
  274. keboola_agent_cli/services/lineage_service.py +274 -0
  275. keboola_agent_cli/services/mcp_service.py +1498 -0
  276. keboola_agent_cli/services/mcp_transport.py +259 -0
  277. keboola_agent_cli/services/member_service.py +593 -0
  278. keboola_agent_cli/services/org_service.py +619 -0
  279. keboola_agent_cli/services/project_service.py +947 -0
  280. keboola_agent_cli/services/repo_validate_service.py +767 -0
  281. keboola_agent_cli/services/schedule_service.py +731 -0
  282. keboola_agent_cli/services/search_service.py +331 -0
  283. keboola_agent_cli/services/semantic_layer_service.py +1497 -0
  284. keboola_agent_cli/services/sharing_service.py +307 -0
  285. keboola_agent_cli/services/storage_service.py +2524 -0
  286. keboola_agent_cli/services/stream_service.py +395 -0
  287. keboola_agent_cli/services/sync_service.py +2244 -0
  288. keboola_agent_cli/services/variables_service.py +447 -0
  289. keboola_agent_cli/services/version_service.py +1038 -0
  290. keboola_agent_cli/services/workspace_service.py +1103 -0
  291. keboola_agent_cli/stream_client.py +217 -0
  292. keboola_agent_cli/sync/__init__.py +1 -0
  293. keboola_agent_cli/sync/branch_mapping.py +174 -0
  294. keboola_agent_cli/sync/clone.py +211 -0
  295. keboola_agent_cli/sync/code_extraction.py +655 -0
  296. keboola_agent_cli/sync/config_format.py +290 -0
  297. keboola_agent_cli/sync/diff_engine.py +566 -0
  298. keboola_agent_cli/sync/git_utils.py +93 -0
  299. keboola_agent_cli/sync/manifest.py +162 -0
  300. keboola_agent_cli/sync/naming.py +90 -0
  301. keboola_agent_cli/sync/secrets.py +62 -0
  302. keboola_agent_cli/sync/sql_split.py +134 -0
  303. keboola_cli-0.63.4.dist-info/METADATA +308 -0
  304. keboola_cli-0.63.4.dist-info/RECORD +306 -0
  305. keboola_cli-0.63.4.dist-info/WHEEL +4 -0
  306. 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
+ }