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/commands.py ADDED
@@ -0,0 +1,91 @@
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 CommandPlan:
10
+ """Suggested shell commands for installing a repository."""
11
+
12
+ title: str
13
+ commands: list[str]
14
+
15
+
16
+ def create_command_plan(analysis: RepoAnalysis) -> CommandPlan:
17
+ """Create suggested installation commands from repository analysis."""
18
+ ecosystems = analysis.detected_ecosystems
19
+ repo_name = analysis.repo_path.name.replace("_", "-").lower()
20
+
21
+ if "docker" in ecosystems:
22
+ return CommandPlan(
23
+ title="Docker installation commands",
24
+ commands=[
25
+ f"docker build -t {repo_name} .",
26
+ f"docker run --rm -it {repo_name}",
27
+ ],
28
+ )
29
+
30
+ if "conda" in ecosystems:
31
+ env_name = "simfix-env"
32
+ if analysis.conda_environment is not None and analysis.conda_environment.name:
33
+ env_name = analysis.conda_environment.name
34
+
35
+ return CommandPlan(
36
+ title="Conda installation commands",
37
+ commands=[
38
+ "conda env create -f environment.yml",
39
+ f"conda activate {env_name}",
40
+ ],
41
+ )
42
+
43
+ if "ros" in ecosystems:
44
+ return CommandPlan(
45
+ title="ROS installation commands",
46
+ commands=[
47
+ "mkdir -p ~/simfix_ws/src",
48
+ "cd ~/simfix_ws/src",
49
+ "# Clone or copy the repository into this src folder",
50
+ "cd ~/simfix_ws",
51
+ "rosdep install --from-paths src --ignore-src -r -y",
52
+ "catkin build # or: colcon build",
53
+ "source devel/setup.bash # or: source install/setup.bash",
54
+ ],
55
+ )
56
+
57
+ if "python" in ecosystems:
58
+ commands = [
59
+ "python -m venv .venv",
60
+ "source .venv/bin/activate",
61
+ ]
62
+
63
+ if analysis.has_requirements_txt:
64
+ commands.append("python -m pip install -r requirements.txt")
65
+
66
+ if analysis.has_pyproject_toml:
67
+ commands.append("python -m pip install -e .")
68
+
69
+ return CommandPlan(
70
+ title="Python installation commands",
71
+ commands=commands,
72
+ )
73
+
74
+ if "cmake/c++" in ecosystems:
75
+ return CommandPlan(
76
+ title="CMake installation commands",
77
+ commands=[
78
+ "mkdir -p build",
79
+ "cd build",
80
+ "cmake ..",
81
+ "cmake --build . -j",
82
+ ],
83
+ )
84
+
85
+ return CommandPlan(
86
+ title="Manual installation commands",
87
+ commands=[
88
+ "# No common dependency file was detected.",
89
+ "# Read the README installation section manually.",
90
+ ],
91
+ )
@@ -0,0 +1,97 @@
1
+ from __future__ import annotations
2
+
3
+ from simfix.analyzer import RepoAnalysis
4
+ from simfix.system import SystemInfo
5
+
6
+ ROS_UBUNTU_HINTS = {
7
+ "melodic": "18.04",
8
+ "noetic": "20.04",
9
+ "humble": "22.04",
10
+ "jazzy": "24.04",
11
+ }
12
+
13
+
14
+ def _detect_ros_distro_hint(analysis: RepoAnalysis) -> str | None:
15
+ """Detect possible ROS distro hints from parsed dependency names."""
16
+ if analysis.ros_package_info is None:
17
+ return None
18
+
19
+ dependencies = [
20
+ dependency.lower() for dependency in analysis.ros_package_info.all_dependencies
21
+ ]
22
+
23
+ joined_dependencies = " ".join(dependencies)
24
+
25
+ for distro in ROS_UBUNTU_HINTS:
26
+ if distro in joined_dependencies:
27
+ return distro
28
+
29
+ ros_info = analysis.ros_package_info
30
+
31
+ if ros_info.build_system == "catkin":
32
+ return "noetic"
33
+
34
+ if ros_info.build_system == "ament":
35
+ return "humble"
36
+
37
+ return None
38
+
39
+
40
+ def generate_compatibility_warnings(
41
+ analysis: RepoAnalysis,
42
+ system_info: SystemInfo,
43
+ ) -> list[str]:
44
+ """Generate compatibility warnings from repo analysis and system information."""
45
+ warnings: list[str] = []
46
+ ecosystems = analysis.detected_ecosystems
47
+
48
+ if "docker" in ecosystems and not system_info.docker_available:
49
+ warnings.append("Dockerfile detected, but Docker was not found on this system.")
50
+
51
+ if (
52
+ "conda" in ecosystems
53
+ and not system_info.conda_available
54
+ and not system_info.mamba_available
55
+ ):
56
+ warnings.append(
57
+ "Conda environment detected, but neither conda nor mamba was found."
58
+ )
59
+
60
+ if (
61
+ "python" in ecosystems
62
+ and not system_info.pip_available
63
+ and not system_info.uv_available
64
+ ):
65
+ warnings.append("Python project detected, but neither pip nor uv was found.")
66
+
67
+ if "ros" in ecosystems and system_info.os_name != "Linux":
68
+ warnings.append(
69
+ "ROS project detected, but the current system is not Linux. "
70
+ "Docker or a Linux machine may be needed."
71
+ )
72
+
73
+ ros_distro_hint = _detect_ros_distro_hint(analysis)
74
+
75
+ if (
76
+ ros_distro_hint is not None
77
+ and system_info.linux_version is not None
78
+ and ROS_UBUNTU_HINTS[ros_distro_hint] != system_info.linux_version
79
+ ):
80
+ warnings.append(
81
+ f"ROS {ros_distro_hint} project detected, which is commonly used with "
82
+ f"Ubuntu {ROS_UBUNTU_HINTS[ros_distro_hint]}, but this system appears "
83
+ f"to be Ubuntu {system_info.linux_version}."
84
+ )
85
+
86
+ if "cmake/c++" in ecosystems and system_info.os_name == "Darwin":
87
+ warnings.append(
88
+ "CMake/C++ project detected on macOS. Some simulator dependencies "
89
+ "may require Linux-specific packages."
90
+ )
91
+
92
+ if "unknown" in ecosystems:
93
+ warnings.append(
94
+ "No common dependency files were detected. Manual inspection is needed."
95
+ )
96
+
97
+ return warnings
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import yaml
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class CondaEnvironment:
12
+ """Parsed conda environment information."""
13
+
14
+ name: str | None
15
+ conda_dependencies: list[str]
16
+ pip_dependencies: list[str]
17
+
18
+
19
+ def parse_conda_environment(path: str | Path) -> CondaEnvironment | None:
20
+ """Parse a conda environment.yml/environment.yaml file."""
21
+ environment_path = Path(path).expanduser().resolve()
22
+
23
+ if not environment_path.exists():
24
+ return None
25
+
26
+ data = yaml.safe_load(environment_path.read_text(encoding="utf-8"))
27
+
28
+ if not isinstance(data, dict):
29
+ return None
30
+
31
+ name = data.get("name")
32
+ raw_dependencies = data.get("dependencies", [])
33
+
34
+ conda_dependencies: list[str] = []
35
+ pip_dependencies: list[str] = []
36
+
37
+ if not isinstance(raw_dependencies, list):
38
+ return CondaEnvironment(
39
+ name=name if isinstance(name, str) else None,
40
+ conda_dependencies=[],
41
+ pip_dependencies=[],
42
+ )
43
+
44
+ for dependency in raw_dependencies:
45
+ if isinstance(dependency, str):
46
+ conda_dependencies.append(dependency)
47
+ elif isinstance(dependency, dict):
48
+ pip_entries: Any = dependency.get("pip")
49
+ if isinstance(pip_entries, list):
50
+ pip_dependencies.extend(
51
+ item for item in pip_entries if isinstance(item, str)
52
+ )
53
+
54
+ return CondaEnvironment(
55
+ name=name if isinstance(name, str) else None,
56
+ conda_dependencies=conda_dependencies,
57
+ pip_dependencies=pip_dependencies,
58
+ )
simfix/conda_fixer.py ADDED
@@ -0,0 +1,145 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import yaml
8
+
9
+ from simfix.pypi import normalize_requirement_name
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class CondaFixResult:
14
+ """Result of fixing a conda environment file."""
15
+
16
+ file_path: Path
17
+ changed: bool
18
+ message: str
19
+
20
+
21
+ def _dependency_name(dependency: str) -> str:
22
+ """Return normalized dependency name without version constraints."""
23
+ separators = ["==", ">=", "<=", "!=", "~=", "=", ">", "<"]
24
+
25
+ for separator in separators:
26
+ if separator in dependency:
27
+ return dependency.split(separator, maxsplit=1)[0].strip().lower()
28
+
29
+ return dependency.strip().lower()
30
+
31
+
32
+ def _is_more_specific(new_dependency: str, old_dependency: str) -> bool:
33
+ """Return True if new dependency has more version information."""
34
+ version_symbols = ["==", ">=", "<=", "!=", "~=", "=", ">", "<"]
35
+
36
+ new_score = sum(symbol in new_dependency for symbol in version_symbols)
37
+ old_score = sum(symbol in old_dependency for symbol in version_symbols)
38
+
39
+ return new_score >= old_score
40
+
41
+
42
+ def _deduplicate_dependencies(dependencies: list[str]) -> list[str]:
43
+ """Remove duplicate dependencies while keeping the more specific entry."""
44
+ normalized: dict[str, str] = {}
45
+
46
+ for dependency in dependencies:
47
+ name = _dependency_name(dependency)
48
+
49
+ if name not in normalized:
50
+ normalized[name] = dependency
51
+ continue
52
+
53
+ if _is_more_specific(dependency, normalized[name]):
54
+ normalized[name] = dependency
55
+
56
+ return list(normalized.values())
57
+
58
+
59
+ def _deduplicate_pip_dependencies(dependencies: list[str]) -> list[str]:
60
+ """Remove duplicate pip dependencies while keeping the more specific entry."""
61
+ normalized: dict[str, str] = {}
62
+
63
+ for dependency in dependencies:
64
+ name = normalize_requirement_name(dependency).lower()
65
+
66
+ if name not in normalized:
67
+ normalized[name] = dependency
68
+ continue
69
+
70
+ if _is_more_specific(dependency, normalized[name]):
71
+ normalized[name] = dependency
72
+
73
+ return list(normalized.values())
74
+
75
+
76
+ def fix_conda_environment_file(repo_path: str | Path) -> CondaFixResult | None:
77
+ """Fix environment.yml or environment.yaml in place."""
78
+ path = Path(repo_path).expanduser().resolve()
79
+
80
+ environment_path = path / "environment.yml"
81
+
82
+ if not environment_path.exists():
83
+ environment_path = path / "environment.yaml"
84
+
85
+ if not environment_path.exists():
86
+ return None
87
+
88
+ old_text = environment_path.read_text(encoding="utf-8")
89
+ data: dict[str, Any] = yaml.safe_load(old_text) or {}
90
+
91
+ dependencies = data.get("dependencies")
92
+
93
+ if not isinstance(dependencies, list):
94
+ return CondaFixResult(
95
+ file_path=environment_path,
96
+ changed=False,
97
+ message="environment.yml has no valid dependencies list.",
98
+ )
99
+
100
+ conda_dependencies: list[str] = []
101
+ pip_dependency_block: dict[str, list[str]] | None = None
102
+ other_entries: list[Any] = []
103
+
104
+ for dependency in dependencies:
105
+ if isinstance(dependency, str):
106
+ conda_dependencies.append(dependency)
107
+ elif isinstance(dependency, dict) and "pip" in dependency:
108
+ pip_values = dependency.get("pip", [])
109
+
110
+ if isinstance(pip_values, list):
111
+ pip_dependency_block = {
112
+ "pip": _deduplicate_pip_dependencies(
113
+ [str(value) for value in pip_values]
114
+ )
115
+ }
116
+ else:
117
+ other_entries.append(dependency)
118
+ else:
119
+ other_entries.append(dependency)
120
+
121
+ fixed_dependencies: list[Any] = []
122
+ fixed_dependencies.extend(_deduplicate_dependencies(conda_dependencies))
123
+ fixed_dependencies.extend(other_entries)
124
+
125
+ if pip_dependency_block is not None:
126
+ fixed_dependencies.append(pip_dependency_block)
127
+
128
+ data["dependencies"] = fixed_dependencies
129
+
130
+ new_text = yaml.safe_dump(
131
+ data,
132
+ sort_keys=False,
133
+ default_flow_style=False,
134
+ )
135
+
136
+ changed = old_text != new_text
137
+
138
+ if changed:
139
+ environment_path.write_text(new_text, encoding="utf-8")
140
+
141
+ return CondaFixResult(
142
+ file_path=environment_path,
143
+ changed=changed,
144
+ message="environment.yml cleaned successfully.",
145
+ )
simfix/cuda_docker.py ADDED
@@ -0,0 +1,163 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ from simfix.system import get_system_info
7
+
8
+ GPU_KEYWORDS = {
9
+ "cuda",
10
+ "cudnn",
11
+ "cupy",
12
+ "nvidia",
13
+ "nvcc",
14
+ "torch",
15
+ "tensorflow",
16
+ "numba.cuda",
17
+ "jax[cuda",
18
+ "onnxruntime-gpu",
19
+ }
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class CudaDockerFixResult:
24
+ """Result of creating a CUDA Dockerfile."""
25
+
26
+ file_path: Path
27
+ changed: bool
28
+ message: str
29
+
30
+
31
+ def _read_text_if_exists(path: Path) -> str:
32
+ """Read text from a file if it exists."""
33
+ if not path.exists():
34
+ return ""
35
+
36
+ return path.read_text(encoding="utf-8", errors="ignore")
37
+
38
+
39
+ def _repo_has_cuda_files(repo_path: Path) -> bool:
40
+ """Return True if repository contains CUDA source files."""
41
+ cuda_suffixes = {".cu", ".cuh"}
42
+
43
+ for file_path in repo_path.rglob("*"):
44
+ if ".git" in file_path.parts:
45
+ continue
46
+
47
+ if file_path.is_file() and file_path.suffix.lower() in cuda_suffixes:
48
+ return True
49
+
50
+ return False
51
+
52
+
53
+ def detect_gpu_project(repo_path: str | Path) -> bool:
54
+ """Detect whether a repository likely needs GPU/CUDA support."""
55
+ path = Path(repo_path).expanduser().resolve()
56
+
57
+ searchable_text = "\n".join(
58
+ [
59
+ _read_text_if_exists(path / "requirements.txt"),
60
+ _read_text_if_exists(path / "pyproject.toml"),
61
+ _read_text_if_exists(path / "environment.yml"),
62
+ _read_text_if_exists(path / "environment.yaml"),
63
+ _read_text_if_exists(path / "README.md"),
64
+ _read_text_if_exists(path / "readme.md"),
65
+ _read_text_if_exists(path / "CMakeLists.txt"),
66
+ ]
67
+ ).lower()
68
+
69
+ if any(keyword in searchable_text for keyword in GPU_KEYWORDS):
70
+ return True
71
+
72
+ return _repo_has_cuda_files(path)
73
+
74
+
75
+ def _gpu_status_message() -> str:
76
+ """Return host GPU/driver status message."""
77
+ system_info = get_system_info()
78
+
79
+ if system_info.nvidia_gpu_available:
80
+ return (
81
+ " NVIDIA GPU detected"
82
+ f" with driver {system_info.nvidia_driver_version or 'unknown'}"
83
+ f" and CUDA {system_info.nvidia_cuda_version or 'unknown'}."
84
+ )
85
+
86
+ return (
87
+ " No NVIDIA GPU/driver was detected on this machine. "
88
+ "GPU containers will need a working NVIDIA driver and "
89
+ "NVIDIA Container Toolkit on the host."
90
+ )
91
+
92
+
93
+ def _cuda_dockerfile(has_requirements: bool) -> str:
94
+ """Return a CUDA Dockerfile."""
95
+ install_requirements = ""
96
+
97
+ if has_requirements:
98
+ install_requirements = """
99
+
100
+ COPY requirements.txt /workspace/requirements.txt
101
+ RUN python3 -m pip install --upgrade pip && \\
102
+ python3 -m pip install -r /workspace/requirements.txt
103
+ """
104
+
105
+ return f"""FROM nvidia/cuda:12.4.1-cudnn-devel-ubuntu22.04
106
+
107
+ SHELL ["/bin/bash", "-c"]
108
+
109
+ ENV DEBIAN_FRONTEND=noninteractive
110
+
111
+ RUN apt-get update && apt-get install -y \\
112
+ build-essential \\
113
+ cmake \\
114
+ git \\
115
+ python3 \\
116
+ python3-pip \\
117
+ python3-venv \\
118
+ libgl1 \\
119
+ libglib2.0-0 \\
120
+ && rm -rf /var/lib/apt/lists/*
121
+
122
+ WORKDIR /workspace
123
+ {install_requirements}
124
+ COPY . /workspace
125
+
126
+ CMD ["/bin/bash"]
127
+ """
128
+
129
+
130
+ def create_cuda_dockerfile(repo_path: str | Path) -> CudaDockerFixResult | None:
131
+ """Create a CUDA Dockerfile for GPU projects.
132
+
133
+ This creates Dockerfile only when GPU/CUDA clues exist and Dockerfile is missing.
134
+ """
135
+ path = Path(repo_path).expanduser().resolve()
136
+
137
+ if not detect_gpu_project(path):
138
+ return None
139
+
140
+ dockerfile_path = path / "Dockerfile"
141
+
142
+ if dockerfile_path.exists():
143
+ return CudaDockerFixResult(
144
+ file_path=dockerfile_path,
145
+ changed=False,
146
+ message=(
147
+ "Dockerfile already exists. SimFix did not overwrite it."
148
+ + _gpu_status_message()
149
+ ),
150
+ )
151
+
152
+ has_requirements = (path / "requirements.txt").exists()
153
+
154
+ dockerfile_path.write_text(
155
+ _cuda_dockerfile(has_requirements=has_requirements),
156
+ encoding="utf-8",
157
+ )
158
+
159
+ return CudaDockerFixResult(
160
+ file_path=dockerfile_path,
161
+ changed=True,
162
+ message="Created CUDA Dockerfile for GPU project." + _gpu_status_message(),
163
+ )
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ from simfix.cuda_docker import detect_gpu_project
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class DockerRunFixResult:
11
+ """Result of creating a Docker run helper script."""
12
+
13
+ file_path: Path
14
+ changed: bool
15
+ message: str
16
+
17
+
18
+ def _docker_run_script(image_name: str, use_gpu: bool) -> str:
19
+ """Return a Docker build/run helper script."""
20
+ gpu_flag = " --gpus all" if use_gpu else ""
21
+
22
+ return f"""#!/usr/bin/env bash
23
+ set -e
24
+
25
+ IMAGE_NAME="{image_name}"
26
+
27
+ docker build -t "$IMAGE_NAME" .
28
+
29
+ docker run --rm -it{gpu_flag} \\
30
+ -v "$PWD:/workspace" \\
31
+ -w /workspace \\
32
+ "$IMAGE_NAME"
33
+ """
34
+
35
+
36
+ def create_docker_run_helper(repo_path: str | Path) -> DockerRunFixResult | None:
37
+ """Create a Docker build/run helper script when Dockerfile exists."""
38
+ path = Path(repo_path).expanduser().resolve()
39
+ dockerfile_path = path / "Dockerfile"
40
+
41
+ if not dockerfile_path.exists():
42
+ return None
43
+
44
+ script_path = path / "run_simfix_docker.sh"
45
+
46
+ if script_path.exists():
47
+ return DockerRunFixResult(
48
+ file_path=script_path,
49
+ changed=False,
50
+ message="Docker run helper already exists. SimFix did not overwrite it.",
51
+ )
52
+
53
+ image_name = f"simfix-{path.name}".lower().replace("_", "-")
54
+ use_gpu = detect_gpu_project(path)
55
+
56
+ script_path.write_text(
57
+ _docker_run_script(image_name=image_name, use_gpu=use_gpu),
58
+ encoding="utf-8",
59
+ )
60
+ script_path.chmod(0o755)
61
+
62
+ if use_gpu:
63
+ message = "Created Docker run helper with GPU support."
64
+ else:
65
+ message = "Created Docker run helper."
66
+
67
+ return DockerRunFixResult(
68
+ file_path=script_path,
69
+ changed=True,
70
+ message=message,
71
+ )