1 # -*- coding: utf-8 -*-
2 # Graph topology utilities and dot file generation
4 # Copyright (C) Andrew Bartlett 2018.
6 # Written by Douglas Bagnall <douglas.bagnall@catalyst.net.nz>
8 # This program is free software; you can redistribute it and/or modify
9 # it under the terms of the GNU General Public License as published by
10 # the Free Software Foundation; either version 3 of the License, or
11 # (at your option) any later version.
13 # This program is distributed in the hope that it will be useful,
14 # but WITHOUT ANY WARRANTY; without even the implied warranty of
15 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 # GNU General Public License for more details.
18 # You should have received a copy of the GNU General Public License
19 # along with this program. If not, see <http://www.gnu.org/licenses/>.
21 from __future__ import print_function
22 from __future__ import division
23 from samba import colour
25 from itertools import cycle, groupby
30 def reformat_graph_label(s):
31 """Break DNs over multiple lines, for better shaped and arguably more
32 readable nodes. We try to split after commas, and if necessary
33 after hyphens or failing that in arbitrary places."""
37 s = s.replace(',', ',\n')
39 for p in s.split('\n'):
42 q, p = p.split('-', 1)
47 pieces.append(q + '-')
51 return '\\n'.join(pieces)
54 def quote_graph_label(s, reformat=False):
55 """Escape a string as graphvis requires."""
56 # escaping inside quotes is simple in dot, because only " is escaped.
57 # there is no need to count backslashes in sequences like \\\\"
58 s = s.replace('"', '\"')
60 s = reformat_graph_label(s)
64 def shorten_vertex_names(vertices, suffix=',...', aggressive=False):
65 """Replace the common suffix (in practice, the base DN) of a number of
66 vertices with a short string (default ",..."). If this seems
67 pointless because the replaced string is very short or the results
68 seem strange, the original vertices are retained.
70 :param vertices: a sequence of vertices to shorten
71 :param suffix: the replacement string [",..."]
72 :param aggressive: replace certain common non-suffix strings
74 :return: tuple of (rename map, replacements)
76 The rename map is a dictionary mapping the old vertex names to
77 their shortened versions. If no changes are made, replacements
80 vmap = dict((v, v) for v in vertices)
84 # walk backwards along all the strings until we meet a character
85 # that is not shared by all.
87 vlist = list(vmap.values())
90 c = set(x[i] for x in vlist)
91 if len(c) > 1 or c == {'*'}:
95 # We have indexed beyond the start of a string, which should
96 # only happen if one node is a strict suffix of all others.
97 return vmap, replacements
99 # add one to get to the last unanimous character.
102 # now, we actually really want to split on a comma. So we walk
105 while i < len(x) and x[i] != ',':
108 if i >= -len(suffix):
109 # there is nothing to gain here
110 return vmap, replacements
112 replacements.append((suffix, x[i:]))
114 for k, v in vmap.items():
115 vmap[k] = v[:i] + suffix
118 # Remove known common annoying strings
119 for v in vmap.values():
120 if ',CN=Servers,' not in v:
123 vmap = dict((k, v.replace(',CN=Servers,', ',**,', 1))
124 for k, v in vmap.items())
125 replacements.append(('**', 'CN=Servers'))
127 for v in vmap.values():
128 if not v.startswith('CN=NTDS Settings,'):
131 vmap = dict((k, v.replace('CN=NTDS Settings,', '*,', 1))
132 for k, v in vmap.items())
133 replacements.append(('*', 'CN=NTDS Settings'))
135 return vmap, replacements
142 def compile_graph_key(key_items, nodes_above=[], elisions=None,
143 prefix='key_', width=2):
144 """Generate a dot file snippet that acts as a legend for a graph.
146 :param key_items: sequence of items (is_vertex, style, label)
147 :param nodes_above: list of vertices (pushes key into right position)
148 :param elision: tuple (short, full) indicating suffix replacement
149 :param prefix: string used to generate key node names ["key_"]
150 :param width: default width of node lines
152 Each item in key_items is a tuple of (is_vertex, style, label).
153 is_vertex is a boolean indicating whether the item is a vertex
154 (True) or edge (False). Style is a dot style string for the edge
155 or vertex. label is the text associated with the key item.
162 for i, item in enumerate(key_items):
163 is_vertex, style, label = item
164 tag = '%s%d_' % (prefix, i)
165 label = quote_graph_label(label)
166 name = '%s_label' % tag
169 order_lines.append(name)
170 vertex_names.append(name)
171 vertex_lines.append('%s[label="%s"; %s]' %
172 (name, label, style))
174 edge_names.append(name)
177 order_lines.append(name)
178 edge_lines.append('subgraph cluster_%s {' % tag)
179 edge_lines.append('%s[label=src; color="#000000"; group="%s_g"]' %
181 edge_lines.append('%s[label=dest; color="#000000"; group="%s_g"]' %
183 edge_lines.append('%s -> %s [constraint = false; %s]' % (e1, e2,
185 edge_lines.append(('%s[shape=plaintext; style=solid; width=%f; '
187 (name, width, label))
188 edge_lines.append('}')
192 for i, elision in enumerate(reversed(elisions)):
193 order_lines.append('elision%d' % i)
194 short, long = elision
195 if short[0] == ',' and long[0] == ',':
198 elision_str += ('\nelision%d[shape=plaintext; style=solid; '
199 'label="\ā%sā means ā%sā\\r"]\n'
200 % ((i, short, long)))
204 for n in nodes_above:
205 above_lines.append('"%s" -> %s [style=invis]' %
208 s = ('subgraph cluster_key {\n'
210 'subgraph cluster_key_nodes {\n'
215 'subgraph cluster_key_edges {\n'
224 '%s [style=invis; weight=9]'
226 % (';\n'.join(vertex_lines),
227 '\n'.join(edge_lines),
228 ' '.join(edge_names),
230 ';\n'.join(above_lines),
231 ' -> '.join(order_lines),
237 def dot_graph(vertices, edges,
240 reformat_labels=True,
249 vertex_clusters=None):
250 """Generate a Graphviz representation of a list of vertices and edges.
252 :param vertices: list of vertex names (optional).
253 :param edges: list of (vertex, vertex) pairs
254 :param directed: bool: whether the graph is directed
255 :param title: optional title for the graph
256 :param reformat_labels: whether to wrap long vertex labels
257 :param vertex_colors: if not None, a sequence of colours for the vertices
258 :param edge_colors: if not None, colours for the edges
259 :param edge_labels: if not None, labels for the edges
260 :param vertex_styles: if not None, DOT style strings for vertices
261 :param edge_styles: if not None, DOT style strings for edges
262 :param graph_name: if not None, name of graph
263 :param shorten_names: if True, remove common DN suffixes
264 :param key: (is_vertex, style, description) tuples
265 :param vertex_clusters: list of subgraph cluster names
267 Colour, style, and label lists must be the same length as the
268 corresponding list of edges or vertices (or None).
270 Colours can be HTML RGB strings ("#FF0000") or common names
271 ("red"), or some other formats you don't want to think about.
273 If `vertices` is None, only the vertices mentioned in the edges
274 are shown, and their appearance can be modified using the
275 vertex_colors and vertex_styles arguments. Vertices appearing in
276 the edges but not in the `vertices` list will be shown but their
277 styles can not be modified.
283 vertices = set(x[0] for x in edges) | set(x[1] for x in edges)
286 vlist = list(set(x[0] for x in edges) |
287 set(x[1] for x in edges) |
289 vmap, elisions = shorten_vertex_names(vlist)
290 vertices = [vmap[x] for x in vertices]
291 edges = [(vmap[a], vmap[b]) for a, b in edges]
296 if graph_name is None:
297 graph_name = 'A_samba_tool_production'
300 graph_type = 'digraph'
306 write('/* generated by samba */')
307 write('%s %s {' % (graph_type, graph_name))
308 if title is not None:
309 write('label="%s";' % (title,))
310 write('fontsize=%s;\n' % (FONT_SIZE))
311 write('node[fontname=Helvetica; fontsize=%s];\n' % (FONT_SIZE))
316 for i, v in enumerate(vertices):
317 v = quote_graph_label(v, reformat_labels)
318 quoted_vertices.append(v)
320 if vertex_clusters and vertex_clusters[i]:
321 cluster = vertex_clusters[i]
322 if cluster != prev_cluster:
323 if prev_cluster is not None:
325 prev_cluster = cluster
326 n = quote_graph_label(cluster)
328 write('subgraph cluster_%d {' % cluster_n)
330 write('style = "rounded,dotted";')
331 write('node [style="filled"; fillcolor=white];')
332 write('label = "%s";' % n)
334 if vertex_styles and vertex_styles[i]:
335 attrs.append(vertex_styles[i])
336 if vertex_colors and vertex_colors[i]:
337 attrs.append('color="%s"' % quote_graph_label(vertex_colors[i]))
339 write('"%s" [%s];' % (v, ', '.join(attrs)))
341 write('"%s";' % (v,))
346 for i, edge in enumerate(edges):
349 a = "Missing source value"
351 b = "Missing destination value"
353 a = quote_graph_label(a, reformat_labels)
354 b = quote_graph_label(b, reformat_labels)
358 label = quote_graph_label(edge_labels[i])
359 attrs.append('label="%s"' % label)
361 attrs.append('color="%s"' % quote_graph_label(edge_colors[i]))
363 attrs.append(edge_styles[i]) # no quoting
365 write('"%s" %s "%s" [%s];' % (a, connector, b, ', '.join(attrs)))
367 write('"%s" %s "%s";' % (a, connector, b))
370 key = compile_graph_key(key_items, nodes_above=quoted_vertices,
375 return '\n'.join(out)
380 'alternate rows': (colour.DARK_WHITE, colour.BLACK),
381 'disconnected': colour.RED,
382 'connected': colour.GREEN,
383 'transitive': colour.DARK_YELLOW,
384 'header': colour.UNDERLINE,
385 'reset': colour.C_NORMAL,
388 'alternate rows': (colour.DARK_WHITE, colour.BLACK),
389 'disconnected': colour.REV_RED,
390 'connected': colour.REV_GREEN,
391 'transitive': colour.REV_DARK_YELLOW,
392 'header': colour.UNDERLINE,
393 'reset': colour.C_NORMAL,
396 'alternate rows': (colour.xterm_256_colour(39),
397 colour.xterm_256_colour(45)),
398 #'alternate rows': (colour.xterm_256_colour(246),
399 # colour.xterm_256_colour(247)),
400 'disconnected': colour.xterm_256_colour(124, bg=True),
401 'connected': colour.xterm_256_colour(112),
402 'transitive': colour.xterm_256_colour(214),
403 'transitive scale': (colour.xterm_256_colour(190),
404 colour.xterm_256_colour(184),
405 colour.xterm_256_colour(220),
406 colour.xterm_256_colour(214),
407 colour.xterm_256_colour(208),
409 'header': colour.UNDERLINE,
410 'reset': colour.C_NORMAL,
412 'xterm-256color-heatmap': {
413 'alternate rows': (colour.xterm_256_colour(171),
414 colour.xterm_256_colour(207)),
415 #'alternate rows': (colour.xterm_256_colour(246),
416 # colour.xterm_256_colour(247)),
417 'disconnected': colour.xterm_256_colour(124, bg=True),
418 'connected': colour.xterm_256_colour(112, bg=True),
419 'transitive': colour.xterm_256_colour(214, bg=True),
420 'transitive scale': (colour.xterm_256_colour(190, bg=True),
421 colour.xterm_256_colour(184, bg=True),
422 colour.xterm_256_colour(220, bg=True),
423 colour.xterm_256_colour(214, bg=True),
424 colour.xterm_256_colour(208, bg=True),
426 'header': colour.UNDERLINE,
427 'reset': colour.C_NORMAL,
430 'alternate rows': ('',),
448 'right_arrow': 'ā',
461 def find_transitive_distance(vertices, edges):
462 all_vertices = (set(vertices) |
463 set(e[0] for e in edges) |
464 set(e[1] for e in edges))
466 if all_vertices != set(vertices):
467 print("there are unknown vertices: %s" %
468 (all_vertices - set(vertices)),
471 # with n vertices, we are always less than n hops away from
473 inf = len(all_vertices)
475 for v in all_vertices:
476 distances[v] = {v: 0}
478 for src, dest in edges:
479 distances[src][dest] = distances[src].get(dest, 1)
481 # This algorithm (and implementation) seems very suboptimal.
482 # potentially O(n^4), though n is smallish.
486 for v, d in distances.items():
488 new_distances[v] = new_d
489 for dest, cost in d.items():
490 for leaf, cost2 in distances[dest].items():
491 new_cost = cost + cost2
492 old_cost = d.get(leaf, inf)
493 if new_cost < old_cost:
494 new_d[leaf] = new_cost
497 distances = new_distances
501 # filter out unwanted vertices and infinite links
506 a = distances[v].get(v2, inf)
513 def get_transitive_colourer(colours, n_vertices):
514 if 'transitive scale' in colours:
515 scale = colours['transitive scale']
517 n = 1 + int(n_vertices ** 0.5)
520 if not isinstance(link, int):
522 return scale[min(link * m // n, m - 1)]
526 return colours['transitive']
531 def distance_matrix(vertices, edges,
536 grouping_function=None,
541 charset = CHARSETS['utf8' if utf8 else 'ascii']
542 vertical = charset['vertical']
543 horizontal = charset['horizontal']
544 corner = charset['corner']
545 diagonal = charset['diagonal']
546 missing = charset['missing']
547 right_arrow = charset['right_arrow']
549 colours = COLOUR_SETS[colour]
551 colour_cycle = cycle(colours.get('alternate rows', ('',)))
554 vertices = sorted(set(x[0] for x in edges) | set(x[1] for x in edges))
556 if grouping_function is not None:
557 # we sort and colour according to the grouping function
558 # which can be used to e.g. alternate colours by site.
559 vertices = sorted(vertices, key=grouping_function)
561 for k, v in groupby(vertices, key=grouping_function):
562 c = next(colour_cycle)
563 colour_list.extend(c for x in v)
565 colour_list = [next(colour_cycle) for v in vertices]
568 vlist = list(set(x[0] for x in edges) |
569 set(x[1] for x in edges) |
571 vmap, replacements = shorten_vertex_names(vlist, '+',
573 vertices = [vmap[x] for x in vertices]
574 edges = [(vmap[a], vmap[b]) for a, b in edges]
577 vlen = max(6, max(len(v) for v in vertices))
579 # first, the key for the columns
580 c_header = colours.get('header', '')
581 c_disconn = colours.get('disconnected', '')
582 c_conn = colours.get('connected', '')
583 c_reset = colours.get('reset', '')
585 colour_transitive = get_transitive_colourer(colours, len(vertices))
589 write("%*s %s %sdestination%s" % (vlen, '',
593 for i, v in enumerate(vertices):
594 j = len(vertices) - i
597 start = '%s%ssource%s' % (vspace[:-6], c_header, c_reset)
600 write('%s %s%s%s%s%s %s%s' % (start,
609 verticals += c + vertical
611 connections = find_transitive_distance(vertices, edges)
613 for i, v in enumerate(vertices):
615 links = connections[v]
620 row.append('%s%s' % (c_disconn, missing))
623 row.append('%s%s%s%s' % (c_reset, c, diagonal, c_reset))
625 row.append('%s1%s' % (c_conn, c_reset))
627 ct = colour_transitive(link)
630 row.append('%s%s%s' % (ct, link, c_reset))
632 if row_comments is not None and row_comments[i]:
633 row.append('%s %s %s' % (c_reset, right_arrow, row_comments[i]))
635 write('%s%*s%s %s%s' % (c, vlen, v, c_reset,
636 ''.join(row), c_reset))
638 example_c = next(colour_cycle)
641 for substitute, original in reversed(replacements):
642 write("'%s%s%s' stands for '%s%s%s'" % (example_c,
650 write("Data can get from %ssource%s to %sdestination%s in the "
651 "indicated number of steps." % (c_header, c_reset,
653 write("%s%s%s means zero steps (it is the same DC)" %
654 (example_c, diagonal, c_reset))
655 write("%s1%s means a direct link" % (c_conn, c_reset))
656 write("%s2%s means a transitive link involving two steps "
657 "(i.e. one intermediate DC)" %
658 (colour_transitive(2), c_reset))
659 write("%s%s%s means there is no connection, even through other DCs" %
660 (c_disconn, missing, c_reset))
662 return '\n'.join(lines)
665 def pad_char(char, digits, padding=' '):
668 return ' ' * (digits - 1) + char + padding
671 def transpose_dict_matrix(m):
673 for k1, row in m.items():
674 for k2, dist in row.items():
675 m2.setdefault(k2, {})[k1] = dist
678 def full_matrix(rows,
683 grouping_function=None,
688 xlabel='destination',
694 rows = transpose_dict_matrix(rows)
696 use_padding = digits > 1
698 charset = CHARSETS['utf8' if utf8 else 'ascii']
699 vertical = pad_char(charset['vertical'], digits)
700 horizontal = charset['horizontal'] * (digits + use_padding)
701 corner = pad_char(charset['corner'], digits,
702 charset['horizontal'])
703 diagonal = pad_char(charset['diagonal'], digits)
704 missing = pad_char(charset['missing'], digits)
705 toobig = pad_char('>', digits)
706 right_arrow = charset['right_arrow']
707 empty = pad_char(' ', digits)
709 colours = COLOUR_SETS[colour]
711 colour_cycle = cycle(colours.get('alternate rows', ('',)))
712 vertices = list(rows.keys())
713 if grouping_function is not None:
714 # we sort and colour according to the grouping function
715 # which can be used to e.g. alternate colours by site.
716 vertices.sort(key=grouping_function)
718 for k, v in groupby(vertices, key=grouping_function):
719 c = next(colour_cycle)
720 colour_list.extend(c for x in v)
722 colour_list = [next(colour_cycle) for v in vertices]
725 vmap, replacements = shorten_vertex_names(vertices, '+',
728 for vert, r in rows.items():
729 rows2[vmap[vert]] = dict((vmap[k], v) for k, v in r.items())
732 vertices = list(rows.keys())
734 vlen = max(6, len(xlabel), max(len(v) for v in vertices))
736 # first, the key for the columns
737 c_header = colours.get('header', '')
738 c_disconn = colours.get('disconnected', '')
739 c_conn = colours.get('connected', '')
740 c_reset = colours.get('reset', '')
742 if colour_scale is None:
743 colour_scale = len(rows)
744 colour_transitive = get_transitive_colourer(colours, colour_scale)
748 write("%s %s %s%s%s" % (vspace,
749 empty * (len(rows) + 1),
753 for i, v in enumerate(vertices):
757 start = '%s%s%s%s' % (vspace[:-len(ylabel)],
763 write('%s %s%s%s%s%s %s%s' % (start,
772 verticals += '%s%s' % (c, vertical)
774 end_cell = '%s%s' % (' ' * use_padding, c_reset)
776 for i, v in enumerate(vertices):
782 row.append('%s%s%s' % (c_disconn, missing, c_reset))
784 row.append('%s%s%s%s' % (c_reset, c, diagonal, c_reset))
787 if link >= 10 ** digits:
788 ct = colour_transitive(link)
789 row.append('%s%s%s' % (ct, toobig, c_reset))
795 ct = colour_transitive(link)
796 row.append('%s%*s%s' % (ct, digits, link, end_cell))
798 if row_comments is not None and row_comments[i]:
799 row.append('%s %s %s' % (c_reset, right_arrow, row_comments[i]))
801 write('%s%*s%s %s%s' % (c, vlen, v, c_reset,
802 ''.join(row), c_reset))
804 if overflow or shorten_names:
808 write("'%s%s%s' means greater than %d " %
809 (colour_transitive(10 ** digits),
815 example_c = next(colour_cycle)
816 for substitute, original in reversed(replacements):
817 write("'%s%s%s' stands for '%s%s%s'" % (example_c,
824 return '\n'.join(lines)