#!/usr/bin/env python # Copyright (C) 2002-2020 CERN for the benefit of the ATLAS collaboration # # Created: June 2020, Frank Winklmeier # """ Print target/package dependencies of ATLAS releases. For a given target/package name, the dependencies are printed as a plain list or DOT graph. The recursion depth is configurable. """ import os import re from collections import deque import PyUtils.acmdlib as acmdlib import argparse import pygraphviz def read_package_list(package_file): """Read packages.txt as a source for the full package path""" with open(package_file) as f: packages = [line.rstrip() for line in f if not line.startswith('#')] return dict([(p.split('/')[-1],p) for p in packages]) def externals_name(lib): """Return a short name for an external library""" if '/LCG_' in lib: dirs = lib.split('/') lcg = next(d for d in dirs if d.startswith('LCG_')) return '%s::%s' % (dirs[dirs.index(lcg)+1], dirs[-1]) elif lib.startswith('Gaudi'): return 'Gaudi::%s' % lib else: return os.path.basename(lib) def lrstrip(s, prefix, postfix): """Strip `prefix` and `postfix` from string `s`""" if s.startswith(prefix): s = s[len(prefix):] if s.endswith(postfix): s = s[:-len(postfix)] return s def traverse(graph, root, reverse=False, maxdepth=None, nodegetter=lambda n:n): """Depth-limited BFS edge traversal of graph starting at root. @param graph graph @param root start node for traversal @param reverse traverse graph in reverse @param maxdepth maximum traversal depth (1 = only direct neighbors) @param nodegetter functor returning node names @return edge tuple (parent,node) Inspired by https://github.com/networkx/networkx/tree/master/networkx/algorithms/traversal """ visited_nodes = set() visited_edges = set() queue = deque([(root,root,0)]) neighbors = graph.iterpred if reverse else graph.itersucc while queue: parent,node,level = queue.popleft() if node not in visited_nodes: visited_nodes.add(node) # Add edges to neighbors into queue: if maxdepth is None or level < maxdepth: queue.extend((node,n,level+1) for n in neighbors(node)) # For the last level only edges to already visited nodes: elif level==maxdepth: queue.extend((node,n,level+1) for n in neighbors(node) if n in visited_nodes) if (parent,node) not in visited_edges: visited_edges.add((parent,node)) yield nodegetter(parent), nodegetter(node) def subgraph(graph, sources, reverse=False, maxdepth=None, nodegetter=lambda n : n.attr.get('label')): """Extract subgraph created by traversing from one or more sources. Parameters are the same as in `traverse`. """ g = pygraphviz.AGraph(directed=True) for root in sources: for a,b in traverse(graph, root, reverse=reverse, maxdepth=maxdepth, nodegetter=nodegetter): if a and b and a!=b: if reverse: g.add_edge(b,a) else: g.add_edge(a,b) return g class AthGraph: """Class to hold dependency information for release""" def __init__(self, dotfile, package_paths={}): """Read dotfile and and optionally transform package names to full paths""" # Read dot file: self.graph = pygraphviz.AGraph(dotfile) # Build dictionary for node types: legend = self.graph.get_subgraph('clusterLegend') self.types = { n.attr['label'] : n.attr['shape'] for n in legend.iternodes() } # Build dictionary for node names: self.node = { n.attr['label'] : n.get_name() for n in self.graph.iternodes() } # Extract package dependencies: for e in self.graph.iteredges(): p = e[0].attr['label'] # Decorate target with package name: if p.startswith('Package_'): pkg = lrstrip(p, 'Package_', '_tests') e[0].attr['package'] = e[1].attr['package'] = package_paths.get(pkg,pkg) # Assign "package" names to externals if possible: external_nodes = filter(lambda n : 'package' not in n.attr.keys(), self.graph.iternodes()) for n in external_nodes: name = externals_name(n.attr['label']) n.attr['package'] = name.split('::')[0] n.attr['label'] = name n.attr['external'] = 'yes' def get_node(self, label): """Return graph node for label/target""" return self.graph.get_node(self.node[label]) def ignore_target(self, node): """Check if target should be ignored""" label = node.attr['label'] return True if (label.startswith('__') or # internal targets label.startswith('-') or # compiler flags (e.g. -pthread) node.attr['shape']==self.types['Custom Target']) else False # # Main function and command line arguments # @acmdlib.command(name='cmake.depends', description=__doc__) @acmdlib.argument('names', nargs='+', metavar='NAME', help='package/target name or regular expression') @acmdlib.argument('-t', '--target', action='store_true', help='treat NAME as target instead of package name') @acmdlib.argument('-c', '--clients', action='store_true', help='show clients (instead of dependencies)') @acmdlib.argument('-e', '--externals', action='store_true', help='include external dependencies') @acmdlib.argument('-l', '--long', action='store_true', help='show full package names') @acmdlib.argument('-r', '--recursive', nargs='?', metavar='DEPTH', type=int, default=1, const=None, help='recursively resolve dependencies up to DEPTH (default: unlimited)') @acmdlib.argument('--regex', action='store_true', help='treat NAME as regular expression') @acmdlib.argument('--all', action='store_true', help='do not apply any target filter (e.g. custom targets)') @acmdlib.argument('-d', '--dot', action='store_true', help='print DOT graph') # Debugging/expert options: @acmdlib.argument('--cmakedot', help=argparse.SUPPRESS) def main(args): """Inspect cmake build dependencies""" # Find packages.dot: if not args.cmakedot: try: args.cmakedot = os.path.join(os.environ['AtlasArea'],'InstallArea', os.environ['BINARY_TAG'],'packages.dot') except KeyError: main.parser.error("Cannot find 'packages.dot'. Setup a release or use --cmakedot.") # Read packages.txt if needed: package_paths = {} if args.long: try: package_paths = read_package_list(os.path.join(os.environ['AtlasArea'],'InstallArea', os.environ['BINARY_TAG'],'packages.txt')) except Exception: main.parser.error("Cannot read 'packages.txt'. Setup a release or run without -l/--long.") # In package mode we have one extra level due to the Package_ target: if not args.target and not args.clients and args.recursive is not None: args.recursive += 1 # Read dependencies: d = AthGraph(args.cmakedot, package_paths) # Helper for graph traversal below: def getnode(node): if not args.all and d.ignore_target(node): return None if args.externals or not node.attr['external']: a = 'label' if args.target else 'package' return node.attr[a] graph = pygraphviz.AGraph(name='AthGraph', directed=True) for p in args.names: target = p.split('/')[-1] # in case of full package path # With regex, find all matching targets: if args.regex: r = re.compile(target) targets = [getnode(n) for n in d.graph.iternodes() if r.match(n.attr['label'])] targets = list(filter(lambda t : t is not None, targets)) else: targets = [target] # Find the nodes from which graph traversal starts: sources = [] for l in targets: if not args.target: l = 'Package_'+l try: if d.get_node(l).attr['external'] and not args.externals: print(f"{l} is an external target. Run with -e/--externals.") return 1 # To find clients of a package means finding clients of the targets # within that package. First find all targets within the package: if args.clients and not args.target: sources.extend([b for a,b in traverse(d.graph, d.get_node(l), maxdepth=1)]) else: sources.extend([d.get_node(l)]) except KeyError: print(f"Target with name {l} does not exist.") return 1 # Extract the dependency subgraph: g = subgraph(d.graph, sources, reverse=args.clients, maxdepth=args.recursive, nodegetter=getnode) graph.add_subgraph(name=target) graph.get_subgraph(target).add_edges_from(g.edges()) # Print result: if args.dot: print(graph) else: nodes = [e[0] for e in graph.in_edges_iter()] if args.clients \ else [e[1] for e in graph.out_edges_iter()] for p in sorted(set(nodes)): print(p)