redeploy 0.2.9__tar.gz → 0.2.11__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 (109) hide show
  1. {redeploy-0.2.9 → redeploy-0.2.11}/PKG-INFO +7 -6
  2. {redeploy-0.2.9 → redeploy-0.2.11}/README.md +5 -5
  3. {redeploy-0.2.9 → redeploy-0.2.11}/pyproject.toml +2 -1
  4. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/__init__.py +1 -1
  5. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/cli.py +16 -6
  6. redeploy-0.2.11/redeploy/markpact/__init__.py +14 -0
  7. redeploy-0.2.11/redeploy/markpact/compiler.py +172 -0
  8. redeploy-0.2.11/redeploy/markpact/models.py +24 -0
  9. redeploy-0.2.11/redeploy/markpact/parser.py +72 -0
  10. redeploy-0.2.11/redeploy/spec_loader.py +47 -0
  11. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_cli.py +98 -0
  12. redeploy-0.2.11/redeploy/tests/test_markpact_compiler.py +147 -0
  13. redeploy-0.2.11/redeploy/tests/test_markpact_parser.py +41 -0
  14. redeploy-0.2.11/redeploy/tests/test_spec_loader.py +105 -0
  15. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy.egg-info/PKG-INFO +7 -6
  16. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy.egg-info/SOURCES.txt +8 -0
  17. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy.egg-info/requires.txt +1 -0
  18. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/apply/__init__.py +0 -0
  19. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/apply/executor.py +0 -0
  20. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/data_sync.py +0 -0
  21. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/detect/__init__.py +0 -0
  22. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/detect/detector.py +0 -0
  23. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/detect/probes.py +0 -0
  24. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/detect/remote.py +0 -0
  25. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/detect/templates.py +0 -0
  26. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/detect/workflow.py +0 -0
  27. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/discovery.py +0 -0
  28. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/dsl/__init__.py +0 -0
  29. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/dsl/loader.py +0 -0
  30. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/dsl/parser.py +0 -0
  31. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/dsl_python/__init__.py +0 -0
  32. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/dsl_python/context.py +0 -0
  33. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/dsl_python/decorators.py +0 -0
  34. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/dsl_python/docker_steps.py +0 -0
  35. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/dsl_python/exceptions.py +0 -0
  36. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/dsl_python/runner.py +0 -0
  37. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/dsl_python/steps.py +0 -0
  38. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/fleet.py +0 -0
  39. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/iac/__init__.py +0 -0
  40. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/iac/base.py +0 -0
  41. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/iac/docker_compose.py +0 -0
  42. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/iac/parsers/__init__.py +0 -0
  43. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/iac/parsers/compose.py +0 -0
  44. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/iac/registry.py +0 -0
  45. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/models.py +0 -0
  46. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/observe.py +0 -0
  47. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/parse.py +0 -0
  48. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/patterns.py +0 -0
  49. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/plan/__init__.py +0 -0
  50. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/plan/planner.py +0 -0
  51. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/plugins/__init__.py +0 -0
  52. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/plugins/builtin/__init__.py +0 -0
  53. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/plugins/builtin/browser_reload.py +0 -0
  54. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/plugins/builtin/notify.py +0 -0
  55. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/plugins/builtin/systemd_reload.py +0 -0
  56. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/ssh.py +0 -0
  57. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/steps.py +0 -0
  58. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/__init__.py +0 -0
  59. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_data_sync.py +0 -0
  60. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_discovery.py +0 -0
  61. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_dsl.py +0 -0
  62. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_examples.py +0 -0
  63. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_executor.py +0 -0
  64. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_fleet.py +0 -0
  65. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_git_integration.py +0 -0
  66. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_iac.py +0 -0
  67. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_manifest.py +0 -0
  68. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_models.py +0 -0
  69. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_observe.py +0 -0
  70. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_parse.py +0 -0
  71. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_patterns.py +0 -0
  72. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_planner_edge.py +0 -0
  73. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_plugins.py +0 -0
  74. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_probes.py +0 -0
  75. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_public_api.py +0 -0
  76. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_ssh.py +0 -0
  77. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_templates.py +0 -0
  78. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_verify.py +0 -0
  79. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_version.py +0 -0
  80. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_version_changelog.py +0 -0
  81. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_version_cli.py +0 -0
  82. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_version_manifest.py +0 -0
  83. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_version_sources.py +0 -0
  84. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/tests/test_workflow.py +0 -0
  85. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/verify.py +0 -0
  86. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/version/__init__.py +0 -0
  87. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/version/bump.py +0 -0
  88. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/version/changelog.py +0 -0
  89. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/version/commits.py +0 -0
  90. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/version/diff.py +0 -0
  91. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/version/git_integration.py +0 -0
  92. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/version/git_transaction.py +0 -0
  93. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/version/manifest.py +0 -0
  94. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/version/sources/__init__.py +0 -0
  95. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/version/sources/base.py +0 -0
  96. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/version/sources/json_.py +0 -0
  97. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/version/sources/plain.py +0 -0
  98. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/version/sources/regex.py +0 -0
  99. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/version/sources/toml_.py +0 -0
  100. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/version/sources/yaml_.py +0 -0
  101. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/version/transaction.py +0 -0
  102. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy/version.py +0 -0
  103. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy.egg-info/dependency_links.txt +0 -0
  104. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy.egg-info/entry_points.txt +0 -0
  105. {redeploy-0.2.9 → redeploy-0.2.11}/redeploy.egg-info/top_level.txt +0 -0
  106. {redeploy-0.2.9 → redeploy-0.2.11}/setup.cfg +0 -0
  107. {redeploy-0.2.9 → redeploy-0.2.11}/tests/test_iac.py +0 -0
  108. {redeploy-0.2.9 → redeploy-0.2.11}/tests/test_parse.py +0 -0
  109. {redeploy-0.2.9 → redeploy-0.2.11}/tests/test_redeploy.py +0 -0
@@ -1,11 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: redeploy
3
- Version: 0.2.9
3
+ Version: 0.2.11
4
4
  Summary: Infrastructure migration toolkit: detect → plan → apply
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
7
7
  Requires-Dist: pydantic>=2.0
8
8
  Requires-Dist: pyyaml>=6.0
9
+ Requires-Dist: markdown-it-py>=3.0
9
10
  Requires-Dist: click>=8.0
10
11
  Requires-Dist: loguru>=0.7
11
12
  Requires-Dist: paramiko>=3.0
@@ -26,17 +27,17 @@ Requires-Dist: pfix>=0.1.60; extra == "dev"
26
27
 
27
28
  ## AI Cost Tracking
28
29
 
29
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.9-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.20-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-8.0h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
30
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.11-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
31
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$4.50-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-9.0h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
31
32
 
32
- - 🤖 **LLM usage:** $4.2000 (28 commits)
33
- - 👤 **Human dev:** ~$802 (8.0h @ $100/h, 30min dedup)
33
+ - 🤖 **LLM usage:** $4.5000 (30 commits)
34
+ - 👤 **Human dev:** ~$898 (9.0h @ $100/h, 30min dedup)
34
35
 
35
36
  Generated on 2026-04-20 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
36
37
 
37
38
  ---
38
39
 
