pixi-ros 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.
pixi_ros/mappings.py ADDED
@@ -0,0 +1,298 @@
1
+ """Mapping between ROS package names and conda package names."""
2
+
3
+ from functools import lru_cache
4
+ from pathlib import Path
5
+
6
+ import yaml
7
+ from rattler import Platform
8
+
9
+
10
+ def _detect_platform() -> str:
11
+ """
12
+ Detect the current platform using rattler.
13
+
14
+ Returns:
15
+ Platform name: 'linux', 'osx', 'win64', etc.
16
+ """
17
+ current_platform = Platform.current()
18
+ platform_str = str(current_platform)
19
+
20
+ # Map rattler platform names to ROS platform names
21
+ if platform_str.startswith("osx"):
22
+ return "osx"
23
+ elif platform_str.startswith("win"):
24
+ return "win64"
25
+ elif platform_str.startswith("linux"):
26
+ return "linux"
27
+ else:
28
+ # Default to linux for unknown platforms
29
+ return "linux"
30
+
31
+
32
+ @lru_cache(maxsize=1)
33
+ def _load_channel_mappings() -> dict[str, dict[str, list[str] | dict[str, list[str]]]]:
34
+ """
35
+ Load ROS to conda package mappings from channel.yaml files.
36
+
37
+ The new format is:
38
+ ```yaml
39
+ package_name:
40
+ channel_name:
41
+ - simple_mapping
42
+ other_channel:
43
+ linux: [pkg1, pkg2]
44
+ osx: [pkg3]
45
+ win64: []
46
+ ```
47
+
48
+ Searches for *.yaml files in the following order:
49
+ 1. Current workspace directory (./pixi-ros/*.yaml)
50
+ 2. User config directory (~/.pixi-ros/*.yaml)
51
+ 3. Built-in defaults (packaged with pixi-ros)
52
+
53
+ Returns:
54
+ Dictionary mapping ROS package names to channel/platform mappings
55
+ """
56
+ mappings: dict[str, dict[str, list[str] | dict[str, list[str]]]] = {}
57
+
58
+ # Search locations in priority order
59
+ search_dirs = [
60
+ Path.cwd() / "pixi-ros",
61
+ Path.home() / ".pixi-ros",
62
+ Path(__file__).parent / "data",
63
+ ]
64
+
65
+ for search_dir in search_dirs:
66
+ if not search_dir.exists():
67
+ continue
68
+
69
+ # Load all .yaml files in the directory
70
+ for yaml_file in search_dir.glob("*.yaml"):
71
+ try:
72
+ with open(yaml_file) as f:
73
+ data = yaml.safe_load(f)
74
+ if data:
75
+ # Merge mappings, with earlier directories taking priority
76
+ for package, channels in data.items():
77
+ if package not in mappings:
78
+ mappings[package] = channels
79
+ except Exception:
80
+ # Skip files that fail to parse
81
+ continue
82
+
83
+ return mappings
84
+
85
+
86
+ def get_mappings() -> dict[str, dict[str, list[str] | dict[str, list[str]]]]:
87
+ """
88
+ Get the current ROS to conda package mappings.
89
+
90
+ Returns:
91
+ Dictionary mapping ROS package names to channel/platform mappings
92
+ """
93
+ return _load_channel_mappings()
94
+
95
+
96
+ def reload_mappings():
97
+ """
98
+ Clear the cached mappings and force reload.
99
+
100
+ Useful for testing or when mapping files have been updated.
101
+ """
102
+ _load_channel_mappings.cache_clear()
103
+
104
+
105
+ def map_ros_to_conda(
106
+ ros_package: str, distro: str = "humble", platform_override: str | None = None
107
+ ) -> list[str]:
108
+ """
109
+ Map a ROS package name to its conda package names.
110
+
111
+ Note: The returned list may contain special placeholders like REQUIRE_GL
112
+ or REQUIRE_OPENGL (from the mapping files). These should be expanded
113
+ using expand_gl_requirements().
114
+
115
+ Args:
116
+ ros_package: The ROS package name (e.g., "rclcpp", "udev", "opengl")
117
+ distro: The ROS distribution (e.g., "humble", "iron", "jazzy")
118
+ platform_override: Override platform detection (for testing)
119
+
120
+ Returns:
121
+ List of conda package names, which may include placeholder strings
122
+ like REQUIRE_GL or REQUIRE_OPENGL that need expansion
123
+ (e.g., ["ros-humble-rclcpp"], ["libusb", "libudev"], or ["REQUIRE_OPENGL"])
124
+
125
+ Examples:
126
+ >>> map_ros_to_conda("udev", "humble") # doctest: +SKIP
127
+ ['libusb', 'libudev'] # on linux
128
+ >>> map_ros_to_conda("uncrustify", "humble")
129
+ ['uncrustify']
130
+ >>> map_ros_to_conda("opengl", "humble") # doctest: +SKIP
131
+ ['REQUIRE_OPENGL'] # placeholder from mapping file
132
+ """
133
+ mappings = get_mappings()
134
+ current_platform = platform_override or _detect_platform()
135
+
136
+ # Check if we have a mapping for this package
137
+ if ros_package in mappings:
138
+ channels = mappings[ros_package]
139
+
140
+ # Get the first channel's mapping (usually "pixi")
141
+ if channels:
142
+ # Take the first channel
143
+ channel_mapping = next(iter(channels.values()))
144
+
145
+ # Check if it's platform-specific or a simple list
146
+ if isinstance(channel_mapping, dict):
147
+ # Platform-specific mapping
148
+ packages = channel_mapping.get(current_platform, [])
149
+ return packages if packages else []
150
+ elif isinstance(channel_mapping, list):
151
+ # Simple list mapping
152
+ return channel_mapping
153
+
154
+ # Default mapping: convert underscores to dashes and prepend ros-distro
155
+ # This follows the robostack convention
156
+ conda_name = ros_package.replace("_", "-")
157
+ return [f"ros-{distro}-{conda_name}"]
158
+
159
+
160
+ def expand_gl_requirements(
161
+ conda_packages: list[str], platform_override: str | None = None
162
+ ) -> list[str]:
163
+ """
164
+ Process special GL requirements in a list of conda packages.
165
+
166
+ Replaces REQUIRE_GL and REQUIRE_OPENGL placeholders with actual
167
+ platform-specific conda packages.
168
+
169
+ This is a duplication of the code in:
170
+ https://github.com/RoboStack/vinca/blob/7d3a05e01d6898201a66ba2cf6ea771250671f58/vinca/main.py#L562
171
+
172
+ Args:
173
+ conda_packages: List of conda package names (may contain REQUIRE_(OPEN)GL)
174
+ platform_override: Override platform detection (for testing)
175
+
176
+ Returns:
177
+ List of conda packages with GL requirements expanded
178
+
179
+ Examples:
180
+ >>> expand_gl_requirements(["cmake", "REQUIRE_GL"], platform_override="linux")
181
+ ['cmake', 'libgl-devel']
182
+ >>> expand_gl_requirements(["REQUIRE_GL"], platform_override="osx")
183
+ []
184
+ """
185
+ current_platform = platform_override or _detect_platform()
186
+ result = []
187
+ additional_packages = []
188
+
189
+ for pkg in conda_packages:
190
+ if pkg == "REQUIRE_GL":
191
+ # Replace REQUIRE_GL with platform-specific packages
192
+ if current_platform == "linux":
193
+ additional_packages.append("libgl-devel")
194
+ # On other platforms, just remove it (add nothing)
195
+ elif pkg == "REQUIRE_OPENGL":
196
+ # Replace REQUIRE_OPENGL with platform-specific packages
197
+ if current_platform == "linux":
198
+ # TODO: this should only go into the host dependencies
199
+ additional_packages.extend(["libgl-devel", "libopengl-devel"])
200
+ if current_platform in ["linux", "osx"]:
201
+ # TODO: force this into the run dependencies
202
+ additional_packages.extend(["xorg-libx11", "xorg-libxext"])
203
+ # On windows, just remove it (add nothing)
204
+ else:
205
+ # Regular package, keep it
206
+ result.append(pkg)
207
+
208
+ # Add the additional packages and deduplicate
209
+ result.extend(additional_packages)
210
+
211
+ # Remove duplicates while preserving order
212
+ seen = set()
213
+ deduplicated = []
214
+ for pkg in result:
215
+ if pkg not in seen:
216
+ seen.add(pkg)
217
+ deduplicated.append(pkg)
218
+
219
+ return deduplicated
220
+
221
+
222
+ def is_system_package(package_name: str) -> bool:
223
+ """
224
+ Check if a package is a system/tool package rather than a ROS package.
225
+
226
+ System packages are those that have explicit mappings and don't use
227
+ the ros-distro prefix.
228
+
229
+ Args:
230
+ package_name: The package name to check
231
+
232
+ Returns:
233
+ True if it's a system package, False otherwise
234
+
235
+ Examples:
236
+ >>> is_system_package("cmake")
237
+ True
238
+ >>> is_system_package("rclcpp")
239
+ False
240
+ """
241
+ mappings = get_mappings()
242
+
243
+ # If package is in mappings, it's a system package
244
+ # (ROS packages without mappings will get ros-{distro}- prefix in fallback)
245
+ if package_name in mappings:
246
+ return True
247
+
248
+ # Known system packages not in mapping files
249
+ system_packages = {
250
+ "cmake",
251
+ "git",
252
+ "python",
253
+ "python3",
254
+ }
255
+ return package_name in system_packages
256
+
257
+
258
+ def get_ros_distros() -> list[str]:
259
+ """
260
+ Get list of supported ROS distributions.
261
+
262
+ Returns:
263
+ List of ROS distro names
264
+ """
265
+ return ["foxy", "humble", "jazzy", "kilted"]
266
+
267
+
268
+ def validate_distro(distro: str) -> bool:
269
+ """
270
+ Check if a ROS distro is supported.
271
+
272
+ Args:
273
+ distro: The ROS distribution name
274
+
275
+ Returns:
276
+ True if supported, False otherwise
277
+ """
278
+ return distro in get_ros_distros()
279
+
280
+
281
+ def get_mapping_files_dir() -> Path:
282
+ """
283
+ Get the directory containing the currently active mapping files.
284
+
285
+ Returns:
286
+ Path to directory containing channel.yaml files
287
+ """
288
+ search_dirs = [
289
+ Path.cwd() / "pixi-ros",
290
+ Path.home() / ".pixi-ros",
291
+ ]
292
+
293
+ for search_dir in search_dirs:
294
+ if search_dir.exists() and any(search_dir.glob("*.yaml")):
295
+ return search_dir
296
+
297
+ # Return built-in default path
298
+ return Path(__file__).parent / "data"
@@ -0,0 +1,183 @@
1
+ """Parser for ROS package.xml files."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+
6
+ from lxml import etree
7
+
8
+
9
+ @dataclass
10
+ class PackageXML:
11
+ """Represents a parsed ROS package.xml file."""
12
+
13
+ name: str
14
+ version: str
15
+ description: str
16
+ maintainer: str
17
+ maintainer_email: str
18
+ license: str
19
+ format_version: int
20
+ build_type: str | None = None
21
+ path: Path | None = None # Path to the package.xml file
22
+
23
+ # Different dependency categories
24
+ buildtool_depends: list[str] = field(default_factory=list)
25
+ build_depends: list[str] = field(default_factory=list)
26
+ build_export_depends: list[str] = field(default_factory=list)
27
+ exec_depends: list[str] = field(default_factory=list)
28
+ test_depends: list[str] = field(default_factory=list)
29
+
30
+ # Format 2 compatibility (run_depend)
31
+ run_depends: list[str] = field(default_factory=list)
32
+
33
+ # Generic depends (shorthand for build, export, and exec)
34
+ depends: list[str] = field(default_factory=list)
35
+
36
+ @classmethod
37
+ def from_file(cls, path: Path) -> "PackageXML":
38
+ """
39
+ Parse a package.xml file.
40
+
41
+ Args:
42
+ path: Path to the package.xml file
43
+
44
+ Returns:
45
+ PackageXML object with parsed data
46
+
47
+ Raises:
48
+ FileNotFoundError: If the file doesn't exist
49
+ ValueError: If the XML is malformed or missing required fields
50
+ """
51
+ if not path.exists():
52
+ raise FileNotFoundError(f"package.xml not found at {path}")
53
+
54
+ try:
55
+ tree = etree.parse(str(path))
56
+ root = tree.getroot()
57
+ except etree.XMLSyntaxError as e:
58
+ raise ValueError(f"Invalid XML in {path}: {e}") from e
59
+
60
+ # Get format version (defaults to 1 if not specified)
61
+ format_version = int(root.get("format", "1"))
62
+
63
+ # Extract required fields
64
+ name_elem = root.find("name")
65
+ version_elem = root.find("version")
66
+ description_elem = root.find("description")
67
+ maintainer_elem = root.find("maintainer")
68
+ license_elem = root.find("license")
69
+
70
+ if name_elem is None or name_elem.text is None:
71
+ raise ValueError(f"Missing required 'name' field in {path}")
72
+ if version_elem is None or version_elem.text is None:
73
+ raise ValueError(f"Missing required 'version' field in {path}")
74
+ if description_elem is None or description_elem.text is None:
75
+ raise ValueError(f"Missing required 'description' field in {path}")
76
+ if maintainer_elem is None or maintainer_elem.text is None:
77
+ raise ValueError(f"Missing required 'maintainer' field in {path}")
78
+ if license_elem is None or license_elem.text is None:
79
+ raise ValueError(f"Missing required 'license' field in {path}")
80
+
81
+ # Extract maintainer email
82
+ maintainer_email = maintainer_elem.get("email", "")
83
+
84
+ # Extract build type from export section
85
+ build_type = None
86
+ export_elem = root.find("export")
87
+ if export_elem is not None:
88
+ build_type_elem = export_elem.find("build_type")
89
+ if build_type_elem is not None and build_type_elem.text:
90
+ build_type = build_type_elem.text
91
+
92
+ # Extract dependencies
93
+ def get_deps(tag: str) -> list[str]:
94
+ """Extract all dependencies with the given tag."""
95
+ deps = []
96
+ for elem in root.findall(tag):
97
+ if elem.text:
98
+ deps.append(elem.text.strip())
99
+ return deps
100
+
101
+ # Parse all dependency types
102
+ buildtool_depends = get_deps("buildtool_depend")
103
+ build_depends = get_deps("build_depend")
104
+ build_export_depends = get_deps("build_export_depend")
105
+ exec_depends = get_deps("exec_depend")
106
+ test_depends = get_deps("test_depend")
107
+ depends = get_deps("depend")
108
+
109
+ # Format 2 compatibility
110
+ run_depends = get_deps("run_depend")
111
+
112
+ return cls(
113
+ name=name_elem.text.strip(),
114
+ version=version_elem.text.strip(),
115
+ description=description_elem.text.strip(),
116
+ maintainer=maintainer_elem.text.strip(),
117
+ maintainer_email=maintainer_email,
118
+ license=license_elem.text.strip(),
119
+ format_version=format_version,
120
+ build_type=build_type,
121
+ path=path,
122
+ buildtool_depends=buildtool_depends,
123
+ build_depends=build_depends,
124
+ build_export_depends=build_export_depends,
125
+ exec_depends=exec_depends,
126
+ test_depends=test_depends,
127
+ run_depends=run_depends,
128
+ depends=depends,
129
+ )
130
+
131
+ def get_all_build_dependencies(self) -> list[str]:
132
+ """
133
+ Get all dependencies needed at build time.
134
+
135
+ Returns:
136
+ Combined list of buildtool, build, and generic depends
137
+ """
138
+ deps = set()
139
+ deps.update(self.buildtool_depends)
140
+ deps.update(self.build_depends)
141
+ deps.update(self.depends)
142
+ return sorted(deps)
143
+
144
+ def get_all_runtime_dependencies(self) -> list[str]:
145
+ """
146
+ Get all dependencies needed at runtime.
147
+
148
+ Handles both format 2 (run_depend) and format 3 (exec_depend).
149
+
150
+ Returns:
151
+ Combined list of exec, run, and generic depends
152
+ """
153
+ deps = set()
154
+ deps.update(self.exec_depends)
155
+ deps.update(self.run_depends) # Format 2 compatibility
156
+ deps.update(self.depends)
157
+ return sorted(deps)
158
+
159
+ def get_all_test_dependencies(self) -> list[str]:
160
+ """
161
+ Get all dependencies needed for testing.
162
+
163
+ Returns:
164
+ List of test depends
165
+ """
166
+ return sorted(set(self.test_depends))
167
+
168
+ def get_all_dependencies(self) -> list[str]:
169
+ """
170
+ Get all unique dependencies across all categories.
171
+
172
+ Returns:
173
+ Combined, deduplicated list of all dependencies
174
+ """
175
+ deps = set()
176
+ deps.update(self.buildtool_depends)
177
+ deps.update(self.build_depends)
178
+ deps.update(self.build_export_depends)
179
+ deps.update(self.exec_depends)
180
+ deps.update(self.run_depends)
181
+ deps.update(self.depends)
182
+ deps.update(self.test_depends)
183
+ return sorted(deps)
pixi_ros/utils.py ADDED
@@ -0,0 +1,80 @@
1
+ """Shared utility functions for pixi-ros."""
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+ from rattler import Version, VersionSpec
7
+
8
+
9
+ def detect_cmake_version_requirement(package_path: Path) -> str | None:
10
+ """
11
+ Detect cmake version requirement from CMakeLists.txt.
12
+
13
+ Args:
14
+ package_path: Path to the package directory
15
+
16
+ Returns:
17
+ Version constraint for cmake, or None if not applicable
18
+ """
19
+ cmake_file = package_path / "CMakeLists.txt"
20
+ if not cmake_file.exists():
21
+ return None
22
+
23
+ try:
24
+ content = cmake_file.read_text()
25
+ # Look for cmake_minimum_required(VERSION x.y)
26
+ match = re.search(
27
+ r"cmake_minimum_required\s*\(\s*VERSION\s+([\d.]+)",
28
+ content,
29
+ re.IGNORECASE,
30
+ )
31
+ if match:
32
+ version_str = match.group(1)
33
+ # Parse the detected version
34
+ detected_version = Version(version_str)
35
+
36
+ # Check if version is less than 3.10
37
+ threshold_spec = VersionSpec("<3.10")
38
+ if threshold_spec.matches(detected_version):
39
+ # CMake versions < 3.10 require cmake <4
40
+ return "<4"
41
+
42
+ except Exception:
43
+ # If we can't read or parse the file, skip it
44
+ pass
45
+
46
+ return None
47
+
48
+
49
+ def is_valid_ros_package_name(name: str) -> bool:
50
+ """
51
+ Check if a package name follows ROS naming conventions.
52
+
53
+ ROS package names should:
54
+ - Only contain lowercase letters, numbers, and underscores
55
+ - Start with a letter
56
+ - Not contain consecutive underscores
57
+
58
+ Args:
59
+ name: Package name to validate
60
+
61
+ Returns:
62
+ True if name is valid, False otherwise
63
+ """
64
+ if not name:
65
+ return False
66
+
67
+ # Must start with a letter
68
+ if not name[0].isalpha():
69
+ return False
70
+
71
+ # Check each character
72
+ for char in name:
73
+ if not (char.islower() or char.isdigit() or char == "_"):
74
+ return False
75
+
76
+ # No consecutive underscores
77
+ if "__" in name:
78
+ return False
79
+
80
+ return True