splent-cli 1.7.0__tar.gz → 1.9.0__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 (167) hide show
  1. {splent_cli-1.7.0/src/splent_cli.egg-info → splent_cli-1.9.0}/PKG-INFO +2 -1
  2. {splent_cli-1.7.0 → splent_cli-1.9.0}/pyproject.toml +2 -1
  3. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/check/check_pyproject.py +53 -14
  4. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/database/db_migrate.py +39 -3
  5. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/export/export_puml.py +100 -25
  6. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/feature/feature_release.py +40 -10
  7. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/feature/feature_xray.py +10 -7
  8. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/product/product_containers.py +3 -3
  9. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/product/product_deploy.py +6 -2
  10. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/product/product_env.py +6 -1
  11. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/spl/spl_add_feature.py +25 -0
  12. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/services/preflight.py +50 -0
  13. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/services/release.py +25 -3
  14. {splent_cli-1.7.0 → splent_cli-1.9.0/src/splent_cli.egg-info}/PKG-INFO +2 -1
  15. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli.egg-info/requires.txt +1 -0
  16. {splent_cli-1.7.0 → splent_cli-1.9.0}/LICENSE +0 -0
  17. {splent_cli-1.7.0 → splent_cli-1.9.0}/README.md +0 -0
  18. {splent_cli-1.7.0 → splent_cli-1.9.0}/setup.cfg +0 -0
  19. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/__init__.py +0 -0
  20. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/__main__.py +0 -0
  21. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/cli.py +0 -0
  22. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/__init__.py +0 -0
  23. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/cache/__init__.py +0 -0
  24. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/cache/cache_clear.py +0 -0
  25. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/cache/cache_orphans.py +0 -0
  26. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/cache/cache_outdated.py +0 -0
  27. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/cache/cache_prune.py +0 -0
  28. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/cache/cache_size.py +0 -0
  29. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/cache/cache_status.py +0 -0
  30. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/cache/cache_usage.py +0 -0
  31. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/cache/cache_versions.py +0 -0
  32. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/check/__init__.py +0 -0
  33. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/check/check_deps.py +0 -0
  34. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/check/check_docker.py +0 -0
  35. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/check/check_env.py +0 -0
  36. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/check/check_features.py +0 -0
  37. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/check/check_github.py +0 -0
  38. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/check/check_infra.py +0 -0
  39. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/check/check_product.py +0 -0
  40. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/check/check_pypi.py +0 -0
  41. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/clear/__init__.py +0 -0
  42. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/clear/clear_build.py +0 -0
  43. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/clear/clear_log.py +0 -0
  44. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/clear/clear_uploads.py +0 -0
  45. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/command/__init__.py +0 -0
  46. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/command/command_create.py +0 -0
  47. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/coverage.py +0 -0
  48. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/database/db_console.py +0 -0
  49. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/database/db_dump.py +0 -0
  50. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/database/db_reset.py +0 -0
  51. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/database/db_restore.py +0 -0
  52. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/database/db_rollback.py +0 -0
  53. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/database/db_seed.py +0 -0
  54. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/database/db_status.py +0 -0
  55. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/database/db_upgrade.py +0 -0
  56. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/doctor.py +0 -0
  57. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/env/env_list.py +0 -0
  58. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/env/env_set.py +0 -0
  59. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/env/env_show.py +0 -0
  60. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/export/__init__.py +0 -0
  61. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/feature/feature_add.py +0 -0
  62. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/feature/feature_attach.py +0 -0
  63. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/feature/feature_clean.py +0 -0
  64. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/feature/feature_clone.py +0 -0
  65. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/feature/feature_compat.py +0 -0
  66. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/feature/feature_compile.py +0 -0
  67. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/feature/feature_contract.py +0 -0
  68. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/feature/feature_create.py +0 -0
  69. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/feature/feature_delete.py +0 -0
  70. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/feature/feature_detach.py +0 -0
  71. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/feature/feature_discard.py +0 -0
  72. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/feature/feature_drift.py +0 -0
  73. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/feature/feature_env.py +0 -0
  74. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/feature/feature_fork.py +0 -0
  75. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/feature/feature_git.py +0 -0
  76. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/feature/feature_hook_add.py +0 -0
  77. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/feature/feature_hook_remove.py +0 -0
  78. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/feature/feature_hooks.py +0 -0
  79. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/feature/feature_impact.py +0 -0
  80. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/feature/feature_inject_config.py +0 -0
  81. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/feature/feature_install.py +0 -0
  82. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/feature/feature_list.py +0 -0
  83. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/feature/feature_order.py +0 -0
  84. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/feature/feature_outdated.py +0 -0
  85. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/feature/feature_pin.py +0 -0
  86. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/feature/feature_pip_install.py +0 -0
  87. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/feature/feature_pull.py +0 -0
  88. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/feature/feature_refine.py +0 -0
  89. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/feature/feature_remove.py +0 -0
  90. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/feature/feature_rename.py +0 -0
  91. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/feature/feature_search.py +0 -0
  92. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/feature/feature_status.py +0 -0
  93. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/feature/feature_sync_template.py +0 -0
  94. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/feature/feature_test.py +0 -0
  95. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/feature/feature_translate.py +0 -0
  96. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/feature/feature_unlock.py +0 -0
  97. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/feature/feature_upgrade.py +0 -0
  98. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/feature/feature_versions.py +0 -0
  99. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/lint.py +0 -0
  100. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/locust.py +0 -0
  101. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/product/__init__.py +0 -0
  102. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/product/product_auto_require.py +0 -0
  103. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/product/product_build.py +0 -0
  104. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/product/product_clean.py +0 -0
  105. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/product/product_commands.py +0 -0
  106. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/product/product_config.py +0 -0
  107. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/product/product_configure.py +0 -0
  108. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/product/product_console.py +0 -0
  109. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/product/product_create.py +0 -0
  110. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/product/product_derive.py +0 -0
  111. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/product/product_deselect.py +0 -0
  112. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/product/product_down.py +0 -0
  113. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/product/product_drift.py +0 -0
  114. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/product/product_list.py +0 -0
  115. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/product/product_logs.py +0 -0
  116. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/product/product_missing.py +0 -0
  117. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/product/product_port.py +0 -0
  118. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/product/product_release.py +0 -0
  119. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/product/product_resolve.py +0 -0
  120. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/product/product_restart.py +0 -0
  121. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/product/product_routes.py +0 -0
  122. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/product/product_run.py +0 -0
  123. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/product/product_select.py +0 -0
  124. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/product/product_shell.py +0 -0
  125. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/product/product_signals.py +0 -0
  126. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/product/product_sync_template.py +0 -0
  127. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/product/product_test.py +0 -0
  128. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/product/product_up.py +0 -0
  129. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/product/product_validate.py +0 -0
  130. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/release/__init__.py +0 -0
  131. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/release/release_core.py +0 -0
  132. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/selenium.py +0 -0
  133. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/spl/__init__.py +0 -0
  134. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/spl/spl_add_constraints.py +0 -0
  135. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/spl/spl_configurations.py +0 -0
  136. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/spl/spl_create.py +0 -0
  137. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/spl/spl_deps.py +0 -0
  138. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/spl/spl_features.py +0 -0
  139. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/spl/spl_fetch.py +0 -0
  140. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/spl/spl_info.py +0 -0
  141. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/spl/spl_list.py +0 -0
  142. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/spl/spl_utils.py +0 -0
  143. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/tokens_setup.py +0 -0
  144. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/uvl/uvl_utils.py +0 -0
  145. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/commands/version.py +0 -0
  146. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/services/__init__.py +0 -0
  147. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/services/compose.py +0 -0
  148. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/services/context.py +0 -0
  149. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/utils/__init__.py +0 -0
  150. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/utils/cache_utils.py +0 -0
  151. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/utils/command_loader.py +0 -0
  152. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/utils/contract_freshness.py +0 -0
  153. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/utils/db_utils.py +0 -0
  154. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/utils/decorators.py +0 -0
  155. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/utils/dynamic_imports.py +0 -0
  156. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/utils/feature_installer.py +0 -0
  157. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/utils/feature_utils.py +0 -0
  158. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/utils/git_url.py +0 -0
  159. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/utils/integrity.py +0 -0
  160. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/utils/lifecycle.py +0 -0
  161. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/utils/manifest.py +0 -0
  162. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/utils/path_utils.py +0 -0
  163. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli/utils/template_drift.py +0 -0
  164. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli.egg-info/SOURCES.txt +0 -0
  165. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli.egg-info/dependency_links.txt +0 -0
  166. {splent_cli-1.7.0 → splent_cli-1.9.0}/src/splent_cli.egg-info/entry_points.txt +0 -0
  167. {splent_cli-1.7.0 → splent_cli-1.9.0}/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.7.0
3
+ Version: 1.9.0
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
@@ -10,6 +10,7 @@ Description-Content-Type: text/markdown
10
10
  License-File: LICENSE
11
11
  Requires-Dist: click==8.1.8
12
12
  Requires-Dist: packaging>=24.0
13
+ Requires-Dist: requests>=2.31.0
13
14
  Provides-Extra: dev
14
15
  Requires-Dist: setuptools==80.3.1; extra == "dev"
15
16
  Requires-Dist: pytest==8.3.4; extra == "dev"
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "splent_cli"
7
- version = "1.7.0"
7
+ version = "1.9.0"
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"
@@ -14,6 +14,7 @@ license-files = ["LICENSE"]
14
14
  dependencies = [
15
15
  "click==8.1.8",
16
16
  "packaging>=24.0",
17
+ "requests>=2.31.0",
17
18
  ]
18
19
 
19
20
  [project.optional-dependencies]
@@ -9,7 +9,6 @@ import click
9
9
  import tomllib
10
10
 
11
11
  from splent_cli.services import context
12
- from splent_cli.utils.feature_utils import read_features_from_data
13
12
 
14
13
 
15
14
  def _find_missing_pkgs(deps: list) -> list:
@@ -88,29 +87,69 @@ def check_pyproject():
88
87
  _ok(f"All {len(deps)} dependencies satisfied")
