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/__init__.py +3 -0
- pixi_ros/cli.py +77 -0
- pixi_ros/config.py +34 -0
- pixi_ros/data/README.md +56 -0
- pixi_ros/data/README_PIXI.md.template +125 -0
- pixi_ros/data/conda-forge.yaml +1049 -0
- pixi_ros/init.py +548 -0
- pixi_ros/mappings.py +298 -0
- pixi_ros/package_xml.py +183 -0
- pixi_ros/utils.py +80 -0
- pixi_ros/workspace.py +213 -0
- pixi_ros-0.1.0.dist-info/METADATA +212 -0
- pixi_ros-0.1.0.dist-info/RECORD +15 -0
- pixi_ros-0.1.0.dist-info/WHEEL +4 -0
- pixi_ros-0.1.0.dist-info/entry_points.txt +2 -0
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"
|
pixi_ros/package_xml.py
ADDED
|
@@ -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
|