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/pypi.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class PyPIPackageInfo:
|
|
11
|
+
"""Basic package information from PyPI."""
|
|
12
|
+
|
|
13
|
+
name: str
|
|
14
|
+
exists: bool
|
|
15
|
+
latest_version: str | None = None
|
|
16
|
+
error: str | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def normalize_requirement_name(requirement: str) -> str:
|
|
20
|
+
"""Extract package name from a requirement string.
|
|
21
|
+
|
|
22
|
+
Examples:
|
|
23
|
+
numpy>=1.26 -> numpy
|
|
24
|
+
matplotlib==3.8.0 -> matplotlib
|
|
25
|
+
scipy[dev]>=1.11 -> scipy
|
|
26
|
+
"""
|
|
27
|
+
requirement = requirement.strip()
|
|
28
|
+
|
|
29
|
+
match = re.match(r"^[A-Za-z0-9_.-]+", requirement)
|
|
30
|
+
|
|
31
|
+
if match is None:
|
|
32
|
+
return requirement
|
|
33
|
+
|
|
34
|
+
name = match.group(0)
|
|
35
|
+
|
|
36
|
+
if "[" in name:
|
|
37
|
+
name = name.split("[", maxsplit=1)[0]
|
|
38
|
+
|
|
39
|
+
return name
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def check_pypi_package(package_name: str, timeout: float = 5.0) -> PyPIPackageInfo:
|
|
43
|
+
"""Check whether a package exists on PyPI."""
|
|
44
|
+
url = f"https://pypi.org/pypi/{package_name}/json"
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
response = requests.get(url, timeout=timeout)
|
|
48
|
+
except requests.RequestException as exc:
|
|
49
|
+
return PyPIPackageInfo(
|
|
50
|
+
name=package_name,
|
|
51
|
+
exists=False,
|
|
52
|
+
error=str(exc),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
if response.status_code == 404:
|
|
56
|
+
return PyPIPackageInfo(name=package_name, exists=False)
|
|
57
|
+
|
|
58
|
+
if response.status_code != 200:
|
|
59
|
+
return PyPIPackageInfo(
|
|
60
|
+
name=package_name,
|
|
61
|
+
exists=False,
|
|
62
|
+
error=f"HTTP {response.status_code}",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
data = response.json()
|
|
66
|
+
latest_version = data.get("info", {}).get("version")
|
|
67
|
+
|
|
68
|
+
return PyPIPackageInfo(
|
|
69
|
+
name=package_name,
|
|
70
|
+
exists=True,
|
|
71
|
+
latest_version=latest_version,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def check_pypi_packages(requirements: list[str]) -> list[PyPIPackageInfo]:
|
|
76
|
+
"""Check multiple requirement strings against PyPI."""
|
|
77
|
+
results: list[PyPIPackageInfo] = []
|
|
78
|
+
|
|
79
|
+
for requirement in requirements:
|
|
80
|
+
package_name = normalize_requirement_name(requirement)
|
|
81
|
+
results.append(check_pypi_package(package_name))
|
|
82
|
+
|
|
83
|
+
return results
|
simfix/pyproject.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
import tomllib
|
|
9
|
+
except ModuleNotFoundError: # pragma: no cover
|
|
10
|
+
import tomli as tomllib
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class PyProjectInfo:
|
|
15
|
+
"""Basic information extracted from pyproject.toml."""
|
|
16
|
+
|
|
17
|
+
project_name: str | None
|
|
18
|
+
dependencies: list[str]
|
|
19
|
+
optional_dependencies: dict[str, list[str]]
|
|
20
|
+
build_system_requires: list[str]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _as_string_list(value: Any) -> list[str]:
|
|
24
|
+
if not isinstance(value, list):
|
|
25
|
+
return []
|
|
26
|
+
|
|
27
|
+
return [item for item in value if isinstance(item, str)]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse_pyproject(path: str | Path) -> PyProjectInfo | None:
|
|
31
|
+
"""Parse pyproject.toml and extract common dependency fields."""
|
|
32
|
+
pyproject_path = Path(path).expanduser().resolve()
|
|
33
|
+
|
|
34
|
+
if not pyproject_path.exists():
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
data = tomllib.loads(pyproject_path.read_text(encoding="utf-8"))
|
|
38
|
+
|
|
39
|
+
project = data.get("project", {})
|
|
40
|
+
build_system = data.get("build-system", {})
|
|
41
|
+
tool = data.get("tool", {})
|
|
42
|
+
poetry = tool.get("poetry", {}) if isinstance(tool, dict) else {}
|
|
43
|
+
|
|
44
|
+
project_name = None
|
|
45
|
+
dependencies: list[str] = []
|
|
46
|
+
optional_dependencies: dict[str, list[str]] = {}
|
|
47
|
+
build_system_requires: list[str] = []
|
|
48
|
+
|
|
49
|
+
if isinstance(project, dict):
|
|
50
|
+
name = project.get("name")
|
|
51
|
+
if isinstance(name, str):
|
|
52
|
+
project_name = name
|
|
53
|
+
|
|
54
|
+
dependencies.extend(_as_string_list(project.get("dependencies")))
|
|
55
|
+
|
|
56
|
+
raw_optional = project.get("optional-dependencies", {})
|
|
57
|
+
if isinstance(raw_optional, dict):
|
|
58
|
+
for group_name, group_deps in raw_optional.items():
|
|
59
|
+
if isinstance(group_name, str):
|
|
60
|
+
optional_dependencies[group_name] = _as_string_list(group_deps)
|
|
61
|
+
|
|
62
|
+
if isinstance(build_system, dict):
|
|
63
|
+
build_system_requires = _as_string_list(build_system.get("requires"))
|
|
64
|
+
|
|
65
|
+
if isinstance(poetry, dict):
|
|
66
|
+
poetry_name = poetry.get("name")
|
|
67
|
+
if project_name is None and isinstance(poetry_name, str):
|
|
68
|
+
project_name = poetry_name
|
|
69
|
+
|
|
70
|
+
poetry_dependencies = poetry.get("dependencies", {})
|
|
71
|
+
if isinstance(poetry_dependencies, dict):
|
|
72
|
+
for name, requirement in poetry_dependencies.items():
|
|
73
|
+
if name == "python":
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
if isinstance(requirement, str):
|
|
77
|
+
dependencies.append(f"{name}{requirement}")
|
|
78
|
+
else:
|
|
79
|
+
dependencies.append(str(name))
|
|
80
|
+
|
|
81
|
+
poetry_groups = poetry.get("group", {})
|
|
82
|
+
if isinstance(poetry_groups, dict):
|
|
83
|
+
for group_name, group_data in poetry_groups.items():
|
|
84
|
+
if not isinstance(group_name, str) or not isinstance(group_data, dict):
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
group_dependencies = group_data.get("dependencies", {})
|
|
88
|
+
if not isinstance(group_dependencies, dict):
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
optional_dependencies[group_name] = [
|
|
92
|
+
f"{name}{requirement}"
|
|
93
|
+
if isinstance(requirement, str)
|
|
94
|
+
else str(name)
|
|
95
|
+
for name, requirement in group_dependencies.items()
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
return PyProjectInfo(
|
|
99
|
+
project_name=project_name,
|
|
100
|
+
dependencies=list(dict.fromkeys(dependencies)),
|
|
101
|
+
optional_dependencies=optional_dependencies,
|
|
102
|
+
build_system_requires=build_system_requires,
|
|
103
|
+
)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def parse_requirements_file(path: str | Path) -> list[str]:
|
|
7
|
+
"""Parse a requirements.txt file and return dependency lines.
|
|
8
|
+
|
|
9
|
+
This parser keeps version specifiers but ignores comments, empty lines,
|
|
10
|
+
editable installs, recursive requirement files, and pip options.
|
|
11
|
+
"""
|
|
12
|
+
requirements_path = Path(path).expanduser().resolve()
|
|
13
|
+
|
|
14
|
+
if not requirements_path.exists():
|
|
15
|
+
return []
|
|
16
|
+
|
|
17
|
+
dependencies: list[str] = []
|
|
18
|
+
|
|
19
|
+
for line in requirements_path.read_text(encoding="utf-8").splitlines():
|
|
20
|
+
clean_line = line.strip()
|
|
21
|
+
|
|
22
|
+
if not clean_line:
|
|
23
|
+
continue
|
|
24
|
+
|
|
25
|
+
if clean_line.startswith("#"):
|
|
26
|
+
continue
|
|
27
|
+
|
|
28
|
+
if clean_line.startswith(("-r", "--requirement")):
|
|
29
|
+
continue
|
|
30
|
+
|
|
31
|
+
if clean_line.startswith(("-e", "--editable")):
|
|
32
|
+
continue
|
|
33
|
+
|
|
34
|
+
if clean_line.startswith(("-", "--")):
|
|
35
|
+
continue
|
|
36
|
+
|
|
37
|
+
if " #" in clean_line:
|
|
38
|
+
clean_line = clean_line.split(" #", maxsplit=1)[0].strip()
|
|
39
|
+
|
|
40
|
+
dependencies.append(clean_line)
|
|
41
|
+
|
|
42
|
+
return dependencies
|
simfix/repo.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def is_git_url(value: str) -> bool:
|
|
10
|
+
"""Return True if the value looks like a GitHub/GitLab repository URL."""
|
|
11
|
+
parsed = urlparse(value)
|
|
12
|
+
|
|
13
|
+
return parsed.scheme in {"http", "https", "git"} and parsed.netloc != ""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def repo_name_from_url(repo_url: str) -> str:
|
|
17
|
+
"""Extract a clean repository name from a Git URL."""
|
|
18
|
+
path = urlparse(repo_url).path.rstrip("/")
|
|
19
|
+
name = Path(path).name
|
|
20
|
+
|
|
21
|
+
if name.endswith(".git"):
|
|
22
|
+
name = name.removesuffix(".git")
|
|
23
|
+
|
|
24
|
+
if not name:
|
|
25
|
+
raise ValueError(f"Could not determine repository name from URL: {repo_url}")
|
|
26
|
+
|
|
27
|
+
return name
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def clone_repo(repo_url: str, workspace: str | Path = "workspace") -> Path:
|
|
31
|
+
"""Clone a repository into the workspace folder and return its local path."""
|
|
32
|
+
workspace_path = Path(workspace).expanduser().resolve()
|
|
33
|
+
workspace_path.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
|
|
35
|
+
repo_name = repo_name_from_url(repo_url)
|
|
36
|
+
destination = workspace_path / repo_name
|
|
37
|
+
|
|
38
|
+
if destination.exists():
|
|
39
|
+
shutil.rmtree(destination)
|
|
40
|
+
|
|
41
|
+
subprocess.run(
|
|
42
|
+
["git", "clone", "--depth", "1", repo_url, str(destination)],
|
|
43
|
+
check=True,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
return destination
|
simfix/report.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from simfix.analyzer import RepoAnalysis
|
|
6
|
+
from simfix.compatibility import generate_compatibility_warnings
|
|
7
|
+
from simfix.planner import InstallPlan
|
|
8
|
+
from simfix.system import SystemInfo
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _yes_no(value: bool) -> str:
|
|
12
|
+
return "yes" if value else "no"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def generate_markdown_report(
|
|
16
|
+
analysis: RepoAnalysis,
|
|
17
|
+
install_plan: InstallPlan,
|
|
18
|
+
system_info: SystemInfo,
|
|
19
|
+
) -> str:
|
|
20
|
+
"""Generate a Markdown report from SimFix analysis results."""
|
|
21
|
+
warnings = generate_compatibility_warnings(analysis, system_info)
|
|
22
|
+
|
|
23
|
+
lines: list[str] = [
|
|
24
|
+
"# SimFix Report",
|
|
25
|
+
"",
|
|
26
|
+
"## Repository",
|
|
27
|
+
"",
|
|
28
|
+
f"- Path: `{analysis.repo_path}`",
|
|
29
|
+
f"- Detected ecosystem(s): `{', '.join(analysis.detected_ecosystems)}`",
|
|
30
|
+
"",
|
|
31
|
+
"## Detected dependency files",
|
|
32
|
+
"",
|
|
33
|
+
f"- `requirements.txt`: {_yes_no(analysis.has_requirements_txt)}",
|
|
34
|
+
f"- `pyproject.toml`: {_yes_no(analysis.has_pyproject_toml)}",
|
|
35
|
+
f"- `environment.yml`: {_yes_no(analysis.has_environment_yml)}",
|
|
36
|
+
f"- `Dockerfile`: {_yes_no(analysis.has_dockerfile)}",
|
|
37
|
+
f"- `package.xml`: {_yes_no(analysis.has_package_xml)}",
|
|
38
|
+
f"- `CMakeLists.txt`: {_yes_no(analysis.has_cmake)}",
|
|
39
|
+
f"- `setup.py`: {_yes_no(analysis.has_setup_py)}",
|
|
40
|
+
"",
|
|
41
|
+
"## System",
|
|
42
|
+
"",
|
|
43
|
+
f"- OS: `{system_info.os_name}`",
|
|
44
|
+
f"- OS version: `{system_info.os_version}`",
|
|
45
|
+
f"- Linux distro: `{system_info.linux_distro or '-'}`",
|
|
46
|
+
f"- Linux version: `{system_info.linux_version or '-'}`",
|
|
47
|
+
f"- WSL: `{_yes_no(system_info.is_wsl)}`",
|
|
48
|
+
f"- Architecture: `{system_info.architecture}`",
|
|
49
|
+
f"- Python: `{system_info.python_version}`",
|
|
50
|
+
f"- Git: `{_yes_no(system_info.git_available)}`",
|
|
51
|
+
f"- Docker: `{_yes_no(system_info.docker_available)}`",
|
|
52
|
+
f"- NVIDIA GPU: `{_yes_no(system_info.nvidia_gpu_available)}`",
|
|
53
|
+
f"- NVIDIA driver: `{system_info.nvidia_driver_version or '-'}`",
|
|
54
|
+
f"- NVIDIA CUDA: `{system_info.nvidia_cuda_version or '-'}`",
|
|
55
|
+
f"- CUDA toolkit: `{system_info.cuda_toolkit_version or '-'}`",
|
|
56
|
+
f"- Pip: `{_yes_no(system_info.pip_available)}`",
|
|
57
|
+
f"- Uv: `{_yes_no(system_info.uv_available)}`",
|
|
58
|
+
f"- Conda: `{_yes_no(system_info.conda_available)}`",
|
|
59
|
+
f"- Mamba: `{_yes_no(system_info.mamba_available)}`",
|
|
60
|
+
"",
|
|
61
|
+
"## Install plan",
|
|
62
|
+
"",
|
|
63
|
+
f"- Recommended mode: `{install_plan.recommended_mode}`",
|
|
64
|
+
f"- Reason: {install_plan.reason}",
|
|
65
|
+
"",
|
|
66
|
+
"### Steps",
|
|
67
|
+
"",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
lines.extend(f"{index}. {step}" for index, step in enumerate(install_plan.steps, 1))
|
|
71
|
+
|
|
72
|
+
python_dependencies = analysis.all_python_dependencies
|
|
73
|
+
|
|
74
|
+
if python_dependencies:
|
|
75
|
+
lines.extend(
|
|
76
|
+
[
|
|
77
|
+
"",
|
|
78
|
+
"## Python requirements",
|
|
79
|
+
"",
|
|
80
|
+
]
|
|
81
|
+
)
|
|
82
|
+
lines.extend(f"- `{dependency}`" for dependency in python_dependencies)
|
|
83
|
+
|
|
84
|
+
if analysis.pyproject_info is not None:
|
|
85
|
+
pyproject_info = analysis.pyproject_info
|
|
86
|
+
lines.extend(
|
|
87
|
+
[
|
|
88
|
+
"",
|
|
89
|
+
"## PyProject",
|
|
90
|
+
"",
|
|
91
|
+
f"- Project name: `{pyproject_info.project_name or '-'}`",
|
|
92
|
+
"",
|
|
93
|
+
"### Dependencies",
|
|
94
|
+
"",
|
|
95
|
+
]
|
|
96
|
+
)
|
|
97
|
+
lines.extend(f"- `{dependency}`" for dependency in python_dependencies)
|
|
98
|
+
|
|
99
|
+
lines.extend(["", "### Build system requires", ""])
|
|
100
|
+
lines.extend(
|
|
101
|
+
f"- `{dependency}`" for dependency in pyproject_info.build_system_requires
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
if pyproject_info.optional_dependencies:
|
|
105
|
+
lines.extend(["", "### Optional dependencies", ""])
|
|
106
|
+
for group, dependencies in pyproject_info.optional_dependencies.items():
|
|
107
|
+
lines.append(f"- `{group}`: {', '.join(dependencies)}")
|
|
108
|
+
|
|
109
|
+
if analysis.conda_environment is not None:
|
|
110
|
+
conda_env = analysis.conda_environment
|
|
111
|
+
lines.extend(
|
|
112
|
+
[
|
|
113
|
+
"",
|
|
114
|
+
"## Conda environment",
|
|
115
|
+
"",
|
|
116
|
+
f"- Name: `{conda_env.name or '-'}`",
|
|
117
|
+
"",
|
|
118
|
+
"### Conda packages",
|
|
119
|
+
"",
|
|
120
|
+
]
|
|
121
|
+
)
|
|
122
|
+
lines.extend(f"- `{dependency}`" for dependency in conda_env.conda_dependencies)
|
|
123
|
+
lines.extend(["", "### Pip packages", ""])
|
|
124
|
+
lines.extend(f"- `{dependency}`" for dependency in conda_env.pip_dependencies)
|
|
125
|
+
|
|
126
|
+
if analysis.dockerfile_info is not None:
|
|
127
|
+
docker_info = analysis.dockerfile_info
|
|
128
|
+
lines.extend(
|
|
129
|
+
[
|
|
130
|
+
"",
|
|
131
|
+
"## Dockerfile",
|
|
132
|
+
"",
|
|
133
|
+
"### Base images",
|
|
134
|
+
"",
|
|
135
|
+
]
|
|
136
|
+
)
|
|
137
|
+
lines.extend(f"- `{image}`" for image in docker_info.base_images)
|
|
138
|
+
lines.extend(["", "### Apt packages", ""])
|
|
139
|
+
lines.extend(f"- `{package}`" for package in docker_info.apt_packages)
|
|
140
|
+
lines.extend(["", "### Pip packages", ""])
|
|
141
|
+
lines.extend(f"- `{package}`" for package in docker_info.pip_packages)
|
|
142
|
+
|
|
143
|
+
if analysis.ros_package_info is not None:
|
|
144
|
+
ros_info = analysis.ros_package_info
|
|
145
|
+
lines.extend(
|
|
146
|
+
[
|
|
147
|
+
"",
|
|
148
|
+
"## ROS package",
|
|
149
|
+
"",
|
|
150
|
+
f"- Name: `{ros_info.name or '-'}`",
|
|
151
|
+
f"- Build system: `{ros_info.build_system or '-'}`",
|
|
152
|
+
"",
|
|
153
|
+
"### Dependencies",
|
|
154
|
+
"",
|
|
155
|
+
]
|
|
156
|
+
)
|
|
157
|
+
lines.extend(f"- `{dependency}`" for dependency in ros_info.all_dependencies)
|
|
158
|
+
|
|
159
|
+
if analysis.cmake_info is not None:
|
|
160
|
+
cmake_info = analysis.cmake_info
|
|
161
|
+
lines.extend(
|
|
162
|
+
[
|
|
163
|
+
"",
|
|
164
|
+
"## CMake",
|
|
165
|
+
"",
|
|
166
|
+
f"- Project name: `{cmake_info.project_name or '-'}`",
|
|
167
|
+
f"- Minimum version: `{cmake_info.minimum_version or '-'}`",
|
|
168
|
+
"",
|
|
169
|
+
"### Found packages",
|
|
170
|
+
"",
|
|
171
|
+
]
|
|
172
|
+
)
|
|
173
|
+
lines.extend(f"- `{package}`" for package in cmake_info.found_packages)
|
|
174
|
+
|
|
175
|
+
if warnings:
|
|
176
|
+
lines.extend(["", "## Compatibility warnings", ""])
|
|
177
|
+
lines.extend(f"- {warning}" for warning in warnings)
|
|
178
|
+
|
|
179
|
+
lines.append("")
|
|
180
|
+
|
|
181
|
+
return "\n".join(lines)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def write_markdown_report(
|
|
185
|
+
report_text: str,
|
|
186
|
+
output_path: str | Path = "simfix_report.md",
|
|
187
|
+
) -> Path:
|
|
188
|
+
"""Write a Markdown report to disk."""
|
|
189
|
+
path = Path(output_path).expanduser().resolve()
|
|
190
|
+
path.write_text(report_text, encoding="utf-8")
|
|
191
|
+
return path
|
simfix/ros_docker.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from simfix.analyzer import analyze_repo
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class RosDockerFixResult:
|
|
11
|
+
"""Result of creating a ROS Dockerfile."""
|
|
12
|
+
|
|
13
|
+
file_path: Path
|
|
14
|
+
changed: bool
|
|
15
|
+
message: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _ros1_dockerfile() -> str:
|
|
19
|
+
"""Return a Dockerfile for ROS 1 catkin projects."""
|
|
20
|
+
return """FROM osrf/ros:noetic-desktop-full
|
|
21
|
+
|
|
22
|
+
SHELL ["/bin/bash", "-c"]
|
|
23
|
+
|
|
24
|
+
RUN apt-get update && apt-get install -y \\
|
|
25
|
+
python3-pip \\
|
|
26
|
+
python3-rosdep \\
|
|
27
|
+
python3-catkin-tools \\
|
|
28
|
+
build-essential \\
|
|
29
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
30
|
+
|
|
31
|
+
WORKDIR /workspace
|
|
32
|
+
|
|
33
|
+
COPY . /workspace/src/simfix_project
|
|
34
|
+
|
|
35
|
+
RUN rosdep update || true
|
|
36
|
+
RUN rosdep install --from-paths /workspace/src --ignore-src -r -y || true
|
|
37
|
+
|
|
38
|
+
WORKDIR /workspace
|
|
39
|
+
|
|
40
|
+
RUN source /opt/ros/noetic/setup.bash && \\
|
|
41
|
+
catkin build
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _ros2_dockerfile() -> str:
|
|
46
|
+
"""Return a Dockerfile for ROS 2 ament projects."""
|
|
47
|
+
return """FROM osrf/ros:humble-desktop
|
|
48
|
+
|
|
49
|
+
SHELL ["/bin/bash", "-c"]
|
|
50
|
+
|
|
51
|
+
RUN apt-get update && apt-get install -y \\
|
|
52
|
+
python3-pip \\
|
|
53
|
+
python3-rosdep \\
|
|
54
|
+
python3-colcon-common-extensions \\
|
|
55
|
+
build-essential \\
|
|
56
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
57
|
+
|
|
58
|
+
WORKDIR /workspace
|
|
59
|
+
|
|
60
|
+
COPY . /workspace/src/simfix_project
|
|
61
|
+
|
|
62
|
+
RUN rosdep update || true
|
|
63
|
+
RUN rosdep install --from-paths /workspace/src --ignore-src -r -y || true
|
|
64
|
+
|
|
65
|
+
WORKDIR /workspace
|
|
66
|
+
|
|
67
|
+
RUN source /opt/ros/humble/setup.bash && \\
|
|
68
|
+
colcon build
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def create_ros_dockerfile(repo_path: str | Path) -> RosDockerFixResult | None:
|
|
73
|
+
"""Create a Dockerfile for ROS projects.
|
|
74
|
+
|
|
75
|
+
This creates Dockerfile only when package.xml exists and Dockerfile does not.
|
|
76
|
+
"""
|
|
77
|
+
path = Path(repo_path).expanduser().resolve()
|
|
78
|
+
analysis = analyze_repo(path)
|
|
79
|
+
|
|
80
|
+
if not analysis.has_package_xml:
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
dockerfile_path = path / "Dockerfile"
|
|
84
|
+
|
|
85
|
+
if dockerfile_path.exists():
|
|
86
|
+
return RosDockerFixResult(
|
|
87
|
+
file_path=dockerfile_path,
|
|
88
|
+
changed=False,
|
|
89
|
+
message="Dockerfile already exists. SimFix did not overwrite it.",
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
build_system = None
|
|
93
|
+
|
|
94
|
+
if analysis.ros_package_info is not None:
|
|
95
|
+
build_system = analysis.ros_package_info.build_system
|
|
96
|
+
|
|
97
|
+
if build_system == "ament":
|
|
98
|
+
dockerfile_text = _ros2_dockerfile()
|
|
99
|
+
message = "Created ROS 2 Humble Dockerfile."
|
|
100
|
+
else:
|
|
101
|
+
dockerfile_text = _ros1_dockerfile()
|
|
102
|
+
message = "Created ROS 1 Noetic Dockerfile."
|
|
103
|
+
|
|
104
|
+
dockerfile_path.write_text(dockerfile_text, encoding="utf-8")
|
|
105
|
+
|
|
106
|
+
return RosDockerFixResult(
|
|
107
|
+
file_path=dockerfile_path,
|
|
108
|
+
changed=True,
|
|
109
|
+
message=message,
|
|
110
|
+
)
|
simfix/ros_package.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import xml.etree.ElementTree as ET
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _detect_build_system(build_tool_dependencies: list[str]) -> str | None:
|
|
9
|
+
"""Detect ROS build system from build tool dependencies."""
|
|
10
|
+
dependency_set = set(build_tool_dependencies)
|
|
11
|
+
|
|
12
|
+
if "catkin" in dependency_set:
|
|
13
|
+
return "catkin"
|
|
14
|
+
|
|
15
|
+
if "ament_cmake" in dependency_set or "ament_python" in dependency_set:
|
|
16
|
+
return "ament"
|
|
17
|
+
|
|
18
|
+
return None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class ROSPackageInfo:
|
|
23
|
+
"""Basic information extracted from a ROS package.xml file."""
|
|
24
|
+
|
|
25
|
+
name: str | None
|
|
26
|
+
build_system: str | None
|
|
27
|
+
build_tool_dependencies: list[str]
|
|
28
|
+
build_dependencies: list[str]
|
|
29
|
+
execution_dependencies: list[str]
|
|
30
|
+
test_dependencies: list[str]
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def all_dependencies(self) -> list[str]:
|
|
34
|
+
"""Return all dependency names without duplicates."""
|
|
35
|
+
dependencies = (
|
|
36
|
+
self.build_tool_dependencies
|
|
37
|
+
+ self.build_dependencies
|
|
38
|
+
+ self.execution_dependencies
|
|
39
|
+
+ self.test_dependencies
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
return list(dict.fromkeys(dependencies))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _find_text(root: ET.Element, tag: str) -> str | None:
|
|
46
|
+
element = root.find(tag)
|
|
47
|
+
|
|
48
|
+
if element is None or element.text is None:
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
return element.text.strip()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _find_all_text(root: ET.Element, tags: list[str]) -> list[str]:
|
|
55
|
+
values: list[str] = []
|
|
56
|
+
|
|
57
|
+
for tag in tags:
|
|
58
|
+
for element in root.findall(tag):
|
|
59
|
+
if element.text is not None:
|
|
60
|
+
values.append(element.text.strip())
|
|
61
|
+
|
|
62
|
+
return values
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def parse_ros_package(path: str | Path) -> ROSPackageInfo | None:
|
|
66
|
+
"""Parse a ROS package.xml file."""
|
|
67
|
+
package_path = Path(path).expanduser().resolve()
|
|
68
|
+
|
|
69
|
+
if not package_path.exists():
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
tree = ET.parse(package_path)
|
|
74
|
+
except ET.ParseError:
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
root = tree.getroot()
|
|
78
|
+
|
|
79
|
+
build_tool_dependencies = _find_all_text(root, ["buildtool_depend"])
|
|
80
|
+
|
|
81
|
+
return ROSPackageInfo(
|
|
82
|
+
name=_find_text(root, "name"),
|
|
83
|
+
build_system=_detect_build_system(build_tool_dependencies),
|
|
84
|
+
build_tool_dependencies=build_tool_dependencies,
|
|
85
|
+
build_dependencies=_find_all_text(
|
|
86
|
+
root,
|
|
87
|
+
["build_depend", "depend"],
|
|
88
|
+
),
|
|
89
|
+
execution_dependencies=_find_all_text(
|
|
90
|
+
root,
|
|
91
|
+
["exec_depend", "run_depend", "depend"],
|
|
92
|
+
),
|
|
93
|
+
test_dependencies=_find_all_text(root, ["test_depend"]),
|
|
94
|
+
)
|