redeploy 0.2.8__tar.gz → 0.2.10__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 (103) hide show
  1. {redeploy-0.2.8 → redeploy-0.2.10}/PKG-INFO +6 -6
  2. {redeploy-0.2.8 → redeploy-0.2.10}/README.md +5 -5
  3. {redeploy-0.2.8 → redeploy-0.2.10}/pyproject.toml +1 -1
  4. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/__init__.py +1 -1
  5. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/cli.py +65 -20
  6. redeploy-0.2.10/redeploy/spec_loader.py +41 -0
  7. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/tests/test_cli.py +34 -0
  8. redeploy-0.2.10/redeploy/tests/test_spec_loader.py +44 -0
  9. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/tests/test_version_cli.py +26 -4
  10. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy.egg-info/PKG-INFO +6 -6
  11. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy.egg-info/SOURCES.txt +2 -0
  12. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/apply/__init__.py +0 -0
  13. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/apply/executor.py +0 -0
  14. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/data_sync.py +0 -0
  15. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/detect/__init__.py +0 -0
  16. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/detect/detector.py +0 -0
  17. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/detect/probes.py +0 -0
  18. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/detect/remote.py +0 -0
  19. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/detect/templates.py +0 -0
  20. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/detect/workflow.py +0 -0
  21. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/discovery.py +0 -0
  22. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/dsl/__init__.py +0 -0
  23. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/dsl/loader.py +0 -0
  24. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/dsl/parser.py +0 -0
  25. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/dsl_python/__init__.py +0 -0
  26. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/dsl_python/context.py +0 -0
  27. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/dsl_python/decorators.py +0 -0
  28. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/dsl_python/docker_steps.py +0 -0
  29. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/dsl_python/exceptions.py +0 -0
  30. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/dsl_python/runner.py +0 -0
  31. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/dsl_python/steps.py +0 -0
  32. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/fleet.py +0 -0
  33. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/iac/__init__.py +0 -0
  34. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/iac/base.py +0 -0
  35. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/iac/docker_compose.py +0 -0
  36. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/iac/parsers/__init__.py +0 -0
  37. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/iac/parsers/compose.py +0 -0
  38. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/iac/registry.py +0 -0
  39. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/models.py +0 -0
  40. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/observe.py +0 -0
  41. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/parse.py +0 -0
  42. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/patterns.py +0 -0
  43. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/plan/__init__.py +0 -0
  44. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/plan/planner.py +0 -0
  45. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/plugins/__init__.py +0 -0
  46. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/plugins/builtin/__init__.py +0 -0
  47. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/plugins/builtin/browser_reload.py +0 -0
  48. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/plugins/builtin/notify.py +0 -0
  49. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/plugins/builtin/systemd_reload.py +0 -0
  50. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/ssh.py +0 -0
  51. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/steps.py +0 -0
  52. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/tests/__init__.py +0 -0
  53. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/tests/test_data_sync.py +0 -0
  54. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/tests/test_discovery.py +0 -0
  55. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/tests/test_dsl.py +0 -0
  56. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/tests/test_examples.py +0 -0
  57. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/tests/test_executor.py +0 -0
  58. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/tests/test_fleet.py +0 -0
  59. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/tests/test_git_integration.py +0 -0
  60. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/tests/test_iac.py +0 -0
  61. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/tests/test_manifest.py +0 -0
  62. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/tests/test_models.py +0 -0
  63. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/tests/test_observe.py +0 -0
  64. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/tests/test_parse.py +0 -0
  65. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/tests/test_patterns.py +0 -0
  66. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/tests/test_planner_edge.py +0 -0
  67. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/tests/test_plugins.py +0 -0
  68. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/tests/test_probes.py +0 -0
  69. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/tests/test_public_api.py +0 -0
  70. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/tests/test_ssh.py +0 -0
  71. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/tests/test_templates.py +0 -0
  72. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/tests/test_verify.py +0 -0
  73. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/tests/test_version.py +0 -0
  74. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/tests/test_version_changelog.py +0 -0
  75. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/tests/test_version_manifest.py +0 -0
  76. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/tests/test_version_sources.py +0 -0
  77. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/tests/test_workflow.py +0 -0
  78. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/verify.py +0 -0
  79. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/version/__init__.py +0 -0
  80. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/version/bump.py +0 -0
  81. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/version/changelog.py +0 -0
  82. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/version/commits.py +0 -0
  83. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/version/diff.py +0 -0
  84. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/version/git_integration.py +0 -0
  85. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/version/git_transaction.py +0 -0
  86. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/version/manifest.py +0 -0
  87. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/version/sources/__init__.py +0 -0
  88. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/version/sources/base.py +0 -0
  89. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/version/sources/json_.py +0 -0
  90. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/version/sources/plain.py +0 -0
  91. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/version/sources/regex.py +0 -0
  92. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/version/sources/toml_.py +0 -0
  93. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/version/sources/yaml_.py +0 -0
  94. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/version/transaction.py +0 -0
  95. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy/version.py +0 -0
  96. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy.egg-info/dependency_links.txt +0 -0
  97. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy.egg-info/entry_points.txt +0 -0
  98. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy.egg-info/requires.txt +0 -0
  99. {redeploy-0.2.8 → redeploy-0.2.10}/redeploy.egg-info/top_level.txt +0 -0
  100. {redeploy-0.2.8 → redeploy-0.2.10}/setup.cfg +0 -0
  101. {redeploy-0.2.8 → redeploy-0.2.10}/tests/test_iac.py +0 -0
  102. {redeploy-0.2.8 → redeploy-0.2.10}/tests/test_parse.py +0 -0
  103. {redeploy-0.2.8 → redeploy-0.2.10}/tests/test_redeploy.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: redeploy
