splent-cli 1.2.7__tar.gz → 1.2.9__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 (139) hide show
  1. {splent_cli-1.2.7/src/splent_cli.egg-info → splent_cli-1.2.9}/PKG-INFO +1 -1
  2. {splent_cli-1.2.7 → splent_cli-1.2.9}/pyproject.toml +1 -1
  3. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/cache/cache_orphans.py +1 -1
  4. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/check/check_deps.py +1 -1
  5. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/check/check_features.py +1 -1
  6. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/coverage.py +5 -2
  7. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/database/db_dump.py +21 -1
  8. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/database/db_migrate.py +5 -2
  9. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/database/db_restore.py +3 -1
  10. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/database/db_seed.py +1 -1
  11. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/env/env_set.py +15 -2
  12. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/export_puml.py +65 -44
  13. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/feature/feature_add.py +5 -4
  14. splent_cli-1.2.9/src/splent_cli/commands/feature/feature_attach.py +105 -0
  15. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/feature/feature_clone.py +39 -12
  16. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/feature/feature_create.py +19 -3
  17. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/feature/feature_delete.py +2 -2
  18. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/feature/feature_discard.py +2 -2
  19. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/feature/feature_edit.py +44 -4
  20. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/feature/feature_fork.py +3 -1
  21. splent_cli-1.2.9/src/splent_cli/commands/feature/feature_pip_install.py +96 -0
  22. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/feature/feature_release.py +30 -6
  23. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/feature_compile.py +7 -11
  24. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/locust.py +10 -4
  25. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/product/product_build.py +54 -15
  26. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/product/product_clean.py +4 -0
  27. splent_cli-1.2.9/src/splent_cli/commands/product/product_derive.py +269 -0
  28. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/product/product_release.py +45 -15
  29. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/product/product_select.py +3 -3
  30. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/product/product_sync.py +5 -3
  31. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/product/product_up.py +33 -1
  32. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/selenium.py +5 -1
  33. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/uvl/uvl_fetch.py +1 -2
  34. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/uvl/uvl_sync.py +5 -3
  35. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/version.py +3 -3
  36. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/services/context.py +11 -2
  37. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/services/release.py +74 -25
  38. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/utils/manifest.py +5 -2
  39. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/utils/path_utils.py +12 -1
  40. {splent_cli-1.2.7 → splent_cli-1.2.9/src/splent_cli.egg-info}/PKG-INFO +1 -1
  41. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli.egg-info/SOURCES.txt +1 -0
  42. splent_cli-1.2.7/src/splent_cli/commands/feature/feature_attach.py +0 -173
  43. splent_cli-1.2.7/src/splent_cli/commands/product/product_derive.py +0 -134
  44. {splent_cli-1.2.7 → splent_cli-1.2.9}/LICENSE +0 -0
  45. {splent_cli-1.2.7 → splent_cli-1.2.9}/README.md +0 -0
  46. {splent_cli-1.2.7 → splent_cli-1.2.9}/setup.cfg +0 -0
  47. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/__init__.py +0 -0
  48. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/__main__.py +0 -0
  49. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/cli.py +0 -0
  50. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/__init__.py +0 -0
  51. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/cache/__init__.py +0 -0
  52. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/cache/cache_clear.py +0 -0
  53. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/cache/cache_outdated.py +0 -0
  54. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/cache/cache_prune.py +0 -0
  55. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/cache/cache_size.py +0 -0
  56. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/cache/cache_status.py +0 -0
  57. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/cache/cache_usage.py +0 -0
  58. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/cache/cache_versions.py +0 -0
  59. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/check/__init__.py +0 -0
  60. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/check/check_docker.py +0 -0
  61. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/check/check_env.py +0 -0
  62. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/check/check_github.py +0 -0
  63. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/check/check_pypi.py +0 -0
  64. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/check/check_pyproject.py +0 -0
  65. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/clear_cache.py +0 -0
  66. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/clear_log.py +0 -0
  67. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/clear_uploads.py +0 -0
  68. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/command_create.py +0 -0
  69. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/database/db_console.py +0 -0
  70. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/database/db_reset.py +0 -0
  71. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/database/db_rollback.py +0 -0
  72. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/database/db_status.py +0 -0
  73. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/database/db_upgrade.py +0 -0
  74. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/doctor.py +0 -0
  75. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/env/env_list.py +0 -0
  76. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/env/env_show.py +0 -0
  77. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/feature/feature_contract.py +0 -0
  78. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/feature/feature_detach.py +0 -0
  79. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/feature/feature_diff.py +0 -0
  80. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/feature/feature_drift.py +0 -0
  81. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/feature/feature_env.py +0 -0
  82. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/feature/feature_git.py +0 -0
  83. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/feature/feature_hook_add.py +0 -0
  84. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/feature/feature_hook_remove.py +0 -0
  85. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/feature/feature_hooks.py +0 -0
  86. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/feature/feature_list.py +0 -0
  87. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/feature/feature_order.py +0 -0
  88. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/feature/feature_pull.py +0 -0
  89. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/feature/feature_remove.py +0 -0
  90. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/feature/feature_rename.py +0 -0
  91. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/feature/feature_search.py +0 -0
  92. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/feature/feature_status.py +0 -0
  93. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/feature/feature_sync_template.py +0 -0
  94. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/feature/feature_test.py +0 -0
  95. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/feature/feature_upgrade.py +0 -0
  96. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/feature/feature_versions.py +0 -0
  97. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/linter.py +0 -0
  98. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/product/__init__.py +0 -0
  99. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/product/product_create.py +0 -0
  100. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/product/product_deploy.py +0 -0
  101. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/product/product_down.py +0 -0
  102. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/product/product_drift.py +0 -0
  103. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/product/product_env.py +0 -0
  104. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/product/product_list.py +0 -0
  105. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/product/product_logs.py +0 -0
  106. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/product/product_port.py +0 -0
  107. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/product/product_run.py +0 -0
  108. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/product/product_shell.py +0 -0
  109. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/product/product_status.py +0 -0
  110. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/product/product_sync_template.py +0 -0
  111. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/release/__init__.py +0 -0
  112. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/release/release_core.py +0 -0
  113. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/route_list.py +0 -0
  114. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/tokens.py +0 -0
  115. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/uvl/uvl_check.py +0 -0
  116. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/uvl/uvl_configs.py +0 -0
  117. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/uvl/uvl_deps.py +0 -0
  118. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/uvl/uvl_features.py +0 -0
  119. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/uvl/uvl_fix.py +0 -0
  120. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/uvl/uvl_info.py +0 -0
  121. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/uvl/uvl_missing.py +0 -0
  122. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/uvl/uvl_utils.py +0 -0
  123. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/commands/uvl/uvl_valid.py +0 -0
  124. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/services/__init__.py +0 -0
  125. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/services/compose.py +0 -0
  126. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/utils/__init__.py +0 -0
  127. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/utils/cache_utils.py +0 -0
  128. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/utils/command_loader.py +0 -0
  129. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/utils/db_utils.py +0 -0
  130. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/utils/decorators.py +0 -0
  131. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/utils/dynamic_imports.py +0 -0
  132. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/utils/feature_installer.py +0 -0
  133. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/utils/feature_utils.py +0 -0
  134. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/utils/lifecycle.py +0 -0
  135. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli/utils/template_drift.py +0 -0
  136. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli.egg-info/dependency_links.txt +0 -0
  137. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli.egg-info/entry_points.txt +0 -0
  138. {splent_cli-1.2.7 → splent_cli-1.2.9}/src/splent_cli.egg-info/requires.txt +0 -0
  139. {splent_cli-1.2.7 → splent_cli-1.2.9}/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.7
