diff --git a/Tools/PyUtils/CMakeLists.txt b/Tools/PyUtils/CMakeLists.txt index a2035f304db501c7e69770893591277868af8e7b..21f7fc908c7cd7071ab061317dbef54c7e2f7971 100644 --- a/Tools/PyUtils/CMakeLists.txt +++ b/Tools/PyUtils/CMakeLists.txt @@ -19,7 +19,7 @@ atlas_install_scripts( bin/acmd.py bin/checkFile.py bin/checkPlugins.py bin/diffPoolFiles.py bin/dlldep.py bin/dso-stats.py bin/dump-athfile.py bin/dumpAthfilelite.py bin/filter-and-merge-d3pd.py bin/getMetadata.py bin/gprof2dot bin/issues bin/magnifyPoolFile.py bin/merge-poolfiles.py - bin/pool_extractFileIdentifier.py + bin/apydep.py bin/pool_extractFileIdentifier.py bin/pool_insertFileToCatalog.py bin/print_auditor_callgraph.py bin/pyroot.py bin/vmem-sz.py bin/meta-reader.py bin/meta-diff.py bin/tree-orderer.py POST_BUILD_CMD ${ATLAS_FLAKE8} ) @@ -54,3 +54,19 @@ atlas_add_test( RootUtils atlas_add_test( fprint_test SCRIPT python -m PyUtils.fprint ) + + +# Create python package dependencies in release building mode. +# Used as input for `acmd.py cmake depends`: +if( NOT "${CMAKE_PROJECT_NAME}" STREQUAL "WorkDir" ) + + set( _pydot ${CMAKE_CURRENT_BINARY_DIR}/packages.py.dot ) + add_custom_command( OUTPUT ${_pydot} + COMMAND ${CMAKE_BINARY_DIR}${CMAKE_FILES_DIRECTORY}/atlas_build_run.sh + ${CMAKE_CURRENT_SOURCE_DIR}/bin/apydep.py -o ${_pydot} -p ${CMAKE_BINARY_DIR}/${ATLAS_PLATFORM}/packages.txt ${CMAKE_SOURCE_DIR}/../../ ) + + add_custom_target( build_pydeps ALL DEPENDS ${_pydot} ) + + # Install output if available: + install( FILES ${_pydot} DESTINATION . OPTIONAL ) +endif() diff --git a/Tools/PyUtils/bin/apydep.py b/Tools/PyUtils/bin/apydep.py new file mode 100755 index 0000000000000000000000000000000000000000..c8778245935ad56a8a65583382c6424651458e2c --- /dev/null +++ b/Tools/PyUtils/bin/apydep.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +# Copyright (C) 2002-2020 CERN for the benefit of the ATLAS collaboration +# +# Created: Oct 2020, Frank Winklmeier +# +""" +Extract Python dependencies between packages and create DOT graph. +Both `import` and `include` dependencies are considered. +""" + +import ast +import sys +import os +import argparse +import pygraphviz +from collections import defaultdict + +class DependencyFinder(ast.NodeVisitor): + """Walk an AST collecting import/include statements.""" + + def __init__(self): + self.imports = set() + self.includes = set() + + def visit_Import(self, node): + """import XYZ""" + self.imports.update(alias.name.split('.',1)[0] for alias in node.names) + + def visit_ImportFrom(self, node): + """from XYZ import ABC""" + if node.level==0: # ignore relative imports + self.imports.add(node.module.split('.',1)[0]) + + def visit_Call(self, node): + """"include(XYZ/ABC.py)""" + if isinstance(node.func, ast.Name) and node.func.id=='include' and node.args: + if isinstance(node.args[0], ast.Str): + self.includes.add(node.args[0].s.split('/',1)[0]) + + +def get_dependencies(filename, print_error=False): + """Get all the imports/includes in a file.""" + + try: + tree = ast.parse(open(filename,'rb').read(), filename=filename) + except SyntaxError as e: + if print_error: + print(e, file=sys.stderr) + return DependencyFinder() + + finder = DependencyFinder() + finder.visit(tree) + + return finder + + +def walk_tree(path='./', print_error=False, filterFnc=None): + """Walk the source tree and extract python dependencies, filtered by FilterFnc""" + + pkg = 'UNKNOWN' + deps = defaultdict(lambda : defaultdict(set)) + for root, dirs, files in os.walk(path): + if 'CMakeLists.txt' in files: + pkg = os.path.basename(root) + + if (filterFnc and not filterFnc(pkg)): + continue + + for f in filter(lambda p : os.path.splitext(p)[1]=='.py', files): + d = get_dependencies(os.path.join(root,f), print_error) + deps[pkg]['import'].update(d.imports) + deps[pkg]['include'].update(d.includes) + + return deps + + +def make_graph(deps, filterFnc=None): + """Save the dependencies as dot graph, nodes filtered by filterFnc""" + + graph = pygraphviz.AGraph(name='AthPyGraph', directed=True) + for a in deps: + for t in ['import','include']: + graph.add_edges_from(((a,b) for b in deps[a][t] + if a!=b and (filterFnc is None or (filterFnc(a) and filterFnc(b)))), + label = t) + return graph + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + + parser.add_argument('path', metavar='DIRECTORY', nargs='?', default='./', + help='root of source tree [%(default)s]') + + parser.add_argument('-o', '--output', metavar='FILE', type=str, + help='output file for DOT graph') + + parser.add_argument('-p', '--packages', metavar='FILE', type=str, + help='path to packages.txt file [from release]') + + parser.add_argument('-a', '--all', action='store_true', + help='include non-athena dependencies') + + parser.add_argument('-v', '--verbose', action='store_true', + help='print parse errors') + + args = parser.parse_args() + + packages = None + if not args.all: + package_file = args.packages or os.path.join(os.environ['AtlasArea'],'InstallArea', + os.environ['BINARY_TAG'],'packages.txt') + + try: + with open(package_file) as f: + packages = set(line.rstrip().split('/')[-1] for line in f if not line.startswith('#')) + except FileNotFoundError: + parser.error(f"Cannot read '{package_file}'. Specify via '-p/--packages' or run with '-a/--all'") + + # By default only show athena packages: + filterFnc = None if args.all else lambda p : p in packages + + # Walk source tree and create DOT graph: + g = make_graph(walk_tree(args.path, args.verbose, filterFnc), filterFnc) + + if args.output: + g.write(args.output) + else: + print(g) + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Tools/PyUtils/python/scripts/cmake_depends.py b/Tools/PyUtils/python/scripts/cmake_depends.py index 0d3d628b068ea8bbb8247e91ec87f38494f50f90..df351c9b385b180f669425f115bb207620e854b4 100644 --- a/Tools/PyUtils/python/scripts/cmake_depends.py +++ b/Tools/PyUtils/python/scripts/cmake_depends.py @@ -93,6 +93,16 @@ def subgraph(graph, sources, reverse=False, maxdepth=None, nodegetter=lambda n : return g +def add_legend(graph): + """Add legend to graph""" + graph.add_subgraph(name='clusterLegend', label='Legend') + l = graph.subgraphs()[-1] + for n in 'abcd': + l.add_node(n, shape='point', style='invis') + l.add_edge('a','b', label='C++', constraint=False) + l.add_edge('c','d', label='Python', style='dashed', constraint=False) + + class AthGraph: """Class to hold dependency information for release""" @@ -104,13 +114,13 @@ class AthGraph: # Build dictionary for node types: legend = self.graph.get_subgraph('clusterLegend') - self.types = { n.attr['label'] : n.attr['shape'] for n in legend.iternodes() } + self.types = { n.attr['label'] : n.attr['shape'] for n in legend.nodes_iter() } # Build dictionary for node names: - self.node = { n.attr['label'] : n.get_name() for n in self.graph.iternodes() } + self.node = { n.attr['label'] : n.get_name() for n in self.graph.nodes_iter() } # Extract package dependencies: - for e in self.graph.iteredges(): + for e in self.graph.edges_iter(): p = e[0].attr['label'] # Decorate target with package name: if p.startswith('Package_'): @@ -119,7 +129,7 @@ class AthGraph: # Assign "package" names to externals if possible: external_nodes = filter(lambda n : 'package' not in n.attr.keys(), - self.graph.iternodes()) + self.graph.nodes_iter()) for n in external_nodes: name = externals_name(n.attr['label']) n.attr['package'] = name.split('::')[0] @@ -162,6 +172,9 @@ class AthGraph: type=int, default=1, const=None, help='recursively resolve dependencies up to DEPTH (default: unlimited)') +@acmdlib.argument('--py', action='store_true', + help='add Python dependencies') + @acmdlib.argument('--regex', action='store_true', help='treat NAME as regular expression') @@ -171,8 +184,14 @@ class AthGraph: @acmdlib.argument('-d', '--dot', action='store_true', help='print DOT graph') +@acmdlib.argument('--noLegend', action='store_true', + help='do not add legend to graph') + + # Debugging/expert options: @acmdlib.argument('--cmakedot', help=argparse.SUPPRESS) +@acmdlib.argument('--pydot', help=argparse.SUPPRESS) + def main(args): """Inspect cmake build dependencies""" @@ -185,6 +204,18 @@ def main(args): except KeyError: main.parser.error("Cannot find 'packages.dot'. Setup a release or use --cmakedot.") + # Find packages.py.dot: + pygraph = None + if args.py: + if args.target: + main.parser.error("Python dependencies not possible in target mode.") + + args.pydot = args.pydot or args.cmakedot.replace('.dot','.py.dot') + try: + pygraph = pygraphviz.AGraph(args.pydot) + except Exception: + main.parser.error(f"Cannot read '{args.pydot}'. Setup a release or use --pydot.") + # Read packages.txt if needed: package_paths = {} if args.long: @@ -195,8 +226,9 @@ def main(args): 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: + depth = args.recursive if not args.target and not args.clients and args.recursive is not None: - args.recursive += 1 + depth += 1 # Read dependencies: d = AthGraph(args.cmakedot, package_paths) @@ -208,14 +240,14 @@ def main(args): a = 'label' if args.target else 'package' return node.attr[a] - graph = pygraphviz.AGraph(name='AthGraph', directed=True) + graph = pygraphviz.AGraph(name='AthGraph', directed=True, strict=False) 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 = [getnode(n) for n in d.graph.nodes_iter() if r.match(n.attr['label'])] targets = list(filter(lambda t : t is not None, targets)) else: targets = [target] @@ -242,12 +274,31 @@ def main(args): # Extract the dependency subgraph: g = subgraph(d.graph, sources, reverse=args.clients, - maxdepth=args.recursive, nodegetter=getnode) + maxdepth=depth, nodegetter=getnode) graph.add_subgraph(name=target) graph.get_subgraph(target).add_edges_from(g.edges()) - # Print result: + # Add python dependencies: + if args.py: + # Here the nodes are the actual package names: + pysources = [pygraph.get_node(t) for t in targets if pygraph.has_node(t)] + g = subgraph(pygraph, pysources, reverse=args.clients, + maxdepth=args.recursive, nodegetter=lambda n : n.name) + + graph.get_subgraph(target).add_edges_from(g.edges(), style='dashed') + + # Change style of nodes that have only Python dependencies: + g = graph.get_subgraph(target) + for n in g.nodes_iter(): + if all(e.attr['style']=='dashed' for e in g.edges_iter(n)): + n.attr['style'] = 'dashed' + + + # Output final graph: + if not args.noLegend: + add_legend(graph) + if args.dot: print(graph) else: