codegrapher 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cli/__init__.py +1 -0
- cli/script.py +58 -0
- codegrapher/__init__.py +1 -0
- codegrapher/graph.py +168 -0
- codegrapher/parser.py +380 -0
- codegrapher-0.3.0.dist-info/METADATA +150 -0
- codegrapher-0.3.0.dist-info/RECORD +10 -0
- codegrapher-0.3.0.dist-info/WHEEL +4 -0
- codegrapher-0.3.0.dist-info/entry_points.txt +2 -0
- codegrapher-0.3.0.dist-info/licenses/LICENSE +16 -0
cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .script import cli
|
cli/script.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
|
|
6
|
+
from codegrapher.graph import FunctionGrapher
|
|
7
|
+
from codegrapher.parser import FileObject
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.command()
|
|
11
|
+
@click.argument('code', type=click.Path())
|
|
12
|
+
@click.option('-r', '--recursive', default=False, is_flag=True,
|
|
13
|
+
help='Treat code argument as a directory and parse all files in directory, recursively')
|
|
14
|
+
@click.option('--printed', default=False, is_flag=True, help='Pretty prints the call tree for each class in the file')
|
|
15
|
+
@click.option('--ignore', default=False, is_flag=True, help='Use a .cg_ignore file to ignore functions in call tree')
|
|
16
|
+
@click.option('--remove-builtins', default=False, is_flag=True, help='Removes builtin functions from call trees')
|
|
17
|
+
@click.option('--output', help='Graphviz output file name')
|
|
18
|
+
@click.option('--output-format', default='pdf', help='File type for graphviz output file')
|
|
19
|
+
def cli(code, recursive, printed, ignore, remove_builtins, output, output_format):
|
|
20
|
+
"""
|
|
21
|
+
Parses a file.
|
|
22
|
+
codegrapher [file_name]
|
|
23
|
+
"""
|
|
24
|
+
file_list = []
|
|
25
|
+
if recursive:
|
|
26
|
+
for dirpath, dirnames, filenames in os.walk(code):
|
|
27
|
+
for filename in filenames:
|
|
28
|
+
if re.search(r'\.py$', filename):
|
|
29
|
+
file_list.append(os.sep.join([dirpath, filename]))
|
|
30
|
+
else:
|
|
31
|
+
file_list.append(code)
|
|
32
|
+
|
|
33
|
+
graph = None
|
|
34
|
+
for file_name in file_list:
|
|
35
|
+
file_object = FileObject(file_name)
|
|
36
|
+
file_object.visit()
|
|
37
|
+
if remove_builtins:
|
|
38
|
+
file_object.remove_builtins()
|
|
39
|
+
if ignore:
|
|
40
|
+
file_object.add_ignore_file()
|
|
41
|
+
file_object.ignore_functions()
|
|
42
|
+
if printed:
|
|
43
|
+
click.echo('Classes in file {}:'.format(file_name))
|
|
44
|
+
for class_object in file_object.classes:
|
|
45
|
+
click.echo('=' * 80)
|
|
46
|
+
click.echo(class_object.name)
|
|
47
|
+
click.echo(class_object.pprint())
|
|
48
|
+
click.echo('')
|
|
49
|
+
if output:
|
|
50
|
+
try:
|
|
51
|
+
graph.add_file_to_graph(file_object)
|
|
52
|
+
except AttributeError:
|
|
53
|
+
graph = FunctionGrapher()
|
|
54
|
+
graph.add_file_to_graph(file_object)
|
|
55
|
+
if output:
|
|
56
|
+
graph.name = output
|
|
57
|
+
graph.format = output_format
|
|
58
|
+
graph.render()
|
codegrapher/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = '0.3.0'
|
codegrapher/graph.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
from graphviz import Digraph
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class FilenameNotSpecifiedException(Exception):
|
|
5
|
+
""" An exception raised when a file name is not specified in a :class:`FunctionGrapher` instance before calling
|
|
6
|
+
:func:`FunctionGrapher.render` on it.
|
|
7
|
+
"""
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Node(object):
|
|
12
|
+
""" A class to more easily handle manipulations needed to properly display nodes in a graph.
|
|
13
|
+
Optimized to handle nodes that represent functions in a program.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
tuple (tuple): Contains the namespace, class, and function name for the current node. If namespace is an empty
|
|
17
|
+
string, this contains just the class and function names. If a string is provided to the constructor this
|
|
18
|
+
is a tuple containing just the function name.
|
|
19
|
+
"""
|
|
20
|
+
def __init__(self, input_node):
|
|
21
|
+
if isinstance(input_node, tuple):
|
|
22
|
+
if input_node[0] == '':
|
|
23
|
+
self.tuple = input_node[1:]
|
|
24
|
+
else:
|
|
25
|
+
self.tuple = input_node
|
|
26
|
+
else:
|
|
27
|
+
self.tuple = (input_node,)
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def represent(self):
|
|
31
|
+
""" Provides a string representation of the current node
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
(string): Dotted form of current node, as in `namespace.class.function_name`.
|
|
35
|
+
"""
|
|
36
|
+
return '.'.join(self.tuple)
|
|
37
|
+
|
|
38
|
+
def __str__(self):
|
|
39
|
+
return '.'.join(self.tuple)
|
|
40
|
+
|
|
41
|
+
def __repr__(self):
|
|
42
|
+
return "<Node: {}>".format(self.tuple)
|
|
43
|
+
|
|
44
|
+
def __hash__(self):
|
|
45
|
+
return hash('.'.join(self.tuple))
|
|
46
|
+
|
|
47
|
+
def __eq__(self, other):
|
|
48
|
+
return self.tuple == other.tuple
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class FunctionGrapher(object):
|
|
52
|
+
""" `FunctionGrapher` is a class for producing `graphviz <http://www.graphviz.org/>`_ graphs showing the call
|
|
53
|
+
graph for sets of classes.
|
|
54
|
+
|
|
55
|
+
Attributes:
|
|
56
|
+
name (string): Name to be used when a graph is made.
|
|
57
|
+
nodes (set): Graphviz nodes to be graphed.
|
|
58
|
+
edges (set): Directional edges connecting one node to another.
|
|
59
|
+
format (string): File format for graph. Default is `pdf`.
|
|
60
|
+
"""
|
|
61
|
+
def __init__(self):
|
|
62
|
+
self.name = ''
|
|
63
|
+
self.dot_file = Digraph()
|
|
64
|
+
self.nodes = set()
|
|
65
|
+
self.edges = set()
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def format(self):
|
|
69
|
+
return self.dot_file.format
|
|
70
|
+
|
|
71
|
+
@format.setter
|
|
72
|
+
def format(self, value):
|
|
73
|
+
self.dot_file.format = value
|
|
74
|
+
|
|
75
|
+
def add_file_to_graph(self, file_object):
|
|
76
|
+
""" When given a :class:`codegrapher.parser.FileObject` object, this adds all classes to the current graph.
|
|
77
|
+
|
|
78
|
+
Arguments:
|
|
79
|
+
file_object (:class:`codegrapher.parser.FileObject`): Visitor objects to have all its classes added to the
|
|
80
|
+
current graph.
|
|
81
|
+
"""
|
|
82
|
+
class_namespace = dict((cls.name, file_object.relative_namespace) for cls in file_object.classes)
|
|
83
|
+
for cls in file_object.classes:
|
|
84
|
+
self.add_dict_to_graph(class_namespace, cls.call_tree, file_object.relative_namespace)
|
|
85
|
+
self.add_classes_to_graph(file_object.classes, file_object.relative_namespace)
|
|
86
|
+
|
|
87
|
+
def add_dict_to_graph(self, class_names, dictionary, relative_namespace):
|
|
88
|
+
""" Creates a list of nodes and edges to be rendered. Deduplicates input.
|
|
89
|
+
|
|
90
|
+
Arguments:
|
|
91
|
+
class_names (list): List of class names to be recognized by the graph as `class_name.__init__` nodes.
|
|
92
|
+
dictionary (dict): `ClassObject.call_tree` dict to be added to graph nodes and edges.
|
|
93
|
+
relative_namespace (string): Relative namespace for the current class, i.e. where the current class is
|
|
94
|
+
located relative to the root, in dotted path notation.
|
|
95
|
+
"""
|
|
96
|
+
# todo: better handle project hierarchy by looking at imports
|
|
97
|
+
# add nodes
|
|
98
|
+
for origin in dictionary:
|
|
99
|
+
self.nodes.add(Node(origin))
|
|
100
|
+
for destination in dictionary[origin]:
|
|
101
|
+
if destination[0] in class_names:
|
|
102
|
+
destination = Node((relative_namespace, destination[0], '__init__'))
|
|
103
|
+
else:
|
|
104
|
+
destination = Node(destination)
|
|
105
|
+
self.nodes.add(destination)
|
|
106
|
+
|
|
107
|
+
# add edges
|
|
108
|
+
for origin in dictionary:
|
|
109
|
+
for destination in dictionary[origin]:
|
|
110
|
+
# if destination is a class name, it is a constructor
|
|
111
|
+
if destination[0] in class_names:
|
|
112
|
+
destination = (relative_namespace, destination[0], '__init__')
|
|
113
|
+
else:
|
|
114
|
+
destination = destination
|
|
115
|
+
self.edges.add((Node(origin), Node(destination)))
|
|
116
|
+
|
|
117
|
+
def add_classes_to_graph(self, classes, relative_namespace):
|
|
118
|
+
""" Adds classes with constructors to the set.
|
|
119
|
+
This adds edges between a class constructor and the methods called on those items.
|
|
120
|
+
|
|
121
|
+
Arguments:
|
|
122
|
+
classes (list): list of :class:`codegrapher.parser.ClassObject` items.
|
|
123
|
+
relative_namespace (string): namespace of the current class.
|
|
124
|
+
"""
|
|
125
|
+
for cls in classes:
|
|
126
|
+
# If a class is here, add it as a node
|
|
127
|
+
self.nodes.add(Node((relative_namespace, cls.name)))
|
|
128
|
+
|
|
129
|
+
if not all((fcn.is_classmethod for fcn in cls.functions)):
|
|
130
|
+
# for case where there is at least one non-classmethod, assume implied (or explicit) __init__
|
|
131
|
+
|
|
132
|
+
# make a node between the class name and __init__
|
|
133
|
+
self.nodes.add(Node((relative_namespace, cls.name, '__init__')))
|
|
134
|
+
self.edges.add((Node((relative_namespace, cls.name)), Node((relative_namespace, cls.name, '__init__'))))
|
|
135
|
+
for fcn in cls.functions:
|
|
136
|
+
# skip classmethods and case where init would refer back to itself
|
|
137
|
+
if not fcn.is_classmethod and not fcn.name == '__init__':
|
|
138
|
+
self.edges.add((Node((relative_namespace, cls.name, '__init__')),
|
|
139
|
+
Node((relative_namespace, cls.name, fcn.name))))
|
|
140
|
+
elif fcn.is_classmethod:
|
|
141
|
+
self.edges.add((Node((relative_namespace, cls.name)),
|
|
142
|
+
Node((relative_namespace, cls.name, fcn.name))))
|
|
143
|
+
|
|
144
|
+
else:
|
|
145
|
+
# for the case where there are only classmethods defined
|
|
146
|
+
for fcn in cls.functions:
|
|
147
|
+
self.edges.add((Node((relative_namespace, cls.name)),
|
|
148
|
+
Node((relative_namespace, cls.name, fcn.name))))
|
|
149
|
+
|
|
150
|
+
def render(self, name=None):
|
|
151
|
+
""" Renders the current graph. `Graphviz <http://www.graphviz.org/>`_ must be installed for the graph to be
|
|
152
|
+
rendered.
|
|
153
|
+
|
|
154
|
+
Arguments:
|
|
155
|
+
name (string): filename to override `self.name`.
|
|
156
|
+
|
|
157
|
+
Raises:
|
|
158
|
+
FilenameNotSpecifiedException: If `FunctionGrapher.name` is not specified.
|
|
159
|
+
"""
|
|
160
|
+
for node in self.nodes:
|
|
161
|
+
self.dot_file.node(node.represent)
|
|
162
|
+
for edge in self.edges:
|
|
163
|
+
self.dot_file.edge(edge[0].represent, edge[1].represent)
|
|
164
|
+
if name is None:
|
|
165
|
+
if not self.name:
|
|
166
|
+
raise FilenameNotSpecifiedException
|
|
167
|
+
name = self.name
|
|
168
|
+
self.dot_file.render(name)
|
codegrapher/parser.py
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import copy
|
|
3
|
+
import os
|
|
4
|
+
from pprint import pformat
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class FileObject:
|
|
8
|
+
"""Class for keeping track of files.
|
|
9
|
+
|
|
10
|
+
Attributes:
|
|
11
|
+
modules (dict): dict of current modules with `alias: module_name`, `key:value pairs`.
|
|
12
|
+
aliases (dict): dict of current modules with `alias: original_name`, `key:value pairs`.
|
|
13
|
+
node (:mod:`ast.AST`): AST node for entire file.
|
|
14
|
+
name (string): File name.
|
|
15
|
+
classes (list): :class:`ClassObject` items defined in the current file.
|
|
16
|
+
relative_namespace (string): The namespace for the current file,
|
|
17
|
+
taken from the relative path of the current file
|
|
18
|
+
ignore (set): Functions to be ignored, as defined in a `.cg_ignore` text file.
|
|
19
|
+
"""
|
|
20
|
+
def __init__(self, file_name, modules=None, aliases=None):
|
|
21
|
+
self.modules = copy.deepcopy(modules) if modules else {}
|
|
22
|
+
self.aliases = copy.deepcopy(aliases) if aliases else {}
|
|
23
|
+
self.name = file_name
|
|
24
|
+
self.full_path = os.path.abspath(file_name)
|
|
25
|
+
with open(self.full_path, 'r') as input_file:
|
|
26
|
+
self.node = ast.parse(input_file.read(), filename=self.name)
|
|
27
|
+
self.classes = []
|
|
28
|
+
self.relative_namespace = os.path.splitext(self.name)[0].replace(os.path.sep, '.')
|
|
29
|
+
self.ignore = set()
|
|
30
|
+
|
|
31
|
+
def visit(self):
|
|
32
|
+
"""Visits all the nodes within the current file AST node.
|
|
33
|
+
|
|
34
|
+
Updates `self.classes` for the current instance.
|
|
35
|
+
"""
|
|
36
|
+
file_visitor = FileVisitor(aliases=self.aliases, modules=self.modules)
|
|
37
|
+
file_visitor.visit(self.node)
|
|
38
|
+
self.modules = file_visitor.modules
|
|
39
|
+
self.aliases = file_visitor.aliases
|
|
40
|
+
self.classes = file_visitor.classes
|
|
41
|
+
self.namespace()
|
|
42
|
+
|
|
43
|
+
def remove_builtins(self):
|
|
44
|
+
"""Removes builtins from each class in a `FileObject` instance."""
|
|
45
|
+
for class_object in self.classes:
|
|
46
|
+
class_object.remove_builtins()
|
|
47
|
+
|
|
48
|
+
def add_ignore_file(self):
|
|
49
|
+
"""Use a file `.cg_ignore` to ignore a list of functions from the call graph
|
|
50
|
+
"""
|
|
51
|
+
if os.path.isfile('.cg_ignore'):
|
|
52
|
+
with open('.cg_ignore', 'r') as ignore_file:
|
|
53
|
+
for line in ignore_file:
|
|
54
|
+
if line.strip() and line.strip()[0] != '#':
|
|
55
|
+
self.ignore.add(line.strip())
|
|
56
|
+
|
|
57
|
+
def ignore_functions(self):
|
|
58
|
+
"""Ignore all functions in the current class which are present in the instance's `ignore` attribute.
|
|
59
|
+
"""
|
|
60
|
+
for class_object in self.classes:
|
|
61
|
+
class_object.ignore_functions(self.ignore)
|
|
62
|
+
|
|
63
|
+
def namespace(self):
|
|
64
|
+
"""Programmatically change the name of items in the call tree so they have relative path information
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
for class_object in self.classes:
|
|
68
|
+
class_object.namespace(self.relative_namespace)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ClassObject:
|
|
72
|
+
"""Class for keeping track of classes in code.
|
|
73
|
+
|
|
74
|
+
Attributes:
|
|
75
|
+
modules (dict): dict of current modules with `alias: module_name`, `key:value pairs`.
|
|
76
|
+
aliases (dict): dict of current modules with `alias: original_name`, `key:value pairs`.
|
|
77
|
+
node (:mod:`ast.AST`): AST node for entire class.
|
|
78
|
+
name (string): Class name.
|
|
79
|
+
functions (list): :class:`FunctionObject` items defined in the current class.
|
|
80
|
+
call_tree (dict): dict with `key:value` pairs `(module, FunctionObject.name): (module, identifier)`.
|
|
81
|
+
|
|
82
|
+
"""
|
|
83
|
+
def __init__(self, node=None, aliases=None, modules=None):
|
|
84
|
+
self.modules = copy.deepcopy(modules) if modules else {}
|
|
85
|
+
self.aliases = copy.deepcopy(aliases) if aliases else {}
|
|
86
|
+
self.node = node
|
|
87
|
+
self.name = node.name if node else ''
|
|
88
|
+
self.functions = []
|
|
89
|
+
self.call_tree = {}
|
|
90
|
+
|
|
91
|
+
def visit(self):
|
|
92
|
+
"""Visits all the nodes within the current class AST node.
|
|
93
|
+
|
|
94
|
+
Updates `self.functions` and `self.call_tree` for the current instance.
|
|
95
|
+
"""
|
|
96
|
+
function_visitor = FunctionVisitor(aliases=self.aliases, modules=self.modules)
|
|
97
|
+
function_visitor.visit(self.node)
|
|
98
|
+
self.functions = function_visitor.functions
|
|
99
|
+
self.call_tree = dict(((self.name, k), v) for k, v in function_visitor.calls.items())
|
|
100
|
+
|
|
101
|
+
def remove_builtins(self):
|
|
102
|
+
"""For many classes, we may not want to include builtin functions in the graph.
|
|
103
|
+
Remove builtins from the call tree and from called functions list.
|
|
104
|
+
"""
|
|
105
|
+
self.call_tree = {caller: [call for call in call_list if not self.is_builtin(call[0])]
|
|
106
|
+
for caller, call_list in self.call_tree.items()}
|
|
107
|
+
|
|
108
|
+
def ignore_functions(self, ignore_set):
|
|
109
|
+
"""Ignores all functions matching those specified in a pre-defined ignore set.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
ignore_set (set): Functions whose calls should be removed (ignored) in the class call tree.
|
|
113
|
+
"""
|
|
114
|
+
new_call_tree = {}
|
|
115
|
+
for caller, call_list in self.call_tree.items():
|
|
116
|
+
new_call_list = []
|
|
117
|
+
for call in call_list:
|
|
118
|
+
if call[-1] not in ignore_set:
|
|
119
|
+
new_call_list.append(call)
|
|
120
|
+
new_call_tree[caller] = new_call_list
|
|
121
|
+
|
|
122
|
+
self.call_tree = new_call_tree
|
|
123
|
+
|
|
124
|
+
def namespace(self, relative_namespace):
|
|
125
|
+
"""Take the relative namespace for the class and prepend it to each item defined in the current class.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
relative_namespace (string): Namespace to be prepended to each item in the call tree.
|
|
129
|
+
"""
|
|
130
|
+
new_call_tree = {}
|
|
131
|
+
for caller in self.call_tree:
|
|
132
|
+
new_call_tree[(relative_namespace, caller[0], caller[1])] = self.call_tree[caller]
|
|
133
|
+
self.call_tree = new_call_tree
|
|
134
|
+
|
|
135
|
+
def pprint(self):
|
|
136
|
+
"""Pretty print formatter for class object.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
string
|
|
140
|
+
"""
|
|
141
|
+
return pformat(self.call_tree)
|
|
142
|
+
|
|
143
|
+
@staticmethod
|
|
144
|
+
def is_builtin(fn):
|
|
145
|
+
"""Checks if a """
|
|
146
|
+
if isinstance(fn, str):
|
|
147
|
+
return fn in __builtins__
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
def __repr__(self):
|
|
151
|
+
return "ClassObject {}".format(self.name)
|
|
152
|
+
|
|
153
|
+
def __str__(self):
|
|
154
|
+
functions = [fcn.name for fcn in self.functions]
|
|
155
|
+
return "Class {}\nDefined functions: {}".format(self.name, functions)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class FunctionObject:
|
|
159
|
+
"""Object that stores information within a single function definition
|
|
160
|
+
|
|
161
|
+
attributes:
|
|
162
|
+
modules: dict of current modules with `alias: module_name`, `key:value pairs`.
|
|
163
|
+
aliases: dict of current modules with `alias: original_name`, `key:value pairs`.
|
|
164
|
+
node (:mod:`ast.AST`): AST node for entire function.
|
|
165
|
+
name (string): function name.
|
|
166
|
+
calls (list): `(module, identifier)` tuples describing items called within current node,
|
|
167
|
+
with identifiers decoded form current alias, and modules expanded to their full import paths.
|
|
168
|
+
decorator_list (list): list of decorators, by name as a string, applied to the current function definition.
|
|
169
|
+
is_classmethod (bool): True if the current function is designated as a classmethod by a decorator.
|
|
170
|
+
|
|
171
|
+
"""
|
|
172
|
+
def __init__(self, node=None, aliases=None, modules=None):
|
|
173
|
+
self.modules = copy.deepcopy(modules) if modules else {}
|
|
174
|
+
self.aliases = copy.deepcopy(aliases) if aliases else {}
|
|
175
|
+
self.node = node
|
|
176
|
+
self.name = node.name if node else ''
|
|
177
|
+
self.calls = []
|
|
178
|
+
self.decorator_list = []
|
|
179
|
+
self.is_classmethod = False
|
|
180
|
+
|
|
181
|
+
@classmethod
|
|
182
|
+
def _extract_decorators(cls, node):
|
|
183
|
+
"""Pulls out strings for each item in a decorator list on a FunctionDef node
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
node (:mod:`ast.AST`): Node from which `decorator_list` will be extracted
|
|
187
|
+
Returns:
|
|
188
|
+
(list): List of decorator strings
|
|
189
|
+
"""
|
|
190
|
+
decorator_list = []
|
|
191
|
+
for decorator in node.decorator_list:
|
|
192
|
+
if isinstance(decorator, ast.Name):
|
|
193
|
+
decorator_list.append(decorator.id)
|
|
194
|
+
elif isinstance(decorator, ast.Attribute):
|
|
195
|
+
# this catches things like attr.setter
|
|
196
|
+
decorator_list.append('.'.join([decorator.attr, decorator.value.id]))
|
|
197
|
+
return decorator_list
|
|
198
|
+
|
|
199
|
+
def visit(self):
|
|
200
|
+
"""Visits all the nodes within the current function object's AST node.
|
|
201
|
+
|
|
202
|
+
Updates `self.calls`, `self.modules`, and `self.aliases` for the current instance.
|
|
203
|
+
"""
|
|
204
|
+
visitor = CallVisitor(aliases=self.aliases, modules=self.modules)
|
|
205
|
+
visitor.visit(self.node)
|
|
206
|
+
self.decorator_list = FunctionObject._extract_decorators(self.node)
|
|
207
|
+
if 'classmethod' in self.decorator_list:
|
|
208
|
+
self.is_classmethod = True
|
|
209
|
+
self.calls = visitor.calls
|
|
210
|
+
self.modules.update(visitor.modules)
|
|
211
|
+
self.aliases.update(visitor.aliases)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class CallInspector(ast.NodeVisitor):
|
|
215
|
+
"""Within a call, a Name or Attribute will provide the function name currently in use.
|
|
216
|
+
|
|
217
|
+
Identifies `Name` nodes, which are called as ``name(args)``, and `Attribute` nodes, which are called as
|
|
218
|
+
``object.attr(args)``
|
|
219
|
+
|
|
220
|
+
Attributes:
|
|
221
|
+
module (string): Current module name on which the current call is made.
|
|
222
|
+
identifier (string): Name of the function called.
|
|
223
|
+
"""
|
|
224
|
+
def __init__(self):
|
|
225
|
+
self.module = ''
|
|
226
|
+
self.identifier = ''
|
|
227
|
+
|
|
228
|
+
def visit_Name(self, node):
|
|
229
|
+
self.identifier = node.id
|
|
230
|
+
|
|
231
|
+
def visit_Attribute(self, node):
|
|
232
|
+
# todo: pull out item for the attr to determine whether node defines a classmethod
|
|
233
|
+
# currently does not handle multiple chaining of attr items
|
|
234
|
+
if hasattr(node.value, 'id'):
|
|
235
|
+
self.module = node.value.id
|
|
236
|
+
self.identifier = node.attr
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class ImportVisitor(ast.NodeVisitor):
|
|
240
|
+
"""For import related calls, store the source modules and aliases used.
|
|
241
|
+
Designed to be inherited by other classes that need to know about imports in their current scope.
|
|
242
|
+
|
|
243
|
+
Attributes:
|
|
244
|
+
modules (dict): dict of current modules with `alias: module_name`, `key:value pairs`.
|
|
245
|
+
aliases (dict): dict of current modules with `alias: original_name`, `key:value pairs`.
|
|
246
|
+
"""
|
|
247
|
+
def __init__(self, aliases=None, modules=None):
|
|
248
|
+
self.modules = copy.deepcopy(modules) if modules else {}
|
|
249
|
+
self.aliases = copy.deepcopy(aliases) if aliases else {}
|
|
250
|
+
|
|
251
|
+
def continue_parsing(self, node):
|
|
252
|
+
super(ImportVisitor, self).generic_visit(node)
|
|
253
|
+
|
|
254
|
+
def visit_Import(self, node):
|
|
255
|
+
for item in node.names:
|
|
256
|
+
asname = item.asname if item.asname else item.name
|
|
257
|
+
self.aliases[asname] = item.name
|
|
258
|
+
self.modules[asname] = None
|
|
259
|
+
|
|
260
|
+
def visit_ImportFrom(self, node):
|
|
261
|
+
module = node.module
|
|
262
|
+
for item in node.names:
|
|
263
|
+
asname = item.asname if item.asname else item.name
|
|
264
|
+
self.aliases[asname] = item.name
|
|
265
|
+
self.modules[asname] = module
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class CallVisitor(ImportVisitor):
|
|
269
|
+
"""Finds all calls present in the current scope and inspect them.
|
|
270
|
+
|
|
271
|
+
Attributes:
|
|
272
|
+
call_names (set): set of :class:`CallInspector.identifier` items within current AST node.
|
|
273
|
+
calls (list): `(module, identifier)` items called within current AST node,
|
|
274
|
+
with identifiers decoded form current alias, and modules expanded to their full import paths.
|
|
275
|
+
"""
|
|
276
|
+
def __init__(self, **kwargs):
|
|
277
|
+
super(CallVisitor, self).__init__(**kwargs)
|
|
278
|
+
self.call_names = set()
|
|
279
|
+
self.calls = []
|
|
280
|
+
|
|
281
|
+
def continue_parsing(self, node):
|
|
282
|
+
super(CallVisitor, self).generic_visit(node)
|
|
283
|
+
|
|
284
|
+
def visit_Call(self, node):
|
|
285
|
+
call_visitor = CallInspector()
|
|
286
|
+
call_visitor.visit(node.func)
|
|
287
|
+
|
|
288
|
+
# handles calls within function calls
|
|
289
|
+
for arg in node.args:
|
|
290
|
+
self.continue_parsing(arg)
|
|
291
|
+
if isinstance(arg, ast.Call):
|
|
292
|
+
arg_visitor = CallVisitor(aliases=self.aliases, modules=self.modules)
|
|
293
|
+
arg_visitor.visit(arg)
|
|
294
|
+
self.call_names.update(arg_visitor.call_names)
|
|
295
|
+
self.calls.extend(arg_visitor.calls)
|
|
296
|
+
|
|
297
|
+
self.call_names.add(call_visitor.identifier)
|
|
298
|
+
|
|
299
|
+
# if names are aliased, pull out aliased name
|
|
300
|
+
if call_visitor.identifier in self.aliases:
|
|
301
|
+
identifier = self.aliases[call_visitor.identifier]
|
|
302
|
+
else:
|
|
303
|
+
identifier = call_visitor.identifier
|
|
304
|
+
|
|
305
|
+
if call_visitor.module in self.modules:
|
|
306
|
+
# module is imported and called by attr
|
|
307
|
+
if self.modules[call_visitor.module]:
|
|
308
|
+
module = '.'.join([self.modules[call_visitor.module], self.aliases[call_visitor.module]])
|
|
309
|
+
else:
|
|
310
|
+
module = self.aliases[call_visitor.module]
|
|
311
|
+
elif call_visitor.identifier in self.modules:
|
|
312
|
+
# module is imported, but not called by attr
|
|
313
|
+
module = self.modules[call_visitor.identifier]
|
|
314
|
+
else:
|
|
315
|
+
# no module specified
|
|
316
|
+
module = None
|
|
317
|
+
|
|
318
|
+
if module:
|
|
319
|
+
call = (module, identifier)
|
|
320
|
+
else:
|
|
321
|
+
call = (identifier,)
|
|
322
|
+
|
|
323
|
+
self.calls.append(call)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
class FunctionVisitor(ImportVisitor):
|
|
327
|
+
"""Function definitions are where the function is defined, and the call is where the ast for that function exists.
|
|
328
|
+
|
|
329
|
+
This only looks for items that are called within the scope of a function, and associates those items
|
|
330
|
+
with the function.
|
|
331
|
+
|
|
332
|
+
Attributes:
|
|
333
|
+
defined_functions (set): names of functions found by function visitor instance.
|
|
334
|
+
functions (list): :class:`FunctionObject` instances found by function visitor instance.
|
|
335
|
+
calls (dict): mapping from function names defined to calls within that function definition.
|
|
336
|
+
"""
|
|
337
|
+
def __init__(self, **kwargs):
|
|
338
|
+
super(FunctionVisitor, self).__init__(**kwargs)
|
|
339
|
+
self.defined_functions = set()
|
|
340
|
+
self.functions = []
|
|
341
|
+
self.calls = {}
|
|
342
|
+
|
|
343
|
+
def continue_parsing(self, node):
|
|
344
|
+
super(FunctionVisitor, self).generic_visit(node)
|
|
345
|
+
|
|
346
|
+
def visit_FunctionDef(self, node):
|
|
347
|
+
self.defined_functions.add(node.name)
|
|
348
|
+
function_def = FunctionObject(node=node, aliases=self.aliases, modules=self.modules)
|
|
349
|
+
function_def.visit()
|
|
350
|
+
self.calls[function_def.name] = function_def.calls
|
|
351
|
+
self.functions.append(function_def)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
class FileVisitor(ImportVisitor):
|
|
355
|
+
"""First visitor that should be called on the file level.
|
|
356
|
+
|
|
357
|
+
Attributes:
|
|
358
|
+
classes (list): list of :class:`ClassObject` instances defined in the current file.
|
|
359
|
+
"""
|
|
360
|
+
def __init__(self, **kwargs):
|
|
361
|
+
super(FileVisitor, self).__init__(**kwargs)
|
|
362
|
+
self.classes = []
|
|
363
|
+
|
|
364
|
+
def continue_parsing(self, node):
|
|
365
|
+
super(FileVisitor, self).generic_visit(node)
|
|
366
|
+
|
|
367
|
+
def visit_Module(self, node):
|
|
368
|
+
self.continue_parsing(node)
|
|
369
|
+
|
|
370
|
+
def visit_ClassDef(self, node):
|
|
371
|
+
# once a class is found, create a class object for it and traverse the ast with its visitor
|
|
372
|
+
new_class = ClassObject(node=node, aliases=self.aliases, modules=self.modules)
|
|
373
|
+
new_class.visit()
|
|
374
|
+
self.classes.append(new_class)
|
|
375
|
+
|
|
376
|
+
def remove_builtins(self):
|
|
377
|
+
"""Removes builtins from each class in a `FileVisitor` instance.
|
|
378
|
+
"""
|
|
379
|
+
for class_object in self.classes:
|
|
380
|
+
class_object.remove_builtins()
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: codegrapher
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Code that graphs code
|
|
5
|
+
Project-URL: Homepage, https://github.com/LaurEars/codegrapher
|
|
6
|
+
Project-URL: Repository, https://github.com/LaurEars/codegrapher
|
|
7
|
+
Project-URL: Issues, https://github.com/LaurEars/codegrapher/issues
|
|
8
|
+
Author: Laura Rupprecht
|
|
9
|
+
License: The MIT License (MIT)
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026 Laura Rupprecht
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
|
|
14
|
+
documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
|
|
15
|
+
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
|
|
16
|
+
persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
17
|
+
|
|
18
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
|
|
19
|
+
Software.
|
|
20
|
+
|
|
21
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
|
|
22
|
+
WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
23
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
|
24
|
+
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Keywords: ast,call-graph,code,documentation,graph,graphviz
|
|
27
|
+
Classifier: Development Status :: 4 - Beta
|
|
28
|
+
Classifier: Intended Audience :: Developers
|
|
29
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
30
|
+
Classifier: Programming Language :: Python :: 3
|
|
31
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
32
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
33
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
34
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
37
|
+
Classifier: Topic :: Software Development :: Documentation
|
|
38
|
+
Requires-Python: >=3.8
|
|
39
|
+
Requires-Dist: click>=7.0
|
|
40
|
+
Requires-Dist: graphviz>=0.4.2
|
|
41
|
+
Provides-Extra: dev
|
|
42
|
+
Requires-Dist: coverage>=5.0; extra == 'dev'
|
|
43
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
|
|
44
|
+
Requires-Dist: pytest>=7.0.0; extra == 'dev'
|
|
45
|
+
Description-Content-Type: text/x-rst
|
|
46
|
+
|
|
47
|
+
codegrapher
|
|
48
|
+
===========
|
|
49
|
+
|
|
50
|
+
.. image:: https://github.com/LaurEars/codegrapher/actions/workflows/main.yaml/badge.svg
|
|
51
|
+
:target: https://github.com/LaurEars/codegrapher/actions/workflows/main.yaml
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
Code that graphs code
|
|
55
|
+
---------------------
|
|
56
|
+
Uses the python `AST <https://docs.python.org/3/library/ast.html>`_ to parse Python source code and build a call graph.
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
Output
|
|
60
|
+
------
|
|
61
|
+
An example of the current output of the parser parsing itself.
|
|
62
|
+
|
|
63
|
+
.. image:: docs/codegrapher.png
|
|
64
|
+
:target: docs/codegrapher.png
|
|
65
|
+
:align: center
|
|
66
|
+
:width: 100 %
|
|
67
|
+
:alt: parser.py
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
Installation
|
|
71
|
+
------------
|
|
72
|
+
|
|
73
|
+
.. code:: bash
|
|
74
|
+
|
|
75
|
+
pip install codegrapher
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
To generate graphs, `graphviz <http://www.graphviz.org/Download.php>`_ must be installed.
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
Usage
|
|
82
|
+
-----
|
|
83
|
+
|
|
84
|
+
At the command line
|
|
85
|
+
~~~~~~~~~~~~~~~~~~~
|
|
86
|
+
To parse a file and output results to the console:
|
|
87
|
+
|
|
88
|
+
.. code:: bash
|
|
89
|
+
|
|
90
|
+
codegrapher path/to/file.py --printed
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
To parse a file and output results to a file:
|
|
94
|
+
|
|
95
|
+
.. code:: bash
|
|
96
|
+
|
|
97
|
+
codegrapher path/to/file.py --output output_file_name --output-type png
|
|
98
|
+
|
|
99
|
+
To analyze a directory of files, along with all files it contains:
|
|
100
|
+
|
|
101
|
+
.. code:: bash
|
|
102
|
+
|
|
103
|
+
codegrapher -r path/to/directory --output multiple_file_analysis
|
|
104
|
+
|
|
105
|
+
And if you have a list of functions that aren't useful in your graph, add it to a `.cg_ignore` file:
|
|
106
|
+
|
|
107
|
+
::
|
|
108
|
+
|
|
109
|
+
# cg_ignore file
|
|
110
|
+
# all lines beginning with '#' are ignored
|
|
111
|
+
|
|
112
|
+
# every function calls this, so it's not helpful in my graph:
|
|
113
|
+
log_error
|
|
114
|
+
|
|
115
|
+
# I don't want to see this in my graph:
|
|
116
|
+
parse
|
|
117
|
+
lower
|
|
118
|
+
|
|
119
|
+
Then add the `--ignore` flag to your command. Using the flag `--remove-builtins` provides the same functionality
|
|
120
|
+
for ignoring items found in `__builtins__`.
|
|
121
|
+
|
|
122
|
+
As a Python module
|
|
123
|
+
~~~~~~~~~~~~~~~~~~
|
|
124
|
+
|
|
125
|
+
To easily parse code in Python :
|
|
126
|
+
|
|
127
|
+
.. code:: python
|
|
128
|
+
|
|
129
|
+
from codegrapher.parser import FileObject
|
|
130
|
+
|
|
131
|
+
file_object = FileObject('path/to/file.py')
|
|
132
|
+
file_object.visit()
|
|
133
|
+
|
|
134
|
+
And then to add that code to a graph and render it (using graphviz):
|
|
135
|
+
|
|
136
|
+
.. code:: python
|
|
137
|
+
|
|
138
|
+
from codegrapher.graph import FunctionGrapher
|
|
139
|
+
|
|
140
|
+
graph = FunctionGrapher()
|
|
141
|
+
graph.add_file_to_graph(file_object)
|
|
142
|
+
graph.name = 'name.gv'
|
|
143
|
+
graph.format = 'png'
|
|
144
|
+
graph.render()
|
|
145
|
+
|
|
146
|
+
Which will produce your code as a png file, `name.gv.png`, along with a
|
|
147
|
+
`dot file <http://en.wikipedia.org/wiki/DOT_%28graph_description_language%29>`_ `name.gv`
|
|
148
|
+
|
|
149
|
+
More documentation for the Python module can be found at
|
|
150
|
+
`Read the Docs <http://codegrapher.readthedocs.org/en/latest/>`_.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
cli/__init__.py,sha256=T4hveAC4W-KBh3GWiedPiINl-iHT1ZhcSxqSE1MEwsw,24
|
|
2
|
+
cli/script.py,sha256=a3SRMouuae1ae6OqZoeNgjmo0xIpH8dhIxNXP6Syzqw,2216
|
|
3
|
+
codegrapher/__init__.py,sha256=3wVEs2QD_7OcTlD97cZdCeizd2hUbJJ0GeIO8wQIjrk,22
|
|
4
|
+
codegrapher/graph.py,sha256=Teacm7Hd6w43nM9wiIMaJUJcL_0mZuXHHsoN6aXYoTg,7095
|
|
5
|
+
codegrapher/parser.py,sha256=sjqfXp2H216mP-3W4liIANyvRFDyUe_mfUv8deyEy1E,15052
|
|
6
|
+
codegrapher-0.3.0.dist-info/METADATA,sha256=JPi5u8DMmxMzL-jdW60FWWQ5J4uwMvfETZcIIohYp9k,4872
|
|
7
|
+
codegrapher-0.3.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
8
|
+
codegrapher-0.3.0.dist-info/entry_points.txt,sha256=6SlRVjRRDdv0haGsEyWaY9XbORuQzNim3VE0_pVlJE0,40
|
|
9
|
+
codegrapher-0.3.0.dist-info/licenses/LICENSE,sha256=6bfha4F6flqIqyfRSvW-0aXHFDPaMhERmkLBVLTLQiM,1081
|
|
10
|
+
codegrapher-0.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Laura Rupprecht
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
|
|
6
|
+
documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
|
|
7
|
+
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
|
|
8
|
+
persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
|
|
11
|
+
Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
|
|
14
|
+
WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
15
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
|
16
|
+
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|