#!/usr/bin/env python3 import os import argparse import json from typing import Dict, Any, Optional, Iterable, Tuple from concurrent.futures import ProcessPoolExecutor, as_completed from cli.build.graph import build_mappings, output_graph def find_roles(roles_dir: str) -> Iterable[Tuple[str, str]]: """ Yield (role_name, role_path) for all roles in the given roles_dir. """ for entry in os.listdir(roles_dir): path = os.path.join(roles_dir, entry) if os.path.isdir(path): yield entry, path def process_role( role_name: str, roles_dir: str, depth: int, shadow_folder: Optional[str], output: str, preview: bool, verbose: bool, no_include_role: bool, # currently unused, kept for CLI compatibility no_import_role: bool, # currently unused, kept for CLI compatibility no_dependencies: bool, # currently unused, kept for CLI compatibility no_run_after: bool, # currently unused, kept for CLI compatibility ) -> None: """ Worker function: build graphs and (optionally) write meta/tree.json for a single role. Note: This version no longer adds a custom top-level "dependencies" bucket. Only the graphs returned by build_mappings() are written. """ role_path = os.path.join(roles_dir, role_name) if verbose: print(f"[worker] Processing role: {role_name}") # Build the full graph structure (all dep types / directions) for this role graphs: Dict[str, Any] = build_mappings( start_role=role_name, roles_dir=roles_dir, max_depth=depth, ) # Preview mode: dump graphs to console instead of writing tree.json if preview: for key, data in graphs.items(): if verbose: print(f"[worker] Previewing graph '{key}' for role '{role_name}'") # In preview mode we always output as console output_graph(data, "console", role_name, key) return # Non-preview: write meta/tree.json for this role if shadow_folder: tree_file = os.path.join(shadow_folder, role_name, "meta", "tree.json") else: tree_file = os.path.join(role_path, "meta", "tree.json") os.makedirs(os.path.dirname(tree_file), exist_ok=True) with open(tree_file, "w", encoding="utf-8") as f: json.dump(graphs, f, indent=2) print(f"Wrote {tree_file}") def main(): script_dir = os.path.dirname(os.path.abspath(__file__)) default_roles_dir = os.path.abspath(os.path.join(script_dir, "..", "..", "roles")) parser = argparse.ArgumentParser( description="Generate all graphs for each role and write meta/tree.json" ) parser.add_argument( "-d", "--role_dir", default=default_roles_dir, help=f"Path to roles directory (default: {default_roles_dir})", ) parser.add_argument( "-D", "--depth", type=int, default=0, help="Max recursion depth (>0) or <=0 to stop on cycle", ) parser.add_argument( "-o", "--output", choices=["yaml", "json", "console"], default="json", help="Output format for preview mode", ) parser.add_argument( "-p", "--preview", action="store_true", help="Preview graphs to console instead of writing files", ) parser.add_argument( "-s", "--shadow-folder", type=str, default=None, help="If set, writes tree.json to this shadow folder instead of the role's actual meta/ folder", ) parser.add_argument( "-v", "--verbose", action="store_true", help="Enable verbose logging", ) # Toggles (kept for CLI compatibility, currently only meaningful for future extensions) parser.add_argument( "--no-include-role", action="store_true", help="Reserved: do not include include_role in custom dependency bucket", ) parser.add_argument( "--no-import-role", action="store_true", help="Reserved: do not include import_role in custom dependency bucket", ) parser.add_argument( "--no-dependencies", action="store_true", help="Reserved: do not include meta dependencies in custom dependency bucket", ) parser.add_argument( "--no-run-after", action="store_true", help="Reserved: do not include run_after in custom dependency bucket", ) args = parser.parse_args() if args.verbose: print(f"Roles directory: {args.role_dir}") print(f"Max depth: {args.depth}") print(f"Output format: {args.output}") print(f"Preview mode: {args.preview}") print(f"Shadow folder: {args.shadow_folder}") roles = [role_name for role_name, _ in find_roles(args.role_dir)] # For preview, run sequentially to avoid completely interleaved output. if args.preview: for role_name in roles: process_role( role_name=role_name, roles_dir=args.role_dir, depth=args.depth, shadow_folder=args.shadow_folder, output=args.output, preview=True, verbose=args.verbose, no_include_role=args.no_include_role, no_import_role=args.no_import_role, no_dependencies=args.no_dependencies, no_run_after=args.no_run_after, ) return # Non-preview: roles are processed in parallel with ProcessPoolExecutor() as executor: futures = { executor.submit( process_role, role_name, args.role_dir, args.depth, args.shadow_folder, args.output, False, # preview=False in parallel mode args.verbose, args.no_include_role, args.no_import_role, args.no_dependencies, args.no_run_after, ): role_name for role_name in roles } for future in as_completed(futures): role_name = futures[future] try: future.result() except Exception as exc: # Do not crash the whole run; report the failing role instead. print(f"[ERROR] Role '{role_name}' failed: {exc}") if __name__ == "__main__": main()