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/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
+ )
@@ -0,0 +1 @@
1
+ """Terminal UI for exploring ROS 2 package dependency trees."""