3
- Version: 0.2.8
3
+ Version: 0.2.10
4
4
  Summary: Infrastructure migration toolkit: detect → plan → apply
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -26,17 +26,17 @@ Requires-Dist: pfix>=0.1.60; extra == "dev"
26
26
 
27
27
  ## AI Cost Tracking
28
28
 
29
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.8-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
30
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$4.05-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-7.7h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
29
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.10-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
30
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$4.35-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-8.7h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
31
31
 
32
- - 🤖 **LLM usage:** $4.0500 (27 commits)
33
- - 👤 **Human dev:** ~$773 (7.7h @ $100/h, 30min dedup)
32
+ - 🤖 **LLM usage:** $4.3500 (29 commits)
33
+ - 👤 **Human dev:** ~$866 (8.7h @ $100/h, 30min dedup)
34
34
 
35
35
  Generated on 2026-04-20 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
36
36
 
37
37
  ---
38
38
 
39
- ![PyPI](https://img.shields.io/badge/pypi-redeploy-blue) ![Version](https://img.shields.io/badge/version-0.2.8-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
39
+ ![PyPI](https://img.shields.io/badge/pypi-redeploy-blue) ![Version](https://img.shields.io/badge/version-0.2.10-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
40
40
 
41
41
  Infrastructure migration and device deploy toolkit — VPS, Raspberry Pi kiosk, Podman Quadlet, k3s.
42
42
 
@@ -3,17 +3,17 @@
3
3
 
4
4
  ## AI Cost Tracking
5
5
 
6
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.8-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
7
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$4.05-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-7.7h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
6
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.10-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
7
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$4.35-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-8.7h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
8
8
 
9
- - 🤖 **LLM usage:** $4.0500 (27 commits)
10
- - 👤 **Human dev:** ~$773 (7.7h @ $100/h, 30min dedup)
9
+ - 🤖 **LLM usage:** $4.3500 (29 commits)
10
+ - 👤 **Human dev:** ~$866 (8.7h @ $100/h, 30min dedup)
11
11
 
12
12
  Generated on 2026-04-20 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
13
13
 
14
14
  ---
15
15
 
16
- ![PyPI](https://img.shields.io/badge/pypi-redeploy-blue) ![Version](https://img.shields.io/badge/version-0.2.8-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
16
+ ![PyPI](https://img.shields.io/badge/pypi-redeploy-blue) ![Version](https://img.shields.io/badge/version-0.2.10-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
17
17
 
18
18
  Infrastructure migration and device deploy toolkit — VPS, Raspberry Pi kiosk, Podman Quadlet, k3s.
19
19
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "redeploy"
7
- version = "0.2.8"
7
+ version = "0.2.10"
8
8
  description = "Infrastructure migration toolkit: detect → plan → apply"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -7,7 +7,7 @@ Public API (stable from 0.2.0, semver guaranteed)::
7
7
 
8
8
  Everything not listed in ``__all__`` is internal and may change without notice.
9
9
  """
10
- __version__ = "0.2.8"
10
+ __version__ = "0.2.10"
11
11
 
12
12
  # ── Core models ───────────────────────────────────────────────────────────────
13
13
  from .models import ( # noqa: F401
@@ -853,7 +853,7 @@ def run(ctx, spec_file, dry_run, plan_only, do_detect, plan_out, output, env_nam
853
853
  redeploy run migration.yaml --detect --plan-out plan.yaml
854
854
  """
855
855
  from rich.console import Console
856
- from .models import MigrationSpec, ProjectManifest
856
+ from .models import ProjectManifest
857
857
  from .plan import Planner
858
858
 
859
859
  console = Console()
@@ -868,7 +868,7 @@ def run(ctx, spec_file, dry_run, plan_only, do_detect, plan_out, output, env_nam
868
868
  console.print("[dim] Create one with: redeploy init[/dim]")
869
869
  sys.exit(1)
870
870
 
871
- spec = MigrationSpec.from_file(resolved_spec)
871
+ spec = _load_spec_or_exit(console, resolved_spec)
872
872
 
873
873
  # overlay manifest values — env-specific or global
874
874
  if manifest:
@@ -937,6 +937,16 @@ def _find_manifest_path() -> str:
937
937
  return "redeploy.yaml"
938
938
 
939
939
 
940
+ def _load_spec_or_exit(console, path: str | Path):
941
+ from .spec_loader import SpecLoaderError, load_migration_spec
942
+
943
+ try:
944
+ return load_migration_spec(path)
945
+ except SpecLoaderError as exc:
946
+ console.print(f"[red]✗ {exc}[/red]")
947
+ sys.exit(1)
948
+
949
+
940
950
  def _resolve_device(console, device_id: str) -> tuple:
941
951
  """Resolve device from registry or auto-probe. Returns (device, registry) or (None, None)."""
942
952
  from .discovery import auto_probe
@@ -964,7 +974,7 @@ def _resolve_device(console, device_id: str) -> tuple:
964
974
 
965
975
  def _load_spec_with_manifest(console, spec_file: str | None, dev) -> "MigrationSpec":
966
976
  """Load spec and apply manifest/device overlays."""
967
- from .models import MigrationSpec, ProjectManifest
977
+ from .models import ProjectManifest
968
978
 
969
979
  manifest = ProjectManifest.find_and_load(Path.cwd())
970
980
  resolved_spec = spec_file or (manifest.spec if manifest else "migration.yaml")
@@ -972,7 +982,7 @@ def _load_spec_with_manifest(console, spec_file: str | None, dev) -> "MigrationS
972
982
  console.print(f"[red]✗ spec not found: {resolved_spec}[/red]")
973
983
  sys.exit(1)
974
984
 
975
- spec = MigrationSpec.from_file(resolved_spec)
985
+ spec = _load_spec_or_exit(console, resolved_spec)
976
986
  if manifest:
977
987
  manifest.apply_to_spec(spec)
978
988
 
@@ -1145,7 +1155,7 @@ def status(spec_file):
1145
1155
  """
1146
1156
  from rich.console import Console
1147
1157
  from rich.table import Table
1148
- from .models import MigrationSpec, ProjectManifest
1158
+ from .models import ProjectManifest
1149
1159
 
1150
1160
  console = Console()
1151
1161
 
@@ -1163,7 +1173,7 @@ def status(spec_file):
1163
1173
  resolved = spec_file or (manifest.spec if manifest else "migration.yaml")
1164
1174
  spec_path = Path(resolved)
1165
1175
  if spec_path.exists():
1166
- spec = MigrationSpec.from_file(str(spec_path))
1176
+ spec = _load_spec_or_exit(console, spec_path)
1167
1177
  if manifest:
1168
1178
  manifest.apply_to_spec(spec)
1169
1179
  console.print(f"\n[bold]{spec_path}[/bold] [dim]{spec.name}[/dim]")
@@ -3177,18 +3187,53 @@ def _print_version_scan_review(console, root_sources, packages):
3177
3187
  console.print(" No version sources detected")
3178
3188
 
3179
3189
 
3180
- def _print_version_scan_group(console, label: str, sources, *, default_version: str):
3190
+ def _classify_version_scan_source_confidence(source, *, actual: str) -> str:
3191
+ if actual == "(unreadable)":
3192
+ return "unreadable"
3193
+ if source.format == "regex":
3194
+ return "heuristic"
3195
+ return "certain"
3196
+
3197
+
3198
+ def _format_version_scan_source_status(confidence: str, *, conflict: bool) -> str:
3199
+ parts = [f"confidence={confidence}"]
3200
+ if conflict:
3201
+ parts.append("conflict=yes")
3202
+ return " ".join(parts)
3203
+
3204
+
3205
+ def _default_keep_scanned_source(confidence: str, *, conflict: bool) -> bool:
3206
+ return confidence == "certain" and not conflict
3207
+
3208
+
3209
+ def _summarize_version_scan_group(sources, *, default_version: str):
3181
3210
  detected = _read_detected_source_versions(sources)
3182
3211
  detected_map = {str(source.path): version for source, version in detected}
3183
3212
  unique_versions = {version for _, version in detected}
3184
3213
  chosen_version = detected[0][1] if detected else default_version
3185
- suffix = " (conflict)" if len(unique_versions) > 1 else ""
3214
+ has_conflict = len(unique_versions) > 1
3215
+ reviewed_sources = []
3186
3216
 
3187
- console.print(f" {label}: chosen version {chosen_version}{suffix}")
3188
3217
  for source in sources:
3189
3218
  actual = detected_map.get(str(source.path), "(unreadable)")
3190
- marker = " (conflict)" if len(unique_versions) > 1 and actual != chosen_version else ""
3191
- console.print(f" - {source.path} ({source.format}) current: {actual}{marker}")
3219
+ confidence = _classify_version_scan_source_confidence(source, actual=actual)
3220
+ is_conflict = has_conflict and actual not in {"(unreadable)", chosen_version}
3221
+ reviewed_sources.append((source, actual, confidence, is_conflict))
3222
+
3223
+ return chosen_version, has_conflict, reviewed_sources
3224
+
3225
+
3226
+ def _print_version_scan_group(console, label: str, sources, *, default_version: str):
3227
+ chosen_version, has_conflict, reviewed_sources = _summarize_version_scan_group(
3228
+ sources,
3229
+ default_version=default_version,
3230
+ )
3231
+ suffix = " (conflict)" if has_conflict else ""
3232
+
3233
+ console.print(f" {label}: chosen version {chosen_version}{suffix}")
3234
+ for source, actual, confidence, is_conflict in reviewed_sources:
3235
+ status = _format_version_scan_source_status(confidence, conflict=is_conflict)
3236
+ console.print(f" - {source.path} ({source.format}) current: {actual} {status}")
3192
3237
 
3193
3238
 
3194
3239
  def _iter_version_scan_groups(root_sources, packages):
@@ -3205,18 +3250,18 @@ def _review_detected_sources_interactively(console, root_sources, packages) -> s
3205
3250
  console.print("[bold]Interactive scan review[/bold]")
3206
3251
 
3207
3252
  for label, sources, default_version in _iter_version_scan_groups(root_sources, packages):
3208
- detected = _read_detected_source_versions(sources)
3209
- detected_map = {str(source.path): version for source, version in detected}
3210
- unique_versions = {version for _, version in detected}
3211
- chosen_version = detected[0][1] if detected else default_version
3212
- suffix = " (conflict)" if len(unique_versions) > 1 else ""
3253
+ chosen_version, has_conflict, reviewed_sources = _summarize_version_scan_group(
3254
+ sources,
3255
+ default_version=default_version,
3256
+ )
3257
+ suffix = " (conflict)" if has_conflict else ""
3213
3258
 
3214
3259
  console.print(f"\n[bold]{label}[/bold]: chosen version {chosen_version}{suffix}")
3215
- for source in sources:
3216
- actual = detected_map.get(str(source.path), "(unreadable)")
3260
+ for source, actual, confidence, is_conflict in reviewed_sources:
3261
+ status = _format_version_scan_source_status(confidence, conflict=is_conflict)
3217
3262
  keep = click.confirm(
3218
- f"Keep {source.path} ({source.format}) current={actual}?",
3219
- default=True,
3263
+ f"Keep {source.path} ({source.format}) current={actual} {status}?",
3264
+ default=_default_keep_scanned_source(confidence, conflict=is_conflict),
3220
3265
  )
3221
3266
  if not keep:
3222
3267
  rejected.add(source.path.as_posix())
@@ -0,0 +1,41 @@
1
+ """Load deployment specs from supported file formats."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+
6
+ from .models import MigrationSpec
7
+
8
+
9
+ class SpecLoaderError(ValueError):
10
+ """Base error raised when a deployment spec cannot be loaded."""
11
+
12
+
13
+ class UnsupportedSpecFormatError(SpecLoaderError):
14
+ """Raised when the spec file uses an unsupported format."""
15
+
16
+
17
+ def load_migration_spec(path: str | Path) -> MigrationSpec:
18
+ """Load a deployment spec from disk.
19
+
20
+ Supported today:
21
+ - `.yaml`
22
+ - `.yml`
23
+
24
+ Reserved for future support:
25
+ - `.md` markpact/markdown specs
26
+ """
27
+ spec_path = Path(path)
28
+ suffix = spec_path.suffix.lower()
29
+
30
+ if suffix in {"", ".yaml", ".yml"}:
31
+ return MigrationSpec.from_file(spec_path)
32
+
33
+ if suffix == ".md":
34
+ raise UnsupportedSpecFormatError(
35
+ "Unsupported spec format '.md': markdown/markpact specs are not implemented yet. "
36
+ "Use YAML (.yaml or .yml)."
37
+ )
38
+
39
+ raise UnsupportedSpecFormatError(
40
+ f"Unsupported spec format '{suffix}': use YAML (.yaml or .yml)."
41
+ )
@@ -101,6 +101,16 @@ class TestRunPlanOnly:
101
101
  result = _runner().invoke(cli, ["run", str(spec), "--plan-only", "--env", "prod"])
102
102
  assert result.exit_code == 0
103
103
 
104
+ def test_run_plan_only_rejects_markdown_spec_with_clear_error(self, tmp_path):
105
+ spec = tmp_path / "migration.md"
106
+ spec.write_text("# markpact prototype\n", encoding="utf-8")
107
+
108
+ result = _runner().invoke(cli, ["run", str(spec), "--plan-only"])
109
+
110
+ assert result.exit_code == 1
111
+ assert "Unsupported spec format '.md'" in result.output
112
+ assert "markdown/markpact specs are not implemented" in result.output
113
+
104
114
 
105
115
  # ── redeploy status ───────────────────────────────────────────────────────────
106
116
 
@@ -123,6 +133,17 @@ class TestStatus:
123
133
  assert result.exit_code == 0
124
134
  assert "myapp" in result.output
125
135
 
136
+ def test_status_rejects_markdown_spec_with_clear_error(self, tmp_path):
137
+ spec = tmp_path / "migration.md"
138
+ spec.write_text("# markpact prototype\n", encoding="utf-8")
139
+
140
+ with patch("redeploy.models.ProjectManifest.find_and_load", return_value=None):
141
+ result = _runner().invoke(cli, ["status", str(spec)])
142
+
143
+ assert result.exit_code == 1
144
+ assert "Unsupported spec format '.md'" in result.output
145
+ assert "markdown/markpact specs are not implemented" in result.output
146
+
126
147
 
127
148
  # ── redeploy init ─────────────────────────────────────────────────────────────
128
149
 
@@ -192,6 +213,19 @@ class TestDevices:
192
213
  assert data[0]["id"] == "x@1"
193
214
 
194
215
 
216
+ class TestTarget:
217
+ def test_target_rejects_markdown_spec_with_clear_error(self, tmp_path):
218
+ spec = tmp_path / "migration.md"
219
+ spec.write_text("# markpact prototype\n", encoding="utf-8")
220
+
221
+ with patch("redeploy.cli._resolve_device", return_value=(None, None)):
222
+ result = _runner().invoke(cli, ["target", "pi@192.168.1.42", str(spec), "--plan-only"])
223
+
224
+ assert result.exit_code == 1
225
+ assert "Unsupported spec format '.md'" in result.output
226
+ assert "markdown/markpact specs are not implemented" in result.output
227
+
228
+
195
229
  # ── redeploy apply ────────────────────────────────────────────────────────────
196
230
 
197
231
 
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ import textwrap
4
+
5
+ import pytest
6
+
7
+ from redeploy.spec_loader import UnsupportedSpecFormatError, load_migration_spec
8
+
9
+
10
+ def _migration_yaml() -> str:
11
+ return textwrap.dedent("""\
12
+ name: loader test
13
+ source:
14
+ strategy: docker_full
15
+ host: local
16
+ app: myapp
17
+ version: "1.0.0"
18
+ target:
19
+ strategy: docker_full
20
+ host: local
21
+ app: myapp
22
+ version: "1.0.1"
23
+ remote_dir: ~/myapp
24
+ """)
25
+
26
+
27
+ def test_load_migration_spec_reads_yaml(tmp_path):
28
+ spec_path = tmp_path / "migration.yaml"
29
+ spec_path.write_text(_migration_yaml(), encoding="utf-8")
30
+
31
+ spec = load_migration_spec(spec_path)
32
+
33
+ assert spec.name == "loader test"
34
+ assert spec.target.version == "1.0.1"
35
+
36
+
37
+ def test_load_migration_spec_rejects_markdown_for_now(tmp_path):
38
+ spec_path = tmp_path / "migration.md"
39
+ spec_path.write_text("# prototype\n", encoding="utf-8")
40
+
41
+ with pytest.raises(UnsupportedSpecFormatError) as exc_info:
42
+ load_migration_spec(spec_path)
43
+
44
+ assert "markdown/markpact specs are not implemented yet" in str(exc_info.value)
@@ -361,8 +361,9 @@ class TestVersionInit:
361
361
  assert result.exit_code == 0, result.output
362
362
  assert "Scan review" in result.output
363
363
  assert "backend: chosen version 1.0.0 (conflict)" in result.output
364
- assert "backend/VERSION (plain) current: 1.0.0" in result.output
365
- assert "backend/src/version.ts (regex) current: 1.1.0 (conflict)" in result.output
364
+ assert "backend/VERSION (plain) current: 1.0.0 confidence=certain" in result.output
365
+ assert "backend/src/version.ts (regex) current: 1.1.0 confidence=heuristic" in result.output
366
+ assert "conflict=yes" in result.output
366
367
  assert "Review only - manifest not written" in result.output
367
368
  assert not Path(".redeploy/version.yaml").exists()
368
369
 
@@ -385,8 +386,8 @@ class TestVersionInit:
385
386
 
386
387
  assert result.exit_code == 0, result.output
387
388
  assert "Interactive scan review" in result.output
388
- assert "Keep backend/VERSION (plain) current=1.0.0?" in result.output
389
- assert "Keep backend/src/version.ts (regex) current=1.1.0?" in result.output
389
+ assert "Keep backend/VERSION (plain) current=1.0.0 confidence=certain? [Y/n]:" in result.output
390
+ assert "Keep backend/src/version.ts (regex) current=1.1.0 confidence=heuristic conflict=yes? [y/N]:" in result.output
390
391
  assert "Write manifest to .redeploy/version.yaml?" in result.output
391
392
 
392
393
  manifest = yaml.safe_load(Path(".redeploy/version.yaml").read_text(encoding="utf-8"))
@@ -395,6 +396,27 @@ class TestVersionInit:
395
396
  {"path": "backend/VERSION", "format": "plain", "optional": False}
396
397
  ]
397
398
 
399
+ def test_init_scan_interactive_rejects_heuristic_source_by_default(self, tmp_path):
400
+ runner = _runner()
401
+ with runner.isolated_filesystem(temp_dir=tmp_path):
402
+ Path("packages/frontend/src").mkdir(parents=True, exist_ok=True)
403
+ Path("packages/frontend/src/version.ts").write_text(
404
+ 'export const VERSION = "2.1.0"\n',
405
+ encoding="utf-8",
406
+ )
407
+
408
+ result = runner.invoke(
409
+ cli,
410
+ ["version", "init", "--scan", "--interactive"],
411
+ input="\n",
412
+ catch_exceptions=False,
413
+ )
414
+
415
+ assert result.exit_code == 0, result.output
416
+ assert "Keep packages/frontend/src/version.ts (regex) current=2.1.0 confidence=heuristic? [y/N]:" in result.output
417
+ assert "No sources selected - manifest not written" in result.output
418
+ assert not Path(".redeploy/version.yaml").exists()
419
+
398
420
  def test_init_interactive_requires_scan(self, tmp_path):
399
421
  runner = _runner()
400
422
  with runner.isolated_filesystem(temp_dir=tmp_path):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: redeploy
3
- Version: 0.2.8
3
+ Version: 0.2.10
4
4
  Summary: Infrastructure migration toolkit: detect → plan → apply
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -26,17 +26,17 @@ Requires-Dist: pfix>=0.1.60; extra == "dev"
26
26
 
27
27
  ## AI Cost Tracking
28
28
 
29
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.8-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
30
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$4.05-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-7.7h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
29
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.10-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
30
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$4.35-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-8.7h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
31
31
 
32
- - 🤖 **LLM usage:** $4.0500 (27 commits)
33
- - 👤 **Human dev:** ~$773 (7.7h @ $100/h, 30min dedup)
32
+ - 🤖 **LLM usage:** $4.3500 (29 commits)
33
+ - 👤 **Human dev:** ~$866 (8.7h @ $100/h, 30min dedup)
34
34
 
35
35
  Generated on 2026-04-20 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
36
36
 
37
37
  ---
38
38
 
39
- ![PyPI](https://img.shields.io/badge/pypi-redeploy-blue) ![Version](https://img.shields.io/badge/version-0.2.8-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
39
+ ![PyPI](https://img.shields.io/badge/pypi-redeploy-blue) ![Version](https://img.shields.io/badge/version-0.2.10-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
40
40
 
41
41
  Infrastructure migration and device deploy toolkit — VPS, Raspberry Pi kiosk, Podman Quadlet, k3s.
42
42
 
@@ -9,6 +9,7 @@ redeploy/models.py
9
9
  redeploy/observe.py
10
10
  redeploy/parse.py
11
11
  redeploy/patterns.py
12
+ redeploy/spec_loader.py
12
13
  redeploy/ssh.py
13
14
  redeploy/steps.py
14
15
  redeploy/verify.py
@@ -69,6 +70,7 @@ redeploy/tests/test_planner_edge.py
69
70
  redeploy/tests/test_plugins.py
70
71
  redeploy/tests/test_probes.py
71
72
  redeploy/tests/test_public_api.py
73
+ redeploy/tests/test_spec_loader.py
72
74
  redeploy/tests/test_ssh.py
73
75
  redeploy/tests/test_templates.py
74
76
  redeploy/tests/test_verify.py
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes