Skip to content
Snippets Groups Projects
Commit a00a42cc authored by Edward Moyse's avatar Edward Moyse
Browse files

Merge branch 'py_depends' into 'master'

PyUtils: add Python package dependencies to `acmd cmake depends`

See merge request atlas/athena!38812
parents 077d370d 69a92eca
No related branches found
No related tags found
No related merge requests found
...@@ -19,7 +19,7 @@ atlas_install_scripts( bin/acmd.py bin/checkFile.py bin/checkPlugins.py ...@@ -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/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/dumpAthfilelite.py bin/filter-and-merge-d3pd.py bin/getMetadata.py
bin/gprof2dot bin/issues bin/magnifyPoolFile.py bin/merge-poolfiles.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/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 bin/vmem-sz.py bin/meta-reader.py bin/meta-diff.py bin/tree-orderer.py
POST_BUILD_CMD ${ATLAS_FLAKE8} ) POST_BUILD_CMD ${ATLAS_FLAKE8} )
...@@ -54,3 +54,19 @@ atlas_add_test( RootUtils ...@@ -54,3 +54,19 @@ atlas_add_test( RootUtils
atlas_add_test( fprint_test atlas_add_test( fprint_test
SCRIPT python -m PyUtils.fprint ) 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()
#!/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())
...@@ -93,6 +93,16 @@ def subgraph(graph, sources, reverse=False, maxdepth=None, nodegetter=lambda n : ...@@ -93,6 +93,16 @@ def subgraph(graph, sources, reverse=False, maxdepth=None, nodegetter=lambda n :
return g 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 AthGraph:
"""Class to hold dependency information for release""" """Class to hold dependency information for release"""
...@@ -104,13 +114,13 @@ class AthGraph: ...@@ -104,13 +114,13 @@ class AthGraph:
# Build dictionary for node types: # Build dictionary for node types:
legend = self.graph.get_subgraph('clusterLegend') 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: # 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: # Extract package dependencies:
for e in self.graph.iteredges(): for e in self.graph.edges_iter():
p = e[0].attr['label'] p = e[0].attr['label']
# Decorate target with package name: # Decorate target with package name:
if p.startswith('Package_'): if p.startswith('Package_'):
...@@ -119,7 +129,7 @@ class AthGraph: ...@@ -119,7 +129,7 @@ class AthGraph:
# Assign "package" names to externals if possible: # Assign "package" names to externals if possible:
external_nodes = filter(lambda n : 'package' not in n.attr.keys(), external_nodes = filter(lambda n : 'package' not in n.attr.keys(),
self.graph.iternodes()) self.graph.nodes_iter())
for n in external_nodes: for n in external_nodes:
name = externals_name(n.attr['label']) name = externals_name(n.attr['label'])
n.attr['package'] = name.split('::')[0] n.attr['package'] = name.split('::')[0]
...@@ -162,6 +172,9 @@ class AthGraph: ...@@ -162,6 +172,9 @@ class AthGraph:
type=int, default=1, const=None, type=int, default=1, const=None,
help='recursively resolve dependencies up to DEPTH (default: unlimited)') 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', @acmdlib.argument('--regex', action='store_true',
help='treat NAME as regular expression') help='treat NAME as regular expression')
...@@ -171,8 +184,14 @@ class AthGraph: ...@@ -171,8 +184,14 @@ class AthGraph:
@acmdlib.argument('-d', '--dot', action='store_true', @acmdlib.argument('-d', '--dot', action='store_true',
help='print DOT graph') help='print DOT graph')
@acmdlib.argument('--noLegend', action='store_true',
help='do not add legend to graph')
# Debugging/expert options: # Debugging/expert options:
@acmdlib.argument('--cmakedot', help=argparse.SUPPRESS) @acmdlib.argument('--cmakedot', help=argparse.SUPPRESS)
@acmdlib.argument('--pydot', help=argparse.SUPPRESS)
def main(args): def main(args):
"""Inspect cmake build dependencies""" """Inspect cmake build dependencies"""
...@@ -185,6 +204,18 @@ def main(args): ...@@ -185,6 +204,18 @@ def main(args):
except KeyError: except KeyError:
main.parser.error("Cannot find 'packages.dot'. Setup a release or use --cmakedot.") 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: # Read packages.txt if needed:
package_paths = {} package_paths = {}
if args.long: if args.long:
...@@ -195,8 +226,9 @@ def main(args): ...@@ -195,8 +226,9 @@ def main(args):
main.parser.error("Cannot read 'packages.txt'. Setup a release or run without -l/--long.") 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: # 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: if not args.target and not args.clients and args.recursive is not None:
args.recursive += 1 depth += 1
# Read dependencies: # Read dependencies:
d = AthGraph(args.cmakedot, package_paths) d = AthGraph(args.cmakedot, package_paths)
...@@ -208,14 +240,14 @@ def main(args): ...@@ -208,14 +240,14 @@ def main(args):
a = 'label' if args.target else 'package' a = 'label' if args.target else 'package'
return node.attr[a] 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: for p in args.names:
target = p.split('/')[-1] # in case of full package path target = p.split('/')[-1] # in case of full package path
# With regex, find all matching targets: # With regex, find all matching targets:
if args.regex: if args.regex:
r = re.compile(target) 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)) targets = list(filter(lambda t : t is not None, targets))
else: else:
targets = [target] targets = [target]
...@@ -242,12 +274,31 @@ def main(args): ...@@ -242,12 +274,31 @@ def main(args):
# Extract the dependency subgraph: # Extract the dependency subgraph:
g = subgraph(d.graph, sources, reverse=args.clients, g = subgraph(d.graph, sources, reverse=args.clients,
maxdepth=args.recursive, nodegetter=getnode) maxdepth=depth, nodegetter=getnode)
graph.add_subgraph(name=target) graph.add_subgraph(name=target)
graph.get_subgraph(target).add_edges_from(g.edges()) 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: if args.dot:
print(graph) print(graph)
else: else:
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment