#!/usr/bin/env python # coding: utf-8 # # Class Diagrams # # This is a simple viewer for class diagrams. Customized towards the book. # **Prerequisites** # # * _Refer to earlier chapters as notebooks here, as here:_ [Earlier Chapter](Debugger.ipynb). # In[1]: import bookutils.setup # ## Synopsis # # # To [use the code provided in this chapter](Importing.ipynb), write # # ```python # >>> from debuggingbook.ClassDiagram import # ``` # # and then make use of the following features. # # # The function `display_class_hierarchy()` function shows the class hierarchy for the given class (or list of classes). # * The keyword parameter `public_methods`, if given, is a list of "public" methods to be used by clients (default: all methods with docstrings). # * The keyword parameter `abstract_classes`, if given, is a list of classes to be displayed as "abstract" (i.e. with a cursive class name). # # ```python # >>> display_class_hierarchy(D_Class, abstract_classes=[A_Class]) # ``` # ![](PICS/ClassDiagram-synopsis-1.svg) # # # ## Getting a Class Hierarchy # In[2]: import inspect # Using `mro()`, we can access the class hierarchy. We make sure to avoid duplicates created by `class X(X)`. # In[3]: # ignore from typing import Callable, Dict, Type, Set, List, Union, Any, Tuple, Optional # In[4]: def class_hierarchy(cls: Type) -> List[Type]: superclasses = cls.mro() hierarchy = [] last_superclass_name = "" for superclass in superclasses: if superclass.__name__ != last_superclass_name: hierarchy.append(superclass) last_superclass_name = superclass.__name__ return hierarchy # Here's an example: # In[5]: class A_Class: """A Class which does A thing right. Comes with a longer docstring.""" def foo(self) -> None: """The Adventures of the glorious Foo""" pass def quux(self) -> None: """A method that is not used.""" pass # In[6]: class A_Class(A_Class): # We define another function in a separate cell. def second(self) -> None: pass # In[7]: class B_Class(A_Class): """A subclass inheriting some methods.""" VAR = "A variable" def foo(self) -> None: """A WW2 foo fighter.""" pass def bar(self, qux: Any = None, bartender: int = 42) -> None: """A qux walks into a bar. `bartender` is an optional attribute.""" pass # In[8]: SomeType = List[Optional[Union[str, int]]] # In[9]: class C_Class: """A class injecting some method""" def qux(self, arg: SomeType) -> SomeType: return arg # In[10]: class D_Class(B_Class, C_Class): """A subclass inheriting from multiple superclasses. Comes with a fairly long, but meaningless documentation.""" def foo(self) -> None: B_Class.foo(self) # In[11]: class D_Class(D_Class): pass # An incremental addiiton that should not impact D's semantics # In[12]: class_hierarchy(D_Class) # ## Getting a Class Tree # We can use `__bases__` to obtain the immediate base classes. # In[13]: D_Class.__bases__ # `class_tree()` returns a class tree, using the "lowest" (most specialized) class with the same name. # In[14]: def class_tree(cls: Type, lowest: Optional[Type] = None) -> List[Tuple[Type, List]]: ret = [] for base in cls.__bases__: if base.__name__ == cls.__name__: if not lowest: lowest = cls ret += class_tree(base, lowest) else: if lowest: cls = lowest ret.append((cls, class_tree(base))) return ret # In[15]: class_tree(D_Class) # In[16]: class_tree(D_Class)[0][0] # In[17]: assert class_tree(D_Class)[0][0] == D_Class # `class_set()` flattens the tree into a set: # In[18]: def class_set(classes: Union[Type, List[Type]]) -> Set[Type]: if not isinstance(classes, list): classes = [classes] ret = set() def traverse_tree(tree: List[Tuple[Type, List]]) -> None: for (cls, subtrees) in tree: ret.add(cls) for subtree in subtrees: traverse_tree(subtrees) for cls in classes: traverse_tree(class_tree(cls)) return ret # In[19]: class_set(D_Class) # In[20]: assert A_Class in class_set(D_Class) # In[21]: assert B_Class in class_set(D_Class) # In[22]: assert C_Class in class_set(D_Class) # In[23]: assert D_Class in class_set(D_Class) # In[24]: class_set([B_Class, C_Class]) # ### Getting Docs # In[25]: A_Class.__doc__ # In[26]: A_Class.__bases__[0].__doc__ # In[27]: A_Class.__bases__[0].__name__ # In[28]: D_Class.foo # In[29]: D_Class.foo.__doc__ # In[30]: A_Class.foo.__doc__ # In[31]: def docstring(obj: Any) -> str: doc = inspect.getdoc(obj) return doc if doc else "" # In[32]: docstring(A_Class) # In[33]: docstring(D_Class.foo) # In[34]: def unknown() -> None: pass # In[35]: docstring(unknown) # In[36]: import html # In[37]: import re # In[38]: def escape(text: str) -> str: text = html.escape(text) assert '<' not in text assert '>' not in text text = text.replace('{', '{') text = text.replace('|', '|') text = text.replace('}', '}') return text # In[39]: escape("f(foo={})") # In[40]: def escape_doc(docstring: str) -> str: DOC_INDENT = 0 docstring = " ".join( ' ' * DOC_INDENT + escape(line).strip() for line in docstring.split('\n') ) return docstring # In[41]: print(escape_doc("'Hello\n {You|Me}'")) # ## Getting Methods and Variables # In[42]: inspect.getmembers(D_Class) # In[43]: def class_items(cls: Type, pred: Callable) -> List[Tuple[str, Any]]: def _class_items(cls: Type) -> List: all_items = inspect.getmembers(cls, pred) for base in cls.__bases__: all_items += _class_items(base) return all_items unique_items = [] items_seen = set() for (name, item) in _class_items(cls): if name not in items_seen: unique_items.append((name, item)) items_seen.add(name) return unique_items # In[44]: def class_methods(cls: Type) -> List[Tuple[str, Callable]]: return class_items(cls, inspect.isfunction) # In[45]: def defined_in(name: str, cls: Type) -> bool: if not hasattr(cls, name): return False defining_classes = [] def search_superclasses(name: str, cls: Type) -> None: if not hasattr(cls, name): return for base in cls.__bases__: if hasattr(base, name): defining_classes.append(base) search_superclasses(name, base) search_superclasses(name, cls) if any(cls.__name__ != c.__name__ for c in defining_classes): return False # Already defined in superclass return True # In[46]: assert not defined_in('VAR', A_Class) # In[47]: assert defined_in('VAR', B_Class) # In[48]: assert not defined_in('VAR', C_Class) # In[49]: assert not defined_in('VAR', D_Class) # In[50]: def class_vars(cls: Type) -> List[Any]: def is_var(item: Any) -> bool: return not callable(item) return [item for item in class_items(cls, is_var) if not item[0].startswith('__') and defined_in(item[0], cls)] # In[51]: class_methods(D_Class) # In[52]: class_vars(B_Class) # We're only interested in # # * functions _defined_ in that class # * functions that come with a docstring # In[53]: def public_class_methods(cls: Type) -> List[Tuple[str, Callable]]: return [(name, method) for (name, method) in class_methods(cls) if method.__qualname__.startswith(cls.__name__)] # In[54]: def doc_class_methods(cls: Type) -> List[Tuple[str, Callable]]: return [(name, method) for (name, method) in public_class_methods(cls) if docstring(method) is not None] # In[55]: public_class_methods(D_Class) # In[56]: doc_class_methods(D_Class) # In[57]: def overloaded_class_methods(classes: Union[Type, List[Type]]) -> Set[str]: all_methods: Dict[str, Set[Callable]] = {} for cls in class_set(classes): for (name, method) in class_methods(cls): if method.__qualname__.startswith(cls.__name__): all_methods.setdefault(name, set()) all_methods[name].add(cls) return set(name for name in all_methods if len(all_methods[name]) >= 2) # In[58]: overloaded_class_methods(D_Class) # ## Drawing Class Hierarchy with Method Names # In[59]: from inspect import signature # In[60]: import warnings # In[61]: import os # In[62]: def display_class_hierarchy(classes: Union[Type, List[Type]], *, public_methods: Optional[List] = None, abstract_classes: Optional[List] = None, include_methods: bool = True, include_class_vars: bool = True, include_legend: bool = True, local_defs_only: bool = True, types: Dict[str, Any] = {}, project: str = 'fuzzingbook', log: bool = False) -> Any: """Visualize a class hierarchy. `classes` is a Python class (or a list of classes) to be visualized. `public_methods`, if given, is a list of methods to be shown as "public" (bold). (Default: all methods with a docstring) `abstract_classes`, if given, is a list of classes to be shown as "abstract" (cursive). (Default: all classes with an abstract method) `include_methods`: if set (default), include all methods `include_legend`: if set (default), include a legend `local_defs_only`: if set (default), hide details of imported classes `types`: type names with definitions, to be used in docs """ from graphviz import Digraph # type: ignore if project == 'debuggingbook': CLASS_FONT = 'Raleway, Helvetica, Arial, sans-serif' CLASS_COLOR = '#6A0DAD' # HTML 'purple' else: CLASS_FONT = 'Patua One, Helvetica, sans-serif' CLASS_COLOR = '#B03A2E' METHOD_FONT = "'Fira Mono', 'Source Code Pro', 'Courier', monospace" METHOD_COLOR = 'black' if isinstance(classes, list): starting_class = classes[0] else: starting_class = classes classes = [starting_class] title = starting_class.__name__ + " class hierarchy" dot = Digraph(comment=title) dot.attr('node', shape='record', fontname=CLASS_FONT) dot.attr('graph', rankdir='BT', tooltip=title) dot.attr('edge', arrowhead='empty') # Hack to force rendering as HTML, allowing hovers and links in Jupyter dot._repr_html_ = dot._repr_image_svg_xml edges = set() overloaded_methods: Set[str] = set() drawn_classes = set() def method_string(method_name: str, public: bool, overloaded: bool, fontsize: float = 10.0) -> str: method_string = f'' if overloaded: name = f'{method_name}()' else: name = f'{method_name}()' if public: method_string += f'{name}' else: method_string += f'' \ f'{name}' method_string += '' return method_string def var_string(var_name: str, fontsize: int = 10) -> str: var_string = f'' var_string += f'{var_name}' var_string += '' return var_string def is_overloaded(method_name: str, f: Any) -> bool: return (method_name in overloaded_methods or (docstring(f) is not None and "in subclasses" in docstring(f))) def is_abstract(cls: Type) -> bool: if not abstract_classes: return inspect.isabstract(cls) return (cls in abstract_classes or any(c.__name__ == cls.__name__ for c in abstract_classes)) def is_public(method_name: str, f: Any) -> bool: if public_methods: return (method_name in public_methods or f in public_methods or any(f.__qualname__ == m.__qualname__ for m in public_methods)) return bool(docstring(f)) def frame_module(frameinfo: Any) -> str: return os.path.splitext(os.path.basename(frameinfo.frame.f_code.co_filename))[0] def callers() -> List[str]: frames = inspect.getouterframes(inspect.currentframe()) return [frame_module(frameinfo) for frameinfo in frames] def is_local_class(cls: Type) -> bool: return cls.__module__ == '__main__' or cls.__module__ in callers() def class_vars_string(cls: Type, url: str) -> str: cls_vars = class_vars(cls) if len(cls_vars) == 0: return "" vars_string = f'' for (name, var) in cls_vars: if log: print(f" Drawing {name}") var_doc = escape(f"{name} = {repr(var)}") tooltip = f' tooltip="{var_doc}"' href = f' href="{url}"' vars_string += f'' vars_string += '
' vars_string += var_string(name) vars_string += '
' return vars_string def class_methods_string(cls: Type, url: str) -> str: methods = public_class_methods(cls) # return "
".join([name + "()" for (name, f) in methods]) methods_string = f'' public_methods_only = local_defs_only and not is_local_class(cls) methods_seen = False for public in [True, False]: for (name, f) in methods: if public != is_public(name, f): continue if public_methods_only and not public: continue if log: print(f" Drawing {name}()") if is_public(name, f) and not docstring(f): warnings.warn(f"{f.__qualname__}() is listed as public," f" but has no docstring") overloaded = is_overloaded(name, f) sig = str(inspect.signature(f)) # replace 'List[Union[...]]' by the actual type def for tp in types: tp_def = str(types[tp]).replace('typing.', '') sig = sig.replace(tp_def, tp) sig = sig.replace('__main__.', '') method_doc = escape(name + sig) if docstring(f): method_doc += ": " + escape_doc(docstring(f)) if log: print(f" Method doc: {method_doc}") # Tooltips are only shown if a href is present, too tooltip = f' tooltip="{method_doc}"' href = f' href="{url}"' methods_string += f'' methods_seen = True if not methods_seen: return "" methods_string += '
' methods_string += method_string(name, public, overloaded) methods_string += '
' return methods_string def display_class_node(cls: Type) -> None: name = cls.__name__ if name in drawn_classes: return drawn_classes.add(name) if log: print(f"Drawing class {name}") if cls.__module__ == '__main__': url = '#' else: url = cls.__module__ + '.ipynb' if is_abstract(cls): formatted_class_name = f'{cls.__name__}' else: formatted_class_name = cls.__name__ if include_methods or include_class_vars: vars = class_vars_string(cls, url) methods = class_methods_string(cls, url) spec = '<{' + \ formatted_class_name + '' if include_class_vars and vars: spec += '|' + vars if include_methods and methods: spec += '|' + methods spec += '}>' else: spec = '<' + formatted_class_name + '>' class_doc = escape('class ' + cls.__name__) if docstring(cls): class_doc += ': ' + escape_doc(docstring(cls)) else: warnings.warn(f"Class {cls.__name__} has no docstring") dot.node(name, spec, tooltip=class_doc, href=url) def display_class_trees(trees: List[Tuple[Type, List]]) -> None: for tree in trees: (cls, subtrees) = tree display_class_node(cls) for subtree in subtrees: (subcls, _) = subtree if (cls.__name__, subcls.__name__) not in edges: dot.edge(cls.__name__, subcls.__name__) edges.add((cls.__name__, subcls.__name__)) display_class_trees(subtrees) def display_legend() -> None: fontsize = 8.0 label = f'Legend
' for item in [ method_string("public_method", public=True, overloaded=False, fontsize=fontsize), method_string("private_method", public=False, overloaded=False, fontsize=fontsize), method_string("overloaded_method", public=False, overloaded=True, fontsize=fontsize) ]: label += '• ' + item + '
' label += f'' \ 'Hover over names to see doc' \ '
' dot.node('Legend', label=f'<{label}>', shape='plain', fontsize=str(fontsize + 2)) for cls in classes: tree = class_tree(cls) overloaded_methods = overloaded_class_methods(cls) display_class_trees(tree) if include_legend: display_legend() return dot # In[63]: display_class_hierarchy(D_Class, types={'SomeType': SomeType}, project='debuggingbook', log=True) # In[64]: display_class_hierarchy(D_Class, types={'SomeType': SomeType}, project='fuzzingbook') # Here is a variant with abstract classes and logging: # In[65]: display_class_hierarchy([A_Class, B_Class], abstract_classes=[A_Class], public_methods=[ A_Class.quux, ], log=True) # ## Synopsis # The function `display_class_hierarchy()` function shows the class hierarchy for the given class (or list of classes). # * The keyword parameter `public_methods`, if given, is a list of "public" methods to be used by clients (default: all methods with docstrings). # * The keyword parameter `abstract_classes`, if given, is a list of classes to be displayed as "abstract" (i.e. with a cursive class name). # In[66]: display_class_hierarchy(D_Class, abstract_classes=[A_Class]) # ## Exercises # Enjoy!