robopilot 1.1.0__py3-none-any.whl
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.
- robopilot/__init__.py +0 -0
- robopilot/apply/__init__.py +5 -0
- robopilot/apply/apply_plan.py +218 -0
- robopilot/apply_plan/__init__.py +13 -0
- robopilot/apply_plan/plan.py +216 -0
- robopilot/apply_preview/__init__.py +5 -0
- robopilot/apply_preview/preview.py +156 -0
- robopilot/debugger/__init__.py +6 -0
- robopilot/debugger/log_analyzer.py +198 -0
- robopilot/deps/__init__.py +5 -0
- robopilot/deps/analyzer.py +440 -0
- robopilot/detector/__init__.py +5 -0
- robopilot/detector/project_detector.py +349 -0
- robopilot/diff/__init__.py +5 -0
- robopilot/diff/spec_diff.py +189 -0
- robopilot/generator/__init__.py +2 -0
- robopilot/generator/project_generator.py +106 -0
- robopilot/generator/project_spec.py +78 -0
- robopilot/generator/task_classifier.py +55 -0
- robopilot/generator/template_registry.py +177 -0
- robopilot/generator/templates.py +541 -0
- robopilot/graph/__init__.py +6 -0
- robopilot/graph/mermaid_generator.py +34 -0
- robopilot/history/__init__.py +15 -0
- robopilot/history/journal.py +226 -0
- robopilot/inspector/__init__.py +6 -0
- robopilot/inspector/project_inspector.py +287 -0
- robopilot/main.py +1294 -0
- robopilot/migration/__init__.py +31 -0
- robopilot/migration/plan_diff.py +177 -0
- robopilot/migration/plan_validator.py +142 -0
- robopilot/migration/preview.py +267 -0
- robopilot/migration/ros1_to_ros2.py +579 -0
- robopilot/planner/__init__.py +26 -0
- robopilot/planner/base.py +30 -0
- robopilot/planner/llm_planner.py +127 -0
- robopilot/planner/openai_client.py +67 -0
- robopilot/planner/prompts.py +59 -0
- robopilot/planner/provider_config.py +43 -0
- robopilot/planner/rule_based_planner.py +40 -0
- robopilot/refiner/__init__.py +6 -0
- robopilot/refiner/llm_refiner.py +126 -0
- robopilot/refiner/spec_refiner.py +172 -0
- robopilot/repair/__init__.py +9 -0
- robopilot/repair/repair_suggester.py +269 -0
- robopilot/report/__init__.py +5 -0
- robopilot/report/project_report.py +92 -0
- robopilot/rollback/__init__.py +5 -0
- robopilot/rollback/rollback.py +155 -0
- robopilot/ros1/__init__.py +5 -0
- robopilot/ros1/inspector.py +403 -0
- robopilot/spec/__init__.py +13 -0
- robopilot/spec/io.py +178 -0
- robopilot/spec/validator.py +66 -0
- robopilot/utils/__init__.py +2 -0
- robopilot/utils/file_ops.py +30 -0
- robopilot-1.1.0.dist-info/METADATA +207 -0
- robopilot-1.1.0.dist-info/RECORD +62 -0
- robopilot-1.1.0.dist-info/WHEEL +5 -0
- robopilot-1.1.0.dist-info/entry_points.txt +2 -0
- robopilot-1.1.0.dist-info/licenses/LICENSE +21 -0
- robopilot-1.1.0.dist-info/top_level.txt +1 -0
robopilot/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Safely apply a previously exported apply plan."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from robopilot.apply_plan.plan import (
|
|
11
|
+
apply_plan_from_preview,
|
|
12
|
+
load_apply_plan,
|
|
13
|
+
validate_apply_plan,
|
|
14
|
+
)
|
|
15
|
+
from robopilot.apply_preview.preview import preview_apply
|
|
16
|
+
from robopilot.generator.project_generator import render_project_files
|
|
17
|
+
from robopilot.history.journal import record_history_entry
|
|
18
|
+
from robopilot.spec.io import load_spec
|
|
19
|
+
from robopilot.spec.validator import validate_spec
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
SAFETY_NOTE = (
|
|
23
|
+
"RoboPilot apply validates the plan, re-runs apply-preview, refuses stale "
|
|
24
|
+
"plans and conflicts, and only writes files listed in files_to_create or "
|
|
25
|
+
"files_to_update when --confirm is provided."
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class ApplySummary:
|
|
31
|
+
"""Summary of a dry-run or confirmed apply operation."""
|
|
32
|
+
|
|
33
|
+
plan_path: str
|
|
34
|
+
project_path: str
|
|
35
|
+
dry_run: bool
|
|
36
|
+
files_created: tuple[str, ...]
|
|
37
|
+
files_updated: tuple[str, ...]
|
|
38
|
+
files_kept: tuple[str, ...]
|
|
39
|
+
backups_created: tuple[str, ...]
|
|
40
|
+
skipped_files: tuple[str, ...]
|
|
41
|
+
conflicts: tuple[str, ...]
|
|
42
|
+
safety_note: str
|
|
43
|
+
|
|
44
|
+
def to_dict(self) -> dict[str, object]:
|
|
45
|
+
"""Return a stable JSON-serializable representation."""
|
|
46
|
+
return {
|
|
47
|
+
"plan_path": self.plan_path,
|
|
48
|
+
"project_path": self.project_path,
|
|
49
|
+
"dry_run": self.dry_run,
|
|
50
|
+
"files_created": list(self.files_created),
|
|
51
|
+
"files_updated": list(self.files_updated),
|
|
52
|
+
"files_kept": list(self.files_kept),
|
|
53
|
+
"backups_created": list(self.backups_created),
|
|
54
|
+
"skipped_files": list(self.skipped_files),
|
|
55
|
+
"conflicts": list(self.conflicts),
|
|
56
|
+
"safety_note": self.safety_note,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def apply_from_plan(plan_path: Path, *, confirm: bool = False) -> ApplySummary:
|
|
61
|
+
"""Dry-run or apply a saved plan after conservative safety checks."""
|
|
62
|
+
plan = load_apply_plan(plan_path)
|
|
63
|
+
validation = validate_apply_plan(plan)
|
|
64
|
+
if not validation.is_valid:
|
|
65
|
+
raise ValueError("Invalid apply plan: " + "; ".join(validation.errors))
|
|
66
|
+
|
|
67
|
+
spec_path = Path(_string_field(plan, "spec_path"))
|
|
68
|
+
project_path = Path(_string_field(plan, "project_path"))
|
|
69
|
+
spec = load_spec(spec_path)
|
|
70
|
+
spec_validation = validate_spec(spec)
|
|
71
|
+
if not spec_validation.is_valid:
|
|
72
|
+
raise ValueError("Invalid ProjectSpec: " + "; ".join(spec_validation.errors))
|
|
73
|
+
|
|
74
|
+
fresh_preview = preview_apply(spec_path, project_path)
|
|
75
|
+
fresh_plan = apply_plan_from_preview(fresh_preview)
|
|
76
|
+
_ensure_plan_is_fresh(plan, fresh_plan)
|
|
77
|
+
|
|
78
|
+
conflicts = tuple(_list_field(plan, "conflicts"))
|
|
79
|
+
if confirm and conflicts:
|
|
80
|
+
raise ValueError("Refusing to apply because conflicts are present.")
|
|
81
|
+
|
|
82
|
+
files_to_create = tuple(_validate_relative_paths(_list_field(plan, "files_to_create")))
|
|
83
|
+
files_to_update = tuple(_validate_relative_paths(_list_field(plan, "files_to_update")))
|
|
84
|
+
files_to_keep = tuple(_validate_relative_paths(_list_field(plan, "files_to_keep")))
|
|
85
|
+
expected_files = {
|
|
86
|
+
path.as_posix(): content for path, content in render_project_files(spec).items()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
_ensure_planned_files_are_expected(files_to_create + files_to_update, expected_files)
|
|
90
|
+
|
|
91
|
+
if not confirm:
|
|
92
|
+
return ApplySummary(
|
|
93
|
+
plan_path=str(plan_path),
|
|
94
|
+
project_path=str(project_path),
|
|
95
|
+
dry_run=True,
|
|
96
|
+
files_created=(),
|
|
97
|
+
files_updated=(),
|
|
98
|
+
files_kept=files_to_keep,
|
|
99
|
+
backups_created=(),
|
|
100
|
+
skipped_files=tuple(sorted(files_to_create + files_to_update + files_to_keep + conflicts)),
|
|
101
|
+
conflicts=conflicts,
|
|
102
|
+
safety_note="Dry run only. No files were modified. " + SAFETY_NOTE,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
created: list[str] = []
|
|
106
|
+
updated: list[str] = []
|
|
107
|
+
backups: list[str] = []
|
|
108
|
+
backup_root = project_path / ".robopilot_backups" / _timestamp()
|
|
109
|
+
|
|
110
|
+
for relative_path in files_to_create:
|
|
111
|
+
target = project_path / relative_path
|
|
112
|
+
if target.exists():
|
|
113
|
+
raise ValueError(f"Refusing to create existing file: {relative_path}")
|
|
114
|
+
_write_expected_file(target, expected_files[relative_path])
|
|
115
|
+
created.append(relative_path)
|
|
116
|
+
|
|
117
|
+
for relative_path in files_to_update:
|
|
118
|
+
target = project_path / relative_path
|
|
119
|
+
if not target.is_file():
|
|
120
|
+
raise ValueError(f"Refusing to update missing file: {relative_path}")
|
|
121
|
+
backup_path = backup_root / relative_path
|
|
122
|
+
backup_path.parent.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
shutil.copy2(target, backup_path)
|
|
124
|
+
backups.append(backup_path.relative_to(project_path).as_posix())
|
|
125
|
+
_write_expected_file(target, expected_files[relative_path])
|
|
126
|
+
updated.append(relative_path)
|
|
127
|
+
|
|
128
|
+
summary = ApplySummary(
|
|
129
|
+
plan_path=str(plan_path),
|
|
130
|
+
project_path=str(project_path),
|
|
131
|
+
dry_run=False,
|
|
132
|
+
files_created=tuple(sorted(created)),
|
|
133
|
+
files_updated=tuple(sorted(updated)),
|
|
134
|
+
files_kept=files_to_keep,
|
|
135
|
+
backups_created=tuple(sorted(backups)),
|
|
136
|
+
skipped_files=tuple(sorted(files_to_keep + conflicts)),
|
|
137
|
+
conflicts=conflicts,
|
|
138
|
+
safety_note=SAFETY_NOTE,
|
|
139
|
+
)
|
|
140
|
+
record_history_entry(
|
|
141
|
+
project_path=project_path,
|
|
142
|
+
operation="apply",
|
|
143
|
+
plan_path=str(plan_path),
|
|
144
|
+
dry_run=False,
|
|
145
|
+
success=True,
|
|
146
|
+
files_created=summary.files_created,
|
|
147
|
+
files_updated=summary.files_updated,
|
|
148
|
+
files_kept=summary.files_kept,
|
|
149
|
+
conflicts=summary.conflicts,
|
|
150
|
+
skipped_files=summary.skipped_files,
|
|
151
|
+
summary=(
|
|
152
|
+
f"Applied plan with {len(summary.files_created)} files created "
|
|
153
|
+
f"and {len(summary.files_updated)} files updated."
|
|
154
|
+
),
|
|
155
|
+
)
|
|
156
|
+
return summary
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _ensure_plan_is_fresh(
|
|
160
|
+
saved_plan: dict[str, object],
|
|
161
|
+
fresh_plan: dict[str, object],
|
|
162
|
+
) -> None:
|
|
163
|
+
compared_fields = (
|
|
164
|
+
"spec_path",
|
|
165
|
+
"project_path",
|
|
166
|
+
"package_name",
|
|
167
|
+
"selected_template",
|
|
168
|
+
"files_to_create",
|
|
169
|
+
"files_to_update",
|
|
170
|
+
"files_to_keep",
|
|
171
|
+
"conflicts",
|
|
172
|
+
"missing_project",
|
|
173
|
+
)
|
|
174
|
+
for field in compared_fields:
|
|
175
|
+
if saved_plan.get(field) != fresh_plan.get(field):
|
|
176
|
+
raise ValueError(f"Refusing to apply stale plan: {field} changed.")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _ensure_planned_files_are_expected(
|
|
180
|
+
relative_paths: tuple[str, ...],
|
|
181
|
+
expected_files: dict[str, str],
|
|
182
|
+
) -> None:
|
|
183
|
+
for relative_path in relative_paths:
|
|
184
|
+
if relative_path not in expected_files:
|
|
185
|
+
raise ValueError(f"Refusing to modify unexpected file: {relative_path}")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _write_expected_file(path: Path, content: str) -> None:
|
|
189
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
190
|
+
path.write_text(content, encoding="utf-8")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _string_field(plan: dict[str, object], field: str) -> str:
|
|
194
|
+
value = plan.get(field)
|
|
195
|
+
if not isinstance(value, str):
|
|
196
|
+
raise ValueError(f"{field} must be a string.")
|
|
197
|
+
return value
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _list_field(plan: dict[str, object], field: str) -> tuple[str, ...]:
|
|
201
|
+
value = plan.get(field)
|
|
202
|
+
if not isinstance(value, list):
|
|
203
|
+
raise ValueError(f"{field} must be a list.")
|
|
204
|
+
return tuple(str(item) for item in value)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _validate_relative_paths(paths: tuple[str, ...]) -> tuple[str, ...]:
|
|
208
|
+
validated: list[str] = []
|
|
209
|
+
for raw_path in paths:
|
|
210
|
+
path = Path(raw_path)
|
|
211
|
+
if path.is_absolute() or ".." in path.parts:
|
|
212
|
+
raise ValueError(f"Refusing unsafe relative path: {raw_path}")
|
|
213
|
+
validated.append(path.as_posix())
|
|
214
|
+
return tuple(validated)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _timestamp() -> str:
|
|
218
|
+
return datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Read-only apply plan export helpers."""
|
|
2
|
+
|
|
3
|
+
from robopilot.apply_plan.plan import (
|
|
4
|
+
ApplyPlanValidationResult,
|
|
5
|
+
export_apply_plan,
|
|
6
|
+
validate_apply_plan_file,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"ApplyPlanValidationResult",
|
|
11
|
+
"export_apply_plan",
|
|
12
|
+
"validate_apply_plan_file",
|
|
13
|
+
]
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Export and validate read-only apply plans."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from robopilot.apply_preview.preview import ApplyPreviewResult, preview_apply
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
GENERATED_BY = "RoboPilot ApplyPlan"
|
|
14
|
+
SUPPORTED_FORMATS = {"yaml", "json"}
|
|
15
|
+
REQUIRED_FIELDS = (
|
|
16
|
+
"generated_by",
|
|
17
|
+
"spec_path",
|
|
18
|
+
"project_path",
|
|
19
|
+
"package_name",
|
|
20
|
+
"selected_template",
|
|
21
|
+
"files_to_create",
|
|
22
|
+
"files_to_update",
|
|
23
|
+
"files_to_keep",
|
|
24
|
+
"conflicts",
|
|
25
|
+
"missing_project",
|
|
26
|
+
"safety_note",
|
|
27
|
+
"suggested_next_steps",
|
|
28
|
+
)
|
|
29
|
+
LIST_FIELDS = {
|
|
30
|
+
"files_to_create",
|
|
31
|
+
"files_to_update",
|
|
32
|
+
"files_to_keep",
|
|
33
|
+
"conflicts",
|
|
34
|
+
"suggested_next_steps",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class ApplyPlanValidationResult:
|
|
40
|
+
"""Validation result for a serialized apply plan."""
|
|
41
|
+
|
|
42
|
+
is_valid: bool
|
|
43
|
+
errors: tuple[str, ...]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def export_apply_plan(
|
|
47
|
+
*,
|
|
48
|
+
spec_path: Path,
|
|
49
|
+
project_path: Path,
|
|
50
|
+
output_path: Path,
|
|
51
|
+
output_format: str = "yaml",
|
|
52
|
+
) -> dict[str, object]:
|
|
53
|
+
"""Export a deterministic apply plan without modifying the project."""
|
|
54
|
+
normalized_format = output_format.strip().lower()
|
|
55
|
+
if normalized_format not in SUPPORTED_FORMATS:
|
|
56
|
+
raise ValueError("Unsupported apply plan format. Use 'yaml' or 'json'.")
|
|
57
|
+
|
|
58
|
+
preview = preview_apply(spec_path, project_path)
|
|
59
|
+
plan = apply_plan_from_preview(preview)
|
|
60
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
if normalized_format == "json":
|
|
62
|
+
output_path.write_text(json.dumps(plan, indent=2) + "\n", encoding="utf-8")
|
|
63
|
+
else:
|
|
64
|
+
output_path.write_text(apply_plan_to_yaml(plan), encoding="utf-8")
|
|
65
|
+
return plan
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def apply_plan_from_preview(preview: ApplyPreviewResult) -> dict[str, object]:
|
|
69
|
+
"""Convert an apply-preview result into a stable apply plan mapping."""
|
|
70
|
+
data = preview.to_dict()
|
|
71
|
+
return {
|
|
72
|
+
"generated_by": GENERATED_BY,
|
|
73
|
+
"spec_path": data["spec_path"],
|
|
74
|
+
"project_path": data["project_path"],
|
|
75
|
+
"package_name": data["package_name"],
|
|
76
|
+
"selected_template": data["selected_template"],
|
|
77
|
+
"files_to_create": data["files_to_create"],
|
|
78
|
+
"files_to_update": data["files_to_update"],
|
|
79
|
+
"files_to_keep": data["files_to_keep"],
|
|
80
|
+
"conflicts": data["conflicts"],
|
|
81
|
+
"missing_project": data["missing_project"],
|
|
82
|
+
"safety_note": data["safety_note"],
|
|
83
|
+
"suggested_next_steps": data["suggested_next_steps"],
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def apply_plan_to_yaml(plan: dict[str, object]) -> str:
|
|
88
|
+
"""Serialize an apply plan using RoboPilot's small YAML-like subset."""
|
|
89
|
+
lines: list[str] = []
|
|
90
|
+
for field in REQUIRED_FIELDS:
|
|
91
|
+
value = plan.get(field)
|
|
92
|
+
if field in LIST_FIELDS:
|
|
93
|
+
lines.append(f"{field}:")
|
|
94
|
+
for item in _list_value(value):
|
|
95
|
+
lines.append(f' - "{_quote(str(item))}"')
|
|
96
|
+
continue
|
|
97
|
+
if isinstance(value, bool):
|
|
98
|
+
rendered = "true" if value else "false"
|
|
99
|
+
else:
|
|
100
|
+
rendered = f'"{_quote(str(value or ""))}"'
|
|
101
|
+
lines.append(f"{field}: {rendered}")
|
|
102
|
+
return "\n".join(lines) + "\n"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def validate_apply_plan_file(plan_path: Path) -> ApplyPlanValidationResult:
|
|
106
|
+
"""Validate a serialized apply plan without executing it."""
|
|
107
|
+
try:
|
|
108
|
+
plan = load_apply_plan(plan_path)
|
|
109
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
110
|
+
return ApplyPlanValidationResult(False, (str(exc),))
|
|
111
|
+
return validate_apply_plan(plan)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def validate_apply_plan(plan: dict[str, object]) -> ApplyPlanValidationResult:
|
|
115
|
+
"""Validate required apply plan fields and basic types."""
|
|
116
|
+
errors: list[str] = []
|
|
117
|
+
for field in REQUIRED_FIELDS:
|
|
118
|
+
if field not in plan:
|
|
119
|
+
errors.append(f"{field} is required.")
|
|
120
|
+
|
|
121
|
+
for field in LIST_FIELDS:
|
|
122
|
+
if field in plan and not isinstance(plan[field], list):
|
|
123
|
+
errors.append(f"{field} must be a list.")
|
|
124
|
+
|
|
125
|
+
if "missing_project" in plan and not isinstance(plan["missing_project"], bool):
|
|
126
|
+
errors.append("missing_project must be a boolean.")
|
|
127
|
+
|
|
128
|
+
for field in (
|
|
129
|
+
"generated_by",
|
|
130
|
+
"spec_path",
|
|
131
|
+
"project_path",
|
|
132
|
+
"package_name",
|
|
133
|
+
"selected_template",
|
|
134
|
+
"safety_note",
|
|
135
|
+
):
|
|
136
|
+
if field in plan and not isinstance(plan[field], str):
|
|
137
|
+
errors.append(f"{field} must be a string.")
|
|
138
|
+
|
|
139
|
+
return ApplyPlanValidationResult(is_valid=not errors, errors=tuple(errors))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def load_apply_plan(plan_path: Path) -> dict[str, object]:
|
|
143
|
+
"""Load a JSON or RoboPilot YAML-like apply plan."""
|
|
144
|
+
content = plan_path.read_text(encoding="utf-8")
|
|
145
|
+
if plan_path.suffix.lower() == ".json" or content.lstrip().startswith("{"):
|
|
146
|
+
data = json.loads(content)
|
|
147
|
+
if not isinstance(data, dict):
|
|
148
|
+
raise ValueError("Apply plan JSON must be an object.")
|
|
149
|
+
return data
|
|
150
|
+
return apply_plan_from_yaml(content)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def apply_plan_from_yaml(content: str) -> dict[str, object]:
|
|
154
|
+
"""Parse RoboPilot's small apply-plan YAML-like subset."""
|
|
155
|
+
data: dict[str, object] = {}
|
|
156
|
+
current_list: str | None = None
|
|
157
|
+
for raw_line in content.splitlines():
|
|
158
|
+
if not raw_line.strip() or raw_line.lstrip().startswith("#"):
|
|
159
|
+
continue
|
|
160
|
+
if not raw_line.startswith(" "):
|
|
161
|
+
key, value = _split_key_value(raw_line)
|
|
162
|
+
if value == "" and key in LIST_FIELDS:
|
|
163
|
+
data[key] = []
|
|
164
|
+
current_list = key
|
|
165
|
+
continue
|
|
166
|
+
current_list = None
|
|
167
|
+
if key == "missing_project":
|
|
168
|
+
data[key] = _parse_bool(value)
|
|
169
|
+
else:
|
|
170
|
+
data[key] = _unquote(value)
|
|
171
|
+
continue
|
|
172
|
+
if current_list is None:
|
|
173
|
+
raise ValueError(f"Unexpected indented line outside a list: {raw_line}")
|
|
174
|
+
stripped = raw_line.strip()
|
|
175
|
+
if not stripped.startswith("- "):
|
|
176
|
+
raise ValueError(f"Expected list item: {raw_line}")
|
|
177
|
+
value = _unquote(stripped[2:].strip())
|
|
178
|
+
current_value = data[current_list]
|
|
179
|
+
if not isinstance(current_value, list):
|
|
180
|
+
raise ValueError(f"Expected list field: {current_list}")
|
|
181
|
+
current_value.append(value)
|
|
182
|
+
return data
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _split_key_value(line: str) -> tuple[str, str]:
|
|
186
|
+
if ":" not in line:
|
|
187
|
+
raise ValueError(f"Expected key/value line: {line}")
|
|
188
|
+
key, value = line.split(":", 1)
|
|
189
|
+
return key.strip(), value.strip()
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _parse_bool(value: str) -> bool:
|
|
193
|
+
normalized = _unquote(value).strip().lower()
|
|
194
|
+
if normalized == "true":
|
|
195
|
+
return True
|
|
196
|
+
if normalized == "false":
|
|
197
|
+
return False
|
|
198
|
+
raise ValueError(f"Expected boolean value, got: {value}")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _quote(value: str) -> str:
|
|
202
|
+
return value.replace("\\", "\\\\").replace('"', '\\"')
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _unquote(value: str) -> str:
|
|
206
|
+
if len(value) >= 2 and value[0] == '"' and value[-1] == '"':
|
|
207
|
+
value = value[1:-1]
|
|
208
|
+
return value.replace('\\"', '"').replace("\\\\", "\\")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _list_value(value: object) -> list[Any]:
|
|
212
|
+
if isinstance(value, list):
|
|
213
|
+
return value
|
|
214
|
+
if isinstance(value, tuple):
|
|
215
|
+
return list(value)
|
|
216
|
+
return []
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Read-only preview of applying a ProjectSpec to a project directory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from robopilot.generator.project_generator import render_project_files
|
|
9
|
+
from robopilot.spec.io import load_spec
|
|
10
|
+
from robopilot.spec.validator import validate_spec
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
SAFETY_NOTE = (
|
|
14
|
+
"This is a read-only apply preview. RoboPilot did not modify project files, "
|
|
15
|
+
"execute ROS2, run launch files, run colcon, or execute generated Python code."
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class ApplyPreviewResult:
|
|
21
|
+
"""Structured apply preview result."""
|
|
22
|
+
|
|
23
|
+
spec_path: str
|
|
24
|
+
project_path: str
|
|
25
|
+
package_name: str
|
|
26
|
+
selected_template: str
|
|
27
|
+
files_to_create: tuple[str, ...]
|
|
28
|
+
files_to_update: tuple[str, ...]
|
|
29
|
+
files_to_keep: tuple[str, ...]
|
|
30
|
+
conflicts: tuple[str, ...]
|
|
31
|
+
missing_project: bool
|
|
32
|
+
safety_note: str
|
|
33
|
+
suggested_next_steps: tuple[str, ...]
|
|
34
|
+
|
|
35
|
+
def to_dict(self) -> dict[str, object]:
|
|
36
|
+
"""Return a stable JSON-serializable representation."""
|
|
37
|
+
return {
|
|
38
|
+
"spec_path": self.spec_path,
|
|
39
|
+
"project_path": self.project_path,
|
|
40
|
+
"package_name": self.package_name,
|
|
41
|
+
"selected_template": self.selected_template,
|
|
42
|
+
"files_to_create": list(self.files_to_create),
|
|
43
|
+
"files_to_update": list(self.files_to_update),
|
|
44
|
+
"files_to_keep": list(self.files_to_keep),
|
|
45
|
+
"conflicts": list(self.conflicts),
|
|
46
|
+
"missing_project": self.missing_project,
|
|
47
|
+
"safety_note": self.safety_note,
|
|
48
|
+
"suggested_next_steps": list(self.suggested_next_steps),
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def preview_apply(spec_path: Path, project_path: Path) -> ApplyPreviewResult:
|
|
53
|
+
"""Preview what applying a ProjectSpec would change without writing files."""
|
|
54
|
+
spec = load_spec(spec_path)
|
|
55
|
+
validation = validate_spec(spec)
|
|
56
|
+
if not validation.is_valid:
|
|
57
|
+
raise ValueError("Invalid ProjectSpec: " + "; ".join(validation.errors))
|
|
58
|
+
|
|
59
|
+
expected_files = render_project_files(spec)
|
|
60
|
+
missing_project = not project_path.exists()
|
|
61
|
+
files_to_create: list[str] = []
|
|
62
|
+
files_to_update: list[str] = []
|
|
63
|
+
files_to_keep: list[str] = []
|
|
64
|
+
|
|
65
|
+
for relative_path, expected_content in expected_files.items():
|
|
66
|
+
normalized = _normalize_path(relative_path)
|
|
67
|
+
target = project_path / relative_path
|
|
68
|
+
if missing_project or not target.exists():
|
|
69
|
+
files_to_create.append(normalized)
|
|
70
|
+
continue
|
|
71
|
+
if not target.is_file():
|
|
72
|
+
files_to_update.append(normalized)
|
|
73
|
+
continue
|
|
74
|
+
try:
|
|
75
|
+
existing_content = target.read_text(encoding="utf-8")
|
|
76
|
+
except UnicodeDecodeError:
|
|
77
|
+
files_to_update.append(normalized)
|
|
78
|
+
continue
|
|
79
|
+
if existing_content == expected_content:
|
|
80
|
+
files_to_keep.append(normalized)
|
|
81
|
+
else:
|
|
82
|
+
files_to_update.append(normalized)
|
|
83
|
+
|
|
84
|
+
conflicts = _detect_conflicts(project_path, set(expected_files))
|
|
85
|
+
return ApplyPreviewResult(
|
|
86
|
+
spec_path=str(spec_path),
|
|
87
|
+
project_path=str(project_path),
|
|
88
|
+
package_name=spec.package_name,
|
|
89
|
+
selected_template=spec.selected_template,
|
|
90
|
+
files_to_create=tuple(sorted(files_to_create)),
|
|
91
|
+
files_to_update=tuple(sorted(files_to_update)),
|
|
92
|
+
files_to_keep=tuple(sorted(files_to_keep)),
|
|
93
|
+
conflicts=conflicts,
|
|
94
|
+
missing_project=missing_project,
|
|
95
|
+
safety_note=SAFETY_NOTE,
|
|
96
|
+
suggested_next_steps=_suggest_next_steps(
|
|
97
|
+
missing_project=missing_project,
|
|
98
|
+
files_to_create=files_to_create,
|
|
99
|
+
files_to_update=files_to_update,
|
|
100
|
+
conflicts=conflicts,
|
|
101
|
+
spec_path=spec_path,
|
|
102
|
+
project_path=project_path,
|
|
103
|
+
),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _detect_conflicts(project_path: Path, expected_paths: set[Path]) -> tuple[str, ...]:
|
|
108
|
+
if not project_path.exists() or not project_path.is_dir():
|
|
109
|
+
return ()
|
|
110
|
+
|
|
111
|
+
conflicts: list[str] = []
|
|
112
|
+
for path in project_path.rglob("*"):
|
|
113
|
+
if not path.is_file():
|
|
114
|
+
continue
|
|
115
|
+
relative_path = path.relative_to(project_path)
|
|
116
|
+
if _is_ignored_artifact(relative_path):
|
|
117
|
+
continue
|
|
118
|
+
if relative_path not in expected_paths:
|
|
119
|
+
conflicts.append(_normalize_path(relative_path))
|
|
120
|
+
return tuple(sorted(conflicts))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _suggest_next_steps(
|
|
124
|
+
*,
|
|
125
|
+
missing_project: bool,
|
|
126
|
+
files_to_create: list[str],
|
|
127
|
+
files_to_update: list[str],
|
|
128
|
+
conflicts: tuple[str, ...],
|
|
129
|
+
spec_path: Path,
|
|
130
|
+
project_path: Path,
|
|
131
|
+
) -> tuple[str, ...]:
|
|
132
|
+
steps: list[str] = []
|
|
133
|
+
if missing_project:
|
|
134
|
+
steps.append("Create the project with robopilot generate --spec before applying future changes.")
|
|
135
|
+
if files_to_create or files_to_update:
|
|
136
|
+
steps.append(f"Review this preview before generating from {spec_path}.")
|
|
137
|
+
if conflicts:
|
|
138
|
+
steps.append("Review conflicts manually; RoboPilot will not overwrite unexpected files blindly.")
|
|
139
|
+
if not steps:
|
|
140
|
+
steps.append("No file changes are needed for the expected generated files.")
|
|
141
|
+
steps.append(f"Run robopilot inspect {project_path} for a static project health check.")
|
|
142
|
+
return tuple(steps)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _is_ignored_artifact(relative_path: Path) -> bool:
|
|
146
|
+
parts = set(relative_path.parts)
|
|
147
|
+
return (
|
|
148
|
+
"__pycache__" in parts
|
|
149
|
+
or ".robopilot_backups" in parts
|
|
150
|
+
or ".robopilot_history" in parts
|
|
151
|
+
or relative_path.suffix in {".pyc", ".pyo"}
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _normalize_path(path: Path) -> str:
|
|
156
|
+
return path.as_posix()
|