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