The Assimilation Project  based on Assimilation version 1.1.7.1474836767
drawwithdot.py
Go to the documentation of this file.
1 #!/usr/bin/env python
2 #
3 # vim: smartindent tabstop=4 shiftwidth=4 expandtab number colorcolumn=80
4 #
5 # This file is part of the Assimilation Project.
6 #
7 # Author: Alan Robertson <alanr@unix.sh>
8 # Copyright (C) 2016 - Assimilation Systems Limited
9 #
10 # Free support is available from the Assimilation Project community
11 # - http://assimproj.org
12 # Paid support is available from Assimilation Systems Limited
13 # - http://assimilationsystems.com
14 #
15 # The Assimilation software is free software: you can redistribute it and/or
16 # modify it under the terms of the GNU General Public License as published by
17 # the Free Software Foundation, either version 3 of the License, or
18 # (at your option) any later version.
19 #
20 # The Assimilation software is distributed in the hope that it will be useful,
21 # but WITHOUT ANY WARRANTY; without even the implied warranty of
22 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
23 # GNU General Public License for more details.
24 #
25 # You should have received a copy of the GNU General Public License
26 # along with the Assimilation Project software.
27 # If not, see http://www.gnu.org/licenses/
28 #
29 '''
30 
31 Drawwithdot: Sample program to draw Assimilation graphs using Dot
32  from the 'graphviz' software.
33 
34 The core part of this program is pretty simple.
35 The complicated part is getting the 'dot' diagram to look pretty
36 This is accomplished by fancy format strings.
37 
38 When we format nodes, the following variables are available:
39  id: An idname suitable for matching up in relationship (see below)
40  If the GraphNode supports __getitem__ (mainly Drones) then any
41  item that __getitem__ might return
42  Any attributes of the GraphNode
43 
44 We only format nodes when we have a format string for its nodetype
45 
46 When we format relationships, the following variables are available:
47  from: the idname of the from node in the relationship
48  to: the idname of the to node in the relationship
49  type: the relationship type
50  Any other attributes of the relationship
51 
52  In other words, this relationship looks like this in Cypher notation:
53  from-[:type]->to
54 
55  We only output relationships when we've formatted (selected) both the
56  from and the to node in the relationship, AND we have a format
57  string for that relationship type.
58 
59 
60 This is a very flexible and powerful graph drawing method, for which
61 the code is simple and common - and the formats are more complicated.
62 '''
63 
64 from __future__ import print_function #, unicode_literals
65 import sys, os, optparse
66 import assimcli
67 from AssimCclasses import pyConfigContext, pyNetAddr
68 from AssimCtypes import VERSION_STRING, LONG_LICENSE_STRING, \
69  SHORT_LICENSE_STRING
70 
71 #pylint complaint: too few public methods. It's OK - it's a utility class ;-)
72 #pylint: disable=R0903
73 class DictObj(object):
74  '''This is a class that allows us to see the objects below us as a
75  dict-like object - both for any dict-like characteristics and for its
76  attributes. This is for formatting them with the "usual" Python
77  formatting rules like for example, "%(ipaddr)s".
78  '''
79 
80  def __init__(self, obj, kw=None, failreturn=''):
81  '''Initialization'''
82  self.obj = obj
83  self.failreturn = failreturn
84  if kw is None:
85  kw = {}
86  self.kw = kw
87 
88  @staticmethod
89  def _strip_itemname(name):
90  'Strip the out and return itemname out of the format name'
91  return name if name.find(':') < 0 else name.split(':')[1]
92 
93  @staticmethod
94  def _labelstring(name):
95  'Strip the out and return label out of the format'
96  if name.find(':') < 0:
97  return ''
98  label = name.split(':')[0]
99  if len(label) == 0:
100  return r'\n'
101  return r'\n' + label + ': '
102 
103  @staticmethod
104  def _fixup(value):
105  'Fix up our values for printing neatly in minimal space'
106  if isinstance(value, unicode):
107  if value.startswith('::'):
108  try:
109  ip=pyNetAddr(value)
110  value=repr(ip)
111  except ValueError:
112  pass
113  return str(value).strip()
114  elif isinstance(value, str):
115  return value
116  elif hasattr(value, '__iter__'):
117  ret = '['
118  prefix = ''
119  for item in value:
120  ret += '%s%s' % (prefix, str(item).strip())
121  prefix=', '
122  ret += ']'
123  return ret
124  return str(value)
125 
126  def __contains__(self, name):
127  'Return True if we can find the given attribute/index'
128  name = DictObj._strip_itemname(name)
129  try:
130  if name in self.kw or name in self.obj:
131  return True
132  except TypeError:
133  pass
134  return hasattr(self.obj, name)
135 
136  def __getitem__(self, name):
137  try:
138  ret = self._getitem(self._strip_itemname(name))
139  if ret is None or str(ret) == '':
140  raise ValueError
141  ret = str(ret)
142  ret = ret.strip() if isinstance(ret, (str, unicode)) else ret
143  return self._labelstring(name) + DictObj._fixup(ret)
144  except ValueError:
145  return self._failreturn(name)
146 
147  def _getitem(self, name):
148  'Return the given attribute or item from our object'
149  if name in self.kw:
150  return self.kw[name](self.obj, name) if callable(self.kw[name]) \
151  else self.kw[name]
152  try:
153  #print("Looking for %s in %s." % (name, type(self.obj)),
154  # file=sys.stderr)
155  ret = self.obj.deepget(name) if hasattr(self.obj, 'deepget') \
156  else self.obj[name]
157  if ret is not None:
158  return ret
159  except (IndexError, KeyError, TypeError):
160  pass
161  try:
162  if not hasattr(self.obj, name):
163  #print("Name %s not found in %s." % (name, type(self.obj)),
164  # file=sys.stderr)
165  if name.find('.') > 0:
166  prefix, suffix = name.split('.', 1)
167  base = getattr(self.obj,prefix)
168  subobj = pyConfigContext(base)
169  return subobj.deepget(suffix)
170  #print("Returning getattr( %s, %s." % (type(self.obj), name),
171  # file=sys.stderr)
172  return getattr(self.obj, name)
173  except AttributeError:
174  pass
175  raise ValueError(name)
176 
177  def _failreturn(self, name):
178  '''Return a failure'''
179  if callable(self.failreturn):
180  return self.failreturn(self.obj, name)
181  return self.failreturn
182 
184  '''A fancy DictObj that knows how to get some aggregate data
185  for use as pseudo-attributes of the objects we know and love ;-)
186 
187  '''
188  os_namemap = {
189  'description': 'Description',
190  'distro': 'Distributor ID',
191  'distributor': 'Distributor ID',
192  'release': 'Release',
193  'codename': 'Codename',
194  }
195  @staticmethod
196  def osinfo(obj, name):
197  '''Provide aliases for various OS attributes for formatting
198  These will only work on a Drone node'''
199  if name.startswith('os-'):
200  name = name[3:]
201  try:
202  print ('OBJ' % str(obj))
203  print ('OBJ.os: %s' % str(obj['os']))
204  print ('OBJ.os.data: %s' % str(obj['os']['data']))
205  if name in FancyDictObj.os_namemap:
206  return obj['os']['data'][FancyDictObj.os_namemap[name]]
207  except (KeyError, IndexError,TypeError):
208  return '(unknown)'
209 
210  @staticmethod
211  def proc_name(obj, _name):
212  'Construct a reasonable looking string to display for a process name.'
213  retname = os.path.basename(obj['pathname'])
214  if retname.endswith('java'):
215  retname += r'\n%s' % obj['argv'][-1]
216  elif len(obj['argv']) > 1:
217  if not obj['argv'][1].startswith('-'):
218  retname += ' %s' % obj['argv'][1]
219  return retname
220 
221  @staticmethod
222  def drone_attrs(obj, _name):
223  'Construct a string to set the attributes for Drone nodes.'
224  secscore = obj.bp_category_security_score if \
225  hasattr(obj, 'bp_category_security_score') else 0
226  init=''
227  if obj['status'] != 'up':
228  if obj['reason'] == 'HBSHUTDOWN':
229  init = 'style="filled,dashed" fillcolor=gray90 '
230  else:
231  init = 'style="filled,dashed" fillcolor=hotpink1 fontname=bold '
232  if secscore < 1:
233  return init + 'color=green penwidth=3 '
234  elif secscore <= 10:
235  return init + 'color=yellow penwidth=4 '
236  if secscore <= 20:
237  return init + 'color=orange penwidth=4 '
238  return init + 'color=red penwidth=10'
239 
240  @staticmethod
241  def service_attrs(obj, _name):
242  'Construct a reasonable looking string to set the service options.'
243  ret=' color=blue'
244  if 'server' in obj['roles'] and not obj['is_monitored']:
245  ret += ' style=dashed penwidth=2'
246  if obj['uid'] == 'root' or obj['gid'] == 'root':
247  ret += ' fontcolor=red'
248  if 'server' in obj['roles']:
249  ret += ' shape=folder'
250  else:
251  ret += ' shape=rectangle'
252  return ret
253 
254  @staticmethod
255  def monitor_attrs(obj, _name):
256  'Construct a reasonable looking string to set monitor options.'
257  if obj['monitorclass'] == 'NEVERMON' or not obj['isactive']:
258  return 'style=filled fillcolor=gray90'
259  if not obj['isworking']:
260  return 'color=red penwidth=5 style=filled fillcolor=hotpink'
261  return ''
262 
263  @staticmethod
264  def nic_attrs(obj, _name):
265  'Construct a reasonable looking string to set NIC options.'
266  ret = 'shape=octagon color=navy '
267  if 'carrier' in obj and not obj['carrier']:
268  ret += 'style=dotted penwidth=2 '
269  return ret
270 
271  def __init__(self, obj, kw=None, failreturn=''):
272  DictObj.__init__(self, obj, kw, failreturn)
273  for key in self.os_namemap:
274  self.kw['os-' + key] = FancyDictObj.osinfo
275  for key in ('nodename', 'operating-system', 'machine', 'distributor'
276  'codename', 'distro', 'processor', 'hardware-platform',
277  'kernel-name', 'kernel-release', 'kernel-version'):
278  self.kw['os-' + key] = FancyDictObj.osinfo
279  self.kw['proc-name'] = FancyDictObj.proc_name
280  self.kw['drone-attrs'] = FancyDictObj.drone_attrs
281  self.kw['service-attrs'] = FancyDictObj.service_attrs
282  self.kw['monitor-attrs'] = FancyDictObj.monitor_attrs
283  self.kw['nic-attrs'] = FancyDictObj.nic_attrs
284 
285 
286 class DotGraph(object):
287  '''Class to format Assimilation graphs as 'dot' graphs'''
288  # pylint - too many arguments. It's a bit flexible...
289  # pylint: disable=R0913
290  def __init__(self, formatdict, dburl=None, dronelist=None,
291  dictclass=FancyDictObj, options=None, executor_context=None):
292  '''Initialization
293  Here are the main two things to understand:
294  formatdict is a dict-like object which provides a format string
295  for each kind of relationship and node.
296 
297  These format strings are then interpolated with the values
298  from the relationship or node as filtered by @dictclass objects.
299  The @dictclass object must behave like a dict. When format items
300  are requested by a format in the formatdict, the dictclass object
301  is expected to provide those values.
302  params:
303  @formatdict - dictionary providing format strings for
304  nodes and relationships
305  @dburl - URL for opening the database
306  @dronelist - a possibly-None list of drones to start from
307  @dictclass - a dict-like class which can take a node or
308  relationship as a parameter for its constructor
309  along with extra keywords as the kw parameter.
310  '''
311  self.formatdict = formatdict
312  self.store = assimcli.dbsetup(readonly=True, url=dburl)
313  self.nodeids = None
314  self.dictclass = dictclass
315  self.options = options
316  self.executor_context = executor_context
317  if isinstance(dronelist, (str, unicode)):
318  self.dronelist = [dronelist]
319  else:
320  self.dronelist = dronelist
321 
322  @staticmethod
323  def idname(nodeid):
324  'Format a node id so dot will like it (not numeric)'
325  return 'node_%d' % nodeid
326 
327  def _outnodes(self, nodes):
328  '''Yield the requested nodes, formatted for 'dot'
329  '''
330  self.nodeids = set()
331  #print('NODES: %s' % nodes)
332  nodeformats = self.formatdict['nodes']
333  for node in nodes:
334  nodetype = node['nodetype']
335  if nodetype not in nodeformats:
336  continue
337  nodeid = node['_id']
338  self.nodeids.add(nodeid)
339  dictobj = self.dictclass(node,
340  kw={'id': DotGraph.idname(nodeid)})
341  #print('Nodetype: %s' % node.nodetype, file=sys.stderr)
342  #print('nodeformats: %s' % nodeformats[node.nodetype],
343  # file=sys.stderr)
344  yield nodeformats[nodetype] % dictobj
345 
346  def _outrels(self, relationships):
347  '''Yield relationships, formatted for 'dot'
348  '''
349  relformats = self.formatdict['relationships']
350  for rel in relationships:
351  reltype = rel['type']
352  if (rel['end_node'] not in self.nodeids
353  or rel['start_node'] not in self.nodeids
354  or reltype not in relformats):
355  continue
356  dictobj = self.dictclass(rel, kw={
357  'from': DotGraph.idname(rel['start_node']),
358  'to': DotGraph.idname(rel['end_node'])})
359  yield relformats[reltype] % dictobj
360 
361  def render_options(self):
362  'Render overall graph options as a dot-formatted string'
363  ret = ''
364  if not self.options:
365  return ''
366  for option in self.options:
367  optvalue=self.options[option]
368  if isinstance(optvalue, (str, unicode, bool, int, float, long)):
369  ret += ' %s="%s"' % (str(option), str(optvalue))
370  continue
371  if hasattr(optvalue, '__iter__'):
372  listval=''
373  delim=''
374  for elem in optvalue:
375  listval += '%s%s' % (delim, elem)
376  delim=','
377  ret += ' %s="%s"' % (str(option), str(listval))
378  return ret
379 
380  def __iter__(self):
381  '''Yield 'dot' strings for our nodes and relationships'''
382  yield 'Digraph G {%s\n' % self.render_options()
383  nodetypes = self.formatdict['nodes'].keys()
384  reltypes = self.formatdict['relationships'].keys()
385  if self.dronelist is None:
386  params = {'nodetypes': nodetypes, 'reltypes': reltypes}
387  queryname = 'allhostsubgraph'
388  else:
389  queryname = 'hostsubgraph'
390  params = {'nodetypes': nodetypes, 'reltypes': reltypes,
391  'hostname': self.dronelist}
392  print ('NODETYPES: %s ' % str(nodetypes), file=sys.stderr)
393  print ('RELTYPES: %s ' % str(reltypes), file=sys.stderr)
394  querymeta = assimcli.query.load_query_object(self.store, queryname)
395  queryiter = querymeta.execute(self.executor_context,
396  expandJSON=True, elemsonly=True,
397  **params)
398  # Subgraph queries produce a single row, with two elements:
399  # nodes and relationships
400  for jsonline in queryiter:
401  queryobj = pyConfigContext(jsonline)
402  break
403 
404  for line in self._outnodes(queryobj['nodes']):
405  yield line.strip() + '\n'
406  for line in self._outrels(queryobj['relationships']):
407  yield line.strip() + '\n'
408  yield '}\n'
409 
410  def out(self, outfile=sys.stdout):
411  '''Output nodes and relationships to the 'outfile'.'''
412  outfile.writelines(self.__iter__())
413 
414  def __str__(self):
415  '''Output nodes and relationships in a string.'''
416  ret = ''
417  for line in self.__iter__():
418  ret += '%s\n' % line
419  return ret
420 
421 ip_format = r'''%(id)s [shape=box color=blue label="%(ipaddr)s%(:hostname)s"]'''
422 
423 drone_format = r'''%(id)s [shape=house %(drone-attrs)s label=''' + \
424 '''"%(designation)s''' + \
425 '''%(:os-description)s %(os-codename)s%(:os-kernel-release)''' + \
426 '''s%(Security Risk:bp_category_security_score)s''' + \
427 '''%(Status:status)s%(Reason:reason)s%(:roles)s"]'''
428 
429 switch_format = r'''%(id)s [shape=box color=black penwidth=3 ''' + \
430 r'''label="%(designation)s%(Name:SystemName)s%(:SystemDescription)s''' + \
431 r'''%(Manufacturer:manufacturer)s%(Model:model)s%(Roles:roles)s''' + \
432 r'''%(Address:ManagementAddress)s%(HW Vers:hardware-revision)s''' + \
433 r'''%(FW Vers:firmware-revision)s%(SW Vers:software-revision)s''' + \
434 r'''%(serial:serial-number)s%(Asset:asset-id)s"]'''
435 
436 MAC_format = r'''%(id)s [%(nic-attrs)s ''' + \
437 r'''label="%(macaddr)s%(NIC:ifname)s%(:PortDescription)s''' + \
438 r'''%(:OUI)s%(MTU:json.mtu)s%(Duplex:json.duplex)s%(carrier:carrier)s"]'''
439 processnode_format = r'''%(id)s [%(service-attrs)s label="%(proc-name)s''' + \
440 r'''%(uid:uid)s%(gid:gid)s%(pwd:cwd)s"]'''
441 iptcpportnode_format = r'''%(id)s [shape=rectangle fontsize=10 ''' + \
442 '''label="%(ipaddr)s%(:port)s"]'''
443 monitoraction_format = r'''%(id)s [%(monitor-attrs)s shape=component ''' + \
444  r'''label="%(monitorclass)s%(:monitortype)s"]'''
445 
446 default_relfmt = r'''%(from)s->%(to)s [label=%(type)s]'''
447 ipowner_format = r'''%(from)s->%(to)s [color=hotpink label=ipowner]'''
448 nicowner_format = r'''%(from)s->%(to)s [color=black label=nicowner]'''
449 wiredto_format = r'''%(from)s->%(to)s [color=blue label=wiredto '''+\
450 '''penwidth=3 arrowhead=none]'''
451 
452 #
453 # This defines all our various 'skins'
454 # A skin defines how we tell dot to draw nodes and relationships
455 # in a given diagram.
456 #
457 skin_formats = {
458  'default': { # The default drawing 'skin'
459  'nodes': {
460  'IPaddrNode': ip_format,
461  'NICNode': MAC_format,
462  'Drone': drone_format,
463  'SystemNode': switch_format,
464  'ProcessNode': processnode_format,
465  'IPtcpportNode': iptcpportnode_format,
466  'MonitorAction': monitoraction_format,
467  },
468  'relationships': {
469  'baseip': default_relfmt,
470  'hosting': default_relfmt,
471  'ipowner': ipowner_format,
472  'monitoring': default_relfmt,
473  'nicowner': nicowner_format,
474  'tcpservice': default_relfmt,
475  'tcpclient': default_relfmt,
476  'wiredto': wiredto_format,
477  'RingNext_The_One_Ring': default_relfmt,
478  }
479  }
480 }
481 
482 #
483 # A drawing type is a collection of nodes, relationships that we want to
484 # make sure show up in the drawing
485 #
486 # Eventually this should probably include queries that produce the
487 # particular desired nodes that go with this particular diagram.
488 #
489 drawing_types = {
490  'everything': {
491  'description': 'everything and the kitchen sink',
492  'nodes': skin_formats['default']['nodes'].keys(),
493  'relationships': skin_formats['default']['relationships'].keys()
494  },
495  'network': {
496  'description': 'network diagram',
497  'nodes': ['IPaddrNode', 'NICNode', 'Drone', 'SystemNode'],
498  'relationships': ['ipowner', 'nicowner', 'wiredto'],
499  },
500  'service': {
501  'description': 'attack surface (services) diagram',
502  'nodes': ['Drone', 'ProcessNode', 'IPtcpportNode'],
503  'relationships': [ 'ipowner', 'baseip', 'hosting', 'tcpservice',
504  'tcpclient'],
505  },
506  'monitoring': {
507  'description': 'monitoring diagram',
508  'nodes': ['Drone', 'MonitorAction', 'ProcessNode'],
509  'relationships': ['monitoring', 'hosting', 'tcpservice']
510  },
511  'monring': {
512  'description': 'neighbor monitoring ring diagram',
513  'nodes': ['Drone'],
514  'relationships': ['RingNext_The_One_Ring'],
515  }
516 }
517 
518 def validate_drawing_types(dtypes=None, skins=None):
519  '''We make sure that all the drawing types we have available
520  are well-defined in each of the skins...
521  This is really just for debugging, but it's quick enough to do
522  each time...
523  '''
524  if dtypes is None:
525  dtypes=drawing_types
526  if skins is None:
527  skins=skin_formats
528  for skin in skins:
529  nodetypes = skins[skin]['nodes']
530  reltypes = skins[skin]['relationships']
531  for dtype in dtypes:
532  dnodes = dtypes[dtype]['nodes']
533  for nodetype in dnodes:
534  if nodetype not in nodetypes:
535  raise ValueError('Nodetype %s not in skin %s' %
536  (nodetype, skin))
537  drels = dtypes[dtype]['relationships']
538  for reltype in drels:
539  if reltype not in reltypes:
540  raise ValueError('Relationship type %s not in skin %s' %
541  (reltype, skin))
542 
543 def construct_dot_formats(drawingtype='network', skintype='default'):
544  '''Construct 'dot' formats from our skins and this drawing type'''
545  diagram = drawing_types[drawingtype]
546  skin = skin_formats[skintype]
547  result = {'nodes': {}, 'relationships':{}}
548  for nodetype in diagram['nodes']:
549  result['nodes'][nodetype] = skin['nodes'][nodetype]
550  for reltype in diagram['relationships']:
551  result['relationships'][reltype] = skin['relationships'][reltype]
552  return result
553 
555  'Create help string for the --drawingtype option'
556  ret = 'Type of drawing to create. Must be one of '
557  delim='('
558  for dtype in drawing_types.keys():
559  ret += '%s%s' % (delim, dtype)
560  delim=', '
561  ret += '). '
562  for dtype in drawing_types.keys():
563  ret += '"%s": %s. ' % (dtype, drawing_types[dtype]['description'])
564  return ret
565 
566 
567 if __name__ == '__main__':
569  desc = 'Create illustration from Assimilation graph database'
570  desc += '.\nLicensed under the %s' % LONG_LICENSE_STRING
571  usage = 'usage: drawwithdot [options] '
572  delimiter='('
573  for drawing_type in sorted(drawing_types.keys()):
574  usage += '%s%s' % (delimiter, drawing_type)
575  delimiter = '|'
576  usage += ')'
577 
578 
579  opts = optparse.OptionParser(description=desc, usage=usage,
580  version=VERSION_STRING + ' (License: %s)' % SHORT_LICENSE_STRING)
581  opts.add_option('-d', '--drawingtype',
582  action='store',
583  dest='drawingtype',
584  type='choice',
585  choices=drawing_types.keys(),
587  )
588  opts.set_defaults(drawingtype='everything')
589  opts.add_option('-D', '--dpi',
590  action='store',
591  dest='dpi',
592  type='int',
593  help='Dots-per-inch for drawing'
594  )
595  opts.add_option('-s', '--size',
596  action='store',
597  dest='size',
598  type='int',
599  nargs=2,
600  help='(x,y) dimensions of drawing in inches'
601  )
602  #opts.set_defaults('size', (30,8))
603  cmdoptions, args = opts.parse_args()
604  cmdoptions.skin='default'
605  if len(args) == 1:
606  if args[0] not in drawing_types:
607  print('ERROR: "%s" is not a known illustration type.' % args[0],
608  file=sys.stderr)
609  print('Known illustration types: %s' % str(drawing_types.keys()))
610  exit(1)
611  cmdoptions.drawingtype = args[0]
612  elif len(args) > 1:
613  print ('ERROR: Only one illustration type allowed.', file=sys.stderr)
614  exit(1)
615 
616 
617 
618  # Process all the overall command line options...
619  graphoptions = {}
620  for attr in ('size', 'dpi'):
621  graphoptions[attr] = getattr(cmdoptions, attr)
622 
623  dot = DotGraph(construct_dot_formats(cmdoptions.drawingtype,
624  skintype=cmdoptions.skin),
625  options=graphoptions)
626  dot.out()
def __contains__(self, name)
Definition: drawwithdot.py:126
def __init__(self, obj, kw=None, failreturn='')
Definition: drawwithdot.py:271
def service_attrs(obj, _name)
Definition: drawwithdot.py:241
def construct_dot_formats(drawingtype='network', skintype='default')
Definition: drawwithdot.py:543
def drawing_type_help()
Definition: drawwithdot.py:554
def out(self, outfile=sys.stdout)
Definition: drawwithdot.py:410
def drone_attrs(obj, _name)
Definition: drawwithdot.py:222
def __init__(self, obj, kw=None, failreturn='')
Definition: drawwithdot.py:80
def __getitem__(self, name)
Definition: drawwithdot.py:136
def __init__(self, formatdict, dburl=None, dronelist=None, dictclass=FancyDictObj, options=None, executor_context=None)
Definition: drawwithdot.py:291
def _failreturn(self, name)
Definition: drawwithdot.py:177
def _outnodes(self, nodes)
Definition: drawwithdot.py:327
def _outrels(self, relationships)
Definition: drawwithdot.py:346
def _getitem(self, name)
Definition: drawwithdot.py:147
def monitor_attrs(obj, _name)
Definition: drawwithdot.py:255
def validate_drawing_types(dtypes=None, skins=None)
Definition: drawwithdot.py:518