splent-cli 1.2.9__tar.gz → 1.4.1__tar.gz

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 (177) hide show
  1. {splent_cli-1.2.9/src/splent_cli.egg-info → splent_cli-1.4.1}/PKG-INFO +1 -1
  2. {splent_cli-1.2.9 → splent_cli-1.4.1}/pyproject.toml +1 -1
  3. splent_cli-1.4.1/src/splent_cli/cli.py +149 -0
  4. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/cache/cache_versions.py +2 -1
  5. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/check/check_deps.py +63 -38
  6. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/check/check_features.py +2 -2
  7. splent_cli-1.4.1/src/splent_cli/commands/check/check_infra.py +222 -0
  8. splent_cli-1.4.1/src/splent_cli/commands/check/check_product.py +330 -0
  9. splent_cli-1.4.1/src/splent_cli/commands/clear_cache.py +64 -0
  10. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/clear_log.py +2 -0
  11. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/clear_uploads.py +2 -0
  12. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/coverage.py +2 -0
  13. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/database/db_console.py +3 -0
  14. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/database/db_dump.py +3 -0
  15. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/database/db_migrate.py +2 -0
  16. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/database/db_reset.py +2 -0
  17. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/database/db_restore.py +3 -0
  18. splent_cli-1.4.1/src/splent_cli/commands/database/db_rollback.py +195 -0
  19. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/database/db_seed.py +14 -6
  20. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/database/db_status.py +32 -0
  21. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/database/db_upgrade.py +2 -0
  22. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/doctor.py +22 -12
  23. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/env/env_set.py +1 -21
  24. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/export_puml.py +11 -1
  25. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/feature/feature_add.py +20 -7
  26. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/feature/feature_attach.py +11 -5
  27. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/feature/feature_clone.py +19 -27
  28. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/feature/feature_contract.py +77 -5
  29. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/feature/feature_create.py +14 -2
  30. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/feature/feature_diff.py +12 -2
  31. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/feature/feature_discard.py +3 -2
  32. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/feature/feature_drift.py +1 -0
  33. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/feature/feature_edit.py +100 -29
  34. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/feature/feature_env.py +2 -2
  35. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/feature/feature_fork.py +2 -1
  36. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/feature/feature_hook_add.py +3 -1
  37. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/feature/feature_hook_remove.py +3 -1
  38. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/feature/feature_hooks.py +3 -1
  39. splent_cli-1.4.1/src/splent_cli/commands/feature/feature_inject_config.py +215 -0
  40. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/feature/feature_order.py +98 -3
  41. splent_cli-1.4.1/src/splent_cli/commands/feature/feature_outdated.py +176 -0
  42. splent_cli-1.4.1/src/splent_cli/commands/feature/feature_pin.py +141 -0
  43. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/feature/feature_pull.py +3 -1
  44. splent_cli-1.4.1/src/splent_cli/commands/feature/feature_release.py +386 -0
  45. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/feature/feature_remove.py +22 -6
  46. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/feature/feature_rename.py +3 -1
  47. splent_cli-1.4.1/src/splent_cli/commands/feature/feature_status.py +426 -0
  48. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/feature/feature_sync_template.py +1 -0
  49. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/feature/feature_test.py +32 -4
  50. splent_cli-1.4.1/src/splent_cli/commands/feature/feature_translate.py +189 -0
  51. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/feature/feature_upgrade.py +4 -8
  52. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/feature/feature_versions.py +7 -10
  53. splent_cli-1.4.1/src/splent_cli/commands/feature/feature_xray.py +463 -0
  54. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/feature_compile.py +18 -27
  55. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/locust.py +3 -0
  56. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/product/product_build.py +90 -35
  57. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/product/product_clean.py +6 -9
  58. splent_cli-1.4.1/src/splent_cli/commands/product/product_commands.py +95 -0
  59. splent_cli-1.2.9/src/splent_cli/commands/uvl/uvl_sync.py → splent_cli-1.4.1/src/splent_cli/commands/product/product_complete.py +11 -16
  60. splent_cli-1.4.1/src/splent_cli/commands/product/product_config.py +173 -0
  61. splent_cli-1.4.1/src/splent_cli/commands/product/product_configure.py +1053 -0
  62. splent_cli-1.4.1/src/splent_cli/commands/product/product_console.py +108 -0
  63. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/product/product_create.py +56 -3
  64. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/product/product_deploy.py +74 -8
  65. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/product/product_derive.py +92 -77
  66. splent_cli-1.4.1/src/splent_cli/commands/product/product_deselect.py +35 -0
  67. splent_cli-1.4.1/src/splent_cli/commands/product/product_down.py +110 -0
  68. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/product/product_env.py +45 -11
  69. splent_cli-1.2.9/src/splent_cli/commands/uvl/uvl_missing.py → splent_cli-1.4.1/src/splent_cli/commands/product/product_missing.py +9 -14
  70. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/product/product_port.py +3 -6
  71. splent_cli-1.4.1/src/splent_cli/commands/product/product_release.py +117 -0
  72. splent_cli-1.4.1/src/splent_cli/commands/product/product_restart.py +58 -0
  73. splent_cli-1.4.1/src/splent_cli/commands/product/product_routes.py +121 -0
  74. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/product/product_select.py +0 -2
  75. splent_cli-1.4.1/src/splent_cli/commands/product/product_signals.py +64 -0
  76. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/product/product_status.py +49 -4
  77. splent_cli-1.4.1/src/splent_cli/commands/product/product_sync.py +256 -0
  78. splent_cli-1.4.1/src/splent_cli/commands/product/product_test.py +113 -0
  79. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/product/product_up.py +6 -9
  80. splent_cli-1.4.1/src/splent_cli/commands/product/product_validate.py +147 -0
  81. splent_cli-1.4.1/src/splent_cli/commands/release/release_core.py +46 -0
  82. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/selenium.py +1 -0
  83. splent_cli-1.4.1/src/splent_cli/commands/spl/spl_add_feature.py +217 -0
  84. splent_cli-1.4.1/src/splent_cli/commands/spl/spl_configs.py +147 -0
  85. splent_cli-1.4.1/src/splent_cli/commands/spl/spl_create.py +51 -0
  86. splent_cli-1.2.9/src/splent_cli/commands/uvl/uvl_deps.py → splent_cli-1.4.1/src/splent_cli/commands/spl/spl_deps.py +20 -30
  87. splent_cli-1.4.1/src/splent_cli/commands/spl/spl_features.py +41 -0
  88. splent_cli-1.4.1/src/splent_cli/commands/spl/spl_fetch.py +36 -0
  89. splent_cli-1.2.9/src/splent_cli/commands/uvl/uvl_fix.py → splent_cli-1.4.1/src/splent_cli/commands/spl/spl_fix.py +37 -54
  90. splent_cli-1.2.9/src/splent_cli/commands/uvl/uvl_info.py → splent_cli-1.4.1/src/splent_cli/commands/spl/spl_info.py +23 -18
  91. splent_cli-1.4.1/src/splent_cli/commands/spl/spl_list.py +55 -0
  92. splent_cli-1.4.1/src/splent_cli/commands/spl/spl_utils.py +93 -0
  93. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/uvl/uvl_utils.py +96 -13
  94. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/version.py +7 -16
  95. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/services/compose.py +4 -2
  96. splent_cli-1.4.1/src/splent_cli/services/context.py +89 -0
  97. splent_cli-1.4.1/src/splent_cli/services/preflight.py +88 -0
  98. splent_cli-1.4.1/src/splent_cli/services/release.py +407 -0
  99. splent_cli-1.4.1/src/splent_cli/utils/__init__.py +0 -0
  100. splent_cli-1.4.1/src/splent_cli/utils/contract_freshness.py +116 -0
  101. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/utils/feature_utils.py +55 -0
  102. splent_cli-1.4.1/src/splent_cli/utils/git_url.py +60 -0
  103. splent_cli-1.4.1/src/splent_cli/utils/integrity.py +172 -0
  104. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/utils/lifecycle.py +4 -3
  105. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/utils/manifest.py +4 -2
  106. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/utils/template_drift.py +25 -0
  107. {splent_cli-1.2.9 → splent_cli-1.4.1/src/splent_cli.egg-info}/PKG-INFO +1 -1
  108. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli.egg-info/SOURCES.txt +34 -11
  109. splent_cli-1.2.9/src/splent_cli/cli.py +0 -88
  110. splent_cli-1.2.9/src/splent_cli/commands/clear_cache.py +0 -75
  111. splent_cli-1.2.9/src/splent_cli/commands/database/db_rollback.py +0 -65
  112. splent_cli-1.2.9/src/splent_cli/commands/feature/feature_release.py +0 -483
  113. splent_cli-1.2.9/src/splent_cli/commands/feature/feature_status.py +0 -200
  114. splent_cli-1.2.9/src/splent_cli/commands/product/product_down.py +0 -65
  115. splent_cli-1.2.9/src/splent_cli/commands/product/product_release.py +0 -143
  116. splent_cli-1.2.9/src/splent_cli/commands/product/product_sync.py +0 -155
  117. splent_cli-1.2.9/src/splent_cli/commands/release/release_core.py +0 -244
  118. splent_cli-1.2.9/src/splent_cli/commands/route_list.py +0 -92
  119. splent_cli-1.2.9/src/splent_cli/commands/uvl/uvl_check.py +0 -156
  120. splent_cli-1.2.9/src/splent_cli/commands/uvl/uvl_configs.py +0 -58
  121. splent_cli-1.2.9/src/splent_cli/commands/uvl/uvl_features.py +0 -49
  122. splent_cli-1.2.9/src/splent_cli/commands/uvl/uvl_fetch.py +0 -55
  123. splent_cli-1.2.9/src/splent_cli/commands/uvl/uvl_valid.py +0 -81
  124. splent_cli-1.2.9/src/splent_cli/services/context.py +0 -32
  125. splent_cli-1.2.9/src/splent_cli/services/release.py +0 -207
  126. {splent_cli-1.2.9 → splent_cli-1.4.1}/LICENSE +0 -0
  127. {splent_cli-1.2.9 → splent_cli-1.4.1}/README.md +0 -0
  128. {splent_cli-1.2.9 → splent_cli-1.4.1}/setup.cfg +0 -0
  129. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/__init__.py +0 -0
  130. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/__main__.py +0 -0
  131. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/__init__.py +0 -0
  132. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/cache/__init__.py +0 -0
  133. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/cache/cache_clear.py +0 -0
  134. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/cache/cache_orphans.py +0 -0
  135. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/cache/cache_outdated.py +0 -0
  136. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/cache/cache_prune.py +0 -0
  137. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/cache/cache_size.py +0 -0
  138. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/cache/cache_status.py +0 -0
  139. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/cache/cache_usage.py +0 -0
  140. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/check/__init__.py +0 -0
  141. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/check/check_docker.py +0 -0
  142. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/check/check_env.py +0 -0
  143. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/check/check_github.py +0 -0
  144. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/check/check_pypi.py +0 -0
  145. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/check/check_pyproject.py +0 -0
  146. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/command_create.py +0 -0
  147. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/env/env_list.py +0 -0
  148. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/env/env_show.py +0 -0
  149. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/feature/feature_delete.py +0 -0
  150. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/feature/feature_detach.py +0 -0
  151. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/feature/feature_git.py +0 -0
  152. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/feature/feature_list.py +0 -0
  153. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/feature/feature_pip_install.py +0 -0
  154. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/feature/feature_search.py +0 -0
  155. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/linter.py +0 -0
  156. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/product/__init__.py +0 -0
  157. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/product/product_drift.py +0 -0
  158. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/product/product_list.py +0 -0
  159. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/product/product_logs.py +0 -0
  160. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/product/product_run.py +0 -0
  161. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/product/product_shell.py +0 -0
  162. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/product/product_sync_template.py +0 -0
  163. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/release/__init__.py +0 -0
  164. {splent_cli-1.2.9/src/splent_cli/services → splent_cli-1.4.1/src/splent_cli/commands/spl}/__init__.py +0 -0
  165. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/commands/tokens.py +0 -0
  166. {splent_cli-1.2.9/src/splent_cli/utils → splent_cli-1.4.1/src/splent_cli/services}/__init__.py +0 -0
  167. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/utils/cache_utils.py +0 -0
  168. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/utils/command_loader.py +0 -0
  169. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/utils/db_utils.py +0 -0
  170. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/utils/decorators.py +0 -0
  171. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/utils/dynamic_imports.py +0 -0
  172. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/utils/feature_installer.py +0 -0
  173. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli/utils/path_utils.py +0 -0
  174. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli.egg-info/dependency_links.txt +0 -0
  175. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli.egg-info/entry_points.txt +0 -0
  176. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli.egg-info/requires.txt +0 -0
  177. {splent_cli-1.2.9 → splent_cli-1.4.1}/src/splent_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: splent_cli
3
- Version: 1.2.9
3
+ Version: 1.4.1
4
4
  Summary: SPLENT-CLI is a CLI to be able to work on your development more easily.
5
5
  Author-email: DiversoLab <diversolab@us.es>
6
6
  Project-URL: Homepage, https://github.com/diverso-lab/splent_cli
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "splent_cli"
7
- version = "1.2.9"
7
+ version = "1.4.1"
8
8
  description = "SPLENT-CLI is a CLI to be able to work on your development more easily."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -0,0 +1,149 @@
1
+ import os
2
+
3
+ import click
4
+ from dotenv import load_dotenv
5
+
6
+ from splent_cli.utils.dynamic_imports import get_app
7
+ from splent_cli.utils.command_loader import load_commands
8
+ from splent_cli.utils.db_utils import check_db_connection
9
+
10
+ load_dotenv()
11
+
12
+
13
+ class SPLENTCLI(click.Group):
14
+ """
15
+ Main SPLENT CLI class.
16
+
17
+ - Automatically injects the Flask app context for commands marked with `requires_app = True`.
18
+ - Checks DB connectivity for commands marked with `requires_db = True`.
19
+ - Discovers CLI commands contributed by features via ``app.extensions["splent_feature_commands"]``.
20
+ - Displays commands grouped by category for a cleaner, more readable help output.
21
+ """
22
+
23
+ # ── Feature-contributed commands ──────────────────────────────
24
+
25
+ def _load_feature_commands(self) -> dict[str, click.BaseCommand]:
26
+ """Return ``{"feature:<name>": click.Group, ...}`` from the Flask app.
27
+
28
+ Each feature that defines CLI commands gets a Click Group registered
29
+ as ``feature:<short_name>``. Individual commands become subcommands::
30
+
31
+ splent feature:mail config
32
+ splent feature:mail check --to user@example.com
33
+
34
+ Commands are only available when a product is active and the app can
35
+ be built. On any failure the result is an empty dict — built-in CLI
36
+ commands are never affected.
37
+ """
38
+ if hasattr(self, "_feature_cmds_cache"):
39
+ return self._feature_cmds_cache
40
+
41
+ self._feature_cmds_cache: dict[str, click.BaseCommand] = {}
42
+ try:
43
+ app = get_app()
44
+ with app.app_context():
45
+ registry = app.extensions.get("splent_feature_commands", {})
46
+ for feature_short, commands in registry.items():
47
+ group = click.Group(
48
+ name=f"feature:{feature_short}",
49
+ help=f"Commands contributed by splent_feature_{feature_short}.",
50
+ )
51
+ group.requires_app = True # type: ignore[attr-defined]
52
+ for cmd in commands:
53
+ group.add_command(cmd)
54
+ self._feature_cmds_cache[group.name] = group
55
+ except Exception as e:
56
+ if os.getenv("SPLENT_DEBUG"):
57
+ click.secho(f" ⚠ Feature commands not loaded: {e}", fg="yellow", err=True)
58
+ return self._feature_cmds_cache
59
+
60
+ def get_command(self, ctx, cmd_name):
61
+ # Built-in commands take priority
62
+ cmd = super().get_command(ctx, cmd_name)
63
+ if cmd is not None:
64
+ return cmd
65
+ # Fall back to feature-contributed command groups
66
+ feat_cmds = self._load_feature_commands()
67
+ return feat_cmds.get(cmd_name)
68
+
69
+ def list_commands(self, ctx):
70
+ builtin = super().list_commands(ctx)
71
+ feat = sorted(self._load_feature_commands().keys())
72
+ return builtin + feat
73
+
74
+ # ── App context injection ─────────────────────────────────────
75
+
76
+ def invoke(self, ctx):
77
+ cmd_name = ctx.protected_args[0] if ctx.protected_args else None
78
+ command = self.get_command(ctx, cmd_name)
79
+
80
+ if command and getattr(command, "requires_app", False):
81
+ app = get_app()
82
+ if getattr(command, "requires_db", False):
83
+ if not check_db_connection(app):
84
+ raise SystemExit(1)
85
+ with app.app_context():
86
+ return super().invoke(ctx)
87
+
88
+ return super().invoke(ctx)
89
+
90
+ def format_commands(self, ctx, formatter):
91
+ """Group SPLENT commands by category in the CLI help output."""
92
+ all_cmds = self.list_commands(ctx)
93
+ groups = {
94
+ "🌿 Feature Management": [
95
+ cmd for cmd in all_cmds
96
+ if cmd.startswith("feature:") and cmd not in self._load_feature_commands()
97
+ ],
98
+ "🏗️ Product Management": [
99
+ cmd for cmd in all_cmds if cmd.startswith("product:")
100
+ ],
101
+ "🧬 SPL & Variability": [
102
+ cmd for cmd in all_cmds if cmd.startswith(("spl:", "uvl:"))
103
+ ],
104
+ "🧱 Database": [cmd for cmd in all_cmds if cmd.startswith("db:")],
105
+ "💾 Cache": [cmd for cmd in all_cmds if cmd.startswith("cache:")],
106
+ "🧰 Utilities": [
107
+ cmd
108
+ for cmd in all_cmds
109
+ if cmd.startswith(
110
+ ("clear:", "env", "select", "info", "version", "doctor", "tokens")
111
+ )
112
+ ],
113
+ "🐍 Development & QA": [
114
+ cmd
115
+ for cmd in all_cmds
116
+ if cmd.startswith(("linter", "test", "coverage", "locust"))
117
+ ],
118
+ "🔌 Feature Commands": [
119
+ cmd for cmd in all_cmds
120
+ if cmd in self._load_feature_commands()
121
+ ],
122
+ }
123
+ for title, cmds in groups.items():
124
+ if not cmds:
125
+ continue
126
+
127
+ with formatter.section(title):
128
+ rows = []
129
+ for cmd_name in sorted(cmds):
130
+ cmd = self.get_command(ctx, cmd_name)
131
+ if cmd is None or cmd.hidden:
132
+ continue
133
+ rows.append((cmd_name, cmd.get_short_help_str()))
134
+ if rows:
135
+ formatter.write_dl(rows)
136
+
137
+
138
+ @click.group(cls=SPLENTCLI)
139
+ def cli():
140
+ """Command-line interface for managing SPLENT products, features, environments, and development workflows."""
141
+ pass
142
+
143
+
144
+ # Automatically load all command modules
145
+ load_commands(cli)
146
+
147
+
148
+ if __name__ == "__main__":
149
+ cli()
@@ -1,4 +1,5 @@
1
1
  from splent_cli.services import context
