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/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
+ )