39
- ![PyPI](https://img.shields.io/badge/pypi-redeploy-blue) ![Version](https://img.shields.io/badge/version-0.2.9-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
40
+ ![PyPI](https://img.shields.io/badge/pypi-redeploy-blue) ![Version](https://img.shields.io/badge/version-0.2.11-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
40
41
 
41
42
  Infrastructure migration and device deploy toolkit — VPS, Raspberry Pi kiosk, Podman Quadlet, k3s.
42
43
 
@@ -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.9-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.20-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-8.0h-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.11-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.50-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-9.0h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
8
8
 
9
- - 🤖 **LLM usage:** $4.2000 (28 commits)
10
- - 👤 **Human dev:** ~$802 (8.0h @ $100/h, 30min dedup)
9
+ - 🤖 **LLM usage:** $4.5000 (30 commits)
10
+ - 👤 **Human dev:** ~$898 (9.0h @ $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.9-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.11-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,13 +4,14 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "redeploy"
7
- version = "0.2.9"
7
+ version = "0.2.11"
8
8
  description = "Infrastructure migration toolkit: detect → plan → apply"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
11
11
  dependencies = [
12
12
  "pydantic>=2.0",
13
13
  "pyyaml>=6.0",
14
+ "markdown-it-py>=3.0",
14
15
  "click>=8.0",
15
16
  "loguru>=0.7",
16
17
  "paramiko>=3.0",
@@ -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.9"
10
+ __version__ = "0.2.11"
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]")
@@ -0,0 +1,14 @@
1
+ from .compiler import MarkpactCompileError, compile_markpact_document, compile_markpact_document_to_data
2
+ from .models import MarkpactBlock, MarkpactDocument
3
+ from .parser import MarkpactParseError, parse_markpact_file, parse_markpact_text
4
+
5
+ __all__ = [
6
+ "MarkpactBlock",
7
+ "MarkpactCompileError",
8
+ "MarkpactDocument",
9
+ "MarkpactParseError",
10
+ "compile_markpact_document",
11
+ "compile_markpact_document_to_data",
12
+ "parse_markpact_file",
13
+ "parse_markpact_text",
14
+ ]
@@ -0,0 +1,172 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import tomllib
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import yaml
9
+ from pydantic import ValidationError
10
+
11
+ from ..models import InfraSpec, MigrationSpec, MigrationStep
12
+ from ..steps import StepLibrary
13
+ from .models import MarkpactBlock, MarkpactDocument
14
+
15
+
16
+ class MarkpactCompileError(ValueError):
17
+ """Raised when a markpact document cannot be compiled to MigrationSpec."""
18
+
19
+
20
+ _SUPPORTED_BLOCK_KINDS = {"config", "steps"}
21
+ _SUPPORTED_FORMATS = {"yaml", "toml", "json"}
22
+ _ALLOWED_SPEC_KEYS = set(MigrationSpec.model_fields)
23
+ _ALLOWED_INFRA_KEYS = set(InfraSpec.model_fields)
24
+ _ALLOWED_STEP_KEYS = set(MigrationStep.model_fields) | {"insert_before"}
25
+ _COMMON_CONFIG_WRAPPERS = {"deployment", "migration", "spec"}
26
+
27
+
28
+ def compile_markpact_document(document: MarkpactDocument) -> MigrationSpec:
29
+ data = compile_markpact_document_to_data(document)
30
+ try:
31
+ return MigrationSpec(**data)
32
+ except ValidationError as exc:
33
+ raise MarkpactCompileError(
34
+ f"{document.path}: compiled markpact document does not match MigrationSpec: {exc}"
35
+ ) from exc
36
+
37
+
38
+ def compile_markpact_document_to_data(document: MarkpactDocument) -> dict[str, Any]:
39
+ compiled: dict[str, Any] = {}
40
+ saw_config = False
41
+
42
+ for block in document.blocks:
43
+ if block.kind not in _SUPPORTED_BLOCK_KINDS:
44
+ raise MarkpactCompileError(
45
+ f"{document.path}:{block.start_line}: unsupported block kind 'markpact:{block.kind}'. "
46
+ "Phase 1 supports only markpact:config and markpact:steps."
47
+ )
48
+
49
+ payload = _load_block_payload(document.path, block)
50
+ if block.kind == "config":
51
+ saw_config = True
52
+ config_payload = _normalize_config_payload(payload, document.path, block)
53
+ _deep_merge(compiled, config_payload)
54
+ continue
55
+
56
+ steps = _extract_steps(payload, document.path, block)
57
+ compiled.setdefault("extra_steps", [])
58
+ compiled["extra_steps"].extend(steps)
59
+
60
+ if not saw_config:
61
+ raise MarkpactCompileError(
62
+ f"{document.path}: at least one markpact:config block is required."
63
+ )
64
+
65
+ return compiled
66
+
67
+
68
+ def _load_block_payload(path: Path, block: MarkpactBlock) -> Any:
69
+ format_name = (block.format or "yaml").lower()
70
+ if format_name not in _SUPPORTED_FORMATS:
71
+ raise MarkpactCompileError(
72
+ f"{path}:{block.start_line}: unsupported payload format '{format_name}' for {block.label}."
73
+ )
74
+
75
+ content = block.content.strip()
76
+ if not content:
77
+ return {}
78
+
79
+ try:
80
+ if format_name == "yaml":
81
+ return yaml.safe_load(content) or {}
82
+ if format_name == "toml":
83
+ return tomllib.loads(content)
84
+ return json.loads(content)
85
+ except Exception as exc:
86
+ raise MarkpactCompileError(
87
+ f"{path}:{block.start_line}: failed to parse {block.label}: {exc}"
88
+ ) from exc
89
+
90
+
91
+ def _normalize_config_payload(payload: Any, path: Path, block: MarkpactBlock) -> dict[str, Any]:
92
+ if isinstance(payload, dict) and len(payload) == 1:
93
+ key = next(iter(payload))
94
+ if key in _COMMON_CONFIG_WRAPPERS and isinstance(payload[key], dict):
95
+ payload = payload[key]
96
+
97
+ if not isinstance(payload, dict):
98
+ raise MarkpactCompileError(
99
+ f"{path}:{block.start_line}: {block.label} must compile to a mapping."
100
+ )
101
+
102
+ unknown_top = sorted(set(payload) - _ALLOWED_SPEC_KEYS)
103
+ if unknown_top:
104
+ raise MarkpactCompileError(
105
+ f"{path}:{block.start_line}: unsupported config keys: {', '.join(unknown_top)}"
106
+ )
107
+
108
+ for section in ("source", "target"):
109
+ if section not in payload:
110
+ continue
111
+ if not isinstance(payload[section], dict):
112
+ raise MarkpactCompileError(
113
+ f"{path}:{block.start_line}: '{section}' must be a mapping."
114
+ )
115
+ unknown_nested = sorted(set(payload[section]) - _ALLOWED_INFRA_KEYS)
116
+ if unknown_nested:
117
+ raise MarkpactCompileError(
118
+ f"{path}:{block.start_line}: unsupported {section} keys: {', '.join(unknown_nested)}"
119
+ )
120
+
121
+ return payload
122
+
123
+
124
+ def _extract_steps(payload: Any, path: Path, block: MarkpactBlock) -> list[dict[str, Any]]:
125
+ if isinstance(payload, list):
126
+ steps = payload
127
+ elif isinstance(payload, dict) and "extra_steps" in payload:
128
+ steps = payload["extra_steps"]
129
+ else:
130
+ raise MarkpactCompileError(
131
+ f"{path}:{block.start_line}: {block.label} must define a list of steps or an extra_steps list."
132
+ )
133
+
134
+ if not isinstance(steps, list):
135
+ raise MarkpactCompileError(
136
+ f"{path}:{block.start_line}: extra_steps must be a list."
137
+ )
138
+
139
+ validated: list[dict[str, Any]] = []
140
+ for index, raw in enumerate(steps, start=1):
141
+ if not isinstance(raw, dict):
142
+ raise MarkpactCompileError(
143
+ f"{path}:{block.start_line}: step #{index} in {block.label} must be a mapping."
144
+ )
145
+
146
+ unknown_keys = sorted(set(raw) - _ALLOWED_STEP_KEYS)
147
+ if unknown_keys:
148
+ raise MarkpactCompileError(
149
+ f"{path}:{block.start_line}: unsupported step keys in '{raw.get('id', f'#{index}')}'"
150
+ f": {', '.join(unknown_keys)}"
151
+ )
152
+
153
+ raw_for_validation = dict(raw)
154
+ raw_for_validation.pop("insert_before", None)
155
+ try:
156
+ StepLibrary.resolve_from_spec(raw_for_validation)
157
+ except Exception as exc:
158
+ raise MarkpactCompileError(
159
+ f"{path}:{block.start_line}: step '{raw.get('id', f'#{index}')}' is not supported by the current runtime: {exc}"
160
+ ) from exc
161
+
162
+ validated.append(dict(raw))
163
+
164
+ return validated
165
+
166
+
167
+ def _deep_merge(target: dict[str, Any], incoming: dict[str, Any]) -> None:
168
+ for key, value in incoming.items():
169
+ if key in target and isinstance(target[key], dict) and isinstance(value, dict):
170
+ _deep_merge(target[key], value)
171
+ continue
172
+ target[key] = value
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class MarkpactBlock:
9
+ kind: str
10
+ format: str | None
11
+ content: str
12
+ start_line: int
13
+ end_line: int
14
+
15
+ @property
16
+ def label(self) -> str:
17
+ fmt = self.format or "yaml"
18
+ return f"markpact:{self.kind} {fmt}".strip()
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class MarkpactDocument:
23
+ path: Path
24
+ blocks: list[MarkpactBlock] = field(default_factory=list)
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from markdown_it import MarkdownIt
6
+
7
+ from .models import MarkpactBlock, MarkpactDocument
8
+
9
+
10
+ class MarkpactParseError(ValueError):
11
+ """Raised when a markdown markpact document cannot be parsed."""
12
+
13
+
14
+ def parse_markpact_file(path: str | Path) -> MarkpactDocument:
15
+ file_path = Path(path)
16
+ return parse_markpact_text(file_path.read_text(encoding="utf-8"), path=file_path)
17
+
18
+
19
+ def parse_markpact_text(text: str, *, path: str | Path = "migration.md") -> MarkpactDocument:
20
+ source_path = Path(path)
21
+ parser = MarkdownIt("commonmark")
22
+ tokens = parser.parse(text)
23
+ blocks: list[MarkpactBlock] = []
24
+
25
+ for token in tokens:
26
+ if token.type != "fence":
27
+ continue
28
+ parsed = _parse_markpact_fence_info(token.info)
29
+ if parsed is None:
30
+ continue
31
+
32
+ kind, format_name = parsed
33
+ start_line = (token.map[0] + 1) if token.map else 1
34
+ end_line = token.map[1] if token.map else start_line
35
+ blocks.append(MarkpactBlock(
36
+ kind=kind,
37
+ format=format_name,
38
+ content=token.content,
39
+ start_line=start_line,
40
+ end_line=end_line,
41
+ ))
42
+
43
+ if not blocks:
44
+ raise MarkpactParseError(
45
+ f"No markpact blocks found in {source_path}. Expected fenced blocks like ```markpact:config yaml."
46
+ )
47
+
48
+ return MarkpactDocument(path=source_path, blocks=blocks)
49
+
50
+
51
+ def _parse_markpact_fence_info(info: str) -> tuple[str, str | None] | None:
52
+ tokens = [part for part in info.strip().split() if part]
53
+ if not tokens:
54
+ return None
55
+
56
+ kind: str | None = None
57
+ format_name: str | None = None
58
+
59
+ for token in tokens:
60
+ if token.startswith("markpact:"):
61
+ kind = token.split(":", 1)[1].strip().lower()
62
+ continue
63
+ if format_name is None and "=" not in token:
64
+ format_name = token.lower()
65
+
66
+ if not kind:
67
+ return None
68
+
69
+ if format_name is None and kind in {"config", "steps", "rollback"}:
70
+ format_name = "yaml"
71
+
72
+ return kind, format_name
@@ -0,0 +1,47 @@
1
+ """Load deployment specs from supported file formats."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+
6
+ from .markpact import (
7
+ MarkpactCompileError,
8
+ MarkpactParseError,
9
+ compile_markpact_document,
10
+ parse_markpact_file,
11
+ )
12
+ from .models import MigrationSpec
13
+
14
+
15
+ class SpecLoaderError(ValueError):
16
+ """Base error raised when a deployment spec cannot be loaded."""
17
+
18
+
19
+ class UnsupportedSpecFormatError(SpecLoaderError):
20
+ """Raised when the spec file uses an unsupported format."""
21
+
22
+
23
+ def load_migration_spec(path: str | Path) -> MigrationSpec:
24
+ """Load a deployment spec from disk.
25
+
26
+ Supported today:
27
+ - `.yaml`
28
+ - `.yml`
29
+
30
+ Supported markdown subset today:
31
+ - `.md` with markpact:config and markpact:steps blocks only
32
+ """
33
+ spec_path = Path(path)
34
+ suffix = spec_path.suffix.lower()
35
+
36
+ if suffix in {"", ".yaml", ".yml"}:
37
+ return MigrationSpec.from_file(spec_path)
38
+
39
+ if suffix == ".md":
40
+ try:
41
+ return compile_markpact_document(parse_markpact_file(spec_path))
42
+ except (MarkpactParseError, MarkpactCompileError) as exc:
43
+ raise SpecLoaderError(str(exc)) from exc
44
+
45
+ raise UnsupportedSpecFormatError(
46
+ f"Unsupported spec format '{suffix}': use YAML (.yaml or .yml)."
47
+ )
@@ -32,6 +32,64 @@ def _migration_yaml(strategy="docker_full", host="local") -> str:
32
32
  """)
33
33
 
34
34
 
35
+ def _migration_markdown_supported() -> str:
36
+ return textwrap.dedent(
37
+ """\
38
+ # Markdown migration
39
+
40
+ ```markpact:config yaml
41
+ name: markdown migration
42
+ description: migration loaded from markpact markdown
43
+ source:
44
+ strategy: docker_full
45
+ host: local
46
+ app: myapp
47
+ version: "1.0.0"
48
+ target:
49
+ strategy: docker_full
50
+ host: local
51
+ app: myapp
52
+ version: "1.0.1"
53
+ remote_dir: ~/myapp
54
+ ```
55
+
56
+ ```markpact:steps yaml
57
+ extra_steps:
58
+ - id: sync_env
59
+ src: .env
60
+ dst: ~/myapp/.env
61
+ ```
62
+ """
63
+ )
64
+
65
+
66
+ def _migration_markdown_with_unsupported_block() -> str:
67
+ return textwrap.dedent(
68
+ """\
69
+ # Markdown migration
70
+
71
+ ```markpact:config yaml
72
+ name: markdown migration
73
+ source:
74
+ strategy: docker_full
75
+ host: local
76
+ app: myapp
77
+ version: "1.0.0"
78
+ target:
79
+ strategy: docker_full
80
+ host: local
81
+ app: myapp
82
+ version: "1.0.1"
83
+ remote_dir: ~/myapp
84
+ ```
85
+
86
+ ```markpact:python
87
+ print("unsupported in phase 1")
88
+ ```
89
+ """
90
+ )
91
+
92
+
35
93
  def _runner():
36
94
  return CliRunner(mix_stderr=False)
37
95
 
@@ -101,6 +159,24 @@ class TestRunPlanOnly:
101
159
  result = _runner().invoke(cli, ["run", str(spec), "--plan-only", "--env", "prod"])
102
160
  assert result.exit_code == 0
103
161
 
162
+ def test_run_plan_only_supports_markdown_subset(self, tmp_path):
163
+ spec = tmp_path / "migration.md"
164
+ spec.write_text(_migration_markdown_supported(), encoding="utf-8")
165
+
166
+ result = _runner().invoke(cli, ["run", str(spec), "--plan-only"])
167
+
168
+ assert result.exit_code == 0, result.output
169
+ assert "markdown migration" in result.output
170
+
171
+ def test_run_plan_only_rejects_unsupported_markdown_block(self, tmp_path):
172
+ spec = tmp_path / "migration.md"
173
+ spec.write_text(_migration_markdown_with_unsupported_block(), encoding="utf-8")
174
+
175
+ result = _runner().invoke(cli, ["run", str(spec), "--plan-only"])
176
+
177
+ assert result.exit_code == 1
178
+ assert "unsupported block kind 'markpact:python'" in result.output
179
+
104
180
 
105
181
  # ── redeploy status ───────────────────────────────────────────────────────────
106
182
 
@@ -123,6 +199,16 @@ class TestStatus:
123
199
  assert result.exit_code == 0
124
200
  assert "myapp" in result.output
125
201
 
202
+ def test_status_supports_markdown_subset(self, tmp_path):
203
+ spec = tmp_path / "migration.md"
204
+ spec.write_text(_migration_markdown_supported(), encoding="utf-8")
205
+
206
+ with patch("redeploy.models.ProjectManifest.find_and_load", return_value=None):
207
+ result = _runner().invoke(cli, ["status", str(spec)])
208
+
209
+ assert result.exit_code == 0, result.output
210
+ assert "markdown migration" in result.output
211
+
126
212
 
127
213
  # ── redeploy init ─────────────────────────────────────────────────────────────
128
214
 
@@ -192,6 +278,18 @@ class TestDevices:
192
278
  assert data[0]["id"] == "x@1"
193
279
 
194
280
 
281
+ class TestTarget:
282
+ def test_target_supports_markdown_subset(self, tmp_path):
283
+ spec = tmp_path / "migration.md"
284
+ spec.write_text(_migration_markdown_supported(), encoding="utf-8")
285
+
286
+ with patch("redeploy.cli._resolve_device", return_value=(None, None)):
287
+ result = _runner().invoke(cli, ["target", "pi@192.168.1.42", str(spec), "--plan-only"])
288
+
289
+ assert result.exit_code == 0, result.output
290
+ assert "target" in result.output.lower()
291
+
292
+
195
293
  # ── redeploy apply ────────────────────────────────────────────────────────────
196
294
 
197
295