graph_models.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320
  1. # -*- coding: utf-8 -*-
  2. import sys
  3. import json
  4. import os
  5. import tempfile
  6. import six
  7. from django.conf import settings
  8. from django.core.management.base import BaseCommand, CommandError
  9. from django.template import loader
  10. from django_extensions.management.modelviz import ModelGraph, generate_dot
  11. from django_extensions.management.utils import signalcommand
  12. try:
  13. import pygraphviz
  14. HAS_PYGRAPHVIZ = True
  15. except ImportError:
  16. HAS_PYGRAPHVIZ = False
  17. try:
  18. try:
  19. import pydotplus as pydot
  20. except ImportError:
  21. import pydot
  22. HAS_PYDOT = True
  23. except ImportError:
  24. HAS_PYDOT = False
  25. class Command(BaseCommand):
  26. help = "Creates a GraphViz dot file for the specified app names. You can pass multiple app names and they will all be combined into a single model. Output is usually directed to a dot file."
  27. can_import_settings = True
  28. def __init__(self, *args, **kwargs):
  29. """
  30. Allow defaults for arguments to be set in settings.GRAPH_MODELS.
  31. Each argument in self.arguments is a dict where the key is the
  32. space-separated args and the value is our kwarg dict.
  33. The default from settings is keyed as the long arg name with '--'
  34. removed and any '-' replaced by '_'. For example, the default value for
  35. --disable-fields can be set in settings.GRAPH_MODELS['disable_fields'].
  36. """
  37. self.arguments = {
  38. '--pygraphviz': {
  39. 'action': 'store_true',
  40. 'default': False,
  41. 'dest': 'pygraphviz',
  42. 'help': 'Output graph data as image using PyGraphViz.',
  43. },
  44. '--pydot': {
  45. 'action': 'store_true',
  46. 'default': False,
  47. 'dest': 'pydot',
  48. 'help': 'Output graph data as image using PyDot(Plus).',
  49. },
  50. '--dot': {
  51. 'action': 'store_true',
  52. 'default': False,
  53. 'dest': 'dot',
  54. 'help': 'Output graph data as raw DOT (graph description language) text data.',
  55. },
  56. '--json': {
  57. 'action': 'store_true',
  58. 'default': False,
  59. 'dest': 'json',
  60. 'help': 'Output graph data as JSON',
  61. },
  62. '--disable-fields -d': {
  63. 'action': 'store_true',
  64. 'default': False,
  65. 'dest': 'disable_fields',
  66. 'help': 'Do not show the class member fields',
  67. },
  68. '--disable-abstract-fields': {
  69. 'action': 'store_true',
  70. 'default': False,
  71. 'dest': 'disable_abstract_fields',
  72. 'help': 'Do not show the class member fields that were inherited',
  73. },
  74. '--group-models -g': {
  75. 'action': 'store_true',
  76. 'default': False,
  77. 'dest': 'group_models',
  78. 'help': 'Group models together respective to their application',
  79. },
  80. '--all-applications -a': {
  81. 'action': 'store_true',
  82. 'default': False,
  83. 'dest': 'all_applications',
  84. 'help': 'Automatically include all applications from INSTALLED_APPS',
  85. },
  86. '--output -o': {
  87. 'action': 'store',
  88. 'dest': 'outputfile',
  89. 'help': 'Render output file. Type of output dependend on file extensions. Use png or jpg to render graph to image.',
  90. },
  91. '--layout -l': {
  92. 'action': 'store',
  93. 'dest': 'layout',
  94. 'default': 'dot',
  95. 'help': 'Layout to be used by GraphViz for visualization. Layouts: circo dot fdp neato nop nop1 nop2 twopi',
  96. },
  97. '--theme -t': {
  98. 'action': 'store',
  99. 'dest': 'theme',
  100. 'default': 'django2018',
  101. 'help': 'Theme to use. Supplied are \'original\' and \'django2018\'. You can create your own by creating dot templates in \'django_extentions/graph_models/themename/\' template directory.',
  102. },
  103. '--verbose-names -n': {
  104. 'action': 'store_true',
  105. 'default': False,
  106. 'dest': 'verbose_names',
  107. 'help': 'Use verbose_name of models and fields',
  108. },
  109. '--language -L': {
  110. 'action': 'store',
  111. 'dest': 'language',
  112. 'help': 'Specify language used for verbose_name localization',
  113. },
  114. '--exclude-columns -x': {
  115. 'action': 'store',
  116. 'dest': 'exclude_columns',
  117. 'help': 'Exclude specific column(s) from the graph. Can also load exclude list from file.',
  118. },
  119. '--exclude-models -X': {
  120. 'action': 'store',
  121. 'dest': 'exclude_models',
  122. 'help': 'Exclude specific model(s) from the graph. Can also load exclude list from file. Wildcards (*) are allowed.',
  123. },
  124. '--include-models -I': {
  125. 'action': 'store',
  126. 'dest': 'include_models',
  127. 'help': 'Restrict the graph to specified models. Wildcards (*) are allowed.',
  128. },
  129. '--inheritance -e': {
  130. 'action': 'store_true',
  131. 'default': True,
  132. 'dest': 'inheritance',
  133. 'help': 'Include inheritance arrows (default)',
  134. },
  135. '--no-inheritance -E': {
  136. 'action': 'store_false',
  137. 'default': False,
  138. 'dest': 'inheritance',
  139. 'help': 'Do not include inheritance arrows',
  140. },
  141. '--hide-relations-from-fields -R': {
  142. 'action': 'store_false',
  143. 'default': True,
  144. 'dest': 'relations_as_fields',
  145. 'help': 'Do not show relations as fields in the graph.',
  146. },
  147. '--disable-sort-fields -S': {
  148. 'action': 'store_false',
  149. 'default': True,
  150. 'dest': 'sort_fields',
  151. 'help': 'Do not sort fields',
  152. },
  153. '--hide-edge-labels': {
  154. 'action': 'store_true',
  155. 'default': False,
  156. 'dest': 'hide_edge_labels',
  157. 'help': 'Do not showrelations labels in the graph.',
  158. },
  159. '--arrow-shape': {
  160. 'action': 'store',
  161. 'default': 'dot',
  162. 'dest': 'arrow_shape',
  163. 'choices': ['box', 'crow', 'curve', 'icurve', 'diamond', 'dot', 'inv', 'none', 'normal', 'tee', 'vee'],
  164. 'help': 'Arrow shape to use for relations. Default is dot. Available shapes: box, crow, curve, icurve, diamond, dot, inv, none, normal, tee, vee.',
  165. }
  166. }
  167. defaults = getattr(settings, 'GRAPH_MODELS', None)
  168. if defaults:
  169. for argument in self.arguments:
  170. arg_split = argument.split(' ')
  171. setting_opt = arg_split[0].lstrip('-').replace('-', '_')
  172. if setting_opt in defaults:
  173. self.arguments[argument]['default'] = defaults[setting_opt]
  174. super().__init__(*args, **kwargs)
  175. def add_arguments(self, parser):
  176. """Unpack self.arguments for parser.add_arguments."""
  177. parser.add_argument('app_label', nargs='*')
  178. for argument in self.arguments:
  179. parser.add_argument(*argument.split(' '), **self.arguments[argument])
  180. @signalcommand
  181. def handle(self, *args, **options):
  182. args = options['app_label']
  183. if not args and not options['all_applications']:
  184. raise CommandError("need one or more arguments for appname")
  185. # Determine output format based on options, file extension, and library
  186. # availability.
  187. outputfile = options.get("outputfile") or ""
  188. _, outputfile_ext = os.path.splitext(outputfile)
  189. outputfile_ext = outputfile_ext.lower()
  190. output_opts_names = ['pydot', 'pygraphviz', 'json', 'dot']
  191. output_opts = {k: v for k, v in options.items() if k in output_opts_names}
  192. output_opts_count = sum(output_opts.values())
  193. if output_opts_count > 1:
  194. raise CommandError("Only one of %s can be set." % ", ".join(["--%s" % opt for opt in output_opts_names]))
  195. if output_opts_count == 1:
  196. output = next(key for key, val in output_opts.items() if val)
  197. elif not outputfile:
  198. # When neither outputfile nor a output format option are set,
  199. # default to printing .dot format to stdout. Kept for backward
  200. # compatibility.
  201. output = "dot"
  202. elif outputfile_ext == ".dot":
  203. output = "dot"
  204. elif outputfile_ext == ".json":
  205. output = "json"
  206. elif HAS_PYGRAPHVIZ:
  207. output = "pygraphviz"
  208. elif HAS_PYDOT:
  209. output = "pydot"
  210. else:
  211. raise CommandError("Neither pygraphviz nor pydotplus could be found to generate the image. To generate text output, use the --json or --dot options.")
  212. # Consistency check: Abort if --pygraphviz or --pydot options are set
  213. # but no outputfile is specified. Before 2.1.4 this silently fell back
  214. # to printind .dot format to stdout.
  215. if output in ["pydot", "pygraphviz"] and not outputfile:
  216. raise CommandError("An output file (--output) must be specified when --pydot or --pygraphviz are set.")
  217. cli_options = ' '.join(sys.argv[2:])
  218. graph_models = ModelGraph(args, cli_options=cli_options, **options)
  219. graph_models.generate_graph_data()
  220. if output == "json":
  221. graph_data = graph_models.get_graph_data(as_json=True)
  222. return self.render_output_json(graph_data, outputfile)
  223. graph_data = graph_models.get_graph_data(as_json=False)
  224. theme = options['theme']
  225. template_name = os.path.join('django_extensions', 'graph_models', theme, 'digraph.dot')
  226. template = loader.get_template(template_name)
  227. dotdata = generate_dot(graph_data, template=template)
  228. if output == "pygraphviz":
  229. return self.render_output_pygraphviz(dotdata, **options)
  230. if output == "pydot":
  231. return self.render_output_pydot(dotdata, **options)
  232. self.print_output(dotdata, outputfile)
  233. def print_output(self, dotdata, output_file=None):
  234. """Write model data to file or stdout in DOT (text) format."""
  235. if isinstance(dotdata, six.binary_type):
  236. dotdata = dotdata.decode()
  237. if output_file:
  238. with open(output_file, 'wt') as dot_output_f:
  239. dot_output_f.write(dotdata)
  240. else:
  241. self.stdout.write(dotdata)
  242. def render_output_json(self, graph_data, output_file=None):
  243. """Write model data to file or stdout in JSON format."""
  244. if output_file:
  245. with open(output_file, 'wt') as json_output_f:
  246. json.dump(graph_data, json_output_f)
  247. else:
  248. self.stdout.write(json.dumps(graph_data))
  249. def render_output_pygraphviz(self, dotdata, **kwargs):
  250. """Render model data as image using pygraphviz."""
  251. if not HAS_PYGRAPHVIZ:
  252. raise CommandError("You need to install pygraphviz python module")
  253. version = pygraphviz.__version__.rstrip("-svn")
  254. try:
  255. if tuple(int(v) for v in version.split('.')) < (0, 36):
  256. # HACK around old/broken AGraph before version 0.36 (ubuntu ships with this old version)
  257. tmpfile = tempfile.NamedTemporaryFile()
  258. tmpfile.write(dotdata)
  259. tmpfile.seek(0)
  260. dotdata = tmpfile.name
  261. except ValueError:
  262. pass
  263. graph = pygraphviz.AGraph(dotdata)
  264. graph.layout(prog=kwargs['layout'])
  265. graph.draw(kwargs['outputfile'])
  266. def render_output_pydot(self, dotdata, **kwargs):
  267. """Render model data as image using pydot."""
  268. if not HAS_PYDOT:
  269. raise CommandError("You need to install pydot python module")
  270. graph = pydot.graph_from_dot_data(dotdata)
  271. if not graph:
  272. raise CommandError("pydot returned an error")
  273. if isinstance(graph, (list, tuple)):
  274. if len(graph) > 1:
  275. sys.stderr.write("Found more then one graph, rendering only the first one.\n")
  276. graph = graph[0]
  277. output_file = kwargs['outputfile']
  278. formats = [
  279. 'bmp', 'canon', 'cmap', 'cmapx', 'cmapx_np', 'dot', 'dia', 'emf',
  280. 'em', 'fplus', 'eps', 'fig', 'gd', 'gd2', 'gif', 'gv', 'imap',
  281. 'imap_np', 'ismap', 'jpe', 'jpeg', 'jpg', 'metafile', 'pdf',
  282. 'pic', 'plain', 'plain-ext', 'png', 'pov', 'ps', 'ps2', 'svg',
  283. 'svgz', 'tif', 'tiff', 'tk', 'vml', 'vmlz', 'vrml', 'wbmp', 'xdot',
  284. ]
  285. ext = output_file[output_file.rfind('.') + 1:]
  286. format_ = ext if ext in formats else 'raw'
  287. graph.write(output_file, format=format_)