1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229
|
#!/usr/bin/python3
# SPDX-License-Identifier: BSD-3-Clause
"""
This script generates Graphviz-compatible dot scripts from the class
definitions of the containing project. Specify the root class to generate with
the -i (multiple roots can be specified). Specify parts of the hierarchy to
exclude with -x. Default configurations can be specified in the containing
project's setup.cfg under [{SETUP_SECTION}]
"""
from __future__ import annotations
import re
import sys
assert sys.version_info >= (3, 6), 'Script requires Python 3.6+'
import typing as t
from pathlib import Path
from configparser import ConfigParser
from argparse import ArgumentParser, Namespace, FileType
PROJECT_ROOT = (Path(__file__).parent / '..').resolve()
SETUP_SECTION = str(Path(__file__).name) + ':settings'
def main(args: t.List[str] = None):
if args is None:
args = sys.argv[1:]
config = get_config(args)
m = make_class_map(config.source, config.omit)
if config.include or config.exclude:
m = filter_map(m, include_roots=config.include,
exclude_roots=config.exclude)
config.output.write(render_map(m, config.abstract))
def get_config(args: t.List[str]) -> Namespace:
config = ConfigParser(
defaults={
'source': '',
'include': '',
'exclude': '',
'abstract': '',
'omit': '',
'output': '-',
},
delimiters=('=',), default_section=SETUP_SECTION,
empty_lines_in_values=False, interpolation=None,
converters={'list': lambda s: s.strip().splitlines()})
config.read(PROJECT_ROOT / 'setup.cfg')
sect = config[SETUP_SECTION]
# Resolve source and output defaults relative to setup.cfg
if sect['source']:
sect['source'] = '\n'.join(
str(PROJECT_ROOT / source)
for source in sect.getlist('source')
)
if sect['output'] and sect['output'] != '-':
sect['output'] = str(PROJECT_ROOT / sect['output'])
parser = ArgumentParser(description=__doc__.format(**globals()))
parser.add_argument(
'-s', '--source', action='append', metavar='PATH',
default=sect.getlist('source'),
help="the pattern(s) of files to search for classes; can be specified "
"multiple times. Default: %(default)r")
parser.add_argument(
'-i', '--include', action='append', metavar='CLASS',
default=sect.getlist('exclude'),
help="only include classes which have BASE somewhere in their "
"ancestry; can be specified multiple times. Default: %(default)r")
parser.add_argument(
'-x', '--exclude', action='append', metavar='CLASS',
default=sect.getlist('exclude'),
help="exclude any classes which have BASE somewhere in their "
"ancestry; can be specified multiple times. Default: %(default)r")
parser.add_argument(
'-o', '--omit', action='append', metavar='CLASS',
default=sect.getlist('omit'),
help="omit the specified class, but not its descendents from the "
"chart; can be specified multiple times. Default: %(default)r")
parser.add_argument(
'-a', '--abstract', action='append', metavar='CLASS',
default=sect.getlist('abstract'),
help="mark the specified class as abstract, rendering it in a "
"different color; can be specified multiple times. Default: "
"%(default)r")
parser.add_argument(
'output', nargs='?', type=FileType('w'),
default=sect['output'],
help="the file to write the output to; defaults to stdout")
ns = parser.parse_args(args)
ns.abstract = set(ns.abstract)
ns.include = set(ns.include)
ns.exclude = set(ns.exclude)
ns.omit = set(ns.omit)
if not ns.source:
ns.source = [str(PROJECT_ROOT)]
ns.source = set(ns.source)
return ns
def make_class_map(search_paths: t.List[str], omit: t.Set[str])\
-> t.Dict[str, t.Set[str]]:
"""
Find all Python source files under *search_paths*, extract (via a crude
regex) all class definitions and return a mapping of class-name to the list
of base classes.
All classes listed in *omit* will be excluded from the result, but not
their descendents (useful for excluding "object" etc.)
"""
def find_classes() -> t.Iterator[t.Tuple[str, t.Set[str]]]:
class_re = re.compile(
r'^class\s+(?P<name>\w+)\s*(?:\((?P<bases>.*)\))?:', re.MULTILINE)
for path in search_paths:
p = Path(path)
for py_file in p.parent.glob(p.name):
with py_file.open() as f:
for match in class_re.finditer(f.read()):
if match.group('name') not in omit:
yield match.group('name'), {
base.strip()
for base in (
match.group('bases') or 'object'
).split(',')
if base.strip() not in omit
}
return {
name: bases
for name, bases in find_classes()
}
def filter_map(class_map: t.Dict[str, t.Set[str]], include_roots: t.Set[str],
exclude_roots: t.Set[str]) -> t.Dict[str, t.Set[str]]:
"""
Returns *class_map* (which is a mapping such as that returned by
:func:`make_class_map`), with only those classes which have at least one
of the *include_roots* in their ancestry, and none of the *exclude_roots*.
"""
def has_parent(cls: str, parent: str) -> bool:
return cls == parent or any(
has_parent(base, parent) for base in class_map.get(cls, ()))
filtered = {
name: bases
for name, bases in class_map.items()
if (not include_roots or
any(has_parent(name, root) for root in include_roots))
and not any(has_parent(name, root) for root in exclude_roots)
}
pure_bases = {
base for name, bases in filtered.items() for base in bases
} - set(filtered)
# Make a second pass to fill in missing links between classes that are
# only included as bases of other classes
for base in pure_bases:
filtered[base] = pure_bases & class_map[base]
return filtered
def render_map(class_map: t.Dict[str, t.Set[str]], abstract: t.Set[str]) -> str:
"""
Renders *class_map* (which is a mapping such as that returned by
:func:`make_class_map`) to graphviz's dot language.
The *abstract* sequence determines which classes will be rendered lighter
to indicate their abstract nature. All classes with names ending "Mixin"
will be implicitly rendered in a different style.
"""
def all_names(class_map: t.Dict[str, t.Set[str]]) -> t.Iterator[str]:
for name, bases in class_map.items():
yield name
for base in bases:
yield base
template = """\
digraph classes {{
graph [rankdir=RL];
node [shape=rect, style=filled, fontname=Sans, fontsize=10];
edge [];
/* Mixin classes */
node [color="#c69ee0", fontcolor="#000000"]
{mixin_nodes}
/* Abstract classes */
node [color="#9ec6e0", fontcolor="#000000"]
{abstract_nodes}
/* Concrete classes */
node [color="#2980b9", fontcolor="#ffffff"];
{concrete_nodes}
/* Edges */
{edges}
}}
"""
return template.format(
mixin_nodes='\n '.join(
'{name};'.format(name=name)
for name in sorted(set(all_names(class_map)))
if name.endswith('Mixin')
),
abstract_nodes='\n '.join(
'{name};'.format(name=name)
for name in sorted(abstract & set(all_names(class_map)))
),
concrete_nodes='\n '.join(
'{name};'.format(name=name)
for name in sorted(set(all_names(class_map)))
if not name.endswith('Mixin')
and not name in abstract
),
edges='\n '.join(
'{name}->{base};'.format(name=name, base=base)
for name, bases in sorted(class_map.items())
for base in sorted(bases)
),
)
if __name__ == '__main__':
sys.exit(main())
|