rostree 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.
- rostree/__init__.py +20 -0
- rostree/api.py +117 -0
- rostree/cli.py +288 -0
- rostree/core/__init__.py +22 -0
- rostree/core/finder.py +457 -0
- rostree/core/parser.py +105 -0
- rostree/core/tree.py +126 -0
- rostree/tui/__init__.py +1 -0
- rostree/tui/app.py +541 -0
- rostree-0.1.0.dist-info/METADATA +82 -0
- rostree-0.1.0.dist-info/RECORD +14 -0
- rostree-0.1.0.dist-info/WHEEL +4 -0
- rostree-0.1.0.dist-info/entry_points.txt +2 -0
- rostree-0.1.0.dist-info/licenses/LICENSE +29 -0
rostree/core/finder.py
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
"""Discover ROS 2 package paths from install space and source workspace."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class WorkspaceInfo:
|
|
12
|
+
"""Information about a discovered ROS 2 workspace."""
|
|
13
|
+
|
|
14
|
+
path: Path
|
|
15
|
+
has_src: bool = False
|
|
16
|
+
has_install: bool = False
|
|
17
|
+
has_build: bool = False
|
|
18
|
+
packages: list[str] = field(default_factory=list)
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def is_valid(self) -> bool:
|
|
22
|
+
"""True if this looks like a valid ROS 2 workspace."""
|
|
23
|
+
return self.has_src or self.has_install
|
|
24
|
+
|
|
25
|
+
def to_dict(self) -> dict:
|
|
26
|
+
"""Serialize to dict for JSON output."""
|
|
27
|
+
return {
|
|
28
|
+
"path": str(self.path),
|
|
29
|
+
"has_src": self.has_src,
|
|
30
|
+
"has_install": self.has_install,
|
|
31
|
+
"has_build": self.has_build,
|
|
32
|
+
"packages": self.packages,
|
|
33
|
+
"is_valid": self.is_valid,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def scan_for_workspaces(
|
|
38
|
+
roots: list[Path] | None = None,
|
|
39
|
+
*,
|
|
40
|
+
max_depth: int = 4,
|
|
41
|
+
include_home: bool = True,
|
|
42
|
+
include_opt_ros: bool = True,
|
|
43
|
+
) -> list[WorkspaceInfo]:
|
|
44
|
+
"""
|
|
45
|
+
Scan the host machine for ROS 2 workspaces.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
roots: Directories to start scanning from. Defaults to common locations.
|
|
49
|
+
max_depth: How deep to recurse when looking for workspaces.
|
|
50
|
+
include_home: If True and roots is None, include ~/ros*, ~/catkin_ws, etc.
|
|
51
|
+
include_opt_ros: If True and roots is None, include /opt/ros/* distros.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
List of WorkspaceInfo for each discovered workspace.
|
|
55
|
+
"""
|
|
56
|
+
if roots is None:
|
|
57
|
+
roots = []
|
|
58
|
+
home = Path.home()
|
|
59
|
+
if include_home:
|
|
60
|
+
# Common workspace locations in home
|
|
61
|
+
for pattern in ("ros*_ws", "ros2_ws", "catkin_ws", "colcon_ws", "*_ws"):
|
|
62
|
+
roots.extend(home.glob(pattern))
|
|
63
|
+
# Also check common dev directories
|
|
64
|
+
for subdir in ("dev", "src", "projects", "workspace", "workspaces", "sas"):
|
|
65
|
+
candidate = home / subdir
|
|
66
|
+
if candidate.exists() and candidate.is_dir():
|
|
67
|
+
roots.append(candidate)
|
|
68
|
+
if include_opt_ros:
|
|
69
|
+
opt_ros = Path("/opt/ros")
|
|
70
|
+
if opt_ros.exists():
|
|
71
|
+
for distro in opt_ros.iterdir():
|
|
72
|
+
if distro.is_dir():
|
|
73
|
+
roots.append(distro)
|
|
74
|
+
|
|
75
|
+
workspaces: list[WorkspaceInfo] = []
|
|
76
|
+
seen: set[Path] = set()
|
|
77
|
+
|
|
78
|
+
def _is_workspace(p: Path) -> WorkspaceInfo | None:
|
|
79
|
+
"""Check if path is a ROS 2 workspace root."""
|
|
80
|
+
resolved = p.resolve()
|
|
81
|
+
if resolved in seen:
|
|
82
|
+
return None
|
|
83
|
+
has_src = (p / "src").exists() and (p / "src").is_dir()
|
|
84
|
+
has_install = (p / "install").exists() and (p / "install").is_dir()
|
|
85
|
+
has_build = (p / "build").exists() and (p / "build").is_dir()
|
|
86
|
+
# For /opt/ros distros, check share dir
|
|
87
|
+
has_share = (p / "share").exists() and (p / "share").is_dir()
|
|
88
|
+
if has_src or has_install or has_share:
|
|
89
|
+
seen.add(resolved)
|
|
90
|
+
info = WorkspaceInfo(
|
|
91
|
+
path=resolved,
|
|
92
|
+
has_src=has_src,
|
|
93
|
+
has_install=has_install or has_share,
|
|
94
|
+
has_build=has_build,
|
|
95
|
+
)
|
|
96
|
+
# Discover packages
|
|
97
|
+
if has_src:
|
|
98
|
+
info.packages = _list_packages_in_src(p / "src")
|
|
99
|
+
elif has_install:
|
|
100
|
+
info.packages = _list_packages_in_install(p / "install")
|
|
101
|
+
elif has_share:
|
|
102
|
+
info.packages = _list_packages_in_install(p)
|
|
103
|
+
return info
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
def _scan_dir(p: Path, depth: int) -> None:
|
|
107
|
+
if depth > max_depth:
|
|
108
|
+
return
|
|
109
|
+
if not p.exists() or not p.is_dir():
|
|
110
|
+
return
|
|
111
|
+
try:
|
|
112
|
+
ws = _is_workspace(p)
|
|
113
|
+
if ws is not None:
|
|
114
|
+
workspaces.append(ws)
|
|
115
|
+
return # Don't recurse into a workspace
|
|
116
|
+
for child in p.iterdir():
|
|
117
|
+
if child.is_dir() and not child.name.startswith("."):
|
|
118
|
+
_scan_dir(child, depth + 1)
|
|
119
|
+
except PermissionError:
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
for root in roots:
|
|
123
|
+
root_path = Path(root).resolve()
|
|
124
|
+
if root_path.exists():
|
|
125
|
+
# Check if root itself is a workspace
|
|
126
|
+
ws = _is_workspace(root_path)
|
|
127
|
+
if ws is not None:
|
|
128
|
+
workspaces.append(ws)
|
|
129
|
+
else:
|
|
130
|
+
_scan_dir(root_path, 0)
|
|
131
|
+
|
|
132
|
+
return workspaces
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _list_packages_in_src(src: Path) -> list[str]:
|
|
136
|
+
"""List package names from a src directory."""
|
|
137
|
+
packages = []
|
|
138
|
+
for root, _dirs, files in os.walk(src):
|
|
139
|
+
if "package.xml" not in files:
|
|
140
|
+
continue
|
|
141
|
+
pkg_xml = Path(root) / "package.xml"
|
|
142
|
+
try:
|
|
143
|
+
with open(pkg_xml) as f:
|
|
144
|
+
for line in f:
|
|
145
|
+
if "<name>" in line and "</name>" in line:
|
|
146
|
+
start = line.find("<name>") + 6
|
|
147
|
+
end = line.find("</name>")
|
|
148
|
+
name = line[start:end].strip()
|
|
149
|
+
if name:
|
|
150
|
+
packages.append(name)
|
|
151
|
+
break
|
|
152
|
+
except OSError:
|
|
153
|
+
continue
|
|
154
|
+
return sorted(set(packages))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _list_packages_in_install(install: Path) -> list[str]:
|
|
158
|
+
"""List package names from an install or share directory."""
|
|
159
|
+
packages = []
|
|
160
|
+
share = install / "share" if (install / "share").exists() else install
|
|
161
|
+
if not share.exists():
|
|
162
|
+
return packages
|
|
163
|
+
try:
|
|
164
|
+
for child in share.iterdir():
|
|
165
|
+
if child.is_dir() and (child / "package.xml").exists():
|
|
166
|
+
packages.append(child.name)
|
|
167
|
+
except PermissionError:
|
|
168
|
+
pass
|
|
169
|
+
return sorted(packages)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _env_paths(env_var: str) -> list[Path]:
|
|
173
|
+
"""Split an environment variable by os.pathsep and return existing Paths."""
|
|
174
|
+
value = os.environ.get(env_var, "")
|
|
175
|
+
if not value:
|
|
176
|
+
return []
|
|
177
|
+
return [Path(p).resolve() for p in value.split(os.pathsep) if p.strip() and Path(p).exists()]
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _find_package_xml_in_prefix(prefix: Path, package_name: str) -> Path | None:
|
|
181
|
+
"""Look for share/<package_name>/package.xml under a colcon/ament prefix."""
|
|
182
|
+
candidate = prefix / "share" / package_name / "package.xml"
|
|
183
|
+
if candidate.exists():
|
|
184
|
+
return candidate
|
|
185
|
+
# Some layouts use lib/python3.x/site-packages for ament_python
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _find_package_xml_in_src(src_root: Path, package_name: str) -> Path | None:
|
|
190
|
+
"""Recursively search for a directory containing package.xml with matching <name>."""
|
|
191
|
+
for root, _dirs, files in os.walk(src_root, topdown=True):
|
|
192
|
+
if "package.xml" not in files:
|
|
193
|
+
continue
|
|
194
|
+
pkg_xml = Path(root) / "package.xml"
|
|
195
|
+
try:
|
|
196
|
+
with open(pkg_xml) as f:
|
|
197
|
+
for line in f:
|
|
198
|
+
if "<name>" in line and f"<name>{package_name}</name>" in line:
|
|
199
|
+
return pkg_xml
|
|
200
|
+
# Simple line-based check; parser will validate
|
|
201
|
+
if "<name>" in line:
|
|
202
|
+
break
|
|
203
|
+
except OSError:
|
|
204
|
+
continue
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _gather_workspace_src_roots(extra_source_roots: list[Path] | None = None) -> list[Path]:
|
|
209
|
+
"""Collect workspace src roots from env and optional extra roots. Deduplicated."""
|
|
210
|
+
workspace_srcs: list[Path] = []
|
|
211
|
+
for env in ("COLCON_PREFIX_PATH", "AMENT_PREFIX_PATH"):
|
|
212
|
+
for prefix in _env_paths(env):
|
|
213
|
+
parent = prefix.parent
|
|
214
|
+
if parent.name == "install":
|
|
215
|
+
src = parent / "src"
|
|
216
|
+
if src.exists() and src.is_dir():
|
|
217
|
+
workspace_srcs.append(src)
|
|
218
|
+
for env in ("ROS2_WORKSPACE", "COLCON_WORKSPACE"):
|
|
219
|
+
for raw in os.environ.get(env, "").split(os.pathsep):
|
|
220
|
+
p = Path(raw).resolve()
|
|
221
|
+
if p.exists():
|
|
222
|
+
workspace_srcs.append(p / "src" if (p / "src").exists() else p)
|
|
223
|
+
if extra_source_roots:
|
|
224
|
+
for p in extra_source_roots:
|
|
225
|
+
r = Path(p).resolve()
|
|
226
|
+
if r.exists() and r.is_dir():
|
|
227
|
+
workspace_srcs.append(r)
|
|
228
|
+
seen: set[Path] = set()
|
|
229
|
+
out: list[Path] = []
|
|
230
|
+
for src in workspace_srcs:
|
|
231
|
+
canonical = src.resolve()
|
|
232
|
+
if canonical in seen:
|
|
233
|
+
continue
|
|
234
|
+
seen.add(canonical)
|
|
235
|
+
out.append(canonical)
|
|
236
|
+
return out
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def find_package_path(
|
|
240
|
+
package_name: str,
|
|
241
|
+
*,
|
|
242
|
+
extra_source_roots: list[Path] | None = None,
|
|
243
|
+
) -> Path | None:
|
|
244
|
+
"""
|
|
245
|
+
Find the directory containing package.xml for a ROS 2 package.
|
|
246
|
+
|
|
247
|
+
Searches in order:
|
|
248
|
+
1. AMENT_PREFIX_PATH (install space) - share/<package_name>/package.xml
|
|
249
|
+
2. COLCON_PREFIX_PATH (install space) - same
|
|
250
|
+
3. Source workspace: if COLCON_WORKSPACE or common envs point to a workspace,
|
|
251
|
+
scan src for a package with matching name.
|
|
252
|
+
4. Any paths in extra_source_roots (user-added source directories).
|
|
253
|
+
|
|
254
|
+
Returns the path to the package.xml file, or None if not found.
|
|
255
|
+
"""
|
|
256
|
+
# Install space: AMENT_PREFIX_PATH and COLCON_PREFIX_PATH
|
|
257
|
+
for prefix in _env_paths("AMENT_PREFIX_PATH") + _env_paths("COLCON_PREFIX_PATH"):
|
|
258
|
+
p = _find_package_xml_in_prefix(prefix, package_name)
|
|
259
|
+
if p is not None:
|
|
260
|
+
return p
|
|
261
|
+
|
|
262
|
+
workspace_srcs = _gather_workspace_src_roots(extra_source_roots=extra_source_roots)
|
|
263
|
+
for src in workspace_srcs:
|
|
264
|
+
p = _find_package_xml_in_src(src, package_name)
|
|
265
|
+
if p is not None:
|
|
266
|
+
return p
|
|
267
|
+
|
|
268
|
+
return None
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def list_package_paths(
|
|
272
|
+
*,
|
|
273
|
+
extra_source_roots: list[Path] | None = None,
|
|
274
|
+
) -> dict[str, Path]:
|
|
275
|
+
"""
|
|
276
|
+
List all known ROS 2 packages (install + source) and their package.xml paths.
|
|
277
|
+
|
|
278
|
+
extra_source_roots: optional list of directories to scan for package.xml (e.g. user-added).
|
|
279
|
+
|
|
280
|
+
Returns a dict mapping package name -> path to package.xml.
|
|
281
|
+
"""
|
|
282
|
+
result: dict[str, Path] = {}
|
|
283
|
+
|
|
284
|
+
# From install space: each prefix/share/<name>/package.xml
|
|
285
|
+
for prefix in _env_paths("AMENT_PREFIX_PATH") + _env_paths("COLCON_PREFIX_PATH"):
|
|
286
|
+
share = prefix / "share"
|
|
287
|
+
if not share.exists():
|
|
288
|
+
continue
|
|
289
|
+
for child in share.iterdir():
|
|
290
|
+
if child.is_dir():
|
|
291
|
+
pkg_xml = child / "package.xml"
|
|
292
|
+
if pkg_xml.exists():
|
|
293
|
+
result[child.name] = pkg_xml
|
|
294
|
+
|
|
295
|
+
workspace_srcs = _gather_workspace_src_roots(extra_source_roots=extra_source_roots)
|
|
296
|
+
for src in workspace_srcs:
|
|
297
|
+
for root, _dirs, files in os.walk(src):
|
|
298
|
+
if "package.xml" not in files:
|
|
299
|
+
continue
|
|
300
|
+
pkg_xml = Path(root) / "package.xml"
|
|
301
|
+
try:
|
|
302
|
+
with open(pkg_xml) as f:
|
|
303
|
+
for line in f:
|
|
304
|
+
if "<name>" in line and "</name>" in line:
|
|
305
|
+
start = line.find("<name>") + 6
|
|
306
|
+
end = line.find("</name>")
|
|
307
|
+
name = line[start:end].strip()
|
|
308
|
+
if name and name not in result:
|
|
309
|
+
result[name] = pkg_xml
|
|
310
|
+
break
|
|
311
|
+
except OSError:
|
|
312
|
+
continue
|
|
313
|
+
|
|
314
|
+
return result
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _is_system_prefix(prefix: Path) -> bool:
|
|
318
|
+
"""True if prefix is under /opt/ros (ROS distro install)."""
|
|
319
|
+
try:
|
|
320
|
+
prefix_str = str(prefix.resolve())
|
|
321
|
+
return "/opt/ros" in prefix_str
|
|
322
|
+
except Exception:
|
|
323
|
+
return False
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _workspace_root_from_prefix(prefix: Path) -> Path | None:
|
|
327
|
+
"""If prefix is under an install dir, return workspace root (parent of install)."""
|
|
328
|
+
try:
|
|
329
|
+
p = prefix.resolve()
|
|
330
|
+
if p.name == "install" or (p.parent.name == "install"):
|
|
331
|
+
root = p.parent if p.name == "install" else p.parent.parent
|
|
332
|
+
return root
|
|
333
|
+
return p
|
|
334
|
+
except Exception:
|
|
335
|
+
return None
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def list_packages_by_source(
|
|
339
|
+
*,
|
|
340
|
+
extra_source_roots: list[Path] | None = None,
|
|
341
|
+
) -> dict[str, list[str]]:
|
|
342
|
+
"""
|
|
343
|
+
List packages grouped by source (System, Workspace, Other, Source, Added).
|
|
344
|
+
|
|
345
|
+
Lets you distinguish:
|
|
346
|
+
- System: /opt/ros/... (ROS distro)
|
|
347
|
+
- Workspace: first non-system install (your workspace)
|
|
348
|
+
- Other: other install prefixes (third-party workspaces)
|
|
349
|
+
- Source: unbuilt packages from workspace src trees
|
|
350
|
+
- Added: packages from extra_source_roots (user-added paths)
|
|
351
|
+
|
|
352
|
+
Returns dict mapping source_label -> sorted list of package names.
|
|
353
|
+
"""
|
|
354
|
+
by_source: dict[str, list[str]] = {}
|
|
355
|
+
seen: set[str] = set()
|
|
356
|
+
prefixes = _env_paths("AMENT_PREFIX_PATH") + _env_paths("COLCON_PREFIX_PATH")
|
|
357
|
+
workspace_root_used: Path | None = None # first non-system workspace = "Workspace"
|
|
358
|
+
|
|
359
|
+
for prefix in prefixes:
|
|
360
|
+
share = prefix / "share"
|
|
361
|
+
if not share.exists():
|
|
362
|
+
continue
|
|
363
|
+
if _is_system_prefix(prefix):
|
|
364
|
+
label = f"System ({prefix})"
|
|
365
|
+
else:
|
|
366
|
+
root = _workspace_root_from_prefix(prefix)
|
|
367
|
+
root_resolved = root.resolve() if root else prefix.resolve()
|
|
368
|
+
root_str = str(root_resolved)
|
|
369
|
+
if workspace_root_used is None:
|
|
370
|
+
workspace_root_used = root_resolved
|
|
371
|
+
label = f"Workspace ({root_str})"
|
|
372
|
+
elif root_resolved == workspace_root_used:
|
|
373
|
+
label = f"Workspace ({root_str})"
|
|
374
|
+
else:
|
|
375
|
+
label = f"Other ({root_str})"
|
|
376
|
+
if label not in by_source:
|
|
377
|
+
by_source[label] = []
|
|
378
|
+
for child in share.iterdir():
|
|
379
|
+
if child.is_dir() and (child / "package.xml").exists():
|
|
380
|
+
if child.name not in seen:
|
|
381
|
+
seen.add(child.name)
|
|
382
|
+
by_source[label].append(child.name)
|
|
383
|
+
if by_source[label]:
|
|
384
|
+
by_source[label] = sorted(by_source[label])
|
|
385
|
+
|
|
386
|
+
# Source space: workspace src trees (from env)
|
|
387
|
+
workspace_srcs: list[tuple[Path, str]] = []
|
|
388
|
+
for env in ("COLCON_PREFIX_PATH", "AMENT_PREFIX_PATH"):
|
|
389
|
+
for prefix in _env_paths(env):
|
|
390
|
+
parent = prefix.parent
|
|
391
|
+
if parent.name == "install":
|
|
392
|
+
src = parent / "src"
|
|
393
|
+
if src.exists() and src.is_dir():
|
|
394
|
+
root_str = str(parent.parent)
|
|
395
|
+
workspace_srcs.append((src, f"Source ({root_str}/src)"))
|
|
396
|
+
for env in ("ROS2_WORKSPACE", "COLCON_WORKSPACE"):
|
|
397
|
+
for raw in os.environ.get(env, "").split(os.pathsep):
|
|
398
|
+
p = Path(raw).resolve()
|
|
399
|
+
if p.exists():
|
|
400
|
+
src = p / "src" if (p / "src").exists() else p
|
|
401
|
+
workspace_srcs.append((src.resolve(), f"Source ({src})"))
|
|
402
|
+
|
|
403
|
+
seen_src: set[Path] = set()
|
|
404
|
+
for src, label in workspace_srcs:
|
|
405
|
+
if src in seen_src:
|
|
406
|
+
continue
|
|
407
|
+
seen_src.add(src)
|
|
408
|
+
if label not in by_source:
|
|
409
|
+
by_source[label] = []
|
|
410
|
+
for root, _dirs, files in os.walk(src):
|
|
411
|
+
if "package.xml" not in files:
|
|
412
|
+
continue
|
|
413
|
+
pkg_xml = Path(root) / "package.xml"
|
|
414
|
+
try:
|
|
415
|
+
with open(pkg_xml) as f:
|
|
416
|
+
for line in f:
|
|
417
|
+
if "<name>" in line and "</name>" in line:
|
|
418
|
+
start = line.find("<name>") + 6
|
|
419
|
+
end = line.find("</name>")
|
|
420
|
+
name = line[start:end].strip()
|
|
421
|
+
if name and name not in seen:
|
|
422
|
+
seen.add(name)
|
|
423
|
+
by_source[label].append(name)
|
|
424
|
+
break
|
|
425
|
+
except OSError:
|
|
426
|
+
continue
|
|
427
|
+
by_source[label] = sorted(by_source[label])
|
|
428
|
+
|
|
429
|
+
# User-added source roots
|
|
430
|
+
if extra_source_roots:
|
|
431
|
+
for p in extra_source_roots:
|
|
432
|
+
src = Path(p).resolve()
|
|
433
|
+
if not src.exists() or not src.is_dir():
|
|
434
|
+
continue
|
|
435
|
+
label = f"Added ({src})"
|
|
436
|
+
if label not in by_source:
|
|
437
|
+
by_source[label] = []
|
|
438
|
+
for root, _dirs, files in os.walk(src):
|
|
439
|
+
if "package.xml" not in files:
|
|
440
|
+
continue
|
|
441
|
+
pkg_xml = Path(root) / "package.xml"
|
|
442
|
+
try:
|
|
443
|
+
with open(pkg_xml) as f:
|
|
444
|
+
for line in f:
|
|
445
|
+
if "<name>" in line and "</name>" in line:
|
|
446
|
+
start = line.find("<name>") + 6
|
|
447
|
+
end = line.find("</name>")
|
|
448
|
+
name = line[start:end].strip()
|
|
449
|
+
if name and name not in seen:
|
|
450
|
+
seen.add(name)
|
|
451
|
+
by_source[label].append(name)
|
|
452
|
+
break
|
|
453
|
+
except OSError:
|
|
454
|
+
continue
|
|
455
|
+
by_source[label] = sorted(by_source[label])
|
|
456
|
+
|
|
457
|
+
return by_source
|
rostree/core/parser.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Parse ROS 2 package.xml for package metadata and dependencies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import xml.etree.ElementTree as ET
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
# Tags that declare dependency on another ROS package (we collect these for the tree).
|
|
10
|
+
DEPENDENCY_TAGS = (
|
|
11
|
+
"depend",
|
|
12
|
+
"exec_depend",
|
|
13
|
+
"build_depend",
|
|
14
|
+
"build_export_depend",
|
|
15
|
+
"test_depend",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class PackageInfo:
|
|
21
|
+
"""Metadata parsed from a package.xml."""
|
|
22
|
+
|
|
23
|
+
name: str
|
|
24
|
+
version: str
|
|
25
|
+
description: str
|
|
26
|
+
path: Path
|
|
27
|
+
dependencies: list[str] # ROS package names only (no system/vendor deps)
|
|
28
|
+
|
|
29
|
+
def __post_init__(self) -> None:
|
|
30
|
+
# Normalize to set of unique names (order can be preserved if needed)
|
|
31
|
+
self.dependencies = list(dict.fromkeys(self.dependencies))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _is_ros_package_dependency(name: str) -> bool:
|
|
35
|
+
"""Heuristic: ROS packages are typically lowercase with underscores."""
|
|
36
|
+
if not name or not name[0].isalpha():
|
|
37
|
+
return False
|
|
38
|
+
# Filter common non-ROS entries
|
|
39
|
+
if name in ("python3", "python3-pytest", "python3-textual", "python3-rich"):
|
|
40
|
+
return False
|
|
41
|
+
if name.startswith("python3-") or name.startswith("lib"):
|
|
42
|
+
return False
|
|
43
|
+
return True
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parse_package_xml(
|
|
47
|
+
path: Path,
|
|
48
|
+
*,
|
|
49
|
+
include_tags: tuple[str, ...] | None = None,
|
|
50
|
+
) -> PackageInfo | None:
|
|
51
|
+
"""
|
|
52
|
+
Parse a package.xml file and return package name, version, description, and dependencies.
|
|
53
|
+
|
|
54
|
+
Only dependency tags that typically refer to ROS packages are collected;
|
|
55
|
+
buildtool_depend and system-style deps may be excluded by heuristic.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
path: Path to package.xml.
|
|
59
|
+
include_tags: If set, only collect deps from these tags (e.g. ("depend", "exec_depend")
|
|
60
|
+
for runtime-only). If None, use all DEPENDENCY_TAGS.
|
|
61
|
+
|
|
62
|
+
Returns None if the file cannot be read or is not valid package.xml.
|
|
63
|
+
"""
|
|
64
|
+
if not path.exists() or not path.is_file():
|
|
65
|
+
return None
|
|
66
|
+
try:
|
|
67
|
+
tree = ET.parse(path)
|
|
68
|
+
except (ET.ParseError, OSError):
|
|
69
|
+
return None
|
|
70
|
+
root = tree.getroot()
|
|
71
|
+
if root.tag != "package":
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
name = ""
|
|
75
|
+
version = ""
|
|
76
|
+
description = ""
|
|
77
|
+
|
|
78
|
+
for child in root:
|
|
79
|
+
if child.tag == "name" and child.text:
|
|
80
|
+
name = child.text.strip()
|
|
81
|
+
elif child.tag == "version" and child.text:
|
|
82
|
+
version = child.text.strip()
|
|
83
|
+
elif child.tag == "description" and child.text:
|
|
84
|
+
description = child.text.strip()
|
|
85
|
+
|
|
86
|
+
tags = include_tags if include_tags is not None else DEPENDENCY_TAGS
|
|
87
|
+
deps: list[str] = []
|
|
88
|
+
for tag in tags:
|
|
89
|
+
if tag not in DEPENDENCY_TAGS:
|
|
90
|
+
continue
|
|
91
|
+
for elem in root.findall(f".//{tag}"):
|
|
92
|
+
if elem.text:
|
|
93
|
+
dep = elem.text.strip()
|
|
94
|
+
if _is_ros_package_dependency(dep):
|
|
95
|
+
deps.append(dep)
|
|
96
|
+
|
|
97
|
+
if not name:
|
|
98
|
+
return None
|
|
99
|
+
return PackageInfo(
|
|
100
|
+
name=name,
|
|
101
|
+
version=version,
|
|
102
|
+
description=description or "",
|
|
103
|
+
path=path.resolve(),
|
|
104
|
+
dependencies=deps,
|
|
105
|
+
)
|
rostree/core/tree.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Build and represent ROS 2 package dependency trees."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from rostree.core.parser import PackageInfo, parse_package_xml
|
|
9
|
+
from rostree.core.finder import find_package_path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class DependencyNode:
|
|
14
|
+
"""A node in the dependency tree: one ROS package and its direct children."""
|
|
15
|
+
|
|
16
|
+
name: str
|
|
17
|
+
version: str
|
|
18
|
+
description: str
|
|
19
|
+
path: str
|
|
20
|
+
children: list[DependencyNode] = field(default_factory=list)
|
|
21
|
+
# Optional: store raw PackageInfo for API consumers
|
|
22
|
+
package_info: PackageInfo | None = None
|
|
23
|
+
|
|
24
|
+
def to_dict(self) -> dict:
|
|
25
|
+
"""Serialize node to a JSON-friendly dict (for API/frontend)."""
|
|
26
|
+
return {
|
|
27
|
+
"name": self.name,
|
|
28
|
+
"version": self.version,
|
|
29
|
+
"description": self.description,
|
|
30
|
+
"path": str(self.path),
|
|
31
|
+
"children": [c.to_dict() for c in self.children],
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Tags used when runtime_only=True (smaller, faster tree; no build/test deps).
|
|
36
|
+
_RUNTIME_DEPENDENCY_TAGS = ("depend", "exec_depend")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_dependency_tree(
|
|
40
|
+
root_package: str,
|
|
41
|
+
*,
|
|
42
|
+
max_depth: int | None = None,
|
|
43
|
+
include_buildtool: bool = False,
|
|
44
|
+
runtime_only: bool = False,
|
|
45
|
+
extra_source_roots: list[Path] | None = None,
|
|
46
|
+
_depth: int = 0,
|
|
47
|
+
_visited: set[str] | None = None,
|
|
48
|
+
) -> DependencyNode | None:
|
|
49
|
+
"""
|
|
50
|
+
Build a dependency tree starting from a root package name.
|
|
51
|
+
|
|
52
|
+
Traverses depend/exec_depend/build_depend (and optionally buildtool) and
|
|
53
|
+
resolves each dependency to its package.xml, then recurses. Cycles are
|
|
54
|
+
avoided by tracking visited package names.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
root_package: Root ROS package name.
|
|
58
|
+
max_depth: Optional max depth; None means no limit.
|
|
59
|
+
include_buildtool: If True, include buildtool_depend in traversal.
|
|
60
|
+
runtime_only: If True, only depend and exec_depend (no build/test deps);
|
|
61
|
+
much smaller and faster for packages with heavy build toolchains.
|
|
62
|
+
extra_source_roots: Optional list of Paths to scan for packages (user-added).
|
|
63
|
+
_depth: Internal recursion depth.
|
|
64
|
+
_visited: Internal set of already-visited package names.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
DependencyNode for the root, or None if root package is not found.
|
|
68
|
+
"""
|
|
69
|
+
if _visited is None:
|
|
70
|
+
_visited = set()
|
|
71
|
+
if root_package in _visited:
|
|
72
|
+
return DependencyNode(
|
|
73
|
+
name=root_package,
|
|
74
|
+
version="",
|
|
75
|
+
description="(cycle)",
|
|
76
|
+
path="",
|
|
77
|
+
)
|
|
78
|
+
if max_depth is not None and _depth > max_depth:
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
roots: list[Path] | None = None
|
|
82
|
+
if extra_source_roots is not None:
|
|
83
|
+
roots = [Path(p).resolve() for p in extra_source_roots]
|
|
84
|
+
pkg_path = find_package_path(root_package, extra_source_roots=roots)
|
|
85
|
+
if pkg_path is None:
|
|
86
|
+
return DependencyNode(
|
|
87
|
+
name=root_package,
|
|
88
|
+
version="",
|
|
89
|
+
description="(not found)",
|
|
90
|
+
path="",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
include_tags = _RUNTIME_DEPENDENCY_TAGS if runtime_only else None
|
|
94
|
+
info = parse_package_xml(pkg_path, include_tags=include_tags)
|
|
95
|
+
if info is None:
|
|
96
|
+
return DependencyNode(
|
|
97
|
+
name=root_package,
|
|
98
|
+
version="",
|
|
99
|
+
description="(parse error)",
|
|
100
|
+
path=str(pkg_path),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
_visited.add(root_package)
|
|
104
|
+
children: list[DependencyNode] = []
|
|
105
|
+
for dep in info.dependencies:
|
|
106
|
+
child = build_dependency_tree(
|
|
107
|
+
dep,
|
|
108
|
+
max_depth=max_depth,
|
|
109
|
+
include_buildtool=include_buildtool,
|
|
110
|
+
runtime_only=runtime_only,
|
|
111
|
+
extra_source_roots=extra_source_roots,
|
|
112
|
+
_depth=_depth + 1,
|
|
113
|
+
_visited=set(_visited),
|
|
114
|
+
)
|
|
115
|
+
if child is not None:
|
|
116
|
+
children.append(child)
|
|
117
|
+
_visited.discard(root_package)
|
|
118
|
+
|
|
119
|
+
return DependencyNode(
|
|
120
|
+
name=info.name,
|
|
121
|
+
version=info.version,
|
|
122
|
+
description=info.description,
|
|
123
|
+
path=str(info.path),
|
|
124
|
+
children=children,
|
|
125
|
+
package_info=info,
|
|
126
|
+
)
|
rostree/tui/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Terminal UI for exploring ROS 2 package dependency trees."""
|