redeploy 0.2.10__tar.gz → 0.2.12__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 (110) hide show
  1. {redeploy-0.2.10 → redeploy-0.2.12}/PKG-INFO +7 -6
  2. {redeploy-0.2.10 → redeploy-0.2.12}/README.md +5 -5
  3. {redeploy-0.2.10 → redeploy-0.2.12}/pyproject.toml +2 -1
  4. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/__init__.py +1 -1
  5. redeploy-0.2.12/redeploy/markpact/__init__.py +14 -0
  6. redeploy-0.2.12/redeploy/markpact/compiler.py +172 -0
  7. redeploy-0.2.12/redeploy/markpact/models.py +24 -0
  8. redeploy-0.2.12/redeploy/markpact/parser.py +72 -0
  9. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/spec_loader.py +12 -6
  10. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_cli.py +78 -14
  11. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_examples.py +21 -1
  12. redeploy-0.2.12/redeploy/tests/test_markpact_compiler.py +147 -0
  13. redeploy-0.2.12/redeploy/tests/test_markpact_parser.py +41 -0
  14. redeploy-0.2.12/redeploy/tests/test_spec_loader.py +105 -0
  15. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy.egg-info/PKG-INFO +7 -6
  16. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy.egg-info/SOURCES.txt +6 -0
  17. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy.egg-info/requires.txt +1 -0
  18. redeploy-0.2.10/redeploy/tests/test_spec_loader.py +0 -44
  19. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/apply/__init__.py +0 -0
  20. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/apply/executor.py +0 -0
  21. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/cli.py +0 -0
  22. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/data_sync.py +0 -0
  23. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/detect/__init__.py +0 -0
  24. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/detect/detector.py +0 -0
  25. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/detect/probes.py +0 -0
  26. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/detect/remote.py +0 -0
  27. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/detect/templates.py +0 -0
  28. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/detect/workflow.py +0 -0
  29. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/discovery.py +0 -0
  30. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/dsl/__init__.py +0 -0
  31. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/dsl/loader.py +0 -0
  32. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/dsl/parser.py +0 -0
  33. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/dsl_python/__init__.py +0 -0
  34. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/dsl_python/context.py +0 -0
  35. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/dsl_python/decorators.py +0 -0
  36. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/dsl_python/docker_steps.py +0 -0
  37. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/dsl_python/exceptions.py +0 -0
  38. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/dsl_python/runner.py +0 -0
  39. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/dsl_python/steps.py +0 -0
  40. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/fleet.py +0 -0
  41. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/iac/__init__.py +0 -0
  42. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/iac/base.py +0 -0
  43. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/iac/docker_compose.py +0 -0
  44. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/iac/parsers/__init__.py +0 -0
  45. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/iac/parsers/compose.py +0 -0
  46. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/iac/registry.py +0 -0
  47. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/models.py +0 -0
  48. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/observe.py +0 -0
  49. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/parse.py +0 -0
  50. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/patterns.py +0 -0
  51. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/plan/__init__.py +0 -0
  52. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/plan/planner.py +0 -0
  53. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/plugins/__init__.py +0 -0
  54. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/plugins/builtin/__init__.py +0 -0
  55. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/plugins/builtin/browser_reload.py +0 -0
  56. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/plugins/builtin/notify.py +0 -0
  57. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/plugins/builtin/systemd_reload.py +0 -0
  58. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/ssh.py +0 -0
  59. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/steps.py +0 -0
  60. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/__init__.py +0 -0
  61. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_data_sync.py +0 -0
  62. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_discovery.py +0 -0
  63. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_dsl.py +0 -0
  64. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_executor.py +0 -0
  65. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_fleet.py +0 -0
  66. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_git_integration.py +0 -0
  67. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_iac.py +0 -0
  68. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_manifest.py +0 -0
  69. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_models.py +0 -0
  70. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_observe.py +0 -0
  71. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_parse.py +0 -0
  72. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_patterns.py +0 -0
  73. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_planner_edge.py +0 -0
  74. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_plugins.py +0 -0
  75. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_probes.py +0 -0
  76. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_public_api.py +0 -0
  77. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_ssh.py +0 -0
  78. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_templates.py +0 -0
  79. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_verify.py +0 -0
  80. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_version.py +0 -0
  81. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_version_changelog.py +0 -0
  82. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_version_cli.py +0 -0
  83. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_version_manifest.py +0 -0
  84. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_version_sources.py +0 -0
  85. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/tests/test_workflow.py +0 -0
  86. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/verify.py +0 -0
  87. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/version/__init__.py +0 -0
  88. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/version/bump.py +0 -0
  89. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/version/changelog.py +0 -0
  90. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/version/commits.py +0 -0
  91. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/version/diff.py +0 -0
  92. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/version/git_integration.py +0 -0
  93. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/version/git_transaction.py +0 -0
  94. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/version/manifest.py +0 -0
  95. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/version/sources/__init__.py +0 -0
  96. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/version/sources/base.py +0 -0
  97. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/version/sources/json_.py +0 -0
  98. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/version/sources/plain.py +0 -0
  99. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/version/sources/regex.py +0 -0
  100. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/version/sources/toml_.py +0 -0
  101. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/version/sources/yaml_.py +0 -0
  102. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/version/transaction.py +0 -0
  103. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy/version.py +0 -0
  104. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy.egg-info/dependency_links.txt +0 -0
  105. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy.egg-info/entry_points.txt +0 -0
  106. {redeploy-0.2.10 → redeploy-0.2.12}/redeploy.egg-info/top_level.txt +0 -0
  107. {redeploy-0.2.10 → redeploy-0.2.12}/setup.cfg +0 -0
  108. {redeploy-0.2.10 → redeploy-0.2.12}/tests/test_iac.py +0 -0
  109. {redeploy-0.2.10 → redeploy-0.2.12}/tests/test_parse.py +0 -0
  110. {redeploy-0.2.10 → redeploy-0.2.12}/tests/test_redeploy.py +0 -0
@@ -1,11 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: redeploy
3
- Version: 0.2.10
3
+ Version: 0.2.12
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.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)
30
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.12-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.65-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-9.2h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
31
32
 
32
- - 🤖 **LLM usage:** $4.3500 (29 commits)
33
- - 👤 **Human dev:** ~$866 (8.7h @ $100/h, 30min dedup)
33
+ - 🤖 **LLM usage:** $4.6500 (31 commits)
34
+ - 👤 **Human dev:** ~$917 (9.2h @ $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.10-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.12-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.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)
6
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.12-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.65-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-9.2h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
8
8
 
9
- - 🤖 **LLM usage:** $4.3500 (29 commits)
10
- - 👤 **Human dev:** ~$866 (8.7h @ $100/h, 30min dedup)
9
+ - 🤖 **LLM usage:** $4.6500 (31 commits)
10
+ - 👤 **Human dev:** ~$917 (9.2h @ $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.10-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.12-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.10"
7
+ version = "0.2.12"
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.10"
10
+ __version__ = "0.2.12"
11
11
 
12
12
  # ── Core models ───────────────────────────────────────────────────────────────
13
13
  from .models import ( # noqa: F401
@@ -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
@@ -3,6 +3,12 @@ from __future__ import annotations
3
3
 
4
4
  from pathlib import Path
5
5
 
6
+ from .markpact import (
7
+ MarkpactCompileError,
8
+ MarkpactParseError,
9
+ compile_markpact_document,
10
+ parse_markpact_file,
11
+ )
6
12
  from .models import MigrationSpec
7
13
 
8
14
 
@@ -21,8 +27,8 @@ def load_migration_spec(path: str | Path) -> MigrationSpec:
21
27
  - `.yaml`
22
28
  - `.yml`
23
29
 
24
- Reserved for future support:
25
- - `.md` markpact/markdown specs
30
+ Supported markdown subset today:
31
+ - `.md` with markpact:config and markpact:steps blocks only
26
32
  """
27
33
  spec_path = Path(path)
28
34
  suffix = spec_path.suffix.lower()
@@ -31,10 +37,10 @@ def load_migration_spec(path: str | Path) -> MigrationSpec:
31
37
  return MigrationSpec.from_file(spec_path)
32
38
 
33
39
  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
- )
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
38
44
 
39
45
  raise UnsupportedSpecFormatError(
40
46
  f"Unsupported spec format '{suffix}': use YAML (.yaml or .yml)."
@@ -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,15 +159,23 @@ class TestRunPlanOnly:
101
159
  result = _runner().invoke(cli, ["run", str(spec), "--plan-only", "--env", "prod"])
102
160
  assert result.exit_code == 0
103
161
 
104
- def test_run_plan_only_rejects_markdown_spec_with_clear_error(self, tmp_path):
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):
105
172
  spec = tmp_path / "migration.md"
106
- spec.write_text("# markpact prototype\n", encoding="utf-8")
173
+ spec.write_text(_migration_markdown_with_unsupported_block(), encoding="utf-8")
107
174
 
108
175
  result = _runner().invoke(cli, ["run", str(spec), "--plan-only"])
109
176
 
110
177
  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
178
+ assert "unsupported block kind 'markpact:python'" in result.output
113
179
 
114
180
 
115
181
  # ── redeploy status ───────────────────────────────────────────────────────────
@@ -133,16 +199,15 @@ class TestStatus:
133
199
  assert result.exit_code == 0
134
200
  assert "myapp" in result.output
135
201
 
136
- def test_status_rejects_markdown_spec_with_clear_error(self, tmp_path):
202
+ def test_status_supports_markdown_subset(self, tmp_path):
137
203
  spec = tmp_path / "migration.md"
138
- spec.write_text("# markpact prototype\n", encoding="utf-8")
204
+ spec.write_text(_migration_markdown_supported(), encoding="utf-8")
139
205
 
140
206
  with patch("redeploy.models.ProjectManifest.find_and_load", return_value=None):
141
207
  result = _runner().invoke(cli, ["status", str(spec)])
142
208
 
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
209
+ assert result.exit_code == 0, result.output
210
+ assert "markdown migration" in result.output
146
211
 
147
212
 
148
213
  # ── redeploy init ─────────────────────────────────────────────────────────────
@@ -214,16 +279,15 @@ class TestDevices:
214
279
 
215
280
 
216
281
  class TestTarget:
217
- def test_target_rejects_markdown_spec_with_clear_error(self, tmp_path):
282
+ def test_target_supports_markdown_subset(self, tmp_path):
218
283
  spec = tmp_path / "migration.md"
219
- spec.write_text("# markpact prototype\n", encoding="utf-8")
284
+ spec.write_text(_migration_markdown_supported(), encoding="utf-8")
220
285
 
221
286
  with patch("redeploy.cli._resolve_device", return_value=(None, None)):
222
287
  result = _runner().invoke(cli, ["target", "pi@192.168.1.42", str(spec), "--plan-only"])
223
288
 
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
289
+ assert result.exit_code == 0, result.output
290
+ assert "target" in result.output.lower()
227
291
 
228
292
 
229
293
  # ── redeploy apply ────────────────────────────────────────────────────────────
@@ -13,6 +13,7 @@ import yaml
13
13
  from redeploy.fleet import FleetConfig, Stage, DeviceExpectation
14
14
  from redeploy.models import MigrationSpec, DeployStrategy, StepAction
15
15
  from redeploy.plan.planner import Planner
16
+ from redeploy.spec_loader import load_migration_spec
16
17
  from redeploy.steps import StepLibrary
17
18
 
18
19
  EXAMPLES_DIR = Path(__file__).resolve().parents[2] / "examples"
@@ -23,13 +24,16 @@ MIGRATION_YAMLS = sorted(EXAMPLES_DIR.glob("yaml/*/migration.yaml"))
23
24
  MULTIENV_YAMLS = sorted(EXAMPLES_DIR.glob("yaml/10-multienv/*.yaml"))
24
25
  FLEET_YAMLS = sorted(EXAMPLES_DIR.glob("yaml/*/fleet.yaml"))
25
26
  REDEPLOY_YAMLS = sorted(EXAMPLES_DIR.glob("yaml/*/redeploy.yaml"))
27
+ SUPPORTED_MARKDOWN_MIGRATIONS = [
28
+ EXAMPLES_DIR / "md" / "01-vps-version-bump" / "migration.md"
29
+ ]
26
30
 
27
31
 
28
32
  # ── helpers ───────────────────────────────────────────────────────────────────
29
33
 
30
34
 
31
35
  def _load_spec(path: Path) -> MigrationSpec:
32
- return MigrationSpec.from_file(path)
36
+ return load_migration_spec(path)
33
37
 
34
38
 
35
39
  def _plan_from_spec(spec: MigrationSpec):
@@ -85,6 +89,22 @@ class TestMigrationYaml:
85
89
  assert rdeploy.exists(), f"Missing redeploy.yaml in {yaml_path.parent.name}"
86
90
 
87
91
 
92
+ @pytest.mark.parametrize("md_path", SUPPORTED_MARKDOWN_MIGRATIONS, ids=lambda p: p.parent.name)
93
+ class TestSupportedMarkdownMigration:
94
+ def test_parses_without_error(self, md_path):
95
+ spec = _load_spec(md_path)
96
+ assert spec.name
97
+
98
+ def test_plan_generates_steps(self, md_path):
99
+ spec = _load_spec(md_path)
100
+ plan = _plan_from_spec(spec)
101
+ assert len(plan.steps) > 0, f"{md_path.parent.name}: plan has 0 steps"
102
+
103
+ def test_has_readme(self, md_path):
104
+ readme = md_path.parent / "README.md"
105
+ assert readme.exists(), f"Missing README.md in {md_path.parent.name}"
106
+
107
+
88
108
  # ── redeploy.yaml manifest tests ──────────────────────────────────────────────
89
109
 
90
110