2
+ from splent_cli.utils.feature_utils import normalize_namespace
2
3
  import click
3
4
 
4
5
 
@@ -17,7 +18,7 @@ def cache_versions(feature_ref: str):
17
18
  raise SystemExit(1)
18
19
 
19
20
  ns, name = feature_ref.split("/", 1)
20
- ns_fs = ns.replace("-", "_")
21
+ ns_fs = normalize_namespace(ns)
21
22
 
22
23
  workspace = context.workspace()
23
24
  ns_dir = workspace / ".splent_cache" / "features" / ns_fs
@@ -85,12 +85,15 @@ def _parse_uvl_deps(uvl_path: str) -> tuple[dict[str, str], dict[str, set[str]]]
85
85
 
86
86
  def _scan_feature_imports(
87
87
  feature_path: str, feature_name: str, all_packages: set[str]
88
- ) -> set[str]:
89
- """Scan all .py files in a feature and return set of other feature packages imported."""
90
- imported: set[str] = set()
88
+ ) -> tuple[set[str], set[str]]:
89
+ """Scan a feature's source code and templates for cross-feature dependencies.
90
+
91
+ Returns (python_imports, template_deps) — both sets of package names.
92
+ """
93
+ python_imports: set[str] = set()
94
+ template_deps: set[str] = set()
91
95
 
92
96
  src_dir = None
93
- # Find the src directory
94
97
  for org_dir in os.listdir(os.path.join(feature_path, "src")):
95
98
  candidate = os.path.join(feature_path, "src", org_dir, feature_name)
96
99
  if os.path.isdir(candidate):
@@ -98,27 +101,46 @@ def _scan_feature_imports(
98
101
  break
99
102
 
100
103
  if not src_dir:
101
- return imported
104
+ return python_imports, template_deps
105
+
106
+ # Map blueprint names to package names for template dependency detection
107
+ # Convention: blueprint name is the feature short name (e.g. "auth" for splent_feature_auth)
108
+ bp_to_pkg = {}
109
+ for pkg in all_packages:
110
+ short = pkg.replace("splent_feature_", "")
111
+ bp_to_pkg[short] = pkg
102
112
 
103
113
  for root, _, files in os.walk(src_dir):
104
114
  for f in files:
105
- if not f.endswith(".py"):
106
- continue
107
115
  filepath = os.path.join(root, f)
108
- try:
109
- with open(filepath, "r", encoding="utf-8") as fh:
110
- content = fh.read()
111
- except (OSError, PermissionError):
112
- continue
113
116
 
114
- # Find imports: from splent_io.splent_feature_X... or import splent_io.splent_feature_X
115
- for match in re.findall(
116
- r"(?:from|import)\s+splent_io\.(splent_feature_\w+)", content
117
- ):
118
- if match != feature_name and match in all_packages:
119
- imported.add(match)
117
+ if f.endswith(".py"):
118
+ try:
119
+ with open(filepath, "r", encoding="utf-8") as fh:
120
+ content = fh.read()
121
+ except (OSError, PermissionError):
122
+ continue
123
+
124
+ for match in re.findall(
125
+ r"(?:from|import)\s+splent_io\.(splent_feature_\w+)", content
126
+ ):
127
+ if match != feature_name and match in all_packages:
128
+ python_imports.add(match)
129
+
130
+ elif f.endswith(".html"):
131
+ try:
132
+ with open(filepath, "r", encoding="utf-8") as fh:
133
+ content = fh.read()
134
+ except (OSError, PermissionError):
135
+ continue
120
136
 
121
- return imported
137
+ # Detect url_for('blueprint.endpoint', ...) references to other features
138
+ for bp_name in re.findall(r"url_for\s*\(\s*['\"](\w+)\.", content):
139
+ pkg = bp_to_pkg.get(bp_name)
140
+ if pkg and pkg != feature_name:
141
+ template_deps.add(pkg)
142
+
143
+ return python_imports, template_deps
122
144
 
123
145
 
124
146
  # ---------------------------------------------------------------------------
@@ -174,20 +196,24 @@ def check_deps():
174
196
  product = context.require_app()
175
197
  product_dir = os.path.join(workspace, product)
176
198
 
177
- # Read UVL
199
+ # Read UVL (catalog or legacy)
178
200
  try:
179
201
  reader = PyprojectReader.for_product(product_dir)
180
- uvl_cfg = reader.uvl_config
202
+ # 1. Catalog: [tool.splent].spl
203
+ spl_name = reader.splent_config.get("spl")
204
+ if spl_name:
205
+ uvl_path = os.path.join(workspace, "splent_catalog", spl_name, f"{spl_name}.uvl")
206
+ else:
207
+ # 2. Legacy: [tool.splent.uvl].file
208
+ uvl_file = reader.uvl_config.get("file")
209
+ if not uvl_file:
210
+ click.secho(" [✖] No UVL configured. Set [tool.splent].spl or [tool.splent.uvl].file.", fg="red")
211
+ raise SystemExit(1)
212
+ uvl_path = os.path.join(product_dir, "uvl", uvl_file)
181
213
  except (FileNotFoundError, RuntimeError) as e:
182
214
  click.secho(f" [✖] Cannot read pyproject.toml: {e}", fg="red")
183
215
  raise SystemExit(1)
184
216
 
185
- uvl_file = uvl_cfg.get("file")
186
- if not uvl_file:
187
- click.secho(" [✖] No UVL file configured.", fg="red")
188
- raise SystemExit(1)
189
-
190
- uvl_path = os.path.join(product_dir, "uvl", uvl_file)
191
217
  if not os.path.isfile(uvl_path):
192
218
  click.secho(f" [✖] UVL file not found: {uvl_path}", fg="red")
193
219
  raise SystemExit(1)
@@ -217,40 +243,42 @@ def check_deps():
217
243
  )