3
+ Version: 1.2.9
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.7"
7
+ version = "1.2.9"
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"
@@ -58,7 +58,7 @@ def _get_all_product_refs(workspace: Path) -> set:
58
58
  for entry in feats:
59
59
  ref = entry.split("/", 1)[1] if "/" in entry else entry
60
60
  refs.add(ref)
61
- except Exception:
61
+ except (OSError, tomllib.TOMLDecodeError):
62
62
  continue
63
63
  return refs
64
64
 
@@ -108,7 +108,7 @@ def _scan_feature_imports(
108
108
  try:
109
109
  with open(filepath, "r", encoding="utf-8") as fh:
110
110
  content = fh.read()
111
- except Exception:
111
+ except (OSError, PermissionError):
112
112
  continue
113
113
 
114
114
  # Find imports: from splent_io.splent_feature_X... or import splent_io.splent_feature_X
@@ -139,7 +139,7 @@ def check_features():
139
139
  _warn("Uncommitted changes")
140
140
  else:
141
141
  _ok("Git clean")
142
- except (subprocess.TimeoutExpired, FileNotFoundError):
142
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
143
143
  _warn("Could not check git status")
144
144
 
145
145
  click.echo()
@@ -36,5 +36,8 @@ def coverage(module_name, html):
36
36
 
37
37
  try:
38
38
  subprocess.run(coverage_cmd, check=True)
39
- except subprocess.CalledProcessError as e:
40
- click.echo(click.style(f"Error running coverage: {e}", fg="red"))
39
+ except subprocess.CalledProcessError:
40
+ click.echo(
41
+ click.style("❌ Coverage run failed (tests may be failing).", fg="red")
42
+ )
43
+ raise SystemExit(1)
@@ -18,6 +18,20 @@ def db_dump(filename):
18
18
  mariadb_password = os.getenv("MARIADB_PASSWORD")
19
19
  mariadb_database = os.getenv("MARIADB_DATABASE")
20
20
 
21
+ missing = [
22
+ k
23
+ for k, v in {
24
+ "MARIADB_HOSTNAME": mariadb_hostname,
25
+ "MARIADB_USER": mariadb_user,
26
+ "MARIADB_PASSWORD": mariadb_password,
27
+ "MARIADB_DATABASE": mariadb_database,
28
+ }.items()
29
+ if not v
30
+ ]
31
+ if missing:
32
+ click.secho(f"❌ Missing env vars: {', '.join(missing)}", fg="red")
33
+ raise SystemExit(1)
34
+
21
35
  # Generate default filename if not provided
22
36
  if not filename:
23
37
  timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
@@ -28,23 +42,29 @@ def db_dump(filename):
28
42
  filename += ".sql"
29
43
 
30
44
  try:
45
+ env = {**os.environ, "MYSQL_PWD": mariadb_password or ""}
31
46
  with open(filename, "wb") as out:
32
47
  subprocess.run(
33
48
  [
34
49
  "mysqldump",
35
50
  f"-h{mariadb_hostname}",
36
51
  f"-u{mariadb_user}",
37
- f"-p{mariadb_password}",
38
52
  mariadb_database,
39
53
  ],
40
54
  stdout=out,
41
55
  check=True,
56
+ env=env,
42
57
  )
43
58
  click.echo(
44
59
  click.style(f"Database dump created successfully: {filename}", fg="green")
45
60
  )
46
61
  except subprocess.CalledProcessError as e:
47
62
  click.echo(click.style(f"Error creating database dump: {e}", fg="red"))
63
+ if os.path.exists(filename):
64
+ os.remove(filename)
65
+ click.echo(
66
+ click.style(f"Partial file removed: {filename}", fg="yellow")
67
+ )
48
68
 
49
69
 
50
70
  cli_command = db_dump
@@ -109,8 +109,11 @@ def db_migrate(feature):
109
109
 
110
110
  try:
111
111
  alembic_migrate(directory=mdir, message=feat)
112
- except Exception:
113
- pass
112
+ except Exception as e:
113
+ if os.getenv("SPLENT_DEBUG"):
114
+ click.secho(
115
+ f" ⚠️ {feat}: migration generation skipped ({e})", fg="yellow"
116
+ )
114
117
  finally:
115
118
  alembic_logger.setLevel(prev_level)
116
119
 
@@ -57,11 +57,13 @@ def db_restore(filename, yes):
57
57
  raise SystemExit(0)
58
58
 
59
59
  try:
60
+ env = {**os.environ, "MYSQL_PWD": password or ""}
60
61
  with open(filename, "rb") as sql_file:
61
62
  subprocess.run(
62
- ["mysql", f"-h{host}", f"-u{user}", f"-p{password}", database],
63
+ ["mysql", f"-h{host}", f"-u{user}", database],
63
64
  stdin=sql_file,
64
65
  check=True,
66
+ env=env,
65
67
  )
66
68
  click.secho(f"✅ Database restored from: {filename}", fg="green")
67
69
  except subprocess.CalledProcessError as e:
@@ -26,7 +26,7 @@ def _resolve_feature_order(features_raw: list[str]) -> list[str]:
26
26
  uvl_file = uvl_cfg.get("file")
27
27
  if uvl_file:
28
28
  uvl_path = os.path.join(product_dir, "uvl", uvl_file)
29
- except Exception:
29
+ except (OSError, KeyError, AttributeError):
30
30
  pass
31
31
 
32
32
  return FeatureLoadOrderResolver().resolve(features_raw, uvl_path)
@@ -24,6 +24,14 @@ def load_env():
24
24
  return data
25
25
 
26
26
 
27
+ def _validate_env_value(value: str, label: str) -> str:
28
+ """Strip whitespace and reject values containing newlines."""
29
+ value = value.strip()
30
+ if "\n" in value or "\r" in value:
31
+ raise click.ClickException(f"{label} must not contain newlines.")
32
+ return value
33
+
34
+
27
35
  def write_env(env: dict):
28
36
  """Write dict back to .env."""
29
37
  lines = [f"{k}={v}" for k, v in env.items()]
@@ -62,6 +70,9 @@ def set_github_interactive():
62
70
  user = click.prompt("GitHub username", type=str)
63
71
  token = click.prompt("GitHub personal access token", hide_input=True)
64
72
 
73
+ user = _validate_env_value(user, "GitHub username")
74
+ token = _validate_env_value(token, "GitHub token")
75
+
65
76
  set_var("GITHUB_USER", user)
66
77
  set_var("GITHUB_TOKEN", token)
67
78
 
@@ -73,6 +84,8 @@ def set_pypi_interactive():
73
84
  username = "__token__"
74
85
  token = click.prompt("PyPI token", hide_input=True)
75
86
 
87
+ token = _validate_env_value(token, "PyPI token")
88
+
76
89
  set_var("PYPI_USERNAME", username)
77
90
  set_var("PYPI_TOKEN", token)
78
91
 
@@ -86,9 +99,9 @@ def set_developer_interactive():
86
99
  answer = click.prompt("(y/n)", type=click.Choice(["y", "n"], case_sensitive=False))
87
100
  enabled = "true" if answer == "y" else "false"
88
101
 
89
- set_var("SPLENT_DEVELOPER_SSH", enabled)
102
+ set_var("SPLENT_USE_SSH", enabled)
90
103
 
91
- click.secho(f"✔ SPLENT_DEVELOPER_SSH set to {enabled}", fg="green")
104
+ click.secho(f"✔ SPLENT_USE_SSH set to {enabled}", fg="green")
92
105
  remind_source()
93
106
 
94
107
 
@@ -659,50 +659,66 @@ def _render_exports(
659
659
 
660
660
  svg_path = f"{base}.svg"
661
661
  click.echo("🔧 Rendering diagram...")
662
- subprocess.run([plantuml_bin, "-tsvg", puml_path], check=True)
663
-
664
- if export_svg:
665
- click.secho(f"✅ SVG exported: {svg_path}", fg="green")
666
-
667
- if export_pdf:
668
- click.echo("📄 Converting to PDF...")
669
- try:
670
- subprocess.run(
671
- ["rsvg-convert", "-f", "pdf", "-o", f"{base}.pdf", svg_path],
672
- check=True,
673
- )
674
- click.secho(f"✅ PDF exported: {base}.pdf", fg="green")
675
- except FileNotFoundError:
676
- click.secho(
677
- "❌ rsvg-convert not found. Rebuild: make setup-rebuild", fg="red"
678
- )
662
+ try:
663
+ subprocess.run(
664
+ [plantuml_bin, "-tsvg", puml_path],
665
+ check=True,
666
+ capture_output=True,
667
+ )
668
+ except subprocess.CalledProcessError as e:
669
+ click.secho("❌ PlantUML failed to render diagram.", fg="red")
670
+ if os.getenv("SPLENT_DEBUG"):
671
+ stderr = e.stderr
672
+ if isinstance(stderr, bytes):
673
+ stderr = stderr.decode(errors="replace")
674
+ click.secho(stderr[:500], fg="bright_black")
675
+ return
679
676
 
680
- if export_png:
681
- click.echo("🖼️ Converting to PNG...")
682
- try:
683
- subprocess.run(
684
- [
685
- "rsvg-convert",
686
- "-f",
687
- "png",
688
- "--dpi-x",
689
- "150",
690
- "--dpi-y",
691
- "150",
692
- "-o",
693
- f"{base}.png",
694
- svg_path,
695
- ],
696
- check=True,
697
- )
698
- click.secho(f"✅ PNG exported: {base}.png", fg="green")
699
- except FileNotFoundError:
700
- click.secho(
701
- "❌ rsvg-convert not found. Rebuild: make setup-rebuild", fg="red"
702
- )
677
+ try:
678
+ if export_svg:
679
+ click.secho(f"✅ SVG exported: {svg_path}", fg="green")
680
+
681
+ if export_pdf:
682
+ click.echo("📄 Converting to PDF...")
683
+ try:
684
+ subprocess.run(
685
+ ["rsvg-convert", "-f", "pdf", "-o", f"{base}.pdf", svg_path],
686
+ check=True,
687
+ )
688
+ click.secho(f"✅ PDF exported: {base}.pdf", fg="green")
689
+ except FileNotFoundError:
690
+ click.secho(
691
+ "❌ rsvg-convert not found. Rebuild: make setup-rebuild",
692
+ fg="red",
693
+ )
703
694
 
704
- if not export_svg and os.path.exists(svg_path):
705
- os.remove(svg_path)
695
+ if export_png:
696
+ click.echo("🖼️ Converting to PNG...")
697
+ try:
698
+ subprocess.run(
699
+ [
700
+ "rsvg-convert",
701
+ "-f",
702
+ "png",
703
+ "--dpi-x",
704
+ "150",
705
+ "--dpi-y",
706
+ "150",
707
+ "-o",
708
+ f"{base}.png",
709
+ svg_path,
710
+ ],
711
+ check=True,
712
+ )
713
+ click.secho(f"✅ PNG exported: {base}.png", fg="green")
714
+ except FileNotFoundError:
715
+ click.secho(
716
+ "❌ rsvg-convert not found. Rebuild: make setup-rebuild",
717
+ fg="red",
718
+ )
719
+ finally:
720
+ if not export_svg and os.path.exists(svg_path):
721
+ os.remove(svg_path)
706
722
 
707
723
 
708
724
  # ---------------------------------------------------------------------------
@@ -811,8 +827,13 @@ def export_puml(
811
827
  if mode_classes:
812
828
  all_models: dict[str, list[dict]] = {}
813
829
  for package, fpath in feature_paths.items():
814
- for org_dir in os.listdir(os.path.join(fpath, "src")):
815
- models_path = os.path.join(fpath, "src", org_dir, package, "models.py")
830
+ src_root = os.path.join(fpath, "src")
831
+ if not os.path.isdir(src_root):
832
+ continue
833
+ for org_dir in os.listdir(src_root):
834
+ models_path = os.path.join(
835
+ src_root, org_dir, package, "models.py"
836
+ )
816
837
  if os.path.isfile(models_path):
817
838
  parsed = _parse_models(models_path)
818
839
  if parsed:
@@ -74,11 +74,12 @@ def feature_add(full_name):
74
74
  os.makedirs(product_features_dir, exist_ok=True)
75
75
 
76
76
  link_path = os.path.join(product_features_dir, feature_name)
77
- if os.path.islink(link_path) or os.path.exists(link_path):
78
- os.unlink(link_path)
79
-
80
77
  rel_target = os.path.relpath(feature_dir, product_features_dir)
81
- os.symlink(rel_target, link_path)
78
+ try:
79
+ os.symlink(rel_target, link_path)
80
+ except FileExistsError:
81
+ os.unlink(link_path)
82
+ os.symlink(rel_target, link_path)
82
83
  click.echo(f"🔗 Linked {link_path} → {rel_target}")
83
84
 
84
85
  # --------------------------
@@ -0,0 +1,105 @@
1
+ import os
2
+ import tomllib
3
+ import tomli_w
4
+ import click
5
+ from splent_cli.services import context, compose
6
+ from splent_cli.utils.manifest import feature_key, set_feature_state
7
+
8
+
9
+ @click.command(
10
+ "feature:attach",
11
+ short_help="Attach a cached feature version to the current product.",
12
+ )
13
+ @click.argument("feature_identifier", required=True)
14
+ @click.argument("version", required=True)
15
+ def feature_attach(feature_identifier, version):
16
+ """
17
+ Attach a cached feature version to the current product.
18
+
19
+ - Requires the feature to already be in the local cache.
20
+ If not, run: splent feature:clone <namespace>/<feature>@<version>
21
+ - Updates pyproject.toml referencing feature@version.
22
+ - Creates/updates the versioned symlink in features/<namespace>/.
23
+ - Updates the manifest state to 'declared'.
24
+ """
25
+ product = context.require_app()
26
+ ws = context.workspace()
27
+
28
+ # --- Parse feature identifier -------------------------------------------
29
+ namespace, namespace_github, namespace_fs, feature_name = (
30
+ compose.parse_feature_identifier(feature_identifier)
31
+ )
32
+
33
+ cache_base = str(ws / ".splent_cache" / "features" / namespace_fs)
34
+ product_path = str(ws / product)
35
+ pyproject_path = os.path.join(product_path, "pyproject.toml")
36
+
37
+ if not os.path.exists(pyproject_path):
38
+ click.echo("❌ pyproject.toml not found in product.")
39
+ raise SystemExit(1)
40
+
41
+ # --- 1️⃣ Verify feature exists in cache ---------------------------------
42
+ versioned_dir = os.path.join(cache_base, f"{feature_name}@{version}")
43
+
44
+ if not os.path.exists(versioned_dir):
45
+ click.echo(
46
+ f"❌ Feature '{namespace}/{feature_name}@{version}' not found in cache.\n"
47
+ f" Run first: splent feature:clone {namespace}/{feature_name}@{version}"
48
+ )
49
+ raise SystemExit(1)
50
+
51
+ click.echo(f"✅ Cache found → {versioned_dir}")
52
+
53
+ # --- 2️⃣ Update pyproject.toml ------------------------------------------
54
+ full_name = f"{namespace}/{feature_name}@{version}"
55
+ bare_name = f"{namespace}/{feature_name}"
56
+
57
+ with open(pyproject_path, "rb") as f:
58
+ data = tomllib.load(f)
59
+
60
+ from splent_cli.utils.feature_utils import (
61
+ read_features_from_data,
62
+ write_features_to_data,
63
+ )
64
+
65
+ features = read_features_from_data(data)
66
+
67
+ if full_name in features:
68
+ click.echo(f"ℹ️ Feature '{full_name}' already present in pyproject.toml.")
69
+ else:
70
+ # Replace bare entry (added by uvl:sync) or old versioned entry if present
71
+ features = [
72
+ f for f in features if f != bare_name and not f.startswith(f"{bare_name}@")
73
+ ]
74
+ features.append(full_name)
75
+ write_features_to_data(data, features)
76
+ with open(pyproject_path, "wb") as f:
77
+ tomli_w.dump(data, f)
78
+ click.echo(f"🧩 Updated pyproject.toml → {full_name}")
79
+
80
+ # --- 3️⃣ Create/update symlink ------------------------------------------
81
+ product_features_dir = os.path.join(product_path, "features", namespace_fs)
82
+ os.makedirs(product_features_dir, exist_ok=True)
83
+
84
+ new_link = os.path.join(product_features_dir, f"{feature_name}@{version}")
85
+ if os.path.islink(new_link):
86
+ os.unlink(new_link)
87
+ rel_target = os.path.relpath(versioned_dir, product_features_dir)
88
+ os.symlink(rel_target, new_link)
89
+
90
+ click.echo(f"🔗 Linked {new_link} → {rel_target}")
91
+
92
+ # --- 4️⃣ Update manifest ------------------------------------------------
93
+ key = feature_key(namespace_fs, feature_name, version)
94
+ set_feature_state(
95
+ product_path,
96
+ product,
97
+ key,
98
+ "declared",
99
+ namespace=namespace_fs,
100
+ name=feature_name,
101
+ version=version,
102
+ mode="pinned",
103
+ )
104
+
105
+ click.echo("🎯 Feature successfully attached.")
@@ -1,4 +1,5 @@
1
1
  import os
2
+ import re
2
3
  import subprocess
3
4
  import requests
4
5
  import click
@@ -22,7 +23,7 @@ def _get_latest_tag(namespace, repo) -> str | None:
22
23
  r.raise_for_status()
23
24
  tags = r.json()
24
25
  return tags[0]["name"] if tags else None
25
- except Exception:
26
+ except (requests.RequestException, KeyError, IndexError, ValueError):
26
27
  return None
27
28
 
28
29
 
@@ -33,20 +34,26 @@ def _build_repo_url(namespace, repo):
33
34
  1. If SPLENT_USE_SSH=true → SSH
34
35
  2. Else if GITHUB_TOKEN exists → HTTPS with token
35
36
  3. Else → HTTPS read-only
37
+
38
+ Returns a tuple (real_url, display_url) where display_url never contains a token.
36
39
  """
37
40
  use_ssh = os.getenv("SPLENT_USE_SSH", "false").lower() == "true"
38
41
  token = os.getenv("GITHUB_TOKEN")
39
42
 
40
43
  if use_ssh:
41
44
  click.secho("🔐 SSH mode enabled (SPLENT_USE_SSH=true)", fg="cyan")
42
- return f"git@github.com:{namespace}/{repo}.git"
45
+ url = f"git@github.com:{namespace}/{repo}.git"
46
+ return url, url
43
47
 
44
48
  if token:
45
49
  click.secho("🌐 HTTPS with token (SPLENT_USE_SSH not true)", fg="cyan")
46
- return f"https://{token}@github.com/{namespace}/{repo}.git"
50
+ real_url = f"https://{token}@github.com/{namespace}/{repo}.git"
51
+ display_url = f"https://github.com/{namespace}/{repo}.git"
52
+ return real_url, display_url
47
53
 
48
54
  click.secho("🌍 HTTPS read-only (no token, no SSH)", fg="yellow")
49
- return f"https://github.com/{namespace}/{repo}.git"
55
+ url = f"https://github.com/{namespace}/{repo}.git"
56
+ return url, url
50
57
 
51
58
 
52
59
  def _parse_full_name(full_name: str):
@@ -67,6 +74,11 @@ def _parse_full_name(full_name: str):
67
74
  return namespace, repo, version
68
75
 
69
76
 
77
+ def _validate_identifier_part(value: str, label: str):
78
+ if not re.fullmatch(r'[a-zA-Z0-9_\-\.]+', value):
79
+ raise SystemExit(f"❌ Invalid {label}: '{value}'. Only letters, digits, - _ . allowed.")
80
+
81
+
70
82
  # =====================================================================
71
83
  # MAIN
72
84
  # =====================================================================
@@ -88,6 +100,11 @@ def feature_clone(full_name):
88
100
 
89
101
  namespace, repo, version = _parse_full_name(full_name)
90
102
 
103
+ _validate_identifier_part(namespace, "namespace")
104
+ _validate_identifier_part(repo, "repo")
105
+ if version:
106
+ _validate_identifier_part(version, "version")
107
+
91
108
  if not version:
92
109
  click.echo(
93
110
  f"🔍 No version provided → fetching latest tag for {namespace}/{repo}..."
@@ -101,7 +118,7 @@ def feature_clone(full_name):
101
118
  raise SystemExit(1)
102
119
 
103
120
  # Build Git URL based on your ownership
104
- fork_url = _build_repo_url(namespace, repo)
121
+ fork_url, display_url = _build_repo_url(namespace, repo)
105
122
 
106
123
  # Local destination
107
124
  namespace_safe = namespace.replace("-", "_").replace(".", "_")
@@ -115,7 +132,7 @@ def feature_clone(full_name):
115
132
  click.secho(f"⚠️ Folder already exists: {local_path}", fg="yellow")
116
133
  return
117
134
 
118
- click.secho(f"⬇️ Cloning {fork_url}@{version}", fg="cyan")
135
+ click.secho(f"⬇️ Cloning {display_url}@{version}", fg="cyan")
119
136
 
120
137
  # Try clone specific tag/branch (suppress git noise)
121
138
  try:
@@ -138,15 +155,25 @@ def feature_clone(full_name):
138
155
  text=True,
139
156
  )
140
157
  except subprocess.CalledProcessError:
158
+ import shutil
159
+ shutil.rmtree(local_path, ignore_errors=True)
141
160
  click.secho(
142
161
  f"⚠️ Version '{version}' not found. Cloning main instead.", fg="yellow"
143
162
  )
144
- subprocess.run(
145
- ["git", "clone", "--depth", "1", "--quiet", fork_url, local_path],
146
- check=True,
147
- capture_output=True,
148
- text=True,
149
- )
163
+ try:
164
+ subprocess.run(
165
+ ["git", "clone", "--depth", "1", "--quiet", fork_url, local_path],
166
+ check=True,
167
+ capture_output=True,
168
+ text=True,
169
+ )
170
+ except subprocess.CalledProcessError:
171
+ shutil.rmtree(local_path, ignore_errors=True)
172
+ click.secho(
173
+ f"❌ Repository '{namespace}/{repo}' not found or not accessible.",
174
+ fg="red",
175
+ )
176
+ raise SystemExit(1)
150
177
 
151
178
  # Lock files as read-only to prevent accidental edits on pinned features
152
179
  make_feature_readonly(local_path)
@@ -73,6 +73,14 @@ def make_feature(full_name):
73
73
  return
74
74
 
75
75
  # --- Jinja setup ---
76
+ templates_dir = PathUtils.get_splent_cli_templates_dir()
77
+ if not os.path.isdir(templates_dir):
78
+ click.secho(
79
+ f"❌ Templates directory not found: {templates_dir}\n"
80
+ " Ensure the CLI is installed correctly.",
81
+ fg="red",
82
+ )
83
+ raise SystemExit(1)
76
84
  env = setup_jinja_env()
77
85
  template_ctx = {
78
86
  "feature_name": feature_name,
@@ -160,21 +168,22 @@ def make_feature(full_name):
160
168
 
161
169
  # --- Permissions (UID:GID 1000:1000) ---
162
170
  uid, gid = 1000, 1000
171
+ chown_failed = False
163
172
  for root, dirs, files in os.walk(feature_dir):
164
173
  try:
165
174
  os.chown(root, uid, gid)
166
175
  except PermissionError:
167
- pass
176
+ chown_failed = True
168
177
  for d in dirs:
169
178
  try:
170
179
  os.chown(os.path.join(root, d), uid, gid)
171
180
  except PermissionError:
172
- pass
181
+ chown_failed = True
173
182
  for f in files:
174
183
  try:
175
184
  os.chown(os.path.join(root, f), uid, gid)
176
185
  except PermissionError:
177
- pass
186
+ chown_failed = True
178
187
 
179
188
  click.echo(
180
189
  click.style(f"✅ Feature '{full_name}' created successfully!", fg="green")
@@ -182,5 +191,12 @@ def make_feature(full_name):
182
191
  click.echo(click.style(f"📦 Created at: {feature_dir}", fg="blue"))
183
192
  click.echo(click.style(f"🏷️ Namespace: {org_safe}", fg="bright_black"))
184
193
 
194
+ if chown_failed:
195
+ click.secho(
196
+ "⚠️ Could not set ownership 1000:1000 on some files.\n"
197
+ " If running outside the Docker container, this is expected and harmless.",
198
+ fg="yellow",
199
+ )
200
+
185
201
 
186
202
  cli_command = make_feature
@@ -53,7 +53,7 @@ def feature_delete(feature_identifier, version, force):
53
53
  try:
54
54
  with open(pyproject_path, "r", encoding="utf-8") as f:
55
55
  content = f.read()
56
- except Exception:
56
+ except OSError:
57
57
  continue
58
58
 
59
59
  if f"{feature_name}@{version}" in content:
@@ -91,7 +91,7 @@ def feature_delete(feature_identifier, version, force):
91
91
  try:
92
92
  shutil.rmtree(cache_dir)
93
93
  click.echo(f"🧹 Deleted: {cache_dir}")
94
- except Exception as e:
94
+ except OSError as e:
95
95
  click.echo(f"❌ Failed to delete: {e}")
96
96
  raise SystemExit(1)
97
97
 
@@ -28,7 +28,7 @@ def _find_products_using_editable(workspace, feature_name, ns_safe):
28
28
  try:
29
29
  with open(pyproject_path, "rb") as f:
30
30
  data = tomllib.load(f)
31
- except Exception:
31
+ except (OSError, tomllib.TOMLDecodeError):
32
32
  continue
33
33
 
34
34
  features = read_features_from_data(data)
@@ -68,7 +68,7 @@ def feature_discard(feature_name, namespace):
68
68
  click.echo(
69
69
  f"ℹ️ No editable folder found for {feature_name}. Nothing to discard."
70
70
  )
71
- raise SystemExit(0)
71
+ return
72
72
 
73
73
  click.echo(f"🧩 Editable feature detected at:\n {editable_path}")
74
74