89
88
 
90
89
  # Features
91
- features = read_features_from_data(data)
92
- _ok(f"{len(features)} features declared in [tool.splent.features]")
93
-
94
90
  splent = data.get("tool", {}).get("splent", {})
91
+ base_feats = splent.get("features", [])
95
92
  dev_feats = splent.get("features_dev", [])
96
93
  prod_feats = splent.get("features_prod", [])
94
+
95
+ _ok(f"{len(base_feats)} features in [tool.splent].features")
97
96
  if dev_feats:
98
97
  _ok(f"{len(dev_feats)} dev-only features")
99
98
  if prod_feats:
100
99
  _ok(f"{len(prod_feats)} prod-only features")
101
100
 
102
- # UVL
103
- uvl_cfg = splent.get("uvl", {})
104
- uvl_file = uvl_cfg.get("file")
105
- if uvl_file:
106
- app_path = os.path.join(workspace, product)
107
- uvl_path = os.path.join(app_path, "uvl", uvl_file)
108
- if os.path.exists(uvl_path):
109
- _ok(f"UVL file found: uvl/{uvl_file}")
101
+ # Check for duplicates across feature lists
102
+ def _bare_name(entry):
103
+ name = entry.split("/")[-1] if "/" in entry else entry
104
+ return name.split("@")[0]
105
+
106
+ all_entries = base_feats + dev_feats + prod_feats
107
+ seen_names = {}
108
+ for entry in all_entries:
109
+ bare = _bare_name(entry)
110
+ seen_names.setdefault(bare, []).append(entry)
111
+
112
+ for bare, entries in seen_names.items():
113
+ if len(entries) > 1:
114
+ short = bare.replace("splent_feature_", "")
115
+ _fail(f"Duplicate feature '{short}': {', '.join(entries)}")
116
+
117
+ # Check for inconsistent namespaces
118
+ namespaces = set()
119
+ for entry in all_entries:
120
+ if "/" in entry:
121
+ ns = entry.split("/")[0]
122
+ namespaces.add(ns)
123
+ if len(namespaces) > 1:
124
+ _warn(
125
+ f"Inconsistent namespaces: {', '.join(sorted(namespaces))} — use one format consistently"
126
+ )
127
+
128
+ # SPL / UVL
129
+ spl_name = splent.get("spl")
130
+ if spl_name:
131
+ uvl_path = os.path.join(
132
+ workspace, "splent_catalog", spl_name, f"{spl_name}.uvl"
133
+ )
134
+ if os.path.isfile(uvl_path):
135
+ _ok(f"SPL catalog: {spl_name} (UVL found)")
110
136
  else:
111
- _fail(f"UVL file not found: uvl/{uvl_file}")
137
+ _fail(
138
+ f"SPL catalog: {spl_name} — UVL not found at splent_catalog/{spl_name}/{spl_name}.uvl"
139
+ )
112
140
  else:
113
- _warn("No UVL file configured")
141
+ # Legacy fallback
142
+ uvl_cfg = splent.get("uvl", {})
143
+ uvl_file = uvl_cfg.get("file")
144
+ if uvl_file:
145
+ app_path = os.path.join(workspace, product)
146
+ uvl_path = os.path.join(app_path, "uvl", uvl_file)
147
+ if os.path.exists(uvl_path):
148
+ _ok(f"UVL file found: uvl/{uvl_file} (legacy)")
149
+ else:
150
+ _fail(f"UVL file not found: uvl/{uvl_file}")
151
+ else:
152
+ _warn("No SPL configured — set [tool.splent].spl in pyproject.toml")
114
153
 
115
154
  click.echo()
116
155
  if fail:
@@ -2,6 +2,7 @@ import os
2
2
 
3
3
  import click
4
4
  from flask_migrate import migrate as alembic_migrate
5
+ from flask_migrate import revision as alembic_revision
5
6
 
6
7
  from splent_cli.utils.decorators import requires_db
7
8
  from splent_cli.services import context
@@ -54,14 +55,31 @@ def _is_empty_migration(path: str) -> bool:
54
55
  @click.option(
55
56
  "-m", "message", default=None, help="Migration message (defaults to feature name)."
56
57
  )
58
+ @click.option(
59
+ "--empty",
60
+ is_flag=True,
61
+ help=(
62
+ "Generate a blank migration (no autogenerate) and keep it for manual "
63
+ "editing. Required for refinement features that add columns to a base "
64
+ "feature's table — Alembic cannot autodetect runtime-injected mixins."
65
+ ),
66
+ )
57
67
  @context.requires_product
58
- def db_migrate(feature, message):
68
+ def db_migrate(feature, message, empty):
59
69
  """
60
70
  Generate new migration files for features that have schema changes,
61
71
  then apply all pending migrations.
62
72
 
63
- Empty migrations (no schema changes detected) are automatically removed.
73
+ Empty migrations (no schema changes detected) are automatically removed,
74
+ unless ``--empty`` is passed, in which case a blank migration is created
75
+ and kept so you can write ``upgrade()``/``downgrade()`` by hand.
64
76
  """
77
+ if empty and not feature:
78
+ click.echo(
79
+ click.style("❌ --empty requires a FEATURE argument.", fg="red")
80
+ )
81
+ raise SystemExit(1)
82
+
65
83
  if feature:
66
84
  dirs = {}
67
85
  mdir = MigrationManager.get_feature_migration_dir(feature)
@@ -108,7 +126,10 @@ def db_migrate(feature, message):
108
126
  alembic_logger.setLevel(logging.WARNING)
109
127
 
110
128
  try:
111
- alembic_migrate(directory=mdir, message=message or feat)
129
+ if empty:
130
+ alembic_revision(directory=mdir, message=message or feat)
131
+ else:
132
+ alembic_migrate(directory=mdir, message=message or feat)
112
133
  except Exception as e:
113
134
  if os.getenv("SPLENT_DEBUG"):
114
135
  click.secho(
@@ -119,6 +140,21 @@ def db_migrate(feature, message):
119
140
 
120
141
  after = _count_versions(mdir)
121
142
 
143
+ # A blank migration requested with --empty is intentional: keep it so the
144
+ # user can fill in upgrade()/downgrade() by hand (e.g. refinement columns).
145
+ if empty:
146
+ if after > before:
147
+ click.echo(
148
+ click.style(
149
+ f" 📝 {feat}: blank migration generated — "
150
+ f"edit upgrade()/downgrade() and run 'splent db:upgrade'",
151
+ fg="cyan",
152
+ )
153
+ )
154
+ else:
155
+ click.echo(click.style(f" ✔ {feat}: up to date", fg="green"))
156
+ continue
157
+
122
158
  # If a new file was generated, check if it's empty (no real schema changes)
123
159
  if after > before:
124
160
  versions_dir = os.path.join(mdir, "versions")
@@ -149,8 +149,8 @@ def _parse_models(models_path: str) -> list[dict]:
149
149
  text = "\n".join(joined_lines)
150
150
 
151
151
  classes = []
152
- # Split by class definition
153
- class_blocks = re.split(r"^(class \w+\(.*?\):)", text, flags=re.MULTILINE)
152
+ # Split by class definition (with or without parentheses for mixins)
153
+ class_blocks = re.split(r"^(class \w+(?:\(.*?\))?:)", text, flags=re.MULTILINE)
154
154
 
155
155
  i = 1
156
156
  while i < len(class_blocks):
@@ -158,7 +158,7 @@ def _parse_models(models_path: str) -> list[dict]:
158
158
  body = class_blocks[i + 1] if i + 1 < len(class_blocks) else ""
159
159
  i += 2
160
160
 
161
- class_m = re.match(r"class (\w+)\(", header)
161
+ class_m = re.match(r"class (\w+)", header)
162
162
  if not class_m:
163
163
  continue
164
164
  class_name = class_m.group(1)
@@ -213,8 +213,11 @@ def _parse_models(models_path: str) -> list[dict]:
213
213
  if rel_m:
214
214
  rel_name = rel_m.group(1)
215
215
  rel_args = rel_m.group(2)
216
- target_m = re.match(r"(\w+)", rel_args)
217
- target = target_m.group(1) if target_m else "?"
216
+ # Target can be unquoted (User) or quoted ("User" / 'User')
217
+ target_m = re.match(r"""[\"']?(\w+)[\"']?""", rel_args)
218
+ target = target_m.group(1) if target_m else None
219
+ if not target:
220
+ continue
218
221
  uselist = "uselist=False" not in rel_args
219
222
  relationships.append(
220
223
  {
@@ -668,11 +671,16 @@ def _render_exports(
668
671
  )
669
672
  except subprocess.CalledProcessError as e:
670
673
  click.secho("❌ PlantUML failed to render diagram.", fg="red")
671
- if os.getenv("SPLENT_DEBUG"):
672
- stderr = e.stderr
673
- if isinstance(stderr, bytes):
674
- stderr = stderr.decode(errors="replace")
675
- click.secho(stderr[:500], fg="bright_black")
674
+ stderr = e.stderr
675
+ if isinstance(stderr, bytes):
676
+ stderr = stderr.decode(errors="replace")
677
+ if stderr:
678
+ click.secho(stderr.strip(), fg="bright_black")
679
+ stdout = e.stdout
680
+ if isinstance(stdout, bytes):
681
+ stdout = stdout.decode(errors="replace")
682
+ if stdout:
683
+ click.secho(stdout.strip(), fg="bright_black")
676
684
  return
677
685
 
678
686
  try:
@@ -780,21 +788,41 @@ def export_puml(
780
788
  product = context.require_app()
781
789
  product_dir = os.path.join(workspace, product)
782
790
 
783
- # Read UVL
784
- try:
785
- uvl_cfg = PyprojectReader.for_product(product_dir).uvl_config
786
- except (FileNotFoundError, RuntimeError) as e:
787
- click.secho(f"❌ Cannot read pyproject.toml: {e}", fg="red")
788
- raise SystemExit(1)
789
-
790
- uvl_file = uvl_cfg.get("file")
791
- if not uvl_file:
792
- click.secho("❌ No UVL file configured in [tool.splent.uvl].", fg="red")
793
- raise SystemExit(1)
794
-
795
- uvl_path = os.path.join(product_dir, "uvl", uvl_file)
796
- if not os.path.isfile(uvl_path):
797
- click.secho(f"❌ UVL file not found: {uvl_path}", fg="red")
791
+ # Read UVL — try SPL catalog first, fall back to legacy [tool.splent.uvl]
792
+ import tomllib
793
+
794
+ uvl_path = None
795
+ pyproject_path = os.path.join(product_dir, "pyproject.toml")
796
+ if os.path.isfile(pyproject_path):
797
+ with open(pyproject_path, "rb") as f:
798
+ pydata = tomllib.load(f)
799
+ spl_name = pydata.get("tool", {}).get("splent", {}).get("spl")
800
+ if spl_name:
801
+ candidate = os.path.join(
802
+ workspace, "splent_catalog", spl_name, f"{spl_name}.uvl"
803
+ )
804
+ if os.path.isfile(candidate):
805
+ uvl_path = candidate
806
+
807
+ # Legacy fallback: [tool.splent.uvl].file
808
+ if not uvl_path:
809
+ try:
810
+ uvl_cfg = PyprojectReader.for_product(product_dir).uvl_config
811
+ uvl_file = uvl_cfg.get("file")
812
+ if uvl_file:
813
+ candidate = os.path.join(product_dir, "uvl", uvl_file)
814
+ if os.path.isfile(candidate):
815
+ uvl_path = candidate
816
+ except (FileNotFoundError, RuntimeError):
817
+ pass
818
+
819
+ if not uvl_path:
820
+ click.secho(
821
+ "❌ No UVL file found.\n"
822
+ " Set [tool.splent].spl in pyproject.toml (SPL catalog)\n"
823
+ " or [tool.splent.uvl].file (legacy).",
824
+ fg="red",
825
+ )
798
826
  raise SystemExit(1)
799
827
 
800
828
  click.echo(f"📖 Reading UVL model: {uvl_path}")
@@ -848,6 +876,53 @@ def export_puml(
848
876
  if parsed:
849
877
  all_models[package] = parsed
850
878
  break
879
+ # Apply refinement mixins: merge mixin attributes into target models
880
+ for package, fpath in feature_paths.items():
881
+ pyproject_path = os.path.join(fpath, "pyproject.toml")
882
+ if not os.path.isfile(pyproject_path):
883
+ continue
884
+ import tomllib as _tomllib
885
+
886
+ with open(pyproject_path, "rb") as f:
887
+ feat_data = _tomllib.load(f)
888
+ extends = (
889
+ feat_data.get("tool", {})
890
+ .get("splent", {})
891
+ .get("refinement", {})
892
+ .get("extends", {})
893
+ .get("models", [])
894
+ )
895
+ if not extends:
896
+ continue
897
+ # Parse the mixin's models.py
898
+ mixin_models = []
899
+ src_root = os.path.join(fpath, "src")
900
+ if os.path.isdir(src_root):
901
+ for org_dir in os.listdir(src_root):
902
+ mpath = os.path.join(src_root, org_dir, package, "models.py")
903
+ if os.path.isfile(mpath):
904
+ mixin_models = _parse_models(mpath)
905
+ break
906
+ # Merge each mixin into its target model
907
+ for ext in extends:
908
+ target_name = ext.get("target")
909
+ mixin_name = ext.get("mixin")
910
+ if not target_name or not mixin_name:
911
+ continue
912
+ # Find the mixin class in parsed mixin_models
913
+ mixin_cls = next(
914
+ (m for m in mixin_models if m["name"] == mixin_name), None
915
+ )
916
+ if not mixin_cls:
917
+ continue
918
+ # Find the target model across all parsed features
919
+ for feat_models in all_models.values():
920
+ for model in feat_models:
921
+ if model["name"] == target_name:
922
+ model["attributes"].extend(mixin_cls["attributes"])
923
+ model["methods"].extend(mixin_cls["methods"])
924
+ break
925
+
851
926
  click.echo(
852
927
  f"📋 Parsed models from {len(all_models)}/{len(feature_paths)} features."
853
928
  )
@@ -237,10 +237,27 @@ def _extract_translations(translations_dir: Path) -> list[str]:
237
237
  return locales
238
238
 
239
239
 
240
+ def _strip_docstrings_and_comments(text: str) -> str:
241
+ """Remove triple-quoted strings (docstrings) and ``#`` comments.
242
+
243
+ Signal inference scans for ``define_signal("...")`` / ``connect_signal("...")``
244
+ calls. The scaffolded ``signals.py`` ships *examples* of those calls inside a
245
+ module docstring; without stripping them the inferred contract would list
246
+ example signal names the feature never actually emits (e.g. ``item-created``),
247
+ causing spurious collisions in ``feature:xray --validate``.
248
+ """
249
+ # Drop triple-quoted blocks (both ''' and """).
250
+ text = re.sub(r'""".*?"""', "", text, flags=re.DOTALL)
251
+ text = re.sub(r"'''.*?'''", "", text, flags=re.DOTALL)
252
+ # Drop full-line and trailing comments.
253
+ text = re.sub(r"#.*", "", text)
254
+ return text
255
+
256
+
240
257
  def _extract_signals(signals_path: Path) -> tuple[list[str], list[str]]:
241
258
  if not signals_path.exists():
242
259
  return [], []
243
- text = signals_path.read_text()
260
+ text = _strip_docstrings_and_comments(signals_path.read_text())
244
261
  provided = sorted(
245
262
  set(re.findall(r"""define_signal\s*\(\s*['"]([^'"]+)['"]""", text))
246
263
  )
@@ -273,14 +290,24 @@ def _scan_dependencies(
273
290
  if f"splent_feature_{short_name}" != own_feature_name:
274
291
  required_features.add(short_name)
275
292
 
276
- for var in re.findall(
277
- r"""os\.(?:getenv|environ\.get)\s*\(\s*['"]([A-Z][A-Z0-9_]+)['"]""", text
278
- ):
279
- env_vars.add(var)
280
- for var in re.findall(
281
- r"""os\.environ\s*\[\s*['"]([A-Z][A-Z0-9_]+)['"]""", text
282
- ):
283
- env_vars.add(var)
293
+ for line in text.splitlines():
294
+ stripped = line.lstrip()
295
+ # Skip comments and docstrings
296
+ if (
297
+ stripped.startswith("#")
298
+ or stripped.startswith('"""')
299
+ or stripped.startswith("'''")
300
+ ):
301
+ continue
302
+ for var in re.findall(
303
+ r"""os\.(?:getenv|environ\.get)\s*\(\s*['"]([A-Z][A-Z0-9_]+)['"]""",
304
+ stripped,
305
+ ):
306
+ env_vars.add(var)
307
+ for var in re.findall(
308
+ r"""os\.environ\s*\[\s*['"]([A-Z][A-Z0-9_]+)['"]""", stripped
309
+ ):
310
+ env_vars.add(var)
284
311
 
285
312
  return sorted(required_features), sorted(env_vars)
286
313
 
@@ -330,12 +357,14 @@ def write_contract(pyproject_path: str, contract: dict, feature_name: str) -> No
330
357
  text = path.read_text()
331
358
 
332
359
  existing_description = f"{feature_name} feature"
360
+ existing_env = None
333
361
  try:
334
362
  data = tomllib.loads(text)
335
363
  splent_contract = data.get("tool", {}).get("splent", {}).get("contract", {})
336
364
  desc = splent_contract.get("description")
337
365
  if desc:
338
366
  existing_description = desc
367
+ existing_env = splent_contract.get("env")
339
368
  except Exception:
340
369
  pass
341
370
 
@@ -373,7 +402,8 @@ def write_contract(pyproject_path: str, contract: dict, feature_name: str) -> No
373
402
  "# Do not edit manually — re-run `splent feature:contract --write` to refresh.\n"
374
403
  "[tool.splent.contract]\n"
375
404
  f'description = "{existing_description}"\n'
376
- "\n"
405
+ + (f'env = "{existing_env}"\n' if existing_env else "")
406
+ + "\n"
377
407
  "[tool.splent.contract.provides]\n"
378
408
  f"routes = {_toml_list(contract['routes'])}\n"
379
409
  f"blueprints = {_toml_list(contract['blueprints'])}\n"
@@ -478,16 +478,19 @@ def feature_xray(feature_ref, filter_cat, validate, full):
478
478
  if not bp_collisions:
479
479
  ok += 1
480
480
 
481
- # 5. Commands: check for name collisions
482
- all_commands: dict[str, list[str]] = {}
481
+ # 5. Commands: feature-contributed commands are namespaced under
482
+ # ``feature:<name> <cmd>``, so the same command name in two different
483
+ # features (e.g. the scaffold's example ``hello``) never actually collides.
484
+ # We only flag a real duplicate *within* a single feature.
485
+ cmd_collisions = {}
483
486
  for name, fdata in feature_data.items():
484
- for cmd in fdata["contract"].get("provides", {}).get("commands", []):
485
- all_commands.setdefault(cmd, []).append(name)
486
-
487
- cmd_collisions = {c: fs for c, fs in all_commands.items() if len(fs) > 1}
487
+ cmds = fdata["contract"].get("provides", {}).get("commands", [])
488
+ dupes = {c for c in cmds if cmds.count(c) > 1}
489
+ for c in dupes:
490
+ cmd_collisions.setdefault(f"{name}:{c}", []).append(name)
488
491
  for cmd, features_list in cmd_collisions.items():
489
492
  click.secho(
490
- f" ❌ Command '{cmd}' — collision: {', '.join(features_list)}",
493
+ f" ❌ Command '{cmd}' — duplicated within feature",
491
494
  fg="red",
492
495
  )
493
496
  fail += 1
@@ -235,10 +235,10 @@ def product_docker(env_dev, env_prod):
235
235
  seen.add(key)
236
236
  unique.append((label, port, kind))
237
237
 
238
- http_services = [(l, p) for l, p, k in unique if k == "http"]
239
- tcp_services = [(l, p) for l, p, k in unique if k == "tcp"]
238
+ http_services = [(lbl, p) for lbl, p, k in unique if k == "http"]
239
+ tcp_services = [(lbl, p) for lbl, p, k in unique if k == "tcp"]
240
240
  all_services = http_services + tcp_services
241
- label_width = max((len(l) for l, _ in all_services), default=20) + 2
241
+ label_width = max((len(lbl) for lbl, _ in all_services), default=20) + 2
242
242
 
243
243
  if http_services:
244
244
  click.secho(" Accessible services:", bold=True)
@@ -268,13 +268,17 @@ def product_deploy(down, ci):
268
268
  import time
269
269
 
270
270
  healthy = False
271
- for attempt in range(15):
271
+ # The production web container is named ``<product>_web_deploy`` —
272
+ # NOT ``<product>_web`` (that is the dev container). A cold start runs
273
+ # migrations and compiles assets before gunicorn binds, so allow a
274
+ # generous window (~90s) before declaring the app unhealthy.
275
+ for attempt in range(45):
272
276
  try:
273
277
  result = subprocess.run(
274
278
  [
275
279
  "docker",
276
280
  "exec",
277
- f"{product}_web",
281
+ f"{product}_web_deploy",
278
282
  "bash",
279
283
  "-c",
280
284
  "curl -s -o /dev/null -w '%{http_code}' http://localhost:5000/",
@@ -172,7 +172,12 @@ def product_env(generate, merge, env_name, process_all):
172
172
  )
173
173
  raise SystemExit(1)
174
174
 
175
- target_env = os.path.join(docker_dir, ".env")
175
+ # Each environment writes to its own file so a `--prod` merge never
176
+ # clobbers the development `.env` (which the dev containers read via
177
+ # env_file). dev → .env, prod → .env.deploy.
178
+ target_env = os.path.join(
179
+ docker_dir, ".env.deploy" if env_name == "prod" else ".env"
180
+ )
176
181
  if base_env != target_env:
177
182
  shutil.copyfile(base_env, target_env)
178
183
 
@@ -74,6 +74,31 @@ def _scan_feature_deps(
74
74
  ) -> set[str]:
75
75
  """Scan a feature's source for dependencies. Returns set of short_names."""
76
76
  deps = set()
77
+
78
+ # Refinement features declare the feature they refine in pyproject.toml
79
+ # ([tool.splent.refinement].refines). That is a hard dependency — the base
80
+ # feature must load first — so surface it as a constraint even when there is
81
+ # no Python import or FK between them.
82
+ pyproject_path = os.path.join(feature_path, "pyproject.toml")
83
+ if os.path.isfile(pyproject_path):
84
+ try:
85
+ import tomllib
86
+
87
+ with open(pyproject_path, "rb") as fh:
88
+ pydata = tomllib.load(fh)
89
+ refines = (
90
+ pydata.get("tool", {})
91
+ .get("splent", {})
92
+ .get("refinement", {})
93
+ .get("refines")
94
+ )
95
+ if refines and refines != feature_pkg and refines in all_packages:
96
+ short = pkg_to_short.get(refines)
97
+ if short:
98
+ deps.add(short)
99
+ except Exception:
100
+ pass
101
+
77
102
  src_dir = os.path.join(feature_path, "src", "splent_io", feature_pkg)
78
103
  if not os.path.isdir(src_dir):
79
104
  return deps
@@ -106,6 +106,56 @@ def run_preflight(*, interactive: bool = True, build_mode: bool = False) -> bool
106
106
 
107
107
  failed = False
108
108
 
109
+ # Phase 0: pyproject sanity (duplicates, namespaces, SPL)
110
+ splent_cfg = data.get("tool", {}).get("splent", {})
111
+ base_feats = splent_cfg.get("features", [])
112
+ dev_feats = splent_cfg.get("features_dev", [])
113
+ prod_feats = splent_cfg.get("features_prod", [])
114
+
115
+ all_entries = base_feats + dev_feats + prod_feats
116
+ seen = {}
117
+ for entry in all_entries:
118
+ bare = (
119
+ entry.split("/")[-1].split("@")[0] if "/" in entry else entry.split("@")[0]
120
+ )
121
+ seen.setdefault(bare, []).append(entry)
122
+
123
+ duplicates = {k: v for k, v in seen.items() if len(v) > 1}
124
+ if duplicates:
125
+ if interactive:
126
+ for bare, entries in duplicates.items():
127
+ short = bare.replace("splent_feature_", "")
128
+ click.secho(
129
+ f" pyproject duplicate '{short}': {', '.join(entries)}", fg="red"
130
+ )
131
+ failed = True
132
+ else:
133
+ namespaces = set()
134
+ for entry in all_entries:
135
+ if "/" in entry:
136
+ namespaces.add(entry.split("/")[0])
137
+ if len(namespaces) > 1:
138
+ if interactive:
139
+ click.secho(
140
+ f" pyproject inconsistent namespaces: {', '.join(sorted(namespaces))}",
141
+ fg="yellow",
142
+ )
143
+
144
+ spl_name = splent_cfg.get("spl")
145
+ if spl_name:
146
+ uvl_check = os.path.join(
147
+ workspace, "splent_catalog", spl_name, f"{spl_name}.uvl"
148
+ )
149
+ if not os.path.isfile(uvl_check):
150
+ if interactive:
151
+ click.secho(
152
+ f" pyproject SPL '{spl_name}' — UVL not found in catalog", fg="red"
153
+ )
154
+ failed = True
155
+
156
+ if failed:
157
+ return False
158
+
109
159
  # Phase 1: SAT
110
160
  try:
111
161
  sat_ok, selected, _, _ = _run_sat_check(workspace, app_name, data, None, False)
@@ -260,17 +260,39 @@ def build_and_upload_pypi(path: str):
260
260
  raise SystemExit(1)
261
261
 
262
262
  click.echo(" pypi uploading...")
263
+
264
+ # Expand dist/* ourselves: subprocess runs without a shell, so a literal
265
+ # "dist/*" would be passed verbatim to twine and never match any file.
266
+ import glob
267
+
268
+ dist_files = sorted(glob.glob(os.path.join(path, "dist", "*")))
269
+ if not dist_files:
270
+ click.secho(" error: no built artifacts found in dist/", fg="red")
271
+ raise SystemExit(1)
272
+
273
+ # twine reads TWINE_USERNAME / TWINE_PASSWORD from the environment. The CLI
274
+ # loads them from the workspace .env (load_dotenv), so they are already in
275
+ # os.environ — pass it through explicitly. --non-interactive makes twine fail
276
+ # loudly instead of blocking on a credentials prompt; --skip-existing keeps
277
+ # re-runs idempotent (a half-uploaded release can be retried safely).
263
278
  try:
264
279
  subprocess.run(
265
- [sys.executable, "-m", "twine", "upload", "dist/*"],
280
+ [
281
+ sys.executable,
282
+ "-m",
283
+ "twine",
284
+ "upload",
285
+ "--non-interactive",
286
+ "--skip-existing",
287
+ *dist_files,
288
+ ],
266
289
  env=os.environ.copy(),
267
290
  check=True,
268
291
  cwd=path,
269
- capture_output=True,
270
292
  )
271
293
  except subprocess.CalledProcessError as e:
272
294
  click.secho(
273
- f" error: PyPI upload failed: {e.stderr.strip() if e.stderr else ''}",
295
+ f" error: PyPI upload failed (exit {e.returncode}).",
274
296
  fg="red",
275
297
  )
276
298
  click.secho(