simfix 0.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.
- simfix/__init__.py +3 -0
- simfix/analyzer.py +121 -0
- simfix/cli.py +433 -0
- simfix/cmake.py +61 -0
- simfix/commands.py +91 -0
- simfix/compatibility.py +97 -0
- simfix/conda_environment.py +58 -0
- simfix/conda_fixer.py +145 -0
- simfix/cuda_docker.py +163 -0
- simfix/docker_runner.py +71 -0
- simfix/dockerfile.py +122 -0
- simfix/fixer.py +342 -0
- simfix/git_assets.py +109 -0
- simfix/planner.py +93 -0
- simfix/pypi.py +83 -0
- simfix/pyproject.py +103 -0
- simfix/python_requirements.py +42 -0
- simfix/repo.py +46 -0
- simfix/report.py +191 -0
- simfix/ros_docker.py +110 -0
- simfix/ros_package.py +94 -0
- simfix/setup_py.py +45 -0
- simfix/system.py +154 -0
- simfix/system_docker.py +175 -0
- simfix-0.1.0.dist-info/METADATA +286 -0
- simfix-0.1.0.dist-info/RECORD +30 -0
- simfix-0.1.0.dist-info/WHEEL +5 -0
- simfix-0.1.0.dist-info/entry_points.txt +2 -0
- simfix-0.1.0.dist-info/licenses/LICENSE +21 -0
- simfix-0.1.0.dist-info/top_level.txt +1 -0
simfix/dockerfile.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class DockerfileInfo:
|
|
10
|
+
"""Basic information extracted from a Dockerfile."""
|
|
11
|
+
|
|
12
|
+
base_images: list[str]
|
|
13
|
+
apt_packages: list[str]
|
|
14
|
+
pip_packages: list[str]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _join_continued_lines(text: str) -> list[str]:
|
|
18
|
+
"""Join Dockerfile lines ending with backslash."""
|
|
19
|
+
joined_lines: list[str] = []
|
|
20
|
+
current = ""
|
|
21
|
+
|
|
22
|
+
for raw_line in text.splitlines():
|
|
23
|
+
line = raw_line.strip()
|
|
24
|
+
|
|
25
|
+
if not line or line.startswith("#"):
|
|
26
|
+
continue
|
|
27
|
+
|
|
28
|
+
if line.endswith("\\"):
|
|
29
|
+
current += line[:-1].strip() + " "
|
|
30
|
+
else:
|
|
31
|
+
current += line
|
|
32
|
+
joined_lines.append(current.strip())
|
|
33
|
+
current = ""
|
|
34
|
+
|
|
35
|
+
if current:
|
|
36
|
+
joined_lines.append(current.strip())
|
|
37
|
+
|
|
38
|
+
return joined_lines
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _clean_package_token(token: str) -> str:
|
|
42
|
+
"""Clean common shell tokens from a package name."""
|
|
43
|
+
return token.strip().strip("\\").strip()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parse_dockerfile(path: str | Path) -> DockerfileInfo | None:
|
|
47
|
+
"""Parse a Dockerfile and extract basic dependency hints."""
|
|
48
|
+
dockerfile_path = Path(path).expanduser().resolve()
|
|
49
|
+
|
|
50
|
+
if not dockerfile_path.exists():
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
lines = _join_continued_lines(dockerfile_path.read_text(encoding="utf-8"))
|
|
54
|
+
|
|
55
|
+
base_images: list[str] = []
|
|
56
|
+
apt_packages: list[str] = []
|
|
57
|
+
pip_packages: list[str] = []
|
|
58
|
+
|
|
59
|
+
for line in lines:
|
|
60
|
+
upper_line = line.upper()
|
|
61
|
+
|
|
62
|
+
if upper_line.startswith("FROM "):
|
|
63
|
+
parts = line.split()
|
|
64
|
+
if len(parts) >= 2:
|
|
65
|
+
base_images.append(parts[1])
|
|
66
|
+
|
|
67
|
+
if "apt-get install" in line or "apt install" in line:
|
|
68
|
+
install_part = re.split(r"apt-get install|apt install", line, maxsplit=1)[
|
|
69
|
+
-1
|
|
70
|
+
]
|
|
71
|
+
tokens = install_part.split()
|
|
72
|
+
|
|
73
|
+
for token in tokens:
|
|
74
|
+
package = _clean_package_token(token)
|
|
75
|
+
|
|
76
|
+
if not package:
|
|
77
|
+
continue
|
|
78
|
+
|
|
79
|
+
if package.startswith("-"):
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
if package in {"&&", ";", "apt-get", "apt", "install"}:
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
if package.startswith(("rm", "/var/lib/apt/lists")):
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
apt_packages.append(package)
|
|
89
|
+
|
|
90
|
+
pip_install_matches = re.finditer(
|
|
91
|
+
r"(?:python\d* -m pip|pip) install (?P<args>.*?)(?:&&|;|$)",
|
|
92
|
+
line,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
for match in pip_install_matches:
|
|
96
|
+
tokens = match.group("args").split()
|
|
97
|
+
|
|
98
|
+
if "-r" in tokens or "--requirement" in tokens:
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
for token in tokens:
|
|
102
|
+
package = _clean_package_token(token)
|
|
103
|
+
|
|
104
|
+
if not package:
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
if package.startswith("-"):
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
if package in {"&&", ";", "python", "python3", "pip", "install"}:
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
if package.endswith(".txt") or "/" in package:
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
pip_packages.append(package)
|
|
117
|
+
|
|
118
|
+
return DockerfileInfo(
|
|
119
|
+
base_images=base_images,
|
|
120
|
+
apt_packages=apt_packages,
|
|
121
|
+
pip_packages=pip_packages,
|
|
122
|
+
)
|
simfix/fixer.py
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import tempfile
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from simfix.conda_fixer import fix_conda_environment_file
|
|
11
|
+
from simfix.cuda_docker import create_cuda_dockerfile
|
|
12
|
+
from simfix.docker_runner import create_docker_run_helper
|
|
13
|
+
from simfix.git_assets import fix_git_assets
|
|
14
|
+
from simfix.ros_docker import create_ros_dockerfile
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class CombinedFixResult:
|
|
19
|
+
"""Combined result of all fixers."""
|
|
20
|
+
|
|
21
|
+
messages: list[str]
|
|
22
|
+
changed_files: list[Path]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _command_exists(command: str) -> bool:
|
|
26
|
+
"""Return True if a command exists on PATH."""
|
|
27
|
+
return shutil.which(command) is not None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class FixResult:
|
|
32
|
+
"""Result of a dependency fix operation."""
|
|
33
|
+
|
|
34
|
+
file_path: Path
|
|
35
|
+
changed: bool
|
|
36
|
+
message: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def extract_direct_pin_conflict(error_text: str) -> str | None:
|
|
40
|
+
"""Extract the directly pinned package causing a uv conflict.
|
|
41
|
+
|
|
42
|
+
Example uv message:
|
|
43
|
+
Because urdfpy==0.0.22 depends on networkx==2.2 ...
|
|
44
|
+
And because you require networkx==3.1, ...
|
|
45
|
+
returns: "networkx"
|
|
46
|
+
"""
|
|
47
|
+
dependency_match = re.search(
|
|
48
|
+
r"depends on ([A-Za-z0-9_.-]+)==[A-Za-z0-9_.!+-]+",
|
|
49
|
+
error_text,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
if dependency_match is None:
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
dependency_name = dependency_match.group(1).lower()
|
|
56
|
+
|
|
57
|
+
required_matches = re.findall(
|
|
58
|
+
r"you require ([A-Za-z0-9_.-]+)==[A-Za-z0-9_.!+-]+",
|
|
59
|
+
error_text,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
for required_name in required_matches:
|
|
63
|
+
if required_name.lower() == dependency_name:
|
|
64
|
+
return required_name
|
|
65
|
+
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def remove_direct_requirement_pin(
|
|
70
|
+
requirements_text: str,
|
|
71
|
+
package_name: str,
|
|
72
|
+
) -> str:
|
|
73
|
+
"""Remove a direct exact-version pin from requirements text."""
|
|
74
|
+
pattern = re.compile(
|
|
75
|
+
rf"^\s*{re.escape(package_name)}\s*==\s*[^\s#]+.*\n?",
|
|
76
|
+
flags=re.IGNORECASE | re.MULTILINE,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
return pattern.sub("", requirements_text)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def fix_requirements_with_uv(repo_path: str | Path) -> FixResult | None:
|
|
83
|
+
"""Resolve and update requirements.txt in place using uv.
|
|
84
|
+
|
|
85
|
+
This updates the original requirements.txt file.
|
|
86
|
+
"""
|
|
87
|
+
path = Path(repo_path).expanduser().resolve()
|
|
88
|
+
requirements_path = path / "requirements.txt"
|
|
89
|
+
|
|
90
|
+
if not requirements_path.exists():
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
if not _command_exists("uv"):
|
|
94
|
+
return FixResult(
|
|
95
|
+
file_path=requirements_path,
|
|
96
|
+
changed=False,
|
|
97
|
+
message=("uv was not found. Install it with: " "python -m pip install uv"),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
old_text = requirements_path.read_text(encoding="utf-8")
|
|
101
|
+
normalized_text = normalize_pip_requirement_syntax(old_text)
|
|
102
|
+
|
|
103
|
+
if normalized_text != old_text:
|
|
104
|
+
requirements_path.write_text(normalized_text, encoding="utf-8")
|
|
105
|
+
|
|
106
|
+
with tempfile.TemporaryDirectory() as temporary_directory:
|
|
107
|
+
output_path = Path(temporary_directory) / "requirements.txt"
|
|
108
|
+
|
|
109
|
+
result = subprocess.run(
|
|
110
|
+
[
|
|
111
|
+
"uv",
|
|
112
|
+
"pip",
|
|
113
|
+
"compile",
|
|
114
|
+
str(requirements_path),
|
|
115
|
+
"--upgrade",
|
|
116
|
+
"-o",
|
|
117
|
+
str(output_path),
|
|
118
|
+
],
|
|
119
|
+
check=False,
|
|
120
|
+
capture_output=True,
|
|
121
|
+
text=True,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
if result.returncode != 0:
|
|
125
|
+
error_text = result.stderr.strip() or result.stdout.strip()
|
|
126
|
+
conflicting_package = extract_direct_pin_conflict(error_text)
|
|
127
|
+
|
|
128
|
+
if conflicting_package is not None:
|
|
129
|
+
current_text = requirements_path.read_text(encoding="utf-8")
|
|
130
|
+
fixed_text = remove_direct_requirement_pin(
|
|
131
|
+
current_text,
|
|
132
|
+
conflicting_package,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
if fixed_text != current_text:
|
|
136
|
+
requirements_path.write_text(fixed_text, encoding="utf-8")
|
|
137
|
+
|
|
138
|
+
retry_result = subprocess.run(
|
|
139
|
+
[
|
|
140
|
+
"uv",
|
|
141
|
+
"pip",
|
|
142
|
+
"compile",
|
|
143
|
+
str(requirements_path),
|
|
144
|
+
"--upgrade",
|
|
145
|
+
"-o",
|
|
146
|
+
str(output_path),
|
|
147
|
+
],
|
|
148
|
+
check=False,
|
|
149
|
+
capture_output=True,
|
|
150
|
+
text=True,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
if retry_result.returncode == 0:
|
|
154
|
+
new_text = output_path.read_text(encoding="utf-8")
|
|
155
|
+
requirements_path.write_text(new_text, encoding="utf-8")
|
|
156
|
+
|
|
157
|
+
return FixResult(
|
|
158
|
+
file_path=requirements_path,
|
|
159
|
+
changed=True,
|
|
160
|
+
message=(
|
|
161
|
+
"requirements.txt conflict repaired by removing "
|
|
162
|
+
f"the direct {conflicting_package} pin and letting "
|
|
163
|
+
"uv resolve the compatible version."
|
|
164
|
+
),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
return FixResult(
|
|
168
|
+
file_path=requirements_path,
|
|
169
|
+
changed=True,
|
|
170
|
+
message=(
|
|
171
|
+
f"Removed direct {conflicting_package} pin, "
|
|
172
|
+
"but uv still failed: "
|
|
173
|
+
+ (
|
|
174
|
+
retry_result.stderr.strip()
|
|
175
|
+
or retry_result.stdout.strip()
|
|
176
|
+
)
|
|
177
|
+
),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
return FixResult(
|
|
181
|
+
file_path=requirements_path,
|
|
182
|
+
changed=False,
|
|
183
|
+
message=error_text or "uv failed to resolve requirements.",
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
new_text = output_path.read_text(encoding="utf-8")
|
|
187
|
+
|
|
188
|
+
changed = old_text != new_text
|
|
189
|
+
|
|
190
|
+
if changed:
|
|
191
|
+
requirements_path.write_text(new_text, encoding="utf-8")
|
|
192
|
+
|
|
193
|
+
return FixResult(
|
|
194
|
+
file_path=requirements_path,
|
|
195
|
+
changed=changed,
|
|
196
|
+
message="requirements.txt resolved successfully with uv.",
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def fix_pyproject_with_uv(repo_path: str | Path) -> FixResult | None:
|
|
201
|
+
"""Resolve pyproject.toml dependencies into requirements.txt using uv.
|
|
202
|
+
|
|
203
|
+
This creates requirements.txt only when pyproject.toml exists and
|
|
204
|
+
requirements.txt does not already exist.
|
|
205
|
+
"""
|
|
206
|
+
path = Path(repo_path).expanduser().resolve()
|
|
207
|
+
pyproject_path = path / "pyproject.toml"
|
|
208
|
+
requirements_path = path / "requirements.txt"
|
|
209
|
+
|
|
210
|
+
if not pyproject_path.exists():
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
if requirements_path.exists():
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
if not _command_exists("uv"):
|
|
217
|
+
return FixResult(
|
|
218
|
+
file_path=requirements_path,
|
|
219
|
+
changed=False,
|
|
220
|
+
message=("uv was not found. Install it with: " "python -m pip install uv"),
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
result = subprocess.run(
|
|
224
|
+
[
|
|
225
|
+
"uv",
|
|
226
|
+
"pip",
|
|
227
|
+
"compile",
|
|
228
|
+
str(pyproject_path),
|
|
229
|
+
"--upgrade",
|
|
230
|
+
"-o",
|
|
231
|
+
str(requirements_path),
|
|
232
|
+
],
|
|
233
|
+
check=False,
|
|
234
|
+
capture_output=True,
|
|
235
|
+
text=True,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
if result.returncode != 0:
|
|
239
|
+
return FixResult(
|
|
240
|
+
file_path=requirements_path,
|
|
241
|
+
changed=False,
|
|
242
|
+
message=result.stderr.strip()
|
|
243
|
+
or "uv failed to resolve pyproject.toml dependencies.",
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
return FixResult(
|
|
247
|
+
file_path=requirements_path,
|
|
248
|
+
changed=True,
|
|
249
|
+
message="Created requirements.txt from pyproject.toml using uv.",
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def normalize_pip_requirement_syntax(text: str) -> str:
|
|
254
|
+
"""Normalize simple invalid pip requirement syntax.
|
|
255
|
+
|
|
256
|
+
Converts package=version to package==version.
|
|
257
|
+
"""
|
|
258
|
+
normalized_lines: list[str] = []
|
|
259
|
+
|
|
260
|
+
for line in text.splitlines():
|
|
261
|
+
stripped = line.strip()
|
|
262
|
+
|
|
263
|
+
if (
|
|
264
|
+
stripped
|
|
265
|
+
and not stripped.startswith("#")
|
|
266
|
+
and "=" in stripped
|
|
267
|
+
and "==" not in stripped
|
|
268
|
+
and ">=" not in stripped
|
|
269
|
+
and "<=" not in stripped
|
|
270
|
+
and "!=" not in stripped
|
|
271
|
+
and "~=" not in stripped
|
|
272
|
+
):
|
|
273
|
+
name, version = stripped.split("=", maxsplit=1)
|
|
274
|
+
normalized_lines.append(f"{name.strip()}=={version.strip()}")
|
|
275
|
+
else:
|
|
276
|
+
normalized_lines.append(line)
|
|
277
|
+
|
|
278
|
+
return "\n".join(normalized_lines) + "\n"
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def fix_repo(repo_path: str | Path) -> CombinedFixResult:
|
|
282
|
+
"""Run all supported fixers for a repository."""
|
|
283
|
+
messages: list[str] = []
|
|
284
|
+
changed_files: list[Path] = []
|
|
285
|
+
|
|
286
|
+
requirements_result = fix_requirements_with_uv(repo_path)
|
|
287
|
+
|
|
288
|
+
if requirements_result is not None:
|
|
289
|
+
messages.append(requirements_result.message)
|
|
290
|
+
|
|
291
|
+
if requirements_result.changed:
|
|
292
|
+
changed_files.append(requirements_result.file_path)
|
|
293
|
+
|
|
294
|
+
pyproject_result = fix_pyproject_with_uv(repo_path)
|
|
295
|
+
if pyproject_result is not None:
|
|
296
|
+
messages.append(pyproject_result.message)
|
|
297
|
+
|
|
298
|
+
if pyproject_result.changed:
|
|
299
|
+
changed_files.append(pyproject_result.file_path)
|
|
300
|
+
|
|
301
|
+
conda_result = fix_conda_environment_file(repo_path)
|
|
302
|
+
|
|
303
|
+
if conda_result is not None:
|
|
304
|
+
messages.append(conda_result.message)
|
|
305
|
+
|
|
306
|
+
if conda_result.changed:
|
|
307
|
+
changed_files.append(conda_result.file_path)
|
|
308
|
+
|
|
309
|
+
cuda_result = create_cuda_dockerfile(repo_path)
|
|
310
|
+
|
|
311
|
+
if cuda_result is not None:
|
|
312
|
+
messages.append(cuda_result.message)
|
|
313
|
+
|
|
314
|
+
if cuda_result.changed:
|
|
315
|
+
changed_files.append(cuda_result.file_path)
|
|
316
|
+
|
|
317
|
+
ros_result = create_ros_dockerfile(repo_path)
|
|
318
|
+
|
|
319
|
+
if ros_result is not None:
|
|
320
|
+
messages.append(ros_result.message)
|
|
321
|
+
|
|
322
|
+
if ros_result.changed:
|
|
323
|
+
changed_files.append(ros_result.file_path)
|
|
324
|
+
|
|
325
|
+
docker_run_result = create_docker_run_helper(repo_path)
|
|
326
|
+
if docker_run_result is not None:
|
|
327
|
+
messages.append(docker_run_result.message)
|
|
328
|
+
|
|
329
|
+
if docker_run_result.changed:
|
|
330
|
+
changed_files.append(docker_run_result.file_path)
|
|
331
|
+
|
|
332
|
+
git_assets_result = fix_git_assets(repo_path)
|
|
333
|
+
if git_assets_result is not None:
|
|
334
|
+
messages.append(git_assets_result.message)
|
|
335
|
+
|
|
336
|
+
if not messages:
|
|
337
|
+
messages.append("No supported dependency files found to fix yet.")
|
|
338
|
+
|
|
339
|
+
return CombinedFixResult(
|
|
340
|
+
messages=messages,
|
|
341
|
+
changed_files=changed_files,
|
|
342
|
+
)
|
simfix/git_assets.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class GitAssetsFixResult:
|
|
11
|
+
"""Result of fixing git-managed assets."""
|
|
12
|
+
|
|
13
|
+
changed: bool
|
|
14
|
+
message: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _command_exists(command: str) -> bool:
|
|
18
|
+
"""Return True if a command exists on PATH."""
|
|
19
|
+
return shutil.which(command) is not None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _run_command(command: list[str], cwd: Path) -> subprocess.CompletedProcess[str]:
|
|
23
|
+
"""Run a command in a repository."""
|
|
24
|
+
return subprocess.run(
|
|
25
|
+
command,
|
|
26
|
+
cwd=cwd,
|
|
27
|
+
check=False,
|
|
28
|
+
capture_output=True,
|
|
29
|
+
text=True,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _is_git_repo(path: Path) -> bool:
|
|
34
|
+
"""Return True if path is inside a Git repository."""
|
|
35
|
+
result = _run_command(["git", "rev-parse", "--is-inside-work-tree"], path)
|
|
36
|
+
|
|
37
|
+
return result.returncode == 0 and result.stdout.strip() == "true"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _uses_git_lfs(path: Path) -> bool:
|
|
41
|
+
"""Return True if repository appears to use Git LFS."""
|
|
42
|
+
attributes_path = path / ".gitattributes"
|
|
43
|
+
|
|
44
|
+
if not attributes_path.exists():
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
attributes_text = attributes_path.read_text(encoding="utf-8", errors="ignore")
|
|
48
|
+
|
|
49
|
+
return "filter=lfs" in attributes_text
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def fix_git_assets(repo_path: str | Path) -> GitAssetsFixResult | None:
|
|
53
|
+
"""Fix missing git submodules and Git LFS assets."""
|
|
54
|
+
path = Path(repo_path).expanduser().resolve()
|
|
55
|
+
|
|
56
|
+
if not _command_exists("git"):
|
|
57
|
+
return GitAssetsFixResult(
|
|
58
|
+
changed=False,
|
|
59
|
+
message="git was not found, so SimFix could not fix git assets.",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
if not _is_git_repo(path):
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
messages: list[str] = []
|
|
66
|
+
changed = False
|
|
67
|
+
|
|
68
|
+
gitmodules_path = path / ".gitmodules"
|
|
69
|
+
|
|
70
|
+
if gitmodules_path.exists():
|
|
71
|
+
result = _run_command(
|
|
72
|
+
["git", "submodule", "update", "--init", "--recursive"],
|
|
73
|
+
path,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
if result.returncode == 0:
|
|
77
|
+
messages.append("Git submodules updated successfully.")
|
|
78
|
+
changed = True
|
|
79
|
+
else:
|
|
80
|
+
messages.append(
|
|
81
|
+
"Git submodule update failed: "
|
|
82
|
+
+ (result.stderr.strip() or result.stdout.strip())
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
if _uses_git_lfs(path):
|
|
86
|
+
if not _command_exists("git-lfs") and not _command_exists("git-lfs.exe"):
|
|
87
|
+
messages.append(
|
|
88
|
+
"Git LFS files detected, but git-lfs was not found. "
|
|
89
|
+
"Install Git LFS and run: git lfs pull"
|
|
90
|
+
)
|
|
91
|
+
else:
|
|
92
|
+
result = _run_command(["git", "lfs", "pull"], path)
|
|
93
|
+
|
|
94
|
+
if result.returncode == 0:
|
|
95
|
+
messages.append("Git LFS assets pulled successfully.")
|
|
96
|
+
changed = True
|
|
97
|
+
else:
|
|
98
|
+
messages.append(
|
|
99
|
+
"Git LFS pull failed: "
|
|
100
|
+
+ (result.stderr.strip() or result.stdout.strip())
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
if not messages:
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
return GitAssetsFixResult(
|
|
107
|
+
changed=changed,
|
|
108
|
+
message=" ".join(messages),
|
|
109
|
+
)
|
simfix/planner.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from simfix.analyzer import RepoAnalysis
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class InstallPlan:
|
|
10
|
+
"""Recommended installation plan for a repository."""
|
|
11
|
+
|
|
12
|
+
recommended_mode: str
|
|
13
|
+
reason: str
|
|
14
|
+
steps: list[str]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def create_install_plan(analysis: RepoAnalysis) -> InstallPlan:
|
|
18
|
+
"""Create a basic installation plan from repository analysis."""
|
|
19
|
+
ecosystems = analysis.detected_ecosystems
|
|
20
|
+
|
|
21
|
+
if "docker" in ecosystems:
|
|
22
|
+
return InstallPlan(
|
|
23
|
+
recommended_mode="docker",
|
|
24
|
+
reason=(
|
|
25
|
+
"A Dockerfile was found, so container installation " "may be available."
|
|
26
|
+
),
|
|
27
|
+
steps=[
|
|
28
|
+
"Build the Docker image.",
|
|
29
|
+
"Run the container.",
|
|
30
|
+
"Test the simulator inside the container.",
|
|
31
|
+
],
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
if "ros" in ecosystems:
|
|
35
|
+
return InstallPlan(
|
|
36
|
+
recommended_mode="ros",
|
|
37
|
+
reason="A package.xml file was found, so this looks like a ROS project.",
|
|
38
|
+
steps=[
|
|
39
|
+
"Create or use a ROS workspace.",
|
|
40
|
+
"Install ROS dependencies using rosdep.",
|
|
41
|
+
"Build the workspace with catkin or colcon.",
|
|
42
|
+
"Source the workspace setup file.",
|
|
43
|
+
"Run a launch file or example node.",
|
|
44
|
+
],
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
if "conda" in ecosystems:
|
|
48
|
+
return InstallPlan(
|
|
49
|
+
recommended_mode="conda",
|
|
50
|
+
reason="An environment.yml file was found.",
|
|
51
|
+
steps=[
|
|
52
|
+
"Create the conda environment from environment.yml.",
|
|
53
|
+
"Activate the environment.",
|
|
54
|
+
"Install the project in editable mode if needed.",
|
|
55
|
+
"Run tests or examples.",
|
|
56
|
+
],
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
if "python" in ecosystems:
|
|
60
|
+
return InstallPlan(
|
|
61
|
+
recommended_mode="python",
|
|
62
|
+
reason="Python dependency files were found.",
|
|
63
|
+
steps=[
|
|
64
|
+
"Create a Python virtual environment.",
|
|
65
|
+
"Install dependencies from requirements.txt or pyproject.toml.",
|
|
66
|
+
"Install the project in editable mode if needed.",
|
|
67
|
+
"Run tests or examples.",
|
|
68
|
+
],
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
if "cmake/c++" in ecosystems:
|
|
72
|
+
return InstallPlan(
|
|
73
|
+
recommended_mode="cmake",
|
|
74
|
+
reason="A CMakeLists.txt file was found.",
|
|
75
|
+
steps=[
|
|
76
|
+
"Install system build tools and CMake.",
|
|
77
|
+
"Create a build directory.",
|
|
78
|
+
"Configure the project with cmake.",
|
|
79
|
+
"Build the project.",
|
|
80
|
+
"Run available examples or tests.",
|
|
81
|
+
],
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
return InstallPlan(
|
|
85
|
+
recommended_mode="manual",
|
|
86
|
+
reason="No known dependency file was found.",
|
|
87
|
+
steps=[
|
|
88
|
+
"Read the project README installation section.",
|
|
89
|
+
"Check for custom install scripts.",
|
|
90
|
+
"Install dependencies manually.",
|
|
91
|
+
"Run available examples or tests.",
|
|
92
|
+
],
|
|
93
|
+
)
|