218
244
  continue
219
245
 
220
- actual_imports = _scan_feature_imports(fpath, pkg_name, all_packages)
246
+ py_imports, tpl_deps = _scan_feature_imports(fpath, pkg_name, all_packages)
221
247
  allowed = allowed_deps.get(pkg_name, set())
248
+ all_deps = py_imports | tpl_deps
222
249
 
223
- if not actual_imports:
250
+ if not all_deps:
224
251
  click.echo(
225
252
  click.style(" [✔] ", fg="green")
226
253
  + click.style(f"{short}", bold=True)
227
- + " — no cross-feature imports"
254
+ + " — no cross-feature dependencies"
228
255
  )
229
256
  ok += 1
230
257
  continue
231
258
 
232
259
  has_violation = False
233
- for imp in sorted(actual_imports):
260
+
261
+ for imp in sorted(all_deps):
234
262
  imp_short = pkg_to_short.get(imp, imp)
263
+ source = "imports" if imp in py_imports else "references (template)"
235
264
 
236
265
  if imp in allowed:
237
266
  click.echo(
238
267
  click.style(" [✔] ", fg="green")
239
268
  + click.style(f"{short}", bold=True)
240
- + f" imports {imp_short}"
269
+ + f" {source} {imp_short}"
241
270
  + click.style(
242
271
  f" (allowed: {short} => {imp_short})", fg="bright_black"
243
272
  )
244
273
  )
245
274
  ok += 1
246
275
  else:
247
- # Check if the reverse is declared (inverted dependency)
248
276
  reverse_allowed = allowed_deps.get(imp, set())
249
277
  if pkg_name in reverse_allowed:
250
278
  click.echo(
251
279
  click.style(" [✖] ", fg="red")
252
280
  + click.style(f"{short}", bold=True)
253
- + f" imports {imp_short}"
281
+ + f" {source} {imp_short}"
254
282
  + click.style(
255
283
  f" INVERTED — UVL says {imp_short} => {short}, not the reverse",
256
284
  fg="red",
@@ -260,7 +288,7 @@ def check_deps():
260
288
  click.echo(
261
289
  click.style(" [✖] ", fg="red")
262
290
  + click.style(f"{short}", bold=True)
263
- + f" imports {imp_short}"
291
+ + f" {source} {imp_short}"
264
292
  + click.style(
265
293
  f" UNDECLARED — no UVL constraint between {short} and {imp_short}",
266
294
  fg="red",
@@ -269,9 +297,6 @@ def check_deps():
269
297
  violations += 1
270
298
  has_violation = True
271
299
 
272
- if not has_violation:
273
- pass # all imports were OK, already printed
274
-
275
300
  click.echo()
276
301
  if violations:
277
302
  click.secho(
@@ -10,7 +10,7 @@ import click
10
10
  import tomllib
11
11
 
12
12
  from splent_cli.services import context
13
- from splent_cli.utils.feature_utils import read_features_from_data
13
+ from splent_cli.utils.feature_utils import normalize_namespace, read_features_from_data
14
14
 
15
15
 
16
16
  def _pkg_installed(name: str) -> bool:
@@ -72,7 +72,7 @@ def check_features():
72
72
  # Parse entry
73
73
  if "/" in entry:
74
74
  org_raw, rest = entry.split("/", 1)
75
- org_safe = org_raw.replace("-", "_").replace(".", "_")
75
+ org_safe = normalize_namespace(org_raw)
76
76
  else:
77
77
  org_safe = "splent_io"
78
78
  rest = entry
@@ -0,0 +1,222 @@
1
+ """
2
+ check:infra — Validate Docker infrastructure declarations (ports, services, containers, networks).
3
+ """
4
+
5
+ import os
6
+ import subprocess
7
+ import json
8
+
9
+ import click
10
+ import tomllib
11
+
12
+ from splent_cli.services import context, compose
13
+ from splent_cli.utils.feature_utils import read_features_from_data
14
+
15
+
16
+ def _parse_compose_ports(compose_file: str) -> list[tuple[int, str, str]]:
17
+ """Return [(host_port, service_name, source_label)] from a compose file."""
18
+ result = subprocess.run(
19
+ ["docker", "compose", "-f", compose_file, "config", "--format", "json"],
20
+ capture_output=True, text=True,
21
+ )
22
+ if result.returncode != 0:
23
+ return []
24
+ try:
25
+ config = json.loads(result.stdout)
26
+ except json.JSONDecodeError:
27
+ return []
28
+
29
+ ports = []
30
+ for svc_name, svc in config.get("services", {}).items():
31
+ for port in svc.get("ports", []):
32
+ published = port.get("published") if isinstance(port, dict) else None
33
+ if published:
34
+ try:
35
+ ports.append((int(published), svc_name, compose_file))
36
+ except (ValueError, TypeError):
37
+ pass
38
+ return ports
39
+
40
+
41
+ def _parse_compose_services(compose_file: str) -> list[tuple[str, str, str]]:
42
+ """Return [(service_name, container_name_or_None, source_label)]."""
43
+ result = subprocess.run(
44
+ ["docker", "compose", "-f", compose_file, "config", "--format", "json"],
45
+ capture_output=True, text=True,
46
+ )
47
+ if result.returncode != 0:
48
+ return []
49
+ try:
50
+ config = json.loads(result.stdout)
51
+ except json.JSONDecodeError:
52
+ return []
53
+
54
+ services = []
55
+ for svc_name, svc in config.get("services", {}).items():
56
+ container_name = svc.get("container_name")
57
+ services.append((svc_name, container_name, compose_file))
58
+ return services
59
+
60
+
61
+ @click.command("check:infra", short_help="Validate Docker infrastructure (ports, services, networks).")
62
+ def check_infra():
63
+ """Check for port conflicts, duplicate services, container name collisions,
64
+ and network availability across all features and the product."""
65
+ workspace = str(context.workspace())
66
+ product = context.require_app()
67
+ product_path = os.path.join(workspace, product)
68
+ pyproject_path = os.path.join(product_path, "pyproject.toml")
69
+
70
+ ok = fail = warn = 0
71
+
72
+ def _ok(msg):
73
+ nonlocal ok
74
+ ok += 1
75
+ click.echo(click.style(" [OK] ", fg="green") + msg)
76
+
77
+ def _fail(msg):
78
+ nonlocal fail
79
+ fail += 1
80
+ click.echo(click.style(" [FAIL] ", fg="red") + msg)
81
+
82
+ def _warn(msg):
83
+ nonlocal warn
84
+ warn += 1
85
+ click.echo(click.style(" [WARN] ", fg="yellow") + msg)
86
+
87
+ click.echo()
88
+ click.echo(click.style(" Infrastructure check", bold=True))
89
+ click.echo()
90
+
91
+ if not os.path.exists(pyproject_path):
92
+ _fail("pyproject.toml not found")
93
+ raise SystemExit(1)
94
+
95
+ with open(pyproject_path, "rb") as f:
96
+ data = tomllib.load(f)
97
+
98
+ env = os.getenv("SPLENT_ENV", "dev")
99
+ features = read_features_from_data(data, env)
100
+
101
+ # Collect all compose files
102
+ compose_files: list[tuple[str, str]] = [] # (label, path)
103
+
104
+ for feat in features:
105
+ clean = compose.normalize_feature_ref(feat)
106
+ bare_name = clean.split("/")[-1] if "/" in clean else clean
107
+ feat_base = os.path.dirname(compose.feature_docker_dir(workspace, bare_name))
108
+ cf = compose.resolve_file(feat_base, env)
109
+ if cf:
110
+ compose_files.append((bare_name, cf))
111
+
112
+ cf = compose.resolve_file(product_path, env)
113
+ if cf:
114
+ compose_files.append((product, cf))
115
+
116
+ # --- Check 1: Port conflicts between declarations ---
117
+ click.echo(click.style(" Ports", bold=True))
118
+ all_ports: dict[int, list[str]] = {} # port -> [labels]
119
+ for label, cf in compose_files:
120
+ for host_port, svc_name, _ in _parse_compose_ports(cf):
121
+ all_ports.setdefault(host_port, []).append(f"{label}/{svc_name}")
122
+
123
+ port_conflicts = {p: srcs for p, srcs in all_ports.items() if len(srcs) > 1}
124
+ if port_conflicts:
125
+ for port, sources in sorted(port_conflicts.items()):
126
+ _fail(f"Port {port} declared by multiple services: {', '.join(sources)}")
127
+ else:
128
+ _ok(f"No port conflicts ({len(all_ports)} ports declared)")
129
+
130
+ # Check against running containers
131
+ running_conflicts = []
132
+ for port in all_ports:
133
+ result = subprocess.run(
134
+ ["docker", "ps", "--format", "{{.ID}}\t{{.Names}}\t{{.Ports}}"],
135
+ capture_output=True, text=True,
136
+ )
137
+ for line in result.stdout.splitlines():
138
+ parts = line.split("\t", 2)
139
+ if len(parts) < 3:
140
+ continue
141
+ cid, name, ports_str = parts
142
+ if f":{port}->" in ports_str:
143
+ running_conflicts.append((port, name))
144
+
145
+ if running_conflicts:
146
+ for port, cname in running_conflicts:
147
+ _warn(f"Port {port} already in use by running container: {cname}")
148
+ else:
149
+ _ok("No conflicts with running containers")
150
+
151
+ # --- Check 2: Service name collisions ---
152
+ click.echo()
153
+ click.echo(click.style(" Services", bold=True))
154
+ all_services: dict[str, list[str]] = {} # svc_name -> [labels]
155
+ all_container_names: dict[str, list[str]] = {} # container_name -> [labels]
156
+
157
+ for label, cf in compose_files:
158
+ for svc_name, container_name, _ in _parse_compose_services(cf):
159
+ all_services.setdefault(svc_name, []).append(label)
160
+ if container_name:
161
+ all_container_names.setdefault(container_name, []).append(label)
162
+
163
+ svc_conflicts = {s: srcs for s, srcs in all_services.items() if len(srcs) > 1}
164
+ if svc_conflicts:
165
+ for svc, sources in sorted(svc_conflicts.items()):
166
+ _warn(f"Service '{svc}' defined by multiple features: {', '.join(sources)}")
167
+ else:
168
+ _ok(f"No service name collisions ({len(all_services)} services)")
169
+
170
+ cn_conflicts = {c: srcs for c, srcs in all_container_names.items() if len(srcs) > 1}
171
+ if cn_conflicts:
172
+ for cn, sources in sorted(cn_conflicts.items()):
173
+ _fail(f"Container name '{cn}' used by multiple features: {', '.join(sources)}")
174
+ else:
175
+ _ok(f"No container name collisions ({len(all_container_names)} named containers)")
176
+
177
+ # --- Check 3: Network availability ---
178
+ click.echo()
179
+ click.echo(click.style(" Networks", bold=True))
180
+ required_networks: set[str] = set()
181
+ for label, cf in compose_files:
182
+ result = subprocess.run(
183
+ ["docker", "compose", "-f", cf, "config", "--format", "json"],
184
+ capture_output=True, text=True,
185
+ )
186
+ if result.returncode != 0:
187
+ continue
188
+ try:
189
+ config = json.loads(result.stdout)
190
+ except json.JSONDecodeError:
191
+ continue
192
+ for net_name, net_def in config.get("networks", {}).items():
193
+ if isinstance(net_def, dict) and net_def.get("external"):
194
+ required_networks.add(net_name)
195
+
196
+ if required_networks:
197
+ existing_networks = subprocess.run(
198
+ ["docker", "network", "ls", "--format", "{{.Name}}"],
199
+ capture_output=True, text=True,
200
+ ).stdout.splitlines()
201
+ for net in sorted(required_networks):
202
+ if net in existing_networks:
203
+ _ok(f"Network '{net}' exists")
204
+ else:
205
+ _fail(f"External network '{net}' does not exist (run: docker network create {net})")
206
+ else:
207
+ _ok("No external networks required")
208
+
209
+ # --- Summary ---
210
+ click.echo()
211
+ total = ok + fail + warn
212
+ if fail:
213
+ click.secho(f" {fail} check(s) failed, {warn} warning(s), {ok} passed.", fg="red")
214
+ raise SystemExit(1)
215
+ elif warn:
216
+ click.secho(f" All passed with {warn} warning(s) ({ok} checks OK).", fg="yellow")
217
+ else:
218
+ click.secho(f" All {ok} checks passed.", fg="green")
219
+ click.echo()
220
+
221
+
222
+ cli_command = check_infra