bitp 1.0.7__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.
@@ -0,0 +1,903 @@
1
+ #
2
+ # Copyright (C) 2025 Bruce Ashfield <bruce.ashfield@gmail.com>
3
+ #
4
+ # SPDX-License-Identifier: GPL-2.0-only
5
+ #
6
+ """Deps command - show layer and recipe dependencies."""
7
+
8
+ import glob
9
+ import json
10
+ import os
11
+ import re
12
+ import shutil
13
+ import subprocess
14
+ import tempfile
15
+ from dataclasses import dataclass
16
+ from typing import Dict, List, Optional, Set, Tuple
17
+
18
+ from ..core import Colors, fzf_available, get_fzf_color_args, get_fzf_preview_resize_bindings
19
+ from .common import resolve_bblayers_path, extract_layer_paths, parse_layer_conf, LayerInfo
20
+ from .projects import get_preview_window_arg, get_preferred_graph_renderer, set_graph_renderer
21
+
22
+
23
+ # =============================================================================
24
+ # Layer Dependency Graph
25
+ # =============================================================================
26
+
27
+ def build_layer_dependency_graph(layer_paths: List[str]) -> Dict[str, LayerInfo]:
28
+ """
29
+ Build a dependency graph from all layer paths.
30
+
31
+ Returns a dict mapping collection name -> LayerInfo for all parsed layers.
32
+ """
33
+ layers: Dict[str, LayerInfo] = {}
34
+
35
+ for layer_path in layer_paths:
36
+ info = parse_layer_conf(layer_path)
37
+ if info:
38
+ # Use collection name as key, but handle duplicates
39
+ key = info.name
40
+ if key in layers:
41
+ # Prefer layer with higher priority, or first one found
42
+ if info.priority <= layers[key].priority:
43
+ continue
44
+ layers[key] = info
45
+
46
+ return layers
47
+
48
+
49
+ def get_forward_deps(layers: Dict[str, LayerInfo], layer_name: str) -> Set[str]:
50
+ """Get all dependencies of a layer (what it depends on)."""
51
+ if layer_name not in layers:
52
+ return set()
53
+ info = layers[layer_name]
54
+ return set(info.depends)
55
+
56
+
57
+ def get_reverse_deps(layers: Dict[str, LayerInfo], layer_name: str) -> Set[str]:
58
+ """Get all layers that depend on this layer (reverse dependencies)."""
59
+ reverse = set()
60
+ for name, info in layers.items():
61
+ if layer_name in info.depends:
62
+ reverse.add(name)
63
+ return reverse
64
+
65
+
66
+ def get_all_deps_recursive(
67
+ layers: Dict[str, LayerInfo],
68
+ layer_name: str,
69
+ reverse: bool = False,
70
+ visited: Optional[Set[str]] = None,
71
+ ) -> Dict[str, Set[str]]:
72
+ """
73
+ Get full dependency tree recursively.
74
+
75
+ Returns dict mapping each layer to its direct dependencies (or dependents if reverse).
76
+ """
77
+ if visited is None:
78
+ visited = set()
79
+
80
+ result: Dict[str, Set[str]] = {}
81
+
82
+ if layer_name in visited:
83
+ return result
84
+ visited.add(layer_name)
85
+
86
+ if reverse:
87
+ deps = get_reverse_deps(layers, layer_name)
88
+ else:
89
+ deps = get_forward_deps(layers, layer_name)
90
+
91
+ result[layer_name] = deps
92
+
93
+ for dep in deps:
94
+ sub_tree = get_all_deps_recursive(layers, dep, reverse, visited)
95
+ result.update(sub_tree)
96
+
97
+ return result
98
+
99
+
100
+ # =============================================================================
101
+ # Tree Rendering
102
+ # =============================================================================
103
+
104
+ def _render_tree_recursive(
105
+ name: str,
106
+ graph: Dict[str, Set[str]],
107
+ visited: Set[str],
108
+ prefix: str = "",
109
+ is_last: bool = True,
110
+ is_root: bool = False,
111
+ ) -> List[str]:
112
+ """Recursive tree rendering with box-drawing characters."""
113
+ lines = []
114
+
115
+ if is_root:
116
+ lines.append(f"{Colors.bold(name)}")
117
+ else:
118
+ connector = "\u2514\u2500\u2500 " if is_last else "\u251c\u2500\u2500 "
119
+ lines.append(f"{prefix}{connector}{name}")
120
+
121
+ if name in visited:
122
+ return lines
123
+ visited.add(name)
124
+
125
+ children = sorted(graph.get(name, []))
126
+ if is_root:
127
+ child_prefix = ""
128
+ else:
129
+ child_prefix = prefix + (" " if is_last else "\u2502 ")
130
+
131
+ for i, child in enumerate(children):
132
+ is_last_child = (i == len(children) - 1)
133
+ if child not in visited:
134
+ lines.extend(_render_tree_recursive(
135
+ child, graph, visited, child_prefix, is_last_child
136
+ ))
137
+ else:
138
+ # Already visited - show with marker
139
+ connector = "\u2514\u2500\u2500 " if is_last_child else "\u251c\u2500\u2500 "
140
+ lines.append(f"{child_prefix}{connector}{child} {Colors.dim('(see above)')}")
141
+
142
+ return lines
143
+
144
+
145
+ def render_tree_ascii(
146
+ layers: Dict[str, LayerInfo],
147
+ root: str,
148
+ reverse: bool = False,
149
+ ) -> str:
150
+ """Render dependency tree as ASCII using box-drawing characters."""
151
+ graph = get_all_deps_recursive(layers, root, reverse)
152
+ visited: Set[str] = set()
153
+ lines = _render_tree_recursive(root, graph, visited, is_root=True)
154
+ return "\n".join(lines)
155
+
156
+
157
+ def render_tree_grapheasy(
158
+ layers: Dict[str, LayerInfo],
159
+ root: str,
160
+ reverse: bool = False,
161
+ ) -> Optional[str]:
162
+ """Use graph-easy for better ASCII rendering if available."""
163
+ if not shutil.which("graph-easy"):
164
+ return None
165
+
166
+ graph = get_all_deps_recursive(layers, root, reverse)
167
+
168
+ # Build DOT format
169
+ dot_lines = ["digraph deps {", ' rankdir=TB;', ' node [shape=box];']
170
+
171
+ # Highlight root node
172
+ dot_lines.append(f' "{root}" [style=bold];')
173
+
174
+ for node, deps in graph.items():
175
+ for dep in deps:
176
+ if reverse:
177
+ # Reverse: dep depends on node, so arrow from dep to node
178
+ dot_lines.append(f' "{dep}" -> "{node}";')
179
+ else:
180
+ # Forward: node depends on dep, so arrow from node to dep
181
+ dot_lines.append(f' "{node}" -> "{dep}";')
182
+ dot_lines.append("}")
183
+
184
+ dot_content = "\n".join(dot_lines)
185
+
186
+ try:
187
+ result = subprocess.run(
188
+ ["graph-easy", "--as_ascii"],
189
+ input=dot_content,
190
+ capture_output=True,
191
+ text=True,
192
+ timeout=10,
193
+ )
194
+ if result.returncode == 0 and result.stdout.strip():
195
+ return result.stdout
196
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
197
+ pass
198
+
199
+ return None
200
+
201
+
202
+ def render_tree_preferred(
203
+ layers: Dict[str, LayerInfo],
204
+ root: str,
205
+ reverse: bool = False,
206
+ ) -> str:
207
+ """Render tree using the configured graph renderer preference."""
208
+ preference = get_preferred_graph_renderer()
209
+
210
+ if preference == "graph-easy":
211
+ ge_output = render_tree_grapheasy(layers, root, reverse)
212
+ if ge_output:
213
+ return ge_output
214
+ # Fall back to ASCII if graph-easy fails
215
+ return render_tree_ascii(layers, root, reverse)
216
+ else:
217
+ # ASCII or unknown preference
218
+ return render_tree_ascii(layers, root, reverse)
219
+
220
+
221
+ def render_dot(
222
+ layers: Dict[str, LayerInfo],
223
+ root: Optional[str] = None,
224
+ reverse: bool = False,
225
+ ) -> str:
226
+ """Output DOT format for external tools like graphviz."""
227
+ dot_lines = [
228
+ "digraph layer_deps {",
229
+ ' rankdir=TB;',
230
+ ' node [shape=box, fontname="sans-serif"];',
231
+ ' edge [fontname="sans-serif"];',
232
+ ]
233
+
234
+ if root:
235
+ graph = get_all_deps_recursive(layers, root, reverse)
236
+ dot_lines.append(f' "{root}" [style=bold, color=blue];')
237
+ else:
238
+ # Full graph of all layers
239
+ graph = {name: set(info.depends) for name, info in layers.items()}
240
+
241
+ for node, deps in graph.items():
242
+ for dep in deps:
243
+ if reverse:
244
+ dot_lines.append(f' "{dep}" -> "{node}";')
245
+ else:
246
+ dot_lines.append(f' "{node}" -> "{dep}";')
247
+
248
+ dot_lines.append("}")
249
+ return "\n".join(dot_lines)
250
+
251
+
252
+ def render_list(
253
+ layers: Dict[str, LayerInfo],
254
+ root: Optional[str] = None,
255
+ reverse: bool = False,
256
+ ) -> str:
257
+ """Simple list format output."""
258
+ lines = []
259
+
260
+ if root:
261
+ graph = get_all_deps_recursive(layers, root, reverse)
262
+ direction = "depends on" if not reverse else "is required by"
263
+ lines.append(f"{root} {direction}:")
264
+ deps = graph.get(root, set())
265
+ for dep in sorted(deps):
266
+ lines.append(f" {dep}")
267
+ else:
268
+ # List all layers with their dependencies
269
+ for name in sorted(layers.keys()):
270
+ info = layers[name]
271
+ if info.depends:
272
+ lines.append(f"{name}: {', '.join(info.depends)}")
273
+ else:
274
+ lines.append(f"{name}: (no dependencies)")
275
+
276
+ return "\n".join(lines)
277
+
278
+
279
+ # =============================================================================
280
+ # FZF Browser
281
+ # =============================================================================
282
+
283
+ def _build_layer_menu(layers: Dict[str, LayerInfo]) -> str:
284
+ """Build fzf menu input for layer browser."""
285
+ lines = []
286
+ for name in sorted(layers.keys()):
287
+ info = layers[name]
288
+ dep_count = len(info.depends)
289
+ rec_count = len(info.recommends)
290
+
291
+ # Format: name<TAB>priority<TAB>deps<TAB>recommends
292
+ deps_str = f"{dep_count} deps" if dep_count else "no deps"
293
+ recs_str = f", {rec_count} recs" if rec_count else ""
294
+
295
+ line = f"{name}\t{info.priority}\t{deps_str}{recs_str}"
296
+ lines.append(line)
297
+
298
+ return "\n".join(lines)
299
+
300
+
301
+ def _deps_fzf_browser(layers: Dict[str, LayerInfo]) -> int:
302
+ """
303
+ Interactive fzf browser for layer dependencies.
304
+
305
+ Key bindings:
306
+ - Enter: Show dependency tree
307
+ - r: Show reverse dependencies (what depends on this)
308
+ - d: Output DOT format
309
+ - a: Show all layers full graph (DOT)
310
+ - ?: Toggle preview
311
+ - q/esc: Quit
312
+ """
313
+ if not layers:
314
+ print("No layers found.")
315
+ return 1
316
+
317
+ if not fzf_available():
318
+ # Fall back to text list
319
+ for name in sorted(layers.keys()):
320
+ info = layers[name]
321
+ deps_str = ", ".join(info.depends) if info.depends else "(none)"
322
+ print(f" {Colors.green(name):<30} priority={info.priority} deps={deps_str}")
323
+ return 0
324
+
325
+ menu_input = _build_layer_menu(layers)
326
+
327
+ # Create preview data file
328
+ preview_data = {}
329
+ for name, info in layers.items():
330
+ preview_data[name] = {
331
+ "name": info.name,
332
+ "path": info.path,
333
+ "depends": info.depends,
334
+ "recommends": info.recommends,
335
+ "priority": info.priority,
336
+ }
337
+
338
+ preview_data_file = tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False)
339
+ json.dump(preview_data, preview_data_file)
340
+ preview_data_file.close()
341
+
342
+ # Create layers graph for tree preview
343
+ layers_graph = {}
344
+ for name, info in layers.items():
345
+ layers_graph[name] = {
346
+ "depends": info.depends,
347
+ "recommends": info.recommends,
348
+ }
349
+ graph_file = tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False)
350
+ json.dump(layers_graph, graph_file)
351
+ graph_file.close()
352
+
353
+ # Create preview script
354
+ preview_script = tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False)
355
+ preview_script.write(f'''#!/usr/bin/env python3
356
+ import json
357
+ import sys
358
+
359
+ key = sys.argv[1] if len(sys.argv) > 1 else ""
360
+ key = key.strip("'\\"").split("\\t")[0]
361
+
362
+ with open("{preview_data_file.name}", "r") as f:
363
+ data = json.load(f)
364
+
365
+ with open("{graph_file.name}", "r") as f:
366
+ graph = json.load(f)
367
+
368
+ info = data.get(key, {{}})
369
+ if not info:
370
+ print(f"No data for: {{key}}")
371
+ sys.exit(0)
372
+
373
+ name = info.get("name", "")
374
+ path = info.get("path", "")
375
+ depends = info.get("depends", [])
376
+ recommends = info.get("recommends", [])
377
+ priority = info.get("priority", 0)
378
+
379
+ print(f"\\033[1m{{name}}\\033[0m")
380
+ print(f"Priority: {{priority}}")
381
+ print(f"Path: {{path}}")
382
+ print()
383
+
384
+ # Show dependency tree
385
+ def render_tree(node, g, visited, prefix="", is_last=True, is_root=False):
386
+ lines = []
387
+ if is_root:
388
+ lines.append(f"\\033[36mDepends on:\\033[0m")
389
+ connector = "\\u2514\\u2500\\u2500 " if is_last else "\\u251c\\u2500\\u2500 "
390
+ if not is_root:
391
+ lines.append(f"{{prefix}}{{connector}}{{node}}")
392
+ if node in visited:
393
+ return lines
394
+ visited.add(node)
395
+ children = sorted(g.get(node, {{}}).get("depends", []))
396
+ child_prefix = prefix + (" " if is_last else "\\u2502 ")
397
+ for i, child in enumerate(children):
398
+ is_last_child = (i == len(children) - 1)
399
+ lines.extend(render_tree(child, g, visited, child_prefix if not is_root else "", is_last_child))
400
+ return lines
401
+
402
+ if depends:
403
+ visited = set()
404
+ visited.add(key)
405
+ for i, dep in enumerate(sorted(depends)):
406
+ is_last = (i == len(depends) - 1)
407
+ lines = render_tree(dep, graph, visited, "", is_last)
408
+ for line in lines:
409
+ print(f" {{line}}")
410
+ else:
411
+ print("\\033[36mDepends on:\\033[0m (none)")
412
+
413
+ print()
414
+
415
+ # Show what depends on this layer (reverse)
416
+ reverse_deps = []
417
+ for n, g in graph.items():
418
+ if key in g.get("depends", []):
419
+ reverse_deps.append(n)
420
+
421
+ if reverse_deps:
422
+ print("\\033[36mRequired by:\\033[0m")
423
+ for rd in sorted(reverse_deps):
424
+ print(f" {{rd}}")
425
+ else:
426
+ print("\\033[36mRequired by:\\033[0m (none)")
427
+
428
+ if recommends:
429
+ print()
430
+ print("\\033[36mRecommends:\\033[0m")
431
+ for rec in sorted(recommends):
432
+ print(f" {{rec}}")
433
+ ''')
434
+ preview_script.close()
435
+
436
+ preview_cmd = f'python3 "{preview_script.name}" {{1}}'
437
+
438
+ header = """Layer Dependencies Browser
439
+ Enter=tree | r=reverse deps | d=DOT output | a=all DOT | ?=preview | q=quit"""
440
+
441
+ expect_keys = ["ctrl-r", "ctrl-d", "ctrl-a", "esc"]
442
+
443
+ preview_window = get_preview_window_arg("50%")
444
+
445
+ fzf_args = [
446
+ "fzf",
447
+ "--no-multi",
448
+ "--ansi",
449
+ "--height", "100%",
450
+ "--layout=reverse-list",
451
+ "--header", header,
452
+ "--prompt", "Layer: ",
453
+ "--with-nth", "1",
454
+ "--delimiter", "\t",
455
+ "--preview", preview_cmd,
456
+ "--preview-window", preview_window,
457
+ "--bind", "?:toggle-preview",
458
+ "--bind", "pgup:preview-page-up",
459
+ "--bind", "pgdn:preview-page-down",
460
+ "--bind", "q:abort",
461
+ "--bind", "esc:abort",
462
+ "--expect", ",".join(expect_keys),
463
+ ]
464
+
465
+ fzf_args.extend(get_fzf_preview_resize_bindings())
466
+ fzf_args.extend(get_fzf_color_args())
467
+
468
+ try:
469
+ while True:
470
+ result = subprocess.run(
471
+ fzf_args,
472
+ input=menu_input,
473
+ stdout=subprocess.PIPE,
474
+ text=True,
475
+ )
476
+
477
+ if result.returncode != 0 or not result.stdout.strip():
478
+ break
479
+
480
+ lines = result.stdout.split("\n")
481
+ key = lines[0].strip() if lines else ""
482
+ selected = lines[1].split("\t")[0].strip() if len(lines) > 1 else ""
483
+
484
+ if not selected:
485
+ break
486
+
487
+ if key == "ctrl-d":
488
+ # DOT output for selected layer
489
+ print(render_dot(layers, selected, reverse=False))
490
+ input("\nPress Enter to continue...")
491
+ continue
492
+
493
+ if key == "ctrl-r":
494
+ # Reverse dependency tree
495
+ print(f"\n{Colors.bold(selected)} is required by:\n")
496
+ print(render_tree_ascii(layers, selected, reverse=True))
497
+ input("\nPress Enter to continue...")
498
+ continue
499
+
500
+ if key == "ctrl-a":
501
+ # Full DOT graph
502
+ print(render_dot(layers, root=None, reverse=False))
503
+ input("\nPress Enter to continue...")
504
+ continue
505
+
506
+ # Default: show forward dependency tree
507
+ print(f"\n{Colors.bold(selected)} depends on:\n")
508
+
509
+ # Use configured graph renderer preference
510
+ print(render_tree_preferred(layers, selected, reverse=False))
511
+
512
+ input("\nPress Enter to continue...")
513
+
514
+ finally:
515
+ # Clean up temp files
516
+ for f in [preview_data_file.name, preview_script.name, graph_file.name]:
517
+ try:
518
+ os.unlink(f)
519
+ except OSError:
520
+ pass
521
+
522
+ return 0
523
+
524
+
525
+ # =============================================================================
526
+ # Text Output (no fzf)
527
+ # =============================================================================
528
+
529
+ def _list_layers_text(layers: Dict[str, LayerInfo], verbose: bool = False) -> int:
530
+ """List layers in text format (no fzf)."""
531
+ if not layers:
532
+ print("No layers found.")
533
+ return 1
534
+
535
+ for name in sorted(layers.keys()):
536
+ info = layers[name]
537
+ deps_str = ", ".join(info.depends) if info.depends else "(none)"
538
+
539
+ if verbose:
540
+ print(f"{Colors.bold(name)}")
541
+ print(f" Path: {info.path}")
542
+ print(f" Priority: {info.priority}")
543
+ print(f" Depends: {deps_str}")
544
+ if info.recommends:
545
+ print(f" Recommends: {', '.join(info.recommends)}")
546
+ print()
547
+ else:
548
+ print(f" {Colors.green(name):<30} deps: {deps_str}")
549
+
550
+ return 0
551
+
552
+
553
+ # =============================================================================
554
+ # Recipe Dependencies
555
+ # =============================================================================
556
+
557
+ @dataclass
558
+ class RecipeInfo:
559
+ """Recipe metadata for dependency visualization."""
560
+ name: str # Recipe name (PN)
561
+ version: str # Recipe version (PV)
562
+ path: str # Path to .bb file
563
+ layer: str # Layer name
564
+ depends: List[str] # Build-time dependencies (DEPENDS)
565
+ rdepends: List[str] # Runtime dependencies (RDEPENDS)
566
+
567
+
568
+ def _is_valid_package_name(name: str) -> bool:
569
+ """Check if a string looks like a valid BitBake package name."""
570
+ if not name:
571
+ return False
572
+ # Must start with a letter and contain only valid chars
573
+ # Valid: letters, digits, hyphens, underscores, plus (for virtual/), periods
574
+ if not re.match(r'^[a-zA-Z][a-zA-Z0-9+._-]*$', name):
575
+ return False
576
+ # Skip things that look like Python/shell fragments
577
+ if any(c in name for c in "(){}[]'\"@$=,"):
578
+ return False
579
+ # Skip single letters (likely variable fragments like 'd')
580
+ if len(name) == 1:
581
+ return False
582
+ return True
583
+
584
+
585
+ def _clean_deps_string(deps_str: str) -> str:
586
+ """Remove inline Python expressions and clean up a dependency string."""
587
+ # Remove ${@...} inline Python expressions (can be nested)
588
+ # Handle nested braces by iterating
589
+ while '${@' in deps_str:
590
+ start = deps_str.find('${@')
591
+ if start == -1:
592
+ break
593
+ # Find matching closing brace
594
+ depth = 0
595
+ end = start
596
+ for i, c in enumerate(deps_str[start:], start):
597
+ if c == '{':
598
+ depth += 1
599
+ elif c == '}':
600
+ depth -= 1
601
+ if depth == 0:
602
+ end = i
603
+ break
604
+ if end > start:
605
+ deps_str = deps_str[:start] + deps_str[end + 1:]
606
+ else:
607
+ # Malformed expression, just remove from ${@ onwards
608
+ deps_str = deps_str[:start]
609
+ break
610
+
611
+ # Also remove simple ${...} variable references
612
+ deps_str = re.sub(r'\$\{[^}]*\}', ' ', deps_str)
613
+
614
+ return deps_str
615
+
616
+
617
+ def _parse_recipe_depends(filepath: str) -> Tuple[List[str], List[str]]:
618
+ """
619
+ Extract DEPENDS and RDEPENDS from a recipe file.
620
+
621
+ Returns (depends, rdepends) as lists of package names.
622
+ """
623
+ depends = []
624
+ rdepends = []
625
+
626
+ try:
627
+ with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
628
+ content = f.read()
629
+ except (OSError, IOError):
630
+ return depends, rdepends
631
+
632
+ # Handle line continuations
633
+ content = content.replace("\\\n", " ")
634
+
635
+ # Extract DEPENDS
636
+ # Match DEPENDS = "...", DEPENDS += "...", DEPENDS:append = "..."
637
+ depends_patterns = [
638
+ r'^DEPENDS\s*[+:]?=\s*"([^"]*)"',
639
+ r"^DEPENDS\s*[+:]?=\s*'([^']*)'",
640
+ ]
641
+ for pattern in depends_patterns:
642
+ for match in re.finditer(pattern, content, re.MULTILINE):
643
+ deps_str = _clean_deps_string(match.group(1))
644
+ for dep in deps_str.split():
645
+ if _is_valid_package_name(dep) and dep not in depends:
646
+ depends.append(dep)
647
+
648
+ # Extract RDEPENDS (can be RDEPENDS:${PN} or just RDEPENDS)
649
+ rdepends_patterns = [
650
+ r'^RDEPENDS[:\w${}\-]*\s*[+:]?=\s*"([^"]*)"',
651
+ r"^RDEPENDS[:\w${}\-]*\s*[+:]?=\s*'([^']*)'",
652
+ ]
653
+ for pattern in rdepends_patterns:
654
+ for match in re.finditer(pattern, content, re.MULTILINE):
655
+ deps_str = _clean_deps_string(match.group(1))
656
+ for dep in deps_str.split():
657
+ if _is_valid_package_name(dep) and dep not in rdepends:
658
+ rdepends.append(dep)
659
+
660
+ return depends, rdepends
661
+
662
+
663
+ def _find_recipes(layer_paths: List[str], recipe_name: str) -> List[RecipeInfo]:
664
+ """
665
+ Find recipes matching the given name across all layers.
666
+
667
+ Returns list of RecipeInfo for matching recipes.
668
+ """
669
+ recipes = []
670
+ recipe_name_lower = recipe_name.lower()
671
+
672
+ for layer_path in layer_paths:
673
+ layer_name = os.path.basename(layer_path)
674
+
675
+ # Glob for recipes matching the name
676
+ patterns = [
677
+ os.path.join(layer_path, "recipes-*", "*", f"{recipe_name}_*.bb"),
678
+ os.path.join(layer_path, "recipes-*", "*", f"{recipe_name}.bb"),
679
+ os.path.join(layer_path, "recipes-*", f"{recipe_name}_*.bb"),
680
+ os.path.join(layer_path, "recipes-*", f"{recipe_name}.bb"),
681
+ ]
682
+
683
+ for pattern in patterns:
684
+ for filepath in glob.glob(pattern):
685
+ filename = os.path.basename(filepath)
686
+ # Extract PN and PV from filename
687
+ if "_" in filename:
688
+ parts = filename.rsplit("_", 1)
689
+ pn = parts[0]
690
+ pv = parts[1].replace(".bb", "")
691
+ else:
692
+ pn = filename.replace(".bb", "")
693
+ pv = ""
694
+
695
+ depends, rdepends = _parse_recipe_depends(filepath)
696
+
697
+ recipes.append(RecipeInfo(
698
+ name=pn,
699
+ version=pv,
700
+ path=filepath,
701
+ layer=layer_name,
702
+ depends=depends,
703
+ rdepends=rdepends,
704
+ ))
705
+
706
+ # Also try partial match if no exact matches
707
+ if not recipes:
708
+ for layer_path in layer_paths:
709
+ layer_name = os.path.basename(layer_path)
710
+ pattern = os.path.join(layer_path, "recipes-*", "*", "*.bb")
711
+
712
+ for filepath in glob.glob(pattern):
713
+ filename = os.path.basename(filepath)
714
+ pn = filename.split("_")[0] if "_" in filename else filename.replace(".bb", "")
715
+
716
+ if recipe_name_lower in pn.lower():
717
+ if "_" in filename:
718
+ parts = filename.rsplit("_", 1)
719
+ pv = parts[1].replace(".bb", "")
720
+ else:
721
+ pv = ""
722
+
723
+ depends, rdepends = _parse_recipe_depends(filepath)
724
+
725
+ recipes.append(RecipeInfo(
726
+ name=pn,
727
+ version=pv,
728
+ path=filepath,
729
+ layer=layer_name,
730
+ depends=depends,
731
+ rdepends=rdepends,
732
+ ))
733
+
734
+ return recipes
735
+
736
+
737
+ def _render_recipe_tree_ascii(
738
+ recipe: RecipeInfo,
739
+ include_rdepends: bool = False,
740
+ ) -> str:
741
+ """Render recipe dependency tree as ASCII."""
742
+ lines = []
743
+ lines.append(f"{Colors.bold(recipe.name)}")
744
+ if recipe.version:
745
+ lines.append(f"Version: {recipe.version}")
746
+ lines.append(f"Layer: {recipe.layer}")
747
+ lines.append(f"Path: {recipe.path}")
748
+ lines.append("")
749
+
750
+ if recipe.depends:
751
+ lines.append(f"{Colors.cyan('Build dependencies (DEPENDS):')}")
752
+ for i, dep in enumerate(sorted(recipe.depends)):
753
+ is_last = (i == len(recipe.depends) - 1) and not (include_rdepends and recipe.rdepends)
754
+ connector = "\u2514\u2500\u2500 " if is_last else "\u251c\u2500\u2500 "
755
+ lines.append(f" {connector}{dep}")
756
+ else:
757
+ lines.append(f"{Colors.cyan('Build dependencies (DEPENDS):')} (none)")
758
+
759
+ if include_rdepends:
760
+ lines.append("")
761
+ if recipe.rdepends:
762
+ lines.append(f"{Colors.cyan('Runtime dependencies (RDEPENDS):')}")
763
+ for i, dep in enumerate(sorted(recipe.rdepends)):
764
+ is_last = (i == len(recipe.rdepends) - 1)
765
+ connector = "\u2514\u2500\u2500 " if is_last else "\u251c\u2500\u2500 "
766
+ lines.append(f" {connector}{dep}")
767
+ else:
768
+ lines.append(f"{Colors.cyan('Runtime dependencies (RDEPENDS):')} (none)")
769
+
770
+ return "\n".join(lines)
771
+
772
+
773
+ def _render_recipe_dot(
774
+ recipe: RecipeInfo,
775
+ include_rdepends: bool = False,
776
+ ) -> str:
777
+ """Render recipe dependencies as DOT graph."""
778
+ dot_lines = [
779
+ "digraph recipe_deps {",
780
+ ' rankdir=TB;',
781
+ ' node [shape=box, fontname="sans-serif"];',
782
+ f' "{recipe.name}" [style=bold, color=blue];',
783
+ ]
784
+
785
+ for dep in recipe.depends:
786
+ dot_lines.append(f' "{recipe.name}" -> "{dep}";')
787
+
788
+ if include_rdepends:
789
+ for dep in recipe.rdepends:
790
+ dot_lines.append(f' "{recipe.name}" -> "{dep}" [style=dashed, color=gray];')
791
+
792
+ dot_lines.append("}")
793
+ return "\n".join(dot_lines)
794
+
795
+
796
+ # =============================================================================
797
+ # Main Entry Point
798
+ # =============================================================================
799
+
800
+ def run_deps(args) -> int:
801
+ """Main entry point for deps command."""
802
+ deps_command = getattr(args, "deps_command", None)
803
+ layer_name = getattr(args, "layer", None)
804
+ reverse = getattr(args, "reverse", False)
805
+ output_format = getattr(args, "format", "tree")
806
+ list_mode = getattr(args, "list", False)
807
+
808
+ # Get layer paths from bblayers.conf
809
+ bblayers_path = resolve_bblayers_path(getattr(args, "bblayers", None))
810
+ if not bblayers_path:
811
+ print("Could not find bblayers.conf")
812
+ return 1
813
+
814
+ layer_paths = extract_layer_paths(bblayers_path)
815
+ if not layer_paths:
816
+ print("No layers found in bblayers.conf")
817
+ return 1
818
+
819
+ # Build dependency graph
820
+ layers = build_layer_dependency_graph(layer_paths)
821
+ if not layers:
822
+ print("No layer.conf files found")
823
+ return 1
824
+
825
+ # Handle subcommands
826
+ if deps_command == "layers" or deps_command is None:
827
+ # Layer dependencies
828
+
829
+ if layer_name:
830
+ # Specific layer requested
831
+ if layer_name not in layers:
832
+ # Try partial match
833
+ matches = [n for n in layers if layer_name in n]
834
+ if len(matches) == 1:
835
+ layer_name = matches[0]
836
+ elif matches:
837
+ print(f"Ambiguous layer name '{layer_name}'. Matches: {', '.join(matches)}")
838
+ return 1
839
+ else:
840
+ print(f"Layer '{layer_name}' not found. Available: {', '.join(sorted(layers.keys()))}")
841
+ return 1
842
+
843
+ if output_format == "dot":
844
+ print(render_dot(layers, layer_name, reverse))
845
+ elif output_format == "list":
846
+ print(render_list(layers, layer_name, reverse))
847
+ else:
848
+ # Tree format - use configured preference
849
+ direction = "is required by" if reverse else "depends on"
850
+ print(f"{Colors.bold(layer_name)} {direction}:\n")
851
+ print(render_tree_preferred(layers, layer_name, reverse))
852
+ return 0
853
+
854
+ # No specific layer - interactive or list mode
855
+ if list_mode or not fzf_available():
856
+ return _list_layers_text(layers, verbose=getattr(args, "verbose", False))
857
+
858
+ return _deps_fzf_browser(layers)
859
+
860
+ if deps_command == "recipe":
861
+ recipe_name = getattr(args, "recipe", None)
862
+ if not recipe_name:
863
+ print("Recipe name required")
864
+ return 1
865
+
866
+ include_rdepends = getattr(args, "rdepends", False)
867
+
868
+ # Find recipes matching the name
869
+ recipes = _find_recipes(layer_paths, recipe_name)
870
+
871
+ if not recipes:
872
+ print(f"No recipe found matching '{recipe_name}'")
873
+ return 1
874
+
875
+ # If multiple matches, let user pick or show all
876
+ if len(recipes) > 1:
877
+ print(f"Found {len(recipes)} recipes matching '{recipe_name}':")
878
+ for i, r in enumerate(recipes, 1):
879
+ ver_str = f" ({r.version})" if r.version else ""
880
+ print(f" {i}. {r.name}{ver_str} - {r.layer}")
881
+ print()
882
+
883
+ # Use first one for now, or could add interactive selection
884
+ recipe = recipes[0]
885
+ print(f"Showing dependencies for: {recipe.name} from {recipe.layer}\n")
886
+ else:
887
+ recipe = recipes[0]
888
+
889
+ if output_format == "dot":
890
+ print(_render_recipe_dot(recipe, include_rdepends))
891
+ elif output_format == "list":
892
+ # Simple list format
893
+ print(f"{recipe.name}:")
894
+ print(f" DEPENDS: {', '.join(recipe.depends) if recipe.depends else '(none)'}")
895
+ if include_rdepends:
896
+ print(f" RDEPENDS: {', '.join(recipe.rdepends) if recipe.rdepends else '(none)'}")
897
+ else:
898
+ # Tree format
899
+ print(_render_recipe_tree_ascii(recipe, include_rdepends))
900
+
901
+ return 0
902
+
903
+ return 0