rostree 0.1.0__py3-none-any.whl → 0.2.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/cli.py +515 -0
- rostree/tui/app.py +229 -73
- {rostree-0.1.0.dist-info → rostree-0.2.0.dist-info}/METADATA +16 -2
- {rostree-0.1.0.dist-info → rostree-0.2.0.dist-info}/RECORD +7 -7
- {rostree-0.1.0.dist-info → rostree-0.2.0.dist-info}/WHEEL +0 -0
- {rostree-0.1.0.dist-info → rostree-0.2.0.dist-info}/entry_points.txt +0 -0
- {rostree-0.1.0.dist-info → rostree-0.2.0.dist-info}/licenses/LICENSE +0 -0
rostree/cli.py
CHANGED
|
@@ -4,6 +4,8 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import argparse
|
|
6
6
|
import json
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
7
9
|
import sys
|
|
8
10
|
from pathlib import Path
|
|
9
11
|
|
|
@@ -143,6 +145,447 @@ def cmd_tui(args: argparse.Namespace) -> int:
|
|
|
143
145
|
return 0
|
|
144
146
|
|
|
145
147
|
|
|
148
|
+
def _collect_edges(
|
|
149
|
+
node: DependencyNode,
|
|
150
|
+
edges: set[tuple[str, str]],
|
|
151
|
+
visited: set[str] | None = None,
|
|
152
|
+
) -> None:
|
|
153
|
+
"""Recursively collect all edges (parent -> child) from a dependency tree."""
|
|
154
|
+
if visited is None:
|
|
155
|
+
visited = set()
|
|
156
|
+
if node.name in visited:
|
|
157
|
+
return
|
|
158
|
+
visited.add(node.name)
|
|
159
|
+
for child in node.children:
|
|
160
|
+
# Skip special markers
|
|
161
|
+
if child.description in ("(cycle)", "(not found)", "(parse error)"):
|
|
162
|
+
continue
|
|
163
|
+
edges.add((node.name, child.name))
|
|
164
|
+
_collect_edges(child, edges, visited)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _collect_edges_multi(
|
|
168
|
+
trees: list[DependencyNode],
|
|
169
|
+
root_names: set[str],
|
|
170
|
+
) -> tuple[set[tuple[str, str]], set[str]]:
|
|
171
|
+
"""Collect edges from multiple trees, tracking which nodes are roots."""
|
|
172
|
+
edges: set[tuple[str, str]] = set()
|
|
173
|
+
all_nodes: set[str] = set()
|
|
174
|
+
for tree in trees:
|
|
175
|
+
_collect_edges(tree, edges)
|
|
176
|
+
all_nodes.add(tree.name)
|
|
177
|
+
# Also collect all node names from edges
|
|
178
|
+
for parent, child in edges:
|
|
179
|
+
all_nodes.add(parent)
|
|
180
|
+
all_nodes.add(child)
|
|
181
|
+
return edges, all_nodes
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _generate_dot(
|
|
185
|
+
roots: list[DependencyNode],
|
|
186
|
+
title: str | None = None,
|
|
187
|
+
highlight_roots: bool = True,
|
|
188
|
+
) -> str:
|
|
189
|
+
"""Generate DOT (Graphviz) format from dependency trees."""
|
|
190
|
+
root_names = {r.name for r in roots}
|
|
191
|
+
edges: set[tuple[str, str]] = set()
|
|
192
|
+
for root in roots:
|
|
193
|
+
_collect_edges(root, edges)
|
|
194
|
+
|
|
195
|
+
lines = [
|
|
196
|
+
"digraph dependencies {",
|
|
197
|
+
" rankdir=LR;",
|
|
198
|
+
' node [shape=box, style=rounded, fontname="sans-serif"];',
|
|
199
|
+
]
|
|
200
|
+
if title:
|
|
201
|
+
lines.insert(1, f' label="{title}";')
|
|
202
|
+
lines.insert(2, " labelloc=t;")
|
|
203
|
+
|
|
204
|
+
# Highlight root nodes
|
|
205
|
+
if highlight_roots:
|
|
206
|
+
for name in sorted(root_names):
|
|
207
|
+
lines.append(f' "{name}" [style="rounded,filled", fillcolor=lightblue];')
|
|
208
|
+
|
|
209
|
+
for parent, child in sorted(edges):
|
|
210
|
+
lines.append(f' "{parent}" -> "{child}";')
|
|
211
|
+
|
|
212
|
+
lines.append("}")
|
|
213
|
+
return "\n".join(lines)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _generate_mermaid(
|
|
217
|
+
roots: list[DependencyNode],
|
|
218
|
+
title: str | None = None,
|
|
219
|
+
highlight_roots: bool = True,
|
|
220
|
+
) -> str:
|
|
221
|
+
"""Generate Mermaid format from dependency trees."""
|
|
222
|
+
root_names = {r.name for r in roots}
|
|
223
|
+
edges: set[tuple[str, str]] = set()
|
|
224
|
+
for root in roots:
|
|
225
|
+
_collect_edges(root, edges)
|
|
226
|
+
|
|
227
|
+
lines = ["graph LR"]
|
|
228
|
+
if title:
|
|
229
|
+
lines[0] = f"---\ntitle: {title}\n---\ngraph LR"
|
|
230
|
+
|
|
231
|
+
# Style root nodes
|
|
232
|
+
if highlight_roots:
|
|
233
|
+
for name in sorted(root_names):
|
|
234
|
+
lines.append(f" {_mermaid_id(name)}[{name}]")
|
|
235
|
+
lines.append(f" style {_mermaid_id(name)} fill:#lightblue")
|
|
236
|
+
|
|
237
|
+
for parent, child in sorted(edges):
|
|
238
|
+
lines.append(f" {_mermaid_id(parent)} --> {_mermaid_id(child)}")
|
|
239
|
+
|
|
240
|
+
return "\n".join(lines)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _mermaid_id(name: str) -> str:
|
|
244
|
+
"""Convert a package name to a valid Mermaid node ID."""
|
|
245
|
+
# Replace characters that are problematic in Mermaid
|
|
246
|
+
return name.replace("-", "_").replace(".", "_")
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _get_workspace_packages(workspace_path: Path | None = None) -> list[str]:
|
|
250
|
+
"""Get packages from a workspace. If None, use current environment."""
|
|
251
|
+
if workspace_path:
|
|
252
|
+
# Scan the specified workspace
|
|
253
|
+
ws_path = Path(workspace_path).resolve()
|
|
254
|
+
src_path = ws_path / "src" if (ws_path / "src").exists() else ws_path
|
|
255
|
+
if not src_path.exists():
|
|
256
|
+
return []
|
|
257
|
+
from rostree.core.finder import _list_packages_in_src
|
|
258
|
+
|
|
259
|
+
return _list_packages_in_src(src_path)
|
|
260
|
+
else:
|
|
261
|
+
# Use packages from current environment's workspace (not system)
|
|
262
|
+
by_source = list_packages_by_source()
|
|
263
|
+
packages = []
|
|
264
|
+
for label, names in by_source.items():
|
|
265
|
+
# Only include Workspace and Source packages, not System
|
|
266
|
+
if "System" not in label:
|
|
267
|
+
packages.extend(names)
|
|
268
|
+
return packages
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
# Default depth limit for graph to prevent hangs
|
|
272
|
+
GRAPH_DEFAULT_DEPTH = 4
|
|
273
|
+
GRAPH_MAX_PACKAGES = 50
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _check_graphviz() -> bool:
|
|
277
|
+
"""Check if Graphviz (dot) is available."""
|
|
278
|
+
return shutil.which("dot") is not None
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _check_matplotlib() -> bool:
|
|
282
|
+
"""Check if matplotlib and networkx are available."""
|
|
283
|
+
try:
|
|
284
|
+
import matplotlib # noqa: F401
|
|
285
|
+
import networkx # noqa: F401
|
|
286
|
+
|
|
287
|
+
return True
|
|
288
|
+
except ImportError:
|
|
289
|
+
return False
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _render_with_matplotlib(
|
|
293
|
+
edges: set[tuple[str, str]],
|
|
294
|
+
root_names: set[str],
|
|
295
|
+
output_path: Path,
|
|
296
|
+
format: str,
|
|
297
|
+
title: str | None = None,
|
|
298
|
+
) -> bool:
|
|
299
|
+
"""Render graph using matplotlib and networkx (pure Python, no system deps)."""
|
|
300
|
+
try:
|
|
301
|
+
import matplotlib.pyplot as plt
|
|
302
|
+
import networkx as nx
|
|
303
|
+
except ImportError:
|
|
304
|
+
print(
|
|
305
|
+
"Error: matplotlib/networkx not installed. Install with:\n"
|
|
306
|
+
" pip install rostree[viz]\n"
|
|
307
|
+
" # or: pip install matplotlib networkx",
|
|
308
|
+
file=sys.stderr,
|
|
309
|
+
)
|
|
310
|
+
return False
|
|
311
|
+
|
|
312
|
+
try:
|
|
313
|
+
# Create directed graph
|
|
314
|
+
G = nx.DiGraph()
|
|
315
|
+
G.add_edges_from(edges)
|
|
316
|
+
|
|
317
|
+
# Add isolated root nodes (roots with no deps)
|
|
318
|
+
for root in root_names:
|
|
319
|
+
if root not in G:
|
|
320
|
+
G.add_node(root)
|
|
321
|
+
|
|
322
|
+
if len(G.nodes()) == 0:
|
|
323
|
+
print("Error: Graph is empty", file=sys.stderr)
|
|
324
|
+
return False
|
|
325
|
+
|
|
326
|
+
# Create figure
|
|
327
|
+
fig_width = max(12, len(G.nodes()) * 0.5)
|
|
328
|
+
fig_height = max(8, len(G.nodes()) * 0.3)
|
|
329
|
+
fig, ax = plt.subplots(figsize=(fig_width, fig_height))
|
|
330
|
+
|
|
331
|
+
# Layout - hierarchical for dependency graphs
|
|
332
|
+
try:
|
|
333
|
+
# Try graphviz layout if available (best for DAGs)
|
|
334
|
+
pos = nx.nx_agraph.graphviz_layout(G, prog="dot", args="-Grankdir=LR")
|
|
335
|
+
except Exception:
|
|
336
|
+
try:
|
|
337
|
+
# Fall back to spring layout with more iterations
|
|
338
|
+
pos = nx.spring_layout(G, k=2, iterations=50, seed=42)
|
|
339
|
+
except Exception:
|
|
340
|
+
# Last resort: shell layout
|
|
341
|
+
pos = nx.shell_layout(G)
|
|
342
|
+
|
|
343
|
+
# Node colors: root nodes are highlighted
|
|
344
|
+
node_colors = ["lightblue" if n in root_names else "lightgray" for n in G.nodes()]
|
|
345
|
+
|
|
346
|
+
# Draw the graph
|
|
347
|
+
nx.draw_networkx_nodes(
|
|
348
|
+
G,
|
|
349
|
+
pos,
|
|
350
|
+
node_color=node_colors,
|
|
351
|
+
node_size=2000,
|
|
352
|
+
alpha=0.9,
|
|
353
|
+
ax=ax,
|
|
354
|
+
)
|
|
355
|
+
nx.draw_networkx_labels(
|
|
356
|
+
G,
|
|
357
|
+
pos,
|
|
358
|
+
font_size=8,
|
|
359
|
+
font_weight="bold",
|
|
360
|
+
ax=ax,
|
|
361
|
+
)
|
|
362
|
+
nx.draw_networkx_edges(
|
|
363
|
+
G,
|
|
364
|
+
pos,
|
|
365
|
+
edge_color="gray",
|
|
366
|
+
arrows=True,
|
|
367
|
+
arrowsize=15,
|
|
368
|
+
alpha=0.7,
|
|
369
|
+
ax=ax,
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
if title:
|
|
373
|
+
ax.set_title(title, fontsize=14, fontweight="bold")
|
|
374
|
+
|
|
375
|
+
ax.axis("off")
|
|
376
|
+
plt.tight_layout()
|
|
377
|
+
|
|
378
|
+
# Save to file
|
|
379
|
+
plt.savefig(output_path, format=format, dpi=150, bbox_inches="tight")
|
|
380
|
+
plt.close(fig)
|
|
381
|
+
return True
|
|
382
|
+
|
|
383
|
+
except Exception as e:
|
|
384
|
+
print(f"Error rendering with matplotlib: {e}", file=sys.stderr)
|
|
385
|
+
return False
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _render_dot(dot_content: str, output_path: Path, format: str) -> bool:
|
|
389
|
+
"""Render DOT content to an image file using Graphviz."""
|
|
390
|
+
if not _check_graphviz():
|
|
391
|
+
print(
|
|
392
|
+
"Error: Graphviz not found. Install it with:\n"
|
|
393
|
+
" Ubuntu/Debian: sudo apt install graphviz\n"
|
|
394
|
+
" macOS: brew install graphviz\n"
|
|
395
|
+
" Or download from: https://graphviz.org/download/",
|
|
396
|
+
file=sys.stderr,
|
|
397
|
+
)
|
|
398
|
+
return False
|
|
399
|
+
|
|
400
|
+
try:
|
|
401
|
+
result = subprocess.run(
|
|
402
|
+
["dot", f"-T{format}", "-o", str(output_path)],
|
|
403
|
+
input=dot_content,
|
|
404
|
+
capture_output=True,
|
|
405
|
+
text=True,
|
|
406
|
+
timeout=60,
|
|
407
|
+
)
|
|
408
|
+
if result.returncode != 0:
|
|
409
|
+
print(f"Graphviz error: {result.stderr}", file=sys.stderr)
|
|
410
|
+
return False
|
|
411
|
+
return True
|
|
412
|
+
except subprocess.TimeoutExpired:
|
|
413
|
+
print("Error: Graphviz timed out (graph may be too large)", file=sys.stderr)
|
|
414
|
+
return False
|
|
415
|
+
except Exception as e:
|
|
416
|
+
print(f"Error running Graphviz: {e}", file=sys.stderr)
|
|
417
|
+
return False
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _open_file(path: Path) -> bool:
|
|
421
|
+
"""Open a file with the system default application."""
|
|
422
|
+
import platform
|
|
423
|
+
|
|
424
|
+
system = platform.system()
|
|
425
|
+
try:
|
|
426
|
+
if system == "Darwin": # macOS
|
|
427
|
+
subprocess.run(["open", str(path)], check=True)
|
|
428
|
+
elif system == "Windows":
|
|
429
|
+
subprocess.run(["start", "", str(path)], shell=True, check=True)
|
|
430
|
+
else: # Linux and others
|
|
431
|
+
subprocess.run(["xdg-open", str(path)], check=True)
|
|
432
|
+
return True
|
|
433
|
+
except Exception as e:
|
|
434
|
+
print(f"Could not open file: {e}", file=sys.stderr)
|
|
435
|
+
return False
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def cmd_graph(args: argparse.Namespace) -> int:
|
|
439
|
+
"""Generate a dependency graph in DOT or Mermaid format."""
|
|
440
|
+
extra_roots = [Path(p) for p in args.source] if args.source else None
|
|
441
|
+
|
|
442
|
+
# Determine packages to graph
|
|
443
|
+
packages_to_graph: list[str] = []
|
|
444
|
+
|
|
445
|
+
if args.package:
|
|
446
|
+
packages_to_graph = [args.package]
|
|
447
|
+
elif args.workspace:
|
|
448
|
+
# Scan specified workspace
|
|
449
|
+
packages_to_graph = _get_workspace_packages(Path(args.workspace))
|
|
450
|
+
if not packages_to_graph:
|
|
451
|
+
print(f"No packages found in workspace: {args.workspace}", file=sys.stderr)
|
|
452
|
+
return 1
|
|
453
|
+
else:
|
|
454
|
+
# Use current environment's non-system packages
|
|
455
|
+
packages_to_graph = _get_workspace_packages(None)
|
|
456
|
+
if not packages_to_graph:
|
|
457
|
+
print(
|
|
458
|
+
"No workspace packages found. Specify a package or use --workspace.",
|
|
459
|
+
file=sys.stderr,
|
|
460
|
+
)
|
|
461
|
+
return 1
|
|
462
|
+
|
|
463
|
+
# Limit packages for performance
|
|
464
|
+
if len(packages_to_graph) > GRAPH_MAX_PACKAGES and not args.package:
|
|
465
|
+
print(
|
|
466
|
+
f"Warning: Limiting to first {GRAPH_MAX_PACKAGES} packages "
|
|
467
|
+
f"(found {len(packages_to_graph)}). Use -d to limit depth.",
|
|
468
|
+
file=sys.stderr,
|
|
469
|
+
)
|
|
470
|
+
packages_to_graph = packages_to_graph[:GRAPH_MAX_PACKAGES]
|
|
471
|
+
|
|
472
|
+
# Use default depth for workspace graphs (prevent hangs), unlimited for single package
|
|
473
|
+
if args.depth is not None:
|
|
474
|
+
depth = args.depth
|
|
475
|
+
elif args.package:
|
|
476
|
+
depth = None # Unlimited for single package
|
|
477
|
+
else:
|
|
478
|
+
depth = GRAPH_DEFAULT_DEPTH # Limited for workspace-wide
|
|
479
|
+
|
|
480
|
+
# Build trees for all packages
|
|
481
|
+
trees: list[DependencyNode] = []
|
|
482
|
+
for i, pkg in enumerate(packages_to_graph):
|
|
483
|
+
if len(packages_to_graph) > 1:
|
|
484
|
+
print(f"Processing {pkg} ({i + 1}/{len(packages_to_graph)})...", file=sys.stderr)
|
|
485
|
+
tree = build_dependency_tree(
|
|
486
|
+
pkg,
|
|
487
|
+
max_depth=depth,
|
|
488
|
+
runtime_only=args.runtime,
|
|
489
|
+
extra_source_roots=extra_roots,
|
|
490
|
+
)
|
|
491
|
+
if tree is not None:
|
|
492
|
+
trees.append(tree)
|
|
493
|
+
|
|
494
|
+
if not trees:
|
|
495
|
+
print("No valid package trees found.", file=sys.stderr)
|
|
496
|
+
return 1
|
|
497
|
+
|
|
498
|
+
# Generate title
|
|
499
|
+
if args.no_title:
|
|
500
|
+
title = None
|
|
501
|
+
elif args.package:
|
|
502
|
+
title = f"{args.package} dependencies"
|
|
503
|
+
elif args.workspace:
|
|
504
|
+
title = f"Workspace: {Path(args.workspace).name}"
|
|
505
|
+
else:
|
|
506
|
+
title = "Workspace dependencies"
|
|
507
|
+
|
|
508
|
+
if args.format == "mermaid":
|
|
509
|
+
output = _generate_mermaid(trees, title=title)
|
|
510
|
+
else: # dot
|
|
511
|
+
output = _generate_dot(trees, title=title)
|
|
512
|
+
|
|
513
|
+
# Handle rendering to image
|
|
514
|
+
render_format = getattr(args, "render", None)
|
|
515
|
+
if render_format:
|
|
516
|
+
if args.format == "mermaid":
|
|
517
|
+
print(
|
|
518
|
+
"Error: --render only works with DOT format (not mermaid). "
|
|
519
|
+
"Remove -f mermaid or use mermaid.live for rendering.",
|
|
520
|
+
file=sys.stderr,
|
|
521
|
+
)
|
|
522
|
+
return 1
|
|
523
|
+
|
|
524
|
+
# Determine output path
|
|
525
|
+
if args.output:
|
|
526
|
+
# If output specified, use it with proper extension
|
|
527
|
+
out_path = Path(args.output)
|
|
528
|
+
if out_path.suffix.lower() not in (f".{render_format}", ".dot"):
|
|
529
|
+
out_path = out_path.with_suffix(f".{render_format}")
|
|
530
|
+
else:
|
|
531
|
+
# Default filename based on package or workspace
|
|
532
|
+
if args.package:
|
|
533
|
+
base_name = args.package.replace("/", "_")
|
|
534
|
+
elif args.workspace:
|
|
535
|
+
base_name = Path(args.workspace).name
|
|
536
|
+
else:
|
|
537
|
+
base_name = "workspace_deps"
|
|
538
|
+
out_path = Path(f"{base_name}.{render_format}")
|
|
539
|
+
|
|
540
|
+
print(f"Rendering graph to {out_path}...", file=sys.stderr)
|
|
541
|
+
|
|
542
|
+
# Try Graphviz first (best quality), fall back to matplotlib
|
|
543
|
+
rendered = False
|
|
544
|
+
if _check_graphviz():
|
|
545
|
+
rendered = _render_dot(output, out_path, render_format)
|
|
546
|
+
else:
|
|
547
|
+
# Collect edges for matplotlib rendering
|
|
548
|
+
root_names = {t.name for t in trees}
|
|
549
|
+
edges: set[tuple[str, str]] = set()
|
|
550
|
+
for tree in trees:
|
|
551
|
+
_collect_edges(tree, edges)
|
|
552
|
+
|
|
553
|
+
if _check_matplotlib():
|
|
554
|
+
print("Graphviz not found, using matplotlib...", file=sys.stderr)
|
|
555
|
+
rendered = _render_with_matplotlib(
|
|
556
|
+
edges, root_names, out_path, render_format, title
|
|
557
|
+
)
|
|
558
|
+
else:
|
|
559
|
+
print(
|
|
560
|
+
"Error: No rendering backend available.\n"
|
|
561
|
+
"Install one of:\n"
|
|
562
|
+
" 1. Graphviz (system): sudo apt install graphviz\n"
|
|
563
|
+
" 2. matplotlib (pip): pip install rostree[viz]",
|
|
564
|
+
file=sys.stderr,
|
|
565
|
+
)
|
|
566
|
+
return 1
|
|
567
|
+
|
|
568
|
+
if not rendered:
|
|
569
|
+
return 1
|
|
570
|
+
|
|
571
|
+
print(f"Graph image saved to: {out_path}", file=sys.stderr)
|
|
572
|
+
|
|
573
|
+
# Open the file if requested
|
|
574
|
+
if getattr(args, "open", False):
|
|
575
|
+
_open_file(out_path)
|
|
576
|
+
|
|
577
|
+
return 0
|
|
578
|
+
|
|
579
|
+
# Just output text (DOT or Mermaid)
|
|
580
|
+
if args.output:
|
|
581
|
+
Path(args.output).write_text(output)
|
|
582
|
+
print(f"Graph written to: {args.output}", file=sys.stderr)
|
|
583
|
+
else:
|
|
584
|
+
print(output)
|
|
585
|
+
|
|
586
|
+
return 0
|
|
587
|
+
|
|
588
|
+
|
|
146
589
|
def main(argv: list[str] | None = None) -> int:
|
|
147
590
|
"""Main entry point for the rostree CLI."""
|
|
148
591
|
parser = argparse.ArgumentParser(
|
|
@@ -262,6 +705,78 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
262
705
|
)
|
|
263
706
|
tree_parser.set_defaults(func=cmd_tree)
|
|
264
707
|
|
|
708
|
+
# rostree graph
|
|
709
|
+
graph_parser = subparsers.add_parser(
|
|
710
|
+
"graph",
|
|
711
|
+
help="Generate a dependency graph (DOT/Mermaid format)",
|
|
712
|
+
description=(
|
|
713
|
+
"Generate a visual dependency graph. "
|
|
714
|
+
"Without arguments, graphs all workspace packages. "
|
|
715
|
+
"Specify a package name to graph just that package."
|
|
716
|
+
),
|
|
717
|
+
)
|
|
718
|
+
graph_parser.add_argument(
|
|
719
|
+
"package",
|
|
720
|
+
nargs="?",
|
|
721
|
+
help="Package name to graph (optional; without it, graphs workspace)",
|
|
722
|
+
)
|
|
723
|
+
graph_parser.add_argument(
|
|
724
|
+
"-w",
|
|
725
|
+
"--workspace",
|
|
726
|
+
metavar="PATH",
|
|
727
|
+
help="Scan and graph packages from this workspace path",
|
|
728
|
+
)
|
|
729
|
+
graph_parser.add_argument(
|
|
730
|
+
"-f",
|
|
731
|
+
"--format",
|
|
732
|
+
choices=["dot", "mermaid"],
|
|
733
|
+
default="dot",
|
|
734
|
+
help="Output format: dot (Graphviz) or mermaid (default: dot)",
|
|
735
|
+
)
|
|
736
|
+
graph_parser.add_argument(
|
|
737
|
+
"-o",
|
|
738
|
+
"--output",
|
|
739
|
+
metavar="FILE",
|
|
740
|
+
help="Output file (default: stdout)",
|
|
741
|
+
)
|
|
742
|
+
graph_parser.add_argument(
|
|
743
|
+
"-d",
|
|
744
|
+
"--depth",
|
|
745
|
+
type=int,
|
|
746
|
+
default=None,
|
|
747
|
+
help=f"Maximum tree depth (default: {GRAPH_DEFAULT_DEPTH} for workspace, unlimited for single package)",
|
|
748
|
+
)
|
|
749
|
+
graph_parser.add_argument(
|
|
750
|
+
"-r",
|
|
751
|
+
"--runtime",
|
|
752
|
+
action="store_true",
|
|
753
|
+
help="Show only runtime dependencies (depend, exec_depend)",
|
|
754
|
+
)
|
|
755
|
+
graph_parser.add_argument(
|
|
756
|
+
"-s",
|
|
757
|
+
"--source",
|
|
758
|
+
action="append",
|
|
759
|
+
metavar="PATH",
|
|
760
|
+
help="Additional source directories to scan (can be repeated)",
|
|
761
|
+
)
|
|
762
|
+
graph_parser.add_argument(
|
|
763
|
+
"--no-title",
|
|
764
|
+
action="store_true",
|
|
765
|
+
help="Don't include a title in the graph",
|
|
766
|
+
)
|
|
767
|
+
graph_parser.add_argument(
|
|
768
|
+
"--render",
|
|
769
|
+
choices=["png", "svg", "pdf"],
|
|
770
|
+
metavar="FORMAT",
|
|
771
|
+
help="Render to image (png, svg, pdf). Requires Graphviz installed.",
|
|
772
|
+
)
|
|
773
|
+
graph_parser.add_argument(
|
|
774
|
+
"--open",
|
|
775
|
+
action="store_true",
|
|
776
|
+
help="Open the rendered image after creation (use with --render)",
|
|
777
|
+
)
|
|
778
|
+
graph_parser.set_defaults(func=cmd_graph)
|
|
779
|
+
|
|
265
780
|
# rostree tui (default if no command)
|
|
266
781
|
tui_parser = subparsers.add_parser(
|
|
267
782
|
"tui",
|
rostree/tui/app.py
CHANGED
|
@@ -8,34 +8,27 @@ from typing import Any
|
|
|
8
8
|
|
|
9
9
|
from textual.app import App, ComposeResult
|
|
10
10
|
from textual.binding import Binding
|
|
11
|
-
from textual.containers import
|
|
11
|
+
from textual.containers import Container, Vertical
|
|
12
12
|
from textual.screen import ModalScreen
|
|
13
|
-
from textual.widgets import
|
|
13
|
+
from textual.widgets import Footer, Header, Input, Static, Tree
|
|
14
14
|
from textual.widgets.tree import TreeNode
|
|
15
15
|
|
|
16
16
|
from rostree.api import build_tree, list_known_packages_by_source
|
|
17
17
|
|
|
18
|
-
# Welcome banner: ROSTREE
|
|
19
|
-
WELCOME_BANNER = """
|
|
18
|
+
# Welcome banner: ROSTREE (all lines must be same length for proper centering)
|
|
19
|
+
WELCOME_BANNER = """\
|
|
20
20
|
[bold cyan]
|
|
21
|
-
██████╗ ██████╗
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
██║
|
|
26
|
-
╚═╝ ╚═╝
|
|
27
|
-
[/bold cyan]
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
"""
|
|
21
|
+
██████╗ ██████╗ ███████╗ ████████╗ ██████╗ ███████╗ ███████╗
|
|
22
|
+
██╔══██╗ ██╔═══██╗ ██╔════╝ ╚══██╔══╝ ██╔══██╗ ██╔════╝ ██╔════╝
|
|
23
|
+
██████╔╝ ██║ ██║ ███████╗ ██║ ██████╔╝ █████╗ █████╗
|
|
24
|
+
██╔══██╗ ██║ ██║ ╚════██║ ██║ ██╔══██╗ ██╔══╝ ██╔══╝
|
|
25
|
+
██║ ██║ ╚██████╔╝ ███████║ ██║ ██║ ██║ ███████╗ ███████╗
|
|
26
|
+
╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝ ╚══════╝
|
|
27
|
+
[/bold cyan]"""
|
|
28
|
+
|
|
29
|
+
WELCOME_DESC = """[dim]Navigate and visualize ROS 2 package dependency trees.
|
|
30
|
+
Discover packages from your workspace, system installs, and custom paths.
|
|
31
|
+
Search, expand, and explore the full dependency graph interactively.[/]"""
|
|
39
32
|
|
|
40
33
|
# Limits to avoid huge trees and crashes
|
|
41
34
|
MAX_PACKAGES_PER_SOURCE = 80 # max package names per source section
|
|
@@ -123,50 +116,67 @@ def _expand_to_depth(tn: TreeNode, depth: int, current: int = 0) -> None:
|
|
|
123
116
|
pass
|
|
124
117
|
|
|
125
118
|
|
|
126
|
-
class
|
|
127
|
-
"""
|
|
119
|
+
class SearchScreen(ModalScreen[str | None]):
|
|
120
|
+
"""Modal to search for packages/nodes in the tree. Keyboard-only."""
|
|
128
121
|
|
|
129
122
|
BINDINGS = [
|
|
130
|
-
Binding("
|
|
131
|
-
Binding("q", "quit", "Quit", show=True),
|
|
123
|
+
Binding("escape", "cancel", "Cancel", show=True),
|
|
132
124
|
]
|
|
133
125
|
|
|
134
126
|
DEFAULT_CSS = """
|
|
135
|
-
|
|
127
|
+
SearchScreen {
|
|
136
128
|
align: center middle;
|
|
137
129
|
padding: 2 4;
|
|
138
130
|
}
|
|
139
|
-
|
|
131
|
+
SearchScreen #search_title {
|
|
140
132
|
text-align: center;
|
|
141
133
|
padding-bottom: 1;
|
|
142
134
|
}
|
|
143
|
-
|
|
144
|
-
padding: 1 2;
|
|
135
|
+
SearchScreen #search_input {
|
|
145
136
|
width: 60;
|
|
137
|
+
margin: 1 0;
|
|
146
138
|
}
|
|
147
|
-
|
|
139
|
+
SearchScreen #search_hint {
|
|
148
140
|
text-align: center;
|
|
149
|
-
padding-top:
|
|
141
|
+
padding-top: 1;
|
|
150
142
|
}
|
|
151
143
|
"""
|
|
152
144
|
|
|
145
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
146
|
+
super().__init__(**kwargs)
|
|
147
|
+
self._input: Input | None = None
|
|
148
|
+
|
|
153
149
|
def compose(self) -> ComposeResult:
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
150
|
+
with Vertical():
|
|
151
|
+
yield Static(
|
|
152
|
+
"[bold cyan]Search[/]\n\n"
|
|
153
|
+
"Type a package name or partial match to find in the tree.",
|
|
154
|
+
id="search_title",
|
|
155
|
+
markup=True,
|
|
156
|
+
)
|
|
157
|
+
yield Input(
|
|
158
|
+
placeholder="package name...",
|
|
159
|
+
id="search_input",
|
|
160
|
+
)
|
|
161
|
+
yield Static(
|
|
162
|
+
"[dim]Enter[/] = Search · [dim]Escape[/] = Cancel\n"
|
|
163
|
+
"[dim]After search: [bold]n[/bold] = next match, [bold]N[/bold] = previous[/]",
|
|
164
|
+
id="search_hint",
|
|
165
|
+
markup=True,
|
|
166
|
+
)
|
|
161
167
|
|
|
162
168
|
def on_mount(self) -> None:
|
|
163
|
-
self.
|
|
169
|
+
self._input = self.query_one("#search_input", Input)
|
|
170
|
+
self._input.focus()
|
|
164
171
|
|
|
165
|
-
def
|
|
166
|
-
|
|
172
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
173
|
+
if event.input.id != "search_input":
|
|
174
|
+
return
|
|
175
|
+
value = self._input.value.strip() if self._input else ""
|
|
176
|
+
self.dismiss(value if value else None)
|
|
167
177
|
|
|
168
|
-
def
|
|
169
|
-
self.dismiss(
|
|
178
|
+
def action_cancel(self) -> None:
|
|
179
|
+
self.dismiss(None)
|
|
170
180
|
|
|
171
181
|
|
|
172
182
|
class AddSourceScreen(ModalScreen[Path | None]):
|
|
@@ -250,9 +260,15 @@ class DepTreeApp(App[None]):
|
|
|
250
260
|
|
|
251
261
|
TITLE = "rostree"
|
|
252
262
|
BINDINGS = [
|
|
263
|
+
Binding("enter", "start_main", "Start", show=False),
|
|
253
264
|
Binding("escape", "back", "Back", show=True),
|
|
254
265
|
Binding("b", "back", "Back", show=False),
|
|
255
266
|
Binding("a", "add_source", "Add source"),
|
|
267
|
+
Binding("/", "search", "Search"),
|
|
268
|
+
Binding("f", "search", "Search", show=False),
|
|
269
|
+
Binding("n", "next_match", "Next match", show=False),
|
|
270
|
+
Binding("N", "prev_match", "Prev match", show=False),
|
|
271
|
+
Binding("d", "toggle_details", "Details"),
|
|
256
272
|
Binding("q", "quit", "Quit"),
|
|
257
273
|
Binding("r", "refresh", "Refresh"),
|
|
258
274
|
Binding("e", "expand_all", "Expand all"),
|
|
@@ -265,13 +281,41 @@ class DepTreeApp(App[None]):
|
|
|
265
281
|
self._root_node: Any = None
|
|
266
282
|
self._main_started = False
|
|
267
283
|
self._extra_source_roots: list[Path] = []
|
|
284
|
+
self._search_query: str = ""
|
|
285
|
+
self._search_matches: list[TreeNode] = []
|
|
286
|
+
self._search_index: int = 0
|
|
287
|
+
self._details_visible: bool = True
|
|
268
288
|
|
|
269
289
|
DEFAULT_CSS = """
|
|
270
|
-
|
|
290
|
+
/* Welcome screen styles */
|
|
291
|
+
#welcome_container {
|
|
292
|
+
align: center middle;
|
|
293
|
+
width: 100%;
|
|
294
|
+
height: 100%;
|
|
295
|
+
}
|
|
296
|
+
#welcome_banner {
|
|
297
|
+
text-align: center;
|
|
298
|
+
content-align: center middle;
|
|
299
|
+
width: 100%;
|
|
300
|
+
}
|
|
301
|
+
#welcome_desc {
|
|
302
|
+
text-align: center;
|
|
303
|
+
padding: 2 4;
|
|
304
|
+
}
|
|
305
|
+
#welcome_hint {
|
|
306
|
+
text-align: center;
|
|
307
|
+
padding-top: 1;
|
|
308
|
+
}
|
|
309
|
+
/* Main view styles */
|
|
310
|
+
#main_container {
|
|
311
|
+
display: none;
|
|
312
|
+
}
|
|
313
|
+
#nav_hint {
|
|
271
314
|
display: none;
|
|
272
315
|
height: auto;
|
|
273
316
|
padding: 0 1;
|
|
274
317
|
margin-bottom: 1;
|
|
318
|
+
color: $text-muted;
|
|
275
319
|
}
|
|
276
320
|
#details {
|
|
277
321
|
padding: 1 2;
|
|
@@ -283,25 +327,50 @@ class DepTreeApp(App[None]):
|
|
|
283
327
|
|
|
284
328
|
def compose(self) -> ComposeResult:
|
|
285
329
|
yield Header(show_clock=False)
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
330
|
+
# Welcome view (initial)
|
|
331
|
+
with Container(id="welcome_container"):
|
|
332
|
+
yield Static(WELCOME_BANNER, id="welcome_banner", markup=True)
|
|
333
|
+
yield Static(WELCOME_DESC, id="welcome_desc", markup=True)
|
|
334
|
+
yield Static(
|
|
335
|
+
"[cyan]Enter[/] to explore · [dim]q[/] to quit",
|
|
336
|
+
id="welcome_hint",
|
|
337
|
+
markup=True,
|
|
338
|
+
)
|
|
339
|
+
# Main view (hidden initially)
|
|
340
|
+
with Container(id="main_container"):
|
|
341
|
+
yield Static(
|
|
342
|
+
"[dim]← Press [bold]Esc[/bold] or [bold]b[/bold] to return to package list[/]",
|
|
343
|
+
id="nav_hint",
|
|
344
|
+
)
|
|
345
|
+
yield Tree("Dependencies", id="dep_tree")
|
|
346
|
+
yield Static(
|
|
347
|
+
"[dim]↑/↓[/] move · [dim]Enter[/]/[dim]Space[/] select · [dim]Esc[/]/[dim]b[/] = Back",
|
|
348
|
+
id="details",
|
|
349
|
+
)
|
|
293
350
|
yield Footer()
|
|
294
351
|
|
|
295
352
|
def on_mount(self) -> None:
|
|
296
|
-
self.sub_title = "
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
if not
|
|
301
|
-
|
|
353
|
+
self.sub_title = "Dependency Tree Explorer"
|
|
354
|
+
|
|
355
|
+
def on_key(self, event: Any) -> None:
|
|
356
|
+
"""Handle key events - specifically Enter on welcome screen."""
|
|
357
|
+
if not self._main_started and event.key == "enter":
|
|
358
|
+
event.prevent_default()
|
|
359
|
+
event.stop()
|
|
360
|
+
self.action_start_main()
|
|
361
|
+
|
|
362
|
+
def action_start_main(self) -> None:
|
|
363
|
+
"""Transition from welcome screen to main view."""
|
|
364
|
+
if self._main_started:
|
|
302
365
|
return
|
|
303
366
|
self._main_started = True
|
|
304
|
-
|
|
367
|
+
# Hide welcome, show main
|
|
368
|
+
try:
|
|
369
|
+
self.query_one("#welcome_container").styles.display = "none"
|
|
370
|
+
self.query_one("#main_container").styles.display = "block"
|
|
371
|
+
except Exception:
|
|
372
|
+
pass
|
|
373
|
+
self._load_main_view()
|
|
305
374
|
|
|
306
375
|
def _source_color(self, label: str) -> str:
|
|
307
376
|
if "System" in label:
|
|
@@ -314,10 +383,10 @@ class DepTreeApp(App[None]):
|
|
|
314
383
|
return COLOR_ADDED
|
|
315
384
|
return COLOR_SOURCE
|
|
316
385
|
|
|
317
|
-
def
|
|
386
|
+
def _load_main_view(self) -> None:
|
|
318
387
|
try:
|
|
319
388
|
try:
|
|
320
|
-
self.query_one("#
|
|
389
|
+
self.query_one("#nav_hint").styles.display = "none"
|
|
321
390
|
except Exception:
|
|
322
391
|
pass
|
|
323
392
|
tree = self.query_one("#dep_tree", Tree)
|
|
@@ -413,12 +482,9 @@ class DepTreeApp(App[None]):
|
|
|
413
482
|
_expand_to_depth(tree.root, EXPAND_DEPTH_DEFAULT)
|
|
414
483
|
except Exception:
|
|
415
484
|
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
|
-
)
|
|
485
|
+
self._set_details(self._format_node(self._root_node))
|
|
420
486
|
try:
|
|
421
|
-
self.query_one("#
|
|
487
|
+
self.query_one("#nav_hint").styles.display = "block"
|
|
422
488
|
self.query_one("#dep_tree", Tree).focus()
|
|
423
489
|
except Exception:
|
|
424
490
|
pass
|
|
@@ -468,16 +534,12 @@ class DepTreeApp(App[None]):
|
|
|
468
534
|
self._root_package = None
|
|
469
535
|
self._root_node = None
|
|
470
536
|
try:
|
|
471
|
-
self.query_one("#
|
|
537
|
+
self.query_one("#nav_hint").styles.display = "none"
|
|
472
538
|
except Exception:
|
|
473
539
|
pass
|
|
474
540
|
tree = self.query_one("#dep_tree", Tree)
|
|
475
541
|
self._clear_tree(tree)
|
|
476
|
-
self.
|
|
477
|
-
|
|
478
|
-
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
479
|
-
if event.button.id == "back_btn":
|
|
480
|
-
self.action_back()
|
|
542
|
+
self._load_main_view()
|
|
481
543
|
|
|
482
544
|
def action_refresh(self) -> None:
|
|
483
545
|
if not self._main_started:
|
|
@@ -487,7 +549,7 @@ class DepTreeApp(App[None]):
|
|
|
487
549
|
else:
|
|
488
550
|
tree = self.query_one("#dep_tree", Tree)
|
|
489
551
|
self._clear_tree(tree)
|
|
490
|
-
self.
|
|
552
|
+
self._load_main_view()
|
|
491
553
|
|
|
492
554
|
def action_expand_all(self) -> None:
|
|
493
555
|
tree = self.query_one("#dep_tree", Tree)
|
|
@@ -524,6 +586,100 @@ class DepTreeApp(App[None]):
|
|
|
524
586
|
except Exception:
|
|
525
587
|
pass
|
|
526
588
|
|
|
589
|
+
def action_search(self) -> None:
|
|
590
|
+
"""Open search modal."""
|
|
591
|
+
if not self._main_started:
|
|
592
|
+
return
|
|
593
|
+
self.push_screen(SearchScreen(), self._on_search_done)
|
|
594
|
+
|
|
595
|
+
def _on_search_done(self, query: str | None) -> None:
|
|
596
|
+
if not query:
|
|
597
|
+
return
|
|
598
|
+
self._search_query = query.lower()
|
|
599
|
+
self._search_matches = []
|
|
600
|
+
self._search_index = 0
|
|
601
|
+
|
|
602
|
+
tree = self.query_one("#dep_tree", Tree)
|
|
603
|
+
self._collect_matches(tree.root, query.lower())
|
|
604
|
+
|
|
605
|
+
if not self._search_matches:
|
|
606
|
+
self.notify(f"No matches for '{query}'", severity="warning", timeout=2)
|
|
607
|
+
return
|
|
608
|
+
|
|
609
|
+
self.notify(
|
|
610
|
+
f"Found {len(self._search_matches)} match(es) for '{query}'",
|
|
611
|
+
severity="information",
|
|
612
|
+
timeout=2,
|
|
613
|
+
)
|
|
614
|
+
self._goto_match(0)
|
|
615
|
+
|
|
616
|
+
def _collect_matches(self, node: TreeNode, query: str) -> None:
|
|
617
|
+
"""Recursively collect nodes matching the search query."""
|
|
618
|
+
label = str(node.label).lower()
|
|
619
|
+
# Also check the data if it's a string (package name)
|
|
620
|
+
data_str = str(node.data).lower() if node.data else ""
|
|
621
|
+
if query in label or query in data_str:
|
|
622
|
+
self._search_matches.append(node)
|
|
623
|
+
for child in node.children:
|
|
624
|
+
self._collect_matches(child, query)
|
|
625
|
+
|
|
626
|
+
def _goto_match(self, index: int) -> None:
|
|
627
|
+
"""Navigate to and select a specific match."""
|
|
628
|
+
if not self._search_matches:
|
|
629
|
+
return
|
|
630
|
+
self._search_index = index % len(self._search_matches)
|
|
631
|
+
match_node = self._search_matches[self._search_index]
|
|
632
|
+
|
|
633
|
+
# Expand all ancestors so the node is visible
|
|
634
|
+
self._expand_ancestors(match_node)
|
|
635
|
+
|
|
636
|
+
# Select the node
|
|
637
|
+
tree = self.query_one("#dep_tree", Tree)
|
|
638
|
+
tree.select_node(match_node)
|
|
639
|
+
tree.scroll_to_node(match_node)
|
|
640
|
+
|
|
641
|
+
# Show match info
|
|
642
|
+
total = len(self._search_matches)
|
|
643
|
+
current = self._search_index + 1
|
|
644
|
+
self.notify(
|
|
645
|
+
f"Match {current}/{total}: {match_node.label}",
|
|
646
|
+
severity="information",
|
|
647
|
+
timeout=2,
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
def _expand_ancestors(self, node: TreeNode) -> None:
|
|
651
|
+
"""Expand all ancestor nodes to make the target visible."""
|
|
652
|
+
ancestors = []
|
|
653
|
+
parent = node.parent
|
|
654
|
+
while parent is not None:
|
|
655
|
+
ancestors.append(parent)
|
|
656
|
+
parent = parent.parent
|
|
657
|
+
for ancestor in reversed(ancestors):
|
|
658
|
+
ancestor.expand()
|
|
659
|
+
|
|
660
|
+
def action_next_match(self) -> None:
|
|
661
|
+
"""Go to next search match."""
|
|
662
|
+
if not self._search_matches:
|
|
663
|
+
self.notify("No active search. Press / to search.", severity="information", timeout=2)
|
|
664
|
+
return
|
|
665
|
+
self._goto_match(self._search_index + 1)
|
|
666
|
+
|
|
667
|
+
def action_prev_match(self) -> None:
|
|
668
|
+
"""Go to previous search match."""
|
|
669
|
+
if not self._search_matches:
|
|
670
|
+
self.notify("No active search. Press / to search.", severity="information", timeout=2)
|
|
671
|
+
return
|
|
672
|
+
self._goto_match(self._search_index - 1)
|
|
673
|
+
|
|
674
|
+
def action_toggle_details(self) -> None:
|
|
675
|
+
"""Toggle visibility of the details panel."""
|
|
676
|
+
self._details_visible = not self._details_visible
|
|
677
|
+
try:
|
|
678
|
+
details = self.query_one("#details", Static)
|
|
679
|
+
details.styles.display = "block" if self._details_visible else "none"
|
|
680
|
+
except Exception:
|
|
681
|
+
pass
|
|
682
|
+
|
|
527
683
|
def action_quit(self) -> None:
|
|
528
684
|
self.exit()
|
|
529
685
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rostree
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Explore ROS 2 package dependencies from the command line (CLI, TUI, library)
|
|
5
5
|
Author: rostree contributors
|
|
6
6
|
License: MIT
|
|
@@ -21,9 +21,19 @@ Requires-Dist: black==25.1.0; extra == 'dev'
|
|
|
21
21
|
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
22
22
|
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
23
23
|
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
24
|
+
Provides-Extra: viz
|
|
25
|
+
Requires-Dist: matplotlib>=3.7; extra == 'viz'
|
|
26
|
+
Requires-Dist: networkx>=3.0; extra == 'viz'
|
|
24
27
|
Description-Content-Type: text/markdown
|
|
25
28
|
|
|
26
|
-
|
|
29
|
+
```
|
|
30
|
+
██████╗ ██████╗ ███████╗████████╗██████╗ ███████╗███████╗
|
|
31
|
+
██╔══██╗██╔═══██╗██╔════╝╚══██╔══╝██╔══██╗██╔════╝██╔════╝
|
|
32
|
+
██████╔╝██║ ██║███████╗ ██║ ██████╔╝█████╗ █████╗
|
|
33
|
+
██╔══██╗██║ ██║╚════██║ ██║ ██╔══██╗██╔══╝ ██╔══╝
|
|
34
|
+
██║ ██║╚██████╔╝███████║ ██║ ██║ ██║███████╗███████╗
|
|
35
|
+
╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝╚══════╝╚══════╝
|
|
36
|
+
```
|
|
27
37
|
|
|
28
38
|
[](https://github.com/guilyx/rostree/actions/workflows/ci.yml)
|
|
29
39
|
[](https://codecov.io/gh/guilyx/rostree)
|
|
@@ -54,6 +64,10 @@ rostree list --by-source # List packages grouped by source
|
|
|
54
64
|
rostree tree rclpy # Show dependency tree for a package
|
|
55
65
|
rostree tree rclpy --depth 3 # Limit tree depth
|
|
56
66
|
rostree tree rclpy --json # Output as JSON
|
|
67
|
+
rostree graph rclpy --render png # Generate PNG image (requires graphviz)
|
|
68
|
+
rostree graph rclpy --render svg --open # Create SVG and open it
|
|
69
|
+
rostree graph -w ~/ros2_ws --render png # Graph entire workspace to image
|
|
70
|
+
rostree graph rclpy -f mermaid # Mermaid format (text)
|
|
57
71
|
```
|
|
58
72
|
|
|
59
73
|
### TUI mode
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
rostree/__init__.py,sha256=6kFb52bjBJQMUbYeGOGuRDKpZjwKPEj1d91pw80xtD8,437
|
|
2
2
|
rostree/api.py,sha256=A2L7uuj3efJXwqazsL9j6y6hgkwxPBnwGv2dYNtFTwg,3646
|
|
3
|
-
rostree/cli.py,sha256=
|
|
3
|
+
rostree/cli.py,sha256=SXdMznJJuy0wHINgZt3QE2DIinW9EfyahpVVG-berq0,25410
|
|
4
4
|
rostree/core/__init__.py,sha256=glLk6MYQvGz0jTre7cVaQhQ6PiAAWa1lXFFMFnWbHbo,582
|
|
5
5
|
rostree/core/finder.py,sha256=lmfzjjHX-bDHprs3x-GKPztpCS4hVsZUq2fZOg64DTg,16801
|
|
6
6
|
rostree/core/parser.py,sha256=_Yhyjrg7Ir-N80KqJnxPYRcejWepDZDFbw_BTKLBySs,3094
|
|
7
7
|
rostree/core/tree.py,sha256=jBvd7_Pzk4ciO9dWV1r4NyU9q6BaXKiYRimbmK72oD4,4060
|
|
8
8
|
rostree/tui/__init__.py,sha256=hHtp0ZeBL6drxVxnBi1pm2uRds5O-UbNASQ6xRzOJrs,64
|
|
9
|
-
rostree/tui/app.py,sha256=
|
|
10
|
-
rostree-0.
|
|
11
|
-
rostree-0.
|
|
12
|
-
rostree-0.
|
|
13
|
-
rostree-0.
|
|
14
|
-
rostree-0.
|
|
9
|
+
rostree/tui/app.py,sha256=xnWip-Ax1b20ZjH3w9Xs-gmnksLboHUMPV_lfLNwktE,24359
|
|
10
|
+
rostree-0.2.0.dist-info/METADATA,sha256=qcIywivlvRxNz7OexDLnEKJ4hS0YsWqkhM1Ii6OXjaE,4552
|
|
11
|
+
rostree-0.2.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
12
|
+
rostree-0.2.0.dist-info/entry_points.txt,sha256=3FqDola110oRFImx5-IF3CEnfCXA3CDSM2fTzbES2Zo,45
|
|
13
|
+
rostree-0.2.0.dist-info/licenses/LICENSE,sha256=mcuqLv_cT8O1n1fIsOkUNDMnKlrPgP5LVCMyyVl-tGk,1530
|
|
14
|
+
rostree-0.2.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|