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 ADDED
@@ -0,0 +1,3 @@
1
+ """SimFix package."""
2
+
3
+ __version__ = "0.1.0"
simfix/analyzer.py ADDED
@@ -0,0 +1,121 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ from simfix.cmake import CMakeInfo, parse_cmake_file
7
+ from simfix.conda_environment import CondaEnvironment, parse_conda_environment
8
+ from simfix.dockerfile import DockerfileInfo, parse_dockerfile
9
+ from simfix.pypi import normalize_requirement_name
10
+ from simfix.pyproject import PyProjectInfo, parse_pyproject
11
+ from simfix.python_requirements import parse_requirements_file
12
+ from simfix.ros_package import ROSPackageInfo, parse_ros_package
13
+ from simfix.setup_py import parse_setup_py_dependencies
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class RepoAnalysis:
18
+ """Basic analysis result for a simulator repository."""
19
+
20
+ repo_path: Path
21
+ has_requirements_txt: bool
22
+ has_pyproject_toml: bool
23
+ has_environment_yml: bool
24
+ has_dockerfile: bool
25
+ has_package_xml: bool
26
+ has_cmake: bool
27
+ has_setup_py: bool
28
+ setup_py_dependencies: list[str]
29
+ python_requirements: list[str]
30
+ conda_environment: CondaEnvironment | None
31
+ dockerfile_info: DockerfileInfo | None
32
+ ros_package_info: ROSPackageInfo | None
33
+ cmake_info: CMakeInfo | None
34
+ pyproject_info: PyProjectInfo | None
35
+
36
+ @property
37
+ def all_python_dependencies(self) -> list[str]:
38
+ """Return all Python dependencies from supported dependency files."""
39
+ dependencies = list(self.python_requirements)
40
+
41
+ if self.pyproject_info is not None:
42
+ dependencies.extend(self.pyproject_info.dependencies)
43
+
44
+ dependencies.extend(self.setup_py_dependencies)
45
+
46
+ unique_dependencies: dict[str, str] = {}
47
+
48
+ for dependency in dependencies:
49
+ package_name = normalize_requirement_name(dependency).lower()
50
+
51
+ if package_name not in unique_dependencies:
52
+ unique_dependencies[package_name] = dependency
53
+
54
+ return list(unique_dependencies.values())
55
+
56
+ @property
57
+ def detected_ecosystems(self) -> list[str]:
58
+ """Return detected project ecosystems."""
59
+ ecosystems: list[str] = []
60
+
61
+ if self.has_requirements_txt or self.has_pyproject_toml:
62
+ ecosystems.append("python")
63
+
64
+ if self.has_environment_yml:
65
+ ecosystems.append("conda")
66
+
67
+ if self.has_dockerfile:
68
+ ecosystems.append("docker")
69
+
70
+ if self.has_package_xml:
71
+ ecosystems.append("ros")
72
+
73
+ if self.has_cmake:
74
+ ecosystems.append("cmake/c++")
75
+
76
+ if not ecosystems:
77
+ ecosystems.append("unknown")
78
+
79
+ return ecosystems
80
+
81
+
82
+ def analyze_repo(repo_path: str | Path) -> RepoAnalysis:
83
+ """Analyze a local repository path and detect common dependency files."""
84
+ path = Path(repo_path).expanduser().resolve()
85
+
86
+ if not path.exists():
87
+ raise FileNotFoundError(f"Repository path does not exist: {path}")
88
+
89
+ if not path.is_dir():
90
+ raise NotADirectoryError(f"Repository path is not a directory: {path}")
91
+
92
+ requirements_path = path / "requirements.txt"
93
+ environment_path = path / "environment.yml"
94
+ if not environment_path.exists():
95
+ environment_path = path / "environment.yaml"
96
+
97
+ dockerfile_path = path / "Dockerfile"
98
+ package_xml_path = path / "package.xml"
99
+ cmake_path = path / "CMakeLists.txt"
100
+ pyproject_path = path / "pyproject.toml"
101
+ setup_py_path = path / "setup.py"
102
+ setup_py_dependencies = parse_setup_py_dependencies(setup_py_path)
103
+
104
+ return RepoAnalysis(
105
+ repo_path=path,
106
+ has_requirements_txt=requirements_path.exists(),
107
+ has_environment_yml=(path / "environment.yml").exists()
108
+ or (path / "environment.yaml").exists(),
109
+ python_requirements=parse_requirements_file(requirements_path),
110
+ conda_environment=parse_conda_environment(environment_path),
111
+ has_dockerfile=dockerfile_path.exists(),
112
+ dockerfile_info=parse_dockerfile(dockerfile_path),
113
+ has_package_xml=package_xml_path.exists(),
114
+ ros_package_info=parse_ros_package(package_xml_path),
115
+ has_cmake=cmake_path.exists(),
116
+ cmake_info=parse_cmake_file(cmake_path),
117
+ has_pyproject_toml=pyproject_path.exists(),
118
+ pyproject_info=parse_pyproject(pyproject_path),
119
+ has_setup_py=setup_py_path.exists(),
120
+ setup_py_dependencies=setup_py_dependencies,
121
+ )
simfix/cli.py ADDED
@@ -0,0 +1,433 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import typer
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+
9
+ from simfix import __version__
10
+ from simfix.analyzer import analyze_repo
11
+ from simfix.commands import create_command_plan
12
+ from simfix.compatibility import generate_compatibility_warnings
13
+ from simfix.fixer import fix_repo
14
+ from simfix.planner import create_install_plan
15
+ from simfix.pypi import check_pypi_packages
16
+ from simfix.repo import clone_repo, is_git_url
17
+ from simfix.report import generate_markdown_report, write_markdown_report
18
+ from simfix.system import get_system_info
19
+
20
+
21
+ console = Console()
22
+
23
+
24
+ def version_callback(value: bool) -> None:
25
+ """Show SimFix version and exit."""
26
+ if value:
27
+ typer.echo(f"simfix {__version__}")
28
+ raise typer.Exit
29
+
30
+
31
+ app = typer.Typer(
32
+ help="SimFix: dependency checker and installer assistant for simulator repositories.",
33
+ )
34
+
35
+
36
+ @app.callback()
37
+ def main(
38
+ version: bool = typer.Option(
39
+ False,
40
+ "--version",
41
+ help="Show SimFix version and exit.",
42
+ callback=version_callback,
43
+ is_eager=True,
44
+ ),
45
+ ) -> None:
46
+ """SimFix command-line interface."""
47
+
48
+
49
+ console = Console()
50
+
51
+
52
+ def _resolve_repo_path(repo: str) -> Path:
53
+ """Resolve a local path or clone a Git repository URL."""
54
+ if is_git_url(repo):
55
+ console.print("[bold blue]Cloning repository...[/bold blue]")
56
+ return clone_repo(repo)
57
+
58
+ return Path(repo)
59
+
60
+
61
+ @app.command()
62
+ def analyze(repo: str) -> None:
63
+ """Analyze repository dependency files without system diagnostics."""
64
+ repo_path = _resolve_repo_path(repo)
65
+ analysis = analyze_repo(repo_path)
66
+
67
+ console.print("[bold green]SimFix Analyze[/bold green]")
68
+ console.print(f"Repository: {analysis.repo_path}")
69
+
70
+ table = Table(title="Detected dependency files")
71
+ table.add_column("File/type", style="cyan")
72
+ table.add_column("Detected", style="green")
73
+
74
+ table.add_row("requirements.txt", "yes" if analysis.has_requirements_txt else "no")
75
+ table.add_row("pyproject.toml", "yes" if analysis.has_pyproject_toml else "no")
76
+ table.add_row("environment.yml", "yes" if analysis.has_environment_yml else "no")
77
+ table.add_row("Dockerfile", "yes" if analysis.has_dockerfile else "no")
78
+ table.add_row("package.xml / ROS", "yes" if analysis.has_package_xml else "no")
79
+ table.add_row("CMakeLists.txt", "yes" if analysis.has_cmake else "no")
80
+
81
+ console.print(table)
82
+
83
+ ecosystems = ", ".join(analysis.detected_ecosystems)
84
+ console.print(f"[bold]Detected ecosystem(s):[/bold] {ecosystems}")
85
+
86
+
87
+ @app.command()
88
+ def plan(repo: str) -> None:
89
+ """Generate a recommended installation plan for a repository."""
90
+ repo_path = _resolve_repo_path(repo)
91
+ analysis = analyze_repo(repo_path)
92
+ install_plan = create_install_plan(analysis)
93
+
94
+ console.print("[bold green]SimFix Plan[/bold green]")
95
+ console.print(f"Repository: {analysis.repo_path}")
96
+
97
+ ecosystems = ", ".join(analysis.detected_ecosystems)
98
+ console.print(f"[bold]Detected ecosystem(s):[/bold] {ecosystems}")
99
+
100
+ plan_table = Table(title="Install plan")
101
+ plan_table.add_column("Field", style="cyan")
102
+ plan_table.add_column("Value", style="green")
103
+
104
+ plan_table.add_row("Recommended mode", install_plan.recommended_mode)
105
+ plan_table.add_row("Reason", install_plan.reason)
106
+ plan_table.add_row("Steps", "\n".join(install_plan.steps))
107
+
108
+ console.print(plan_table)
109
+
110
+
111
+ @app.command()
112
+ def commands(repo: str) -> None:
113
+ """Show suggested installation commands without running them."""
114
+ repo_path = _resolve_repo_path(repo)
115
+ analysis = analyze_repo(repo_path)
116
+ command_plan = create_command_plan(analysis)
117
+
118
+ console.print("[bold green]SimFix Commands[/bold green]")
119
+ console.print(f"Repository: {analysis.repo_path}")
120
+
121
+ table = Table(title=command_plan.title)
122
+ table.add_column("#", style="cyan")
123
+ table.add_column("Command", style="green")
124
+
125
+ for index, command in enumerate(command_plan.commands, start=1):
126
+ table.add_row(str(index), command)
127
+
128
+ console.print(table)
129
+
130
+
131
+ @app.command()
132
+ def doctor(
133
+ repo: str,
134
+ report: bool = typer.Option(
135
+ False,
136
+ "--report",
137
+ help="Write a Markdown SimFix report to simfix_report.md.",
138
+ ),
139
+ ) -> None:
140
+ """Analyze a local path or Git repository URL."""
141
+ repo_path = _resolve_repo_path(repo)
142
+
143
+ analysis = analyze_repo(repo_path)
144
+ system_info = get_system_info()
145
+
146
+ console.print("[bold green]SimFix Doctor[/bold green]")
147
+ console.print(f"Repository: {analysis.repo_path}")
148
+
149
+ table = Table(title="Detected dependency files")
150
+ table.add_column("File/type", style="cyan")
151
+ table.add_column("Detected", style="green")
152
+
153
+ table.add_row("requirements.txt", "yes" if analysis.has_requirements_txt else "no")
154
+ table.add_row("pyproject.toml", "yes" if analysis.has_pyproject_toml else "no")
155
+ table.add_row("environment.yml", "yes" if analysis.has_environment_yml else "no")
156
+ table.add_row("Dockerfile", "yes" if analysis.has_dockerfile else "no")
157
+ table.add_row("package.xml / ROS", "yes" if analysis.has_package_xml else "no")
158
+ table.add_row("CMakeLists.txt", "yes" if analysis.has_cmake else "no")
159
+ table.add_row("setup.py", "yes" if analysis.has_setup_py else "no")
160
+
161
+ console.print(table)
162
+
163
+ ecosystems = ", ".join(analysis.detected_ecosystems)
164
+ console.print(f"[bold]Detected ecosystem(s):[/bold] {ecosystems}")
165
+
166
+ python_dependencies = analysis.all_python_dependencies
167
+
168
+ if python_dependencies:
169
+ deps_table = Table(title="Python packages")
170
+ deps_table.add_column("Dependency", style="cyan")
171
+
172
+ for dependency in python_dependencies:
173
+ deps_table.add_row(dependency)
174
+
175
+ console.print(deps_table)
176
+
177
+ pypi_results = check_pypi_packages(python_dependencies)
178
+
179
+ pypi_table = Table(title="PyPI check")
180
+ pypi_table.add_column("Package", style="cyan")
181
+ pypi_table.add_column("Status", style="green")
182
+ pypi_table.add_column("Latest version", style="yellow")
183
+
184
+ for package in pypi_results:
185
+ status = "found" if package.exists else "not found"
186
+ latest_version = package.latest_version or "-"
187
+
188
+ pypi_table.add_row(package.name, status, latest_version)
189
+
190
+ console.print(pypi_table)
191
+
192
+ if analysis.pyproject_info is not None:
193
+ pyproject_info = analysis.pyproject_info
194
+
195
+ pyproject_table = Table(title="PyProject info")
196
+ pyproject_table.add_column("Field", style="cyan")
197
+ pyproject_table.add_column("Value", style="green")
198
+
199
+ pyproject_table.add_row("Project name", pyproject_info.project_name or "-")
200
+ pyproject_table.add_row(
201
+ "Dependencies",
202
+ "\n".join(pyproject_info.dependencies) or "-",
203
+ )
204
+ pyproject_table.add_row(
205
+ "Build system requires",
206
+ "\n".join(pyproject_info.build_system_requires) or "-",
207
+ )
208
+
209
+ if pyproject_info.optional_dependencies:
210
+ optional_text = "\n".join(
211
+ f"{group}: {', '.join(deps)}"
212
+ for group, deps in pyproject_info.optional_dependencies.items()
213
+ )
214
+ else:
215
+ optional_text = "-"
216
+
217
+ pyproject_table.add_row("Optional dependencies", optional_text)
218
+
219
+ console.print(pyproject_table)
220
+
221
+ if analysis.conda_environment is not None:
222
+ conda_env = analysis.conda_environment
223
+
224
+ conda_table = Table(title="Conda environment")
225
+ conda_table.add_column("Field", style="cyan")
226
+ conda_table.add_column("Value", style="green")
227
+
228
+ conda_table.add_row("Name", conda_env.name or "-")
229
+ conda_table.add_row(
230
+ "Conda packages",
231
+ "\n".join(conda_env.conda_dependencies) or "-",
232
+ )
233
+ conda_table.add_row(
234
+ "Pip packages",
235
+ "\n".join(conda_env.pip_dependencies) or "-",
236
+ )
237
+
238
+ console.print(conda_table)
239
+
240
+ if analysis.dockerfile_info is not None:
241
+ docker_info = analysis.dockerfile_info
242
+
243
+ docker_table = Table(title="Dockerfile info")
244
+ docker_table.add_column("Field", style="cyan")
245
+ docker_table.add_column("Value", style="green")
246
+
247
+ docker_table.add_row(
248
+ "Base image",
249
+ "\n".join(docker_info.base_images) or "-",
250
+ )
251
+ docker_table.add_row(
252
+ "Apt packages",
253
+ "\n".join(docker_info.apt_packages) or "-",
254
+ )
255
+ docker_table.add_row(
256
+ "Pip packages",
257
+ "\n".join(docker_info.pip_packages) or "-",
258
+ )
259
+
260
+ console.print(docker_table)
261
+
262
+ if analysis.ros_package_info is not None:
263
+ ros_info = analysis.ros_package_info
264
+
265
+ ros_table = Table(title="ROS package info")
266
+ ros_table.add_column("Field", style="cyan")
267
+ ros_table.add_column("Value", style="green")
268
+
269
+ ros_table.add_row("Package name", ros_info.name or "-")
270
+ ros_table.add_row("Build system", ros_info.build_system or "-")
271
+ ros_table.add_row(
272
+ "Build tool dependencies",
273
+ "\n".join(ros_info.build_tool_dependencies) or "-",
274
+ )
275
+ ros_table.add_row(
276
+ "Build dependencies",
277
+ "\n".join(ros_info.build_dependencies) or "-",
278
+ )
279
+ ros_table.add_row(
280
+ "Execution dependencies",
281
+ "\n".join(ros_info.execution_dependencies) or "-",
282
+ )
283
+ ros_table.add_row(
284
+ "Test dependencies",
285
+ "\n".join(ros_info.test_dependencies) or "-",
286
+ )
287
+
288
+ console.print(ros_table)
289
+
290
+ if analysis.cmake_info is not None:
291
+ cmake_info = analysis.cmake_info
292
+
293
+ cmake_table = Table(title="CMake info")
294
+ cmake_table.add_column("Field", style="cyan")
295
+ cmake_table.add_column("Value", style="green")
296
+
297
+ cmake_table.add_row("Project name", cmake_info.project_name or "-")
298
+ cmake_table.add_row("Minimum version", cmake_info.minimum_version or "-")
299
+ cmake_table.add_row(
300
+ "Found packages",
301
+ "\n".join(cmake_info.found_packages) or "-",
302
+ )
303
+
304
+ console.print(cmake_table)
305
+
306
+ install_plan = create_install_plan(analysis)
307
+
308
+ plan_table = Table(title="Install plan")
309
+ plan_table.add_column("Field", style="cyan")
310
+ plan_table.add_column("Value", style="green")
311
+
312
+ plan_table.add_row("Recommended mode", install_plan.recommended_mode)
313
+ plan_table.add_row("Reason", install_plan.reason)
314
+ plan_table.add_row("Steps", "\n".join(install_plan.steps))
315
+
316
+ console.print(plan_table)
317
+
318
+ missing_pypi_packages = [
319
+ result.name for result in pypi_results if not result.exists
320
+ ]
321
+
322
+ if "docker" in analysis.detected_ecosystems:
323
+ console.print(
324
+ "[yellow]Recommendation:[/yellow] "
325
+ "Docker-based installation may be the safest option."
326
+ )
327
+ elif "ros" in analysis.detected_ecosystems:
328
+ console.print(
329
+ "[yellow]Recommendation:[/yellow] "
330
+ "ROS workspace installation is required."
331
+ )
332
+ elif "python" in analysis.detected_ecosystems:
333
+ if missing_pypi_packages:
334
+ console.print(
335
+ "[yellow]Recommendation:[/yellow] "
336
+ "Python dependencies were found, but some packages are not "
337
+ "available on PyPI. Manual/vendor installation may be required."
338
+ )
339
+ console.print(
340
+ "[yellow]Missing from PyPI:[/yellow] "
341
+ + ", ".join(missing_pypi_packages)
342
+ )
343
+ else:
344
+ console.print(
345
+ "[yellow]Recommendation:[/yellow] "
346
+ "Python environment installation is possible."
347
+ )
348
+ else:
349
+ console.print("[yellow]Recommendation:[/yellow] Manual inspection is needed.")
350
+ warnings = generate_compatibility_warnings(analysis, system_info)
351
+
352
+ if warnings:
353
+ warning_table = Table(title="Compatibility warnings")
354
+ warning_table.add_column("Warning", style="yellow")
355
+
356
+ for warning in warnings:
357
+ warning_table.add_row(warning)
358
+
359
+ console.print(warning_table)
360
+
361
+ if report:
362
+ report_text = generate_markdown_report(
363
+ analysis=analysis,
364
+ install_plan=install_plan,
365
+ system_info=system_info,
366
+ )
367
+ report_path = write_markdown_report(report_text)
368
+ console.print(f"[bold green]Report written to:[/bold green] {report_path}")
369
+
370
+
371
+ @app.command()
372
+ def fix(repo: str) -> None:
373
+ """Fix supported dependency/environment files in place."""
374
+ repo_path = _resolve_repo_path(repo)
375
+ result = fix_repo(repo_path)
376
+
377
+ console.print("[bold green]SimFix Fix[/bold green]")
378
+ console.print(f"Repository: {Path(repo_path).resolve()}")
379
+
380
+ for message in result.messages:
381
+ console.print(f"- {message}")
382
+
383
+ if result.changed_files:
384
+ table = Table(title="Changed files")
385
+ table.add_column("File", style="cyan")
386
+
387
+ for file_path in result.changed_files:
388
+ table.add_row(str(file_path))
389
+
390
+ console.print(table)
391
+ else:
392
+ console.print("[yellow]No files changed.[/yellow]")
393
+
394
+ console.print("[yellow]Review changes with git diff before committing.[/yellow]")
395
+
396
+
397
+ @app.command()
398
+ def system() -> None:
399
+ """Show basic system diagnostics."""
400
+ info = get_system_info()
401
+
402
+ table = Table(title="System check")
403
+ table.add_column("Item", style="cyan")
404
+ table.add_column("Value", style="green")
405
+
406
+ table.add_row("OS", info.os_name)
407
+ table.add_row("OS version", info.os_version)
408
+ table.add_row("Linux distro", info.linux_distro or "-")
409
+ table.add_row("Linux version", info.linux_version or "-")
410
+ table.add_row("WSL", "yes" if info.is_wsl else "no")
411
+ table.add_row("Architecture", info.architecture)
412
+ table.add_row("Python", info.python_version)
413
+ table.add_row("Pip", "found" if info.pip_available else "not found")
414
+ table.add_row("Uv", "found" if info.uv_available else "not found")
415
+ table.add_row("Conda", "found" if info.conda_available else "not found")
416
+ table.add_row("Mamba", "found" if info.mamba_available else "not found")
417
+ table.add_row("Git", "found" if info.git_available else "not found")
418
+ table.add_row("Docker", "found" if info.docker_available else "not found")
419
+ table.add_row(
420
+ "NVIDIA GPU",
421
+ "detected" if info.nvidia_gpu_available else "not detected",
422
+ )
423
+ table.add_row("NVIDIA driver", info.nvidia_driver_version or "-")
424
+ table.add_row("NVIDIA CUDA", info.nvidia_cuda_version or "-")
425
+ table.add_row("CUDA toolkit", info.cuda_toolkit_version or "-")
426
+
427
+ console.print(table)
428
+
429
+
430
+ @app.command()
431
+ def version() -> None:
432
+ """Show SimFix version."""
433
+ console.print(f"simfix {__version__}")
simfix/cmake.py ADDED
@@ -0,0 +1,61 @@
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 CMakeInfo:
10
+ """Basic information extracted from CMakeLists.txt."""
11
+
12
+ minimum_version: str | None
13
+ project_name: str | None
14
+ found_packages: list[str]
15
+
16
+
17
+ def _remove_comments(text: str) -> str:
18
+ """Remove simple CMake line comments."""
19
+ lines: list[str] = []
20
+
21
+ for line in text.splitlines():
22
+ clean_line = line.split("#", maxsplit=1)[0]
23
+ lines.append(clean_line)
24
+
25
+ return "\n".join(lines)
26
+
27
+
28
+ def parse_cmake_file(path: str | Path) -> CMakeInfo | None:
29
+ """Parse a CMakeLists.txt file and extract basic information."""
30
+ cmake_path = Path(path).expanduser().resolve()
31
+
32
+ if not cmake_path.exists():
33
+ return None
34
+
35
+ text = _remove_comments(cmake_path.read_text(encoding="utf-8"))
36
+
37
+ minimum_version_match = re.search(
38
+ r"cmake_minimum_required\s*\(\s*VERSION\s+([^) \n]+)",
39
+ text,
40
+ flags=re.IGNORECASE,
41
+ )
42
+
43
+ project_match = re.search(
44
+ r"project\s*\(\s*([A-Za-z0-9_.-]+)",
45
+ text,
46
+ flags=re.IGNORECASE,
47
+ )
48
+
49
+ found_packages = re.findall(
50
+ r"find_package\s*\(\s*([A-Za-z0-9_.-]+)",
51
+ text,
52
+ flags=re.IGNORECASE,
53
+ )
54
+
55
+ return CMakeInfo(
56
+ minimum_version=(
57
+ minimum_version_match.group(1) if minimum_version_match else None
58
+ ),
59
+ project_name=project_match.group(1) if project_match else None,
60
+ found_packages=list(dict.fromkeys(found_packages)),
61
+ )