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/tui/app.py
ADDED
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
"""Textual TUI for navigating ROS 2 package dependency trees."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from textual.app import App, ComposeResult
|
|
10
|
+
from textual.binding import Binding
|
|
11
|
+
from textual.containers import Horizontal, Vertical
|
|
12
|
+
from textual.screen import ModalScreen
|
|
13
|
+
from textual.widgets import Button, Footer, Header, Input, Static, Tree
|
|
14
|
+
from textual.widgets.tree import TreeNode
|
|
15
|
+
|
|
16
|
+
from rostree.api import build_tree, list_known_packages_by_source
|
|
17
|
+
|
|
18
|
+
# Welcome banner: ROSTREE
|
|
19
|
+
WELCOME_BANNER = """
|
|
20
|
+
[bold cyan]
|
|
21
|
+
██████╗ ██████╗ ███████╗████████╗██████╗ ███████╗███████╗
|
|
22
|
+
██╔══██╗██╔═══██╗██╔════╝╚══██╔══╝██╔══██╗██╔════╝██╔════╝
|
|
23
|
+
██████╔╝██║ ██║███████╗ ██║ ██████╔╝█████╗ █████╗
|
|
24
|
+
██╔══██╗██║ ██║╚════██║ ██║ ██╔══██╗██╔══╝ ██╔══╝
|
|
25
|
+
██║ ██║╚██████╔╝███████║ ██║ ██║ ██║███████╗███████╗
|
|
26
|
+
╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝╚══════╝╚══════╝
|
|
27
|
+
[/bold cyan]
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
WELCOME_BODY = """
|
|
31
|
+
[dim]Visualize ROS 2 package dependencies as a navigable tree.[/]
|
|
32
|
+
|
|
33
|
+
• [bold]CLI[/]: rostree scan, rostree list, rostree tree <pkg>
|
|
34
|
+
• [bold]TUI[/]: browse packages, expand/collapse, see details
|
|
35
|
+
• [bold]Library[/]: Python API for scripts and automation
|
|
36
|
+
|
|
37
|
+
[dim]Requires ROS 2 env (source install/setup.bash).[/]
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
# Limits to avoid huge trees and crashes
|
|
41
|
+
MAX_PACKAGES_PER_SOURCE = 80 # max package names per source section
|
|
42
|
+
MAX_TREE_DEPTH = 8
|
|
43
|
+
MAX_TREE_NODES = 500
|
|
44
|
+
EXPAND_DEPTH_DEFAULT = 2
|
|
45
|
+
# TUI uses runtime_only=True (depend + exec_depend only)
|
|
46
|
+
TUI_TREE_MAX_DEPTH = 6
|
|
47
|
+
|
|
48
|
+
# Colors: source sections and tree
|
|
49
|
+
COLOR_SYSTEM = "dim" # /opt/ros/...
|
|
50
|
+
COLOR_WORKSPACE = "bold green" # your workspace
|
|
51
|
+
COLOR_OTHER = "bold cyan" # third-party installs
|
|
52
|
+
COLOR_SOURCE = "bold yellow" # unbuilt source
|
|
53
|
+
COLOR_ADDED = "bold magenta" # user-added paths
|
|
54
|
+
COLOR_HEADER = "bold magenta"
|
|
55
|
+
COLOR_PKG = "white"
|
|
56
|
+
COLOR_STATS = "cyan"
|
|
57
|
+
COLOR_PATH = "dim"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _count_nodes(node: Any) -> int:
|
|
61
|
+
"""Count nodes in tree (for cap)."""
|
|
62
|
+
n = 1
|
|
63
|
+
for c in getattr(node, "children", []):
|
|
64
|
+
n += _count_nodes(c)
|
|
65
|
+
return n
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _node_stats(node: Any) -> tuple[int, int, int]:
|
|
69
|
+
"""Return (direct_children, total_descendants, max_depth) for a node."""
|
|
70
|
+
children = getattr(node, "children", []) or []
|
|
71
|
+
direct = len(children)
|
|
72
|
+
total = 0
|
|
73
|
+
max_d = 0
|
|
74
|
+
for c in children:
|
|
75
|
+
sub_direct, sub_total, sub_depth = _node_stats(c)
|
|
76
|
+
total += 1 + sub_total
|
|
77
|
+
max_d = max(max_d, 1 + sub_depth)
|
|
78
|
+
return direct, total, max_d
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _populate_textual_tree(
|
|
82
|
+
tn: TreeNode,
|
|
83
|
+
node: Any,
|
|
84
|
+
*,
|
|
85
|
+
depth: int = 0,
|
|
86
|
+
max_depth: int = MAX_TREE_DEPTH,
|
|
87
|
+
max_nodes: int = MAX_TREE_NODES,
|
|
88
|
+
node_count: list[int] | None = None,
|
|
89
|
+
) -> None:
|
|
90
|
+
"""Recursively add DependencyNode children; cap depth and total nodes."""
|
|
91
|
+
if node_count is None:
|
|
92
|
+
node_count = [0]
|
|
93
|
+
for child in getattr(node, "children", []):
|
|
94
|
+
if node_count[0] >= max_nodes:
|
|
95
|
+
tn.add_leaf(f"[dim]… truncated ({max_nodes} nodes max)[/]")
|
|
96
|
+
return
|
|
97
|
+
if depth >= max_depth:
|
|
98
|
+
tn.add_leaf(f"[dim]{child.name} …[/]")
|
|
99
|
+
continue
|
|
100
|
+
node_count[0] += 1
|
|
101
|
+
label = f"[{COLOR_PKG}]{child.name}[/] [dim]v{child.version or '?'}[/]"
|
|
102
|
+
child_tn = tn.add(label, expand=False)
|
|
103
|
+
child_tn.data = child
|
|
104
|
+
_populate_textual_tree(
|
|
105
|
+
child_tn,
|
|
106
|
+
child,
|
|
107
|
+
depth=depth + 1,
|
|
108
|
+
max_depth=max_depth,
|
|
109
|
+
max_nodes=max_nodes,
|
|
110
|
+
node_count=node_count,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _expand_to_depth(tn: TreeNode, depth: int, current: int = 0) -> None:
|
|
115
|
+
"""Expand tree nodes up to given depth (0 = root only)."""
|
|
116
|
+
if current >= depth:
|
|
117
|
+
return
|
|
118
|
+
try:
|
|
119
|
+
tn.expand()
|
|
120
|
+
for child in tn.children:
|
|
121
|
+
_expand_to_depth(child, depth, current + 1)
|
|
122
|
+
except Exception:
|
|
123
|
+
pass
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class WelcomeScreen(ModalScreen[bool]):
|
|
127
|
+
"""Welcome / presentation screen. Modal so Enter/q always work."""
|
|
128
|
+
|
|
129
|
+
BINDINGS = [
|
|
130
|
+
Binding("enter", "start", "Start", show=True),
|
|
131
|
+
Binding("q", "quit", "Quit", show=True),
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
DEFAULT_CSS = """
|
|
135
|
+
WelcomeScreen {
|
|
136
|
+
align: center middle;
|
|
137
|
+
padding: 2 4;
|
|
138
|
+
}
|
|
139
|
+
WelcomeScreen #banner {
|
|
140
|
+
text-align: center;
|
|
141
|
+
padding-bottom: 1;
|
|
142
|
+
}
|
|
143
|
+
WelcomeScreen #welcome_body {
|
|
144
|
+
padding: 1 2;
|
|
145
|
+
width: 60;
|
|
146
|
+
}
|
|
147
|
+
WelcomeScreen #welcome_footer {
|
|
148
|
+
text-align: center;
|
|
149
|
+
padding-top: 2;
|
|
150
|
+
}
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
def compose(self) -> ComposeResult:
|
|
154
|
+
yield Static(WELCOME_BANNER, id="banner", markup=True)
|
|
155
|
+
yield Static(WELCOME_BODY, id="welcome_body", markup=True)
|
|
156
|
+
yield Static(
|
|
157
|
+
"[bold]This screen has focus.[/] Press [cyan]Enter[/] to start · [dim]q[/] to quit",
|
|
158
|
+
id="welcome_footer",
|
|
159
|
+
markup=True,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def on_mount(self) -> None:
|
|
163
|
+
self.sub_title = "Enter = start · q = quit"
|
|
164
|
+
|
|
165
|
+
def action_start(self) -> None:
|
|
166
|
+
self.dismiss(True)
|
|
167
|
+
|
|
168
|
+
def action_quit(self) -> None:
|
|
169
|
+
self.dismiss(False)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class AddSourceScreen(ModalScreen[Path | None]):
|
|
173
|
+
"""Modal to enter a path to add as an extra source root. Keyboard-only: type path, Enter to add, Escape to cancel."""
|
|
174
|
+
|
|
175
|
+
BINDINGS = [
|
|
176
|
+
Binding("escape", "cancel", "Cancel", show=True),
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
DEFAULT_CSS = """
|
|
180
|
+
AddSourceScreen {
|
|
181
|
+
align: center middle;
|
|
182
|
+
padding: 2 4;
|
|
183
|
+
}
|
|
184
|
+
AddSourceScreen #add_source_title {
|
|
185
|
+
text-align: center;
|
|
186
|
+
padding-bottom: 1;
|
|
187
|
+
}
|
|
188
|
+
AddSourceScreen #add_source_input {
|
|
189
|
+
width: 60;
|
|
190
|
+
margin: 1 0;
|
|
191
|
+
}
|
|
192
|
+
AddSourceScreen #add_source_hint {
|
|
193
|
+
text-align: center;
|
|
194
|
+
padding-top: 1;
|
|
195
|
+
}
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
199
|
+
super().__init__(**kwargs)
|
|
200
|
+
self._input: Input | None = None
|
|
201
|
+
|
|
202
|
+
def compose(self) -> ComposeResult:
|
|
203
|
+
with Vertical():
|
|
204
|
+
yield Static(
|
|
205
|
+
"[bold cyan]Add source path[/]\n\n"
|
|
206
|
+
"Type a directory path to scan for ROS 2 packages (e.g. /path/to/ws/src).",
|
|
207
|
+
id="add_source_title",
|
|
208
|
+
markup=True,
|
|
209
|
+
)
|
|
210
|
+
yield Input(
|
|
211
|
+
placeholder="/path/to/source/dir",
|
|
212
|
+
id="add_source_input",
|
|
213
|
+
)
|
|
214
|
+
yield Static(
|
|
215
|
+
"[dim]Enter[/] = Add · [dim]Escape[/] = Cancel",
|
|
216
|
+
id="add_source_hint",
|
|
217
|
+
markup=True,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
def on_mount(self) -> None:
|
|
221
|
+
self._input = self.query_one("#add_source_input", Input)
|
|
222
|
+
self._input.focus()
|
|
223
|
+
|
|
224
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
225
|
+
"""Submit on Enter so no mouse/click needed."""
|
|
226
|
+
if event.input.id != "add_source_input":
|
|
227
|
+
return
|
|
228
|
+
self._do_submit()
|
|
229
|
+
|
|
230
|
+
def _do_submit(self) -> None:
|
|
231
|
+
value = self._input.value.strip() if self._input else ""
|
|
232
|
+
if not value:
|
|
233
|
+
self.dismiss(None)
|
|
234
|
+
return
|
|
235
|
+
p = Path(value).expanduser().resolve()
|
|
236
|
+
if not p.exists():
|
|
237
|
+
self.notify(f"Path does not exist: {p}", severity="warning", timeout=3)
|
|
238
|
+
return
|
|
239
|
+
if not p.is_dir():
|
|
240
|
+
self.notify(f"Not a directory: {p}", severity="warning", timeout=3)
|
|
241
|
+
return
|
|
242
|
+
self.dismiss(p)
|
|
243
|
+
|
|
244
|
+
def action_cancel(self) -> None:
|
|
245
|
+
self.dismiss(None)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class DepTreeApp(App[None]):
|
|
249
|
+
"""Terminal UI to explore ROS 2 package dependency trees."""
|
|
250
|
+
|
|
251
|
+
TITLE = "rostree"
|
|
252
|
+
BINDINGS = [
|
|
253
|
+
Binding("escape", "back", "Back", show=True),
|
|
254
|
+
Binding("b", "back", "Back", show=False),
|
|
255
|
+
Binding("a", "add_source", "Add source"),
|
|
256
|
+
Binding("q", "quit", "Quit"),
|
|
257
|
+
Binding("r", "refresh", "Refresh"),
|
|
258
|
+
Binding("e", "expand_all", "Expand all"),
|
|
259
|
+
Binding("c", "collapse_all", "Collapse"),
|
|
260
|
+
]
|
|
261
|
+
|
|
262
|
+
def __init__(self, root_package: str | None = None, **kwargs: Any) -> None:
|
|
263
|
+
super().__init__(**kwargs)
|
|
264
|
+
self._root_package = root_package
|
|
265
|
+
self._root_node: Any = None
|
|
266
|
+
self._main_started = False
|
|
267
|
+
self._extra_source_roots: list[Path] = []
|
|
268
|
+
|
|
269
|
+
DEFAULT_CSS = """
|
|
270
|
+
#back_bar {
|
|
271
|
+
display: none;
|
|
272
|
+
height: auto;
|
|
273
|
+
padding: 0 1;
|
|
274
|
+
margin-bottom: 1;
|
|
275
|
+
}
|
|
276
|
+
#details {
|
|
277
|
+
padding: 1 2;
|
|
278
|
+
border: solid $primary;
|
|
279
|
+
height: auto;
|
|
280
|
+
min-height: 8;
|
|
281
|
+
}
|
|
282
|
+
"""
|
|
283
|
+
|
|
284
|
+
def compose(self) -> ComposeResult:
|
|
285
|
+
yield Header(show_clock=False)
|
|
286
|
+
with Horizontal(id="back_bar"):
|
|
287
|
+
yield Button("← Back to package list", id="back_btn", variant="primary")
|
|
288
|
+
yield Tree("Dependencies", id="dep_tree")
|
|
289
|
+
yield Static(
|
|
290
|
+
"[dim]↑/↓[/] move · [dim]Enter[/]/[dim]Space[/] select · [dim]Tab[/] = switch focus · [dim]Esc[/]/[dim]b[/] = Back",
|
|
291
|
+
id="details",
|
|
292
|
+
)
|
|
293
|
+
yield Footer()
|
|
294
|
+
|
|
295
|
+
def on_mount(self) -> None:
|
|
296
|
+
self.sub_title = "Tab = move focus · Esc = Back · Keys shown in footer"
|
|
297
|
+
self.push_screen(WelcomeScreen(), self._on_welcome_done)
|
|
298
|
+
|
|
299
|
+
def _on_welcome_done(self, start: bool) -> None:
|
|
300
|
+
if not start:
|
|
301
|
+
self.exit(0)
|
|
302
|
+
return
|
|
303
|
+
self._main_started = True
|
|
304
|
+
self._start_main()
|
|
305
|
+
|
|
306
|
+
def _source_color(self, label: str) -> str:
|
|
307
|
+
if "System" in label:
|
|
308
|
+
return COLOR_SYSTEM
|
|
309
|
+
if "Workspace" in label:
|
|
310
|
+
return COLOR_WORKSPACE
|
|
311
|
+
if "Other" in label:
|
|
312
|
+
return COLOR_OTHER
|
|
313
|
+
if "Added" in label:
|
|
314
|
+
return COLOR_ADDED
|
|
315
|
+
return COLOR_SOURCE
|
|
316
|
+
|
|
317
|
+
def _start_main(self) -> None:
|
|
318
|
+
try:
|
|
319
|
+
try:
|
|
320
|
+
self.query_one("#back_bar").styles.display = "none"
|
|
321
|
+
except Exception:
|
|
322
|
+
pass
|
|
323
|
+
tree = self.query_one("#dep_tree", Tree)
|
|
324
|
+
if self._root_package:
|
|
325
|
+
self._load_tree(self._root_package)
|
|
326
|
+
else:
|
|
327
|
+
by_source = list_known_packages_by_source(
|
|
328
|
+
extra_source_roots=self._extra_source_roots or None,
|
|
329
|
+
)
|
|
330
|
+
if not by_source:
|
|
331
|
+
self._set_details(
|
|
332
|
+
"No ROS 2 packages found. Set AMENT_PREFIX_PATH or run from a workspace.\n\n"
|
|
333
|
+
"[dim]a[/] = Add source path"
|
|
334
|
+
)
|
|
335
|
+
tree.root.add_leaf("[dim]No packages in environment[/]")
|
|
336
|
+
try:
|
|
337
|
+
tree.focus()
|
|
338
|
+
except Exception:
|
|
339
|
+
pass
|
|
340
|
+
return
|
|
341
|
+
total = sum(len(names) for names in by_source.values())
|
|
342
|
+
tree.root.label = f"[{COLOR_HEADER}]Packages by source[/]"
|
|
343
|
+
# Order: System, Workspace, Other, Source, Added
|
|
344
|
+
order = ["System", "Workspace", "Other", "Source", "Added"]
|
|
345
|
+
sorted_keys = sorted(
|
|
346
|
+
by_source.keys(),
|
|
347
|
+
key=lambda k: next((i for i, o in enumerate(order) if o in k), 99),
|
|
348
|
+
)
|
|
349
|
+
recap_parts = []
|
|
350
|
+
for label in sorted_keys:
|
|
351
|
+
names = by_source[label]
|
|
352
|
+
color = self._source_color(label)
|
|
353
|
+
section_node = tree.root.add(
|
|
354
|
+
f"[{color}]{label} ({len(names)})[/]",
|
|
355
|
+
expand=True,
|
|
356
|
+
)
|
|
357
|
+
recap_parts.append(f"[{color}]{label.split('(')[0].strip()}: {len(names)}[/]")
|
|
358
|
+
for name in names[:MAX_PACKAGES_PER_SOURCE]:
|
|
359
|
+
child_tn = section_node.add(f"[{color}]{name}[/]", expand=False)
|
|
360
|
+
child_tn.data = name
|
|
361
|
+
if len(names) > MAX_PACKAGES_PER_SOURCE:
|
|
362
|
+
section_node.add_leaf(
|
|
363
|
+
f"[dim]… and {len(names) - MAX_PACKAGES_PER_SOURCE} more[/]"
|
|
364
|
+
)
|
|
365
|
+
self._set_details(
|
|
366
|
+
f"[{COLOR_HEADER}]Package list[/]\n\n"
|
|
367
|
+
f"Total: [{COLOR_STATS}]{total}[/] packages · "
|
|
368
|
+
+ " · ".join(recap_parts)
|
|
369
|
+
+ "\n\n"
|
|
370
|
+
"[dim]↑/↓[/] move · [dim]Enter[/] or [dim]Space[/] on a package = load tree · "
|
|
371
|
+
"[dim]a[/] = Add source · [dim]Esc[/]/[dim]b[/] = Back (when viewing a tree)"
|
|
372
|
+
)
|
|
373
|
+
try:
|
|
374
|
+
self.query_one("#dep_tree", Tree).focus()
|
|
375
|
+
except Exception:
|
|
376
|
+
pass
|
|
377
|
+
except Exception as e:
|
|
378
|
+
self._set_details(f"[red]Error: {e!s}[/]")
|
|
379
|
+
tree = self.query_one("#dep_tree", Tree)
|
|
380
|
+
tree.root.add_leaf("[dim]Error loading packages[/]")
|
|
381
|
+
try:
|
|
382
|
+
tree.focus()
|
|
383
|
+
except Exception:
|
|
384
|
+
pass
|
|
385
|
+
|
|
386
|
+
def _clear_tree(self, tree: Tree) -> None:
|
|
387
|
+
while tree.root.children:
|
|
388
|
+
tree.root.children[0].remove()
|
|
389
|
+
|
|
390
|
+
def _load_tree(self, root_package: str) -> None:
|
|
391
|
+
self._root_package = root_package
|
|
392
|
+
try:
|
|
393
|
+
self._root_node = build_tree(
|
|
394
|
+
root_package,
|
|
395
|
+
max_depth=TUI_TREE_MAX_DEPTH,
|
|
396
|
+
runtime_only=True,
|
|
397
|
+
extra_source_roots=self._extra_source_roots or None,
|
|
398
|
+
)
|
|
399
|
+
except Exception as e:
|
|
400
|
+
self._set_details(f"[red]Error building tree: {e!s}[/]")
|
|
401
|
+
return
|
|
402
|
+
if self._root_node is None:
|
|
403
|
+
self._set_details(f"Package not found: {root_package}")
|
|
404
|
+
return
|
|
405
|
+
tree = self.query_one("#dep_tree", Tree)
|
|
406
|
+
self._clear_tree(tree)
|
|
407
|
+
tree.root.label = (
|
|
408
|
+
f"[{COLOR_HEADER}]{self._root_node.name}[/] [dim]v{self._root_node.version or '?'}[/]"
|
|
409
|
+
)
|
|
410
|
+
tree.root.data = self._root_node
|
|
411
|
+
_populate_textual_tree(tree.root, self._root_node)
|
|
412
|
+
try:
|
|
413
|
+
_expand_to_depth(tree.root, EXPAND_DEPTH_DEFAULT)
|
|
414
|
+
except Exception:
|
|
415
|
+
pass
|
|
416
|
+
self._set_details(
|
|
417
|
+
self._format_node(self._root_node)
|
|
418
|
+
+ "\n\n[dim]Esc[/] or [dim]b[/] = Back to package list · [dim]Tab[/] then [dim]Enter[/] = Back button"
|
|
419
|
+
)
|
|
420
|
+
try:
|
|
421
|
+
self.query_one("#back_bar").styles.display = "block"
|
|
422
|
+
self.query_one("#dep_tree", Tree).focus()
|
|
423
|
+
except Exception:
|
|
424
|
+
pass
|
|
425
|
+
|
|
426
|
+
def _format_node(self, node: Any) -> str:
|
|
427
|
+
name = getattr(node, "name", "?")
|
|
428
|
+
version = getattr(node, "version", "") or "?"
|
|
429
|
+
desc = getattr(node, "description", "") or "(no description)"
|
|
430
|
+
path = getattr(node, "path", "") or "(n/a)"
|
|
431
|
+
|
|
432
|
+
direct, total_desc, max_depth = _node_stats(node)
|
|
433
|
+
|
|
434
|
+
lines = [
|
|
435
|
+
f"[{COLOR_HEADER}]Package[/]",
|
|
436
|
+
f" [{COLOR_PKG}]{name}[/] [dim]v{version}[/]",
|
|
437
|
+
"",
|
|
438
|
+
f"[{COLOR_HEADER}]Description[/]",
|
|
439
|
+
f" {desc}",
|
|
440
|
+
"",
|
|
441
|
+
f"[{COLOR_HEADER}]Stats[/]",
|
|
442
|
+
f" Direct dependencies: [{COLOR_STATS}]{direct}[/]",
|
|
443
|
+
f" Total descendants: [{COLOR_STATS}]{total_desc}[/] [dim](indirect)[/]",
|
|
444
|
+
f" Max depth from here: [{COLOR_STATS}]{max_depth}[/] [dim]levels[/]",
|
|
445
|
+
"",
|
|
446
|
+
f"[{COLOR_HEADER}]Path[/]",
|
|
447
|
+
f" [{COLOR_PATH}]{path}[/]",
|
|
448
|
+
]
|
|
449
|
+
return "\n".join(lines)
|
|
450
|
+
|
|
451
|
+
def _set_details(self, text: str) -> None:
|
|
452
|
+
details = self.query_one("#details", Static)
|
|
453
|
+
details.update(text)
|
|
454
|
+
|
|
455
|
+
def on_tree_node_selected(self, event: Tree.NodeSelected) -> None:
|
|
456
|
+
node = event.node.data
|
|
457
|
+
if node is None:
|
|
458
|
+
return
|
|
459
|
+
if hasattr(node, "name") and hasattr(node, "children"):
|
|
460
|
+
self._set_details(self._format_node(node))
|
|
461
|
+
elif isinstance(node, str):
|
|
462
|
+
self._load_tree(node)
|
|
463
|
+
|
|
464
|
+
def action_back(self) -> None:
|
|
465
|
+
"""Return to the known packages list (only when viewing a tree)."""
|
|
466
|
+
if not self._main_started or not self._root_package:
|
|
467
|
+
return
|
|
468
|
+
self._root_package = None
|
|
469
|
+
self._root_node = None
|
|
470
|
+
try:
|
|
471
|
+
self.query_one("#back_bar").styles.display = "none"
|
|
472
|
+
except Exception:
|
|
473
|
+
pass
|
|
474
|
+
tree = self.query_one("#dep_tree", Tree)
|
|
475
|
+
self._clear_tree(tree)
|
|
476
|
+
self._start_main()
|
|
477
|
+
|
|
478
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
479
|
+
if event.button.id == "back_btn":
|
|
480
|
+
self.action_back()
|
|
481
|
+
|
|
482
|
+
def action_refresh(self) -> None:
|
|
483
|
+
if not self._main_started:
|
|
484
|
+
return
|
|
485
|
+
if self._root_package:
|
|
486
|
+
self._load_tree(self._root_package)
|
|
487
|
+
else:
|
|
488
|
+
tree = self.query_one("#dep_tree", Tree)
|
|
489
|
+
self._clear_tree(tree)
|
|
490
|
+
self._start_main()
|
|
491
|
+
|
|
492
|
+
def action_expand_all(self) -> None:
|
|
493
|
+
tree = self.query_one("#dep_tree", Tree)
|
|
494
|
+
try:
|
|
495
|
+
tree.root.expand_all()
|
|
496
|
+
except Exception:
|
|
497
|
+
tree.root.expand()
|
|
498
|
+
|
|
499
|
+
def action_collapse_all(self) -> None:
|
|
500
|
+
tree = self.query_one("#dep_tree", Tree)
|
|
501
|
+
try:
|
|
502
|
+
tree.root.collapse_all()
|
|
503
|
+
tree.root.expand()
|
|
504
|
+
except Exception:
|
|
505
|
+
pass
|
|
506
|
+
|
|
507
|
+
def action_add_source(self) -> None:
|
|
508
|
+
"""Open modal to add an extra source path."""
|
|
509
|
+
if not self._main_started:
|
|
510
|
+
return
|
|
511
|
+
self.push_screen(AddSourceScreen(), self._on_add_source_done)
|
|
512
|
+
|
|
513
|
+
def _on_add_source_done(self, path: Path | None) -> None:
|
|
514
|
+
if path is None:
|
|
515
|
+
return
|
|
516
|
+
if path in self._extra_source_roots:
|
|
517
|
+
self.notify("Path already added", severity="information", timeout=2)
|
|
518
|
+
return
|
|
519
|
+
self._extra_source_roots.append(path)
|
|
520
|
+
self.notify(f"Added: {path}", severity="information", timeout=2)
|
|
521
|
+
self.action_refresh()
|
|
522
|
+
try:
|
|
523
|
+
self.query_one("#dep_tree", Tree).focus()
|
|
524
|
+
except Exception:
|
|
525
|
+
pass
|
|
526
|
+
|
|
527
|
+
def action_quit(self) -> None:
|
|
528
|
+
self.exit()
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def main() -> None:
|
|
532
|
+
"""Entry point for the rostree TUI."""
|
|
533
|
+
root = None
|
|
534
|
+
if len(sys.argv) > 1:
|
|
535
|
+
root = sys.argv[1].strip()
|
|
536
|
+
app = DepTreeApp(root_package=root)
|
|
537
|
+
app.run()
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
if __name__ == "__main__":
|
|
541
|
+
main()
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rostree
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Explore ROS 2 package dependencies from the command line (CLI, TUI, library)
|
|
5
|
+
Author: rostree contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: cli,dependencies,ros,ros2,tui,visualization
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Requires-Dist: textual>=0.47.0
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: black==25.1.0; extra == 'dev'
|
|
21
|
+
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
22
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
23
|
+
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# rostree
|
|
27
|
+
|
|
28
|
+
[](https://github.com/guilyx/rostree/actions/workflows/ci.yml)
|
|
29
|
+
[](https://codecov.io/gh/guilyx/rostree)
|
|
30
|
+
[](https://pypi.org/project/rostree/)
|
|
31
|
+
[](https://pypi.org/project/rostree/)
|
|
32
|
+
[](https://pypi.org/project/rostree/)
|
|
33
|
+
[](https://github.com/guilyx/rostree/blob/main/LICENSE)
|
|
34
|
+
|
|
35
|
+
Explore ROS 2 package dependencies from the command line (CLI, TUI, library).
|
|
36
|
+
|
|
37
|
+
**Docs:** [docs/README.md](docs/README.md) — overview, package discovery, dependency trees, usage, development.
|
|
38
|
+
|
|
39
|
+
## Quick start
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install -e . # or: uv pip install -e .
|
|
43
|
+
source /opt/ros/<distro>/setup.bash # and/or your workspace install/setup.bash
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### CLI commands
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
rostree # Launch interactive TUI
|
|
50
|
+
rostree scan # Scan host for ROS 2 workspaces
|
|
51
|
+
rostree scan ~/dev --depth 3 # Scan specific directories
|
|
52
|
+
rostree list # List known packages
|
|
53
|
+
rostree list --by-source # List packages grouped by source
|
|
54
|
+
rostree tree rclpy # Show dependency tree for a package
|
|
55
|
+
rostree tree rclpy --depth 3 # Limit tree depth
|
|
56
|
+
rostree tree rclpy --json # Output as JSON
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### TUI mode
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
rostree tui # Interactive terminal UI
|
|
63
|
+
rostree tui rclpy # Start TUI with a specific package tree
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Python API
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from rostree import list_known_packages, get_package_info, build_tree, scan_workspaces
|
|
70
|
+
|
|
71
|
+
packages = list_known_packages()
|
|
72
|
+
root = build_tree("rclpy", max_depth=5, runtime_only=True)
|
|
73
|
+
workspaces = scan_workspaces() # Scan host for ROS 2 workspaces
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Links
|
|
77
|
+
|
|
78
|
+
- [How the system works](docs/overview.md)
|
|
79
|
+
- [How packages are found](docs/package-discovery.md) (workspaces, AMENT_PREFIX_PATH, COLCON_WORKSPACE)
|
|
80
|
+
- [Dependency trees](docs/dependency-trees.md) (package.xml, runtime_only)
|
|
81
|
+
- [Usage](docs/usage.md) (CLI, TUI keys, API)
|
|
82
|
+
- [Development](docs/development.md) (layout, pre-commit, CI)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
rostree/__init__.py,sha256=6kFb52bjBJQMUbYeGOGuRDKpZjwKPEj1d91pw80xtD8,437
|
|
2
|
+
rostree/api.py,sha256=A2L7uuj3efJXwqazsL9j6y6hgkwxPBnwGv2dYNtFTwg,3646
|
|
3
|
+
rostree/cli.py,sha256=gdRS-Bj_25KQ95fZhD-HRbmQFEJGl7NqnAV4kSnGEGg,9073
|
|
4
|
+
rostree/core/__init__.py,sha256=glLk6MYQvGz0jTre7cVaQhQ6PiAAWa1lXFFMFnWbHbo,582
|
|
5
|
+
rostree/core/finder.py,sha256=lmfzjjHX-bDHprs3x-GKPztpCS4hVsZUq2fZOg64DTg,16801
|
|
6
|
+
rostree/core/parser.py,sha256=_Yhyjrg7Ir-N80KqJnxPYRcejWepDZDFbw_BTKLBySs,3094
|
|
7
|
+
rostree/core/tree.py,sha256=jBvd7_Pzk4ciO9dWV1r4NyU9q6BaXKiYRimbmK72oD4,4060
|
|
8
|
+
rostree/tui/__init__.py,sha256=hHtp0ZeBL6drxVxnBi1pm2uRds5O-UbNASQ6xRzOJrs,64
|
|
9
|
+
rostree/tui/app.py,sha256=_vj47K70w5ZJFANRisENHk9UXlwH8YBVh_qKwfxgQv8,18678
|
|
10
|
+
rostree-0.1.0.dist-info/METADATA,sha256=2uYP2pwyhSwBw5F1ye-p3jU5L3a3fk6YkiXLI_nggZg,3218
|
|
11
|
+
rostree-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
12
|
+
rostree-0.1.0.dist-info/entry_points.txt,sha256=3FqDola110oRFImx5-IF3CEnfCXA3CDSM2fTzbES2Zo,45
|
|
13
|
+
rostree-0.1.0.dist-info/licenses/LICENSE,sha256=mcuqLv_cT8O1n1fIsOkUNDMnKlrPgP5LVCMyyVl-tGk,1530
|
|
14
|
+
rostree-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, The rosdep_viz authors
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
Redistribution and use in source and binary forms, with or without
|
|
7
|
+
modification, are permitted provided that the following conditions are met:
|
|
8
|
+
|
|
9
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
10
|
+
list of conditions and the following disclaimer.
|
|
11
|
+
|
|
12
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
13
|
+
this list of conditions and the following disclaimer in the documentation
|
|
14
|
+
and/or other materials provided with the distribution.
|
|
15
|
+
|
|
16
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
17
|
+
contributors may be used to endorse or promote products derived from
|
|
18
|
+
this software without specific prior written permission.
|
|
19
|
+
|
|
20
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
21
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
22
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
23
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
24
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
25
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
26
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
27
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
28
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
29
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|