#!/usr/bin/env python3 # # Copyright 2020 MongoDB Inc. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be included # in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY # KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE # WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. # """ Graph Analysis Command Line Interface. A Command line interface to the graph analysis module. """ import argparse import textwrap import sys from pathlib import Path import copy import networkx import libdeps.analyzer as libdeps_analyzer from libdeps.graph import LibdepsGraph, CountTypes, LinterTypes class LinterSplitArgs(argparse.Action): """Custom argument action for checking multiple choice comma separated list.""" def __call__(self, parser, namespace, values, option_string=None): """Create a multi choice comma separated list.""" selected_choices = [v.upper() for v in ''.join(values).split(',') if v] invalid_choices = [ choice for choice in selected_choices if choice not in self.valid_choices ] if invalid_choices: raise Exception( f"Invalid choices: {invalid_choices}\nMust use choices from {self.valid_choices}") if CountTypes.ALL.name in selected_choices: selected_choices = copy.copy(self.valid_choices) selected_choices.remove(CountTypes.ALL.name) if selected_choices == []: selected_choices = copy.copy(self.default_choices) setattr(namespace, self.dest, [opt.replace('-', '_') for opt in selected_choices]) class CountSplitArgs(LinterSplitArgs): """Special case of common custom arg action for Count types.""" valid_choices = [name[0].replace('_', '-') for name in CountTypes.__members__.items()] default_choices = [ name[0] for name in CountTypes.__members__.items() if name[0] != CountTypes.ALL.name ] class LintSplitArgs(LinterSplitArgs): """Special case of common custom arg action for Count types.""" valid_choices = [name[0].replace('_', '-') for name in LinterTypes.__members__.items()] default_choices = [LinterTypes.PUBLIC_UNUSED.name] class CustomFormatter(argparse.RawTextHelpFormatter, argparse.ArgumentDefaultsHelpFormatter): """Custom arg help formatter for modifying the defaults printed for the custom list action.""" @staticmethod def _get_help_length(enum_type): max_length = max([len(name[0]) for name in enum_type.__members__.items()]) help_text = {} for name in enum_type.__members__.items(): help_text[name[0]] = name[0].lower() + ('-' * (max_length - len(name[0]))) + ": " return help_text def _get_help_string(self, action): if isinstance(action, CountSplitArgs): help_text = self._get_help_length(CountTypes) return textwrap.dedent(f"""\ {action.help} default: all, choices: {help_text[CountTypes.ALL.name]}perform all counts {help_text[CountTypes.NODE.name]}count nodes {help_text[CountTypes.EDGE.name]}count edges {help_text[CountTypes.DIR_EDGE.name]}count edges declared directly on a node {help_text[CountTypes.TRANS_EDGE.name]}count edges induced by direct public edges {help_text[CountTypes.DIR_PUB_EDGE.name]}count edges that are directly public {help_text[CountTypes.PUB_EDGE.name]}count edges that are public {help_text[CountTypes.PRIV_EDGE.name]}count edges that are private {help_text[CountTypes.IF_EDGE.name]}count edges that are interface {help_text[CountTypes.LIB.name]}count library nodes {help_text[CountTypes.PROG.name]}count program nodes """) elif isinstance(action, LintSplitArgs): help_text = self._get_help_length(LinterTypes) return textwrap.dedent(f"""\ {action.help} default: all, choices: {help_text[LinterTypes.ALL.name]}perform all linters {help_text[LinterTypes.PUBLIC_UNUSED.name]}find unnecessary public libdeps """) return super()._get_help_string(action) def setup_args_parser(): """Add and parse the input args.""" parser = argparse.ArgumentParser(formatter_class=CustomFormatter) parser.add_argument('--graph-file', type=str, action='store', help="The LIBDEPS graph to load.", default="build/opt/libdeps/libdeps.graphml") parser.add_argument('--format', choices=['pretty', 'json'], default='pretty', help="The output format type.") parser.add_argument('--build-data', choices=['on', 'off'], default='on', help="Print the invocation and git hash used to build the graph") parser.add_argument('--counts', metavar='COUNT,', nargs='*', action=CountSplitArgs, default=CountSplitArgs.default_choices, help="Output various counts from the graph. Comma separated list.") parser.add_argument('--lint', metavar='LINTER,', nargs='*', action=LintSplitArgs, default=LintSplitArgs.default_choices, help="Perform various linters on the graph. Comma separated list.") parser.add_argument('--direct-depends', action='append', default=[], help="Print the nodes which depends on a given node.") parser.add_argument('--common-depends', nargs='+', action='append', default=[], help="Print the nodes which have a common dependency on all N nodes.") parser.add_argument( '--exclude-depends', nargs='+', action='append', default=[], help= "Print nodes which depend on the first node of N nodes, but exclude all nodes listed there after." ) parser.add_argument('--graph-paths', nargs='+', action='append', default=[], help="[from_node] [to_node]: Print all paths between 2 nodes.") parser.add_argument( '--critical-edges', nargs='+', action='append', default=[], help= "[from_node] [to_node]: Print edges between two nodes, which if removed would break the dependency between those " + "nodes,.") parser.add_argument( '--indegree-one', action='store_true', default=False, help= "Find candidate nodes for merging by searching the graph for nodes with only one node which depends on them." ) args = parser.parse_args() for arg_list in args.graph_paths: if len(arg_list) != 2: parser.error( f'Must pass two args for --graph-paths, [from_node] [to_node], not {arg_list}') for arg_list in args.critical_edges: if len(arg_list) != 2: parser.error( f'Must pass two args for --critical-edges, [from_node] [to_node], not {arg_list}') return parser.parse_args() def strip_build_dir(build_dir, node): """Small util function for making args match the graph paths.""" return str(Path(node).relative_to(build_dir)) def strip_build_dirs(build_dir, nodes): """Small util function for making a list of nodes match graph paths.""" return [strip_build_dir(build_dir, node) for node in nodes] def load_graph_data(graph_file, output_format): """Load a graphml file.""" if output_format == "pretty": sys.stdout.write("Loading graph data...") sys.stdout.flush() graph = networkx.read_graphml(graph_file) if output_format == "pretty": sys.stdout.write("Loaded!\n\n") return graph def main(): """Perform graph analysis based on input args.""" args = setup_args_parser() graph = load_graph_data(args.graph_file, args.format) libdeps_graph = LibdepsGraph(graph=graph) build_dir = libdeps_graph.graph['build_dir'] if libdeps_graph.graph['graph_schema_version'] == 1: libdeps_graph = networkx.reverse_view(libdeps_graph) analysis = libdeps_analyzer.counter_factory(libdeps_graph, args.counts) for analyzer_args in args.direct_depends: analysis.append( libdeps_analyzer.DirectDependents(libdeps_graph, strip_build_dir(build_dir, analyzer_args))) for analyzer_args in args.common_depends: analysis.append( libdeps_analyzer.CommonDependents(libdeps_graph, strip_build_dirs(build_dir, analyzer_args))) for analyzer_args in args.exclude_depends: analysis.append( libdeps_analyzer.ExcludeDependents(libdeps_graph, strip_build_dirs(build_dir, analyzer_args))) for analyzer_args in args.graph_paths: analysis.append( libdeps_analyzer.GraphPaths(libdeps_graph, strip_build_dir(build_dir, analyzer_args[0]), strip_build_dir(build_dir, analyzer_args[1]))) for analyzer_args in args.critical_edges: analysis.append( libdeps_analyzer.CriticalEdges(libdeps_graph, strip_build_dir(build_dir, analyzer_args[0]), strip_build_dir(build_dir, analyzer_args[1]))) if args.indegree_one: analysis.append(libdeps_analyzer.InDegreeOne(libdeps_graph)) analysis += libdeps_analyzer.linter_factory(libdeps_graph, args.lint) if args.build_data: analysis.append(libdeps_analyzer.BuildDataReport(libdeps_graph)) ga = libdeps_analyzer.LibdepsGraphAnalysis(analysis) if args.format == 'pretty': ga_printer = libdeps_analyzer.GaPrettyPrinter(ga) elif args.format == 'json': ga_printer = libdeps_analyzer.GaJsonPrinter(ga) else: return ga_printer.print() if __name__ == "__main__": main()