# Released under the MIT License. See LICENSE for details. # """Documentation generation functionality.""" # pylint: disable=too-many-lines from __future__ import annotations import os import inspect import subprocess from dataclasses import dataclass from typing import TYPE_CHECKING, Union, cast from enum import Enum from efro.error import CleanError from efro.terminal import Clr if TYPE_CHECKING: from types import ModuleType from typing import Optional, Callable, Any, Sequence CATEGORY_STRING = 'Category:' DO_STYLES = False DO_CSS_CLASSES = False STYLE_PAD_L0 = ' style="padding-left: 0px;"' if DO_STYLES else '' STYLE_PAD_L30 = ' style="padding-left: 30px;"' if DO_STYLES else '' STYLE_PAD_L60 = ' style="padding-left: 60px;"' if DO_STYLES else '' class CategoryType(Enum): """Self explanatory.""" FUNCTION = 0 CLASS = 1 @dataclass class AttributeInfo: """Info about an attribute of a class.""" name: str attr_type: Optional[str] = None docs: Optional[str] = None @dataclass class FunctionInfo: """Info about a function/method.""" name: str category: Optional[str] = None method_class: Optional[str] = None docs: Optional[str] = None is_class_method: bool = False @dataclass class ClassInfo: """Info about a class of functions/classes.""" name: str category: str methods: list[FunctionInfo] inherited_methods: list[FunctionInfo] attributes: list[AttributeInfo] parents: list[str] docs: Optional[str] enum_values: Optional[list[str]] def parse_docs_attrs(attrs: list[AttributeInfo], docs: str) -> str: """Given a docs str, parses attribute descriptions contained within.""" docs_lines = docs.splitlines() attr_line = None for i, line in enumerate(docs_lines): if line.strip() in ['Attributes:', 'Attrs:']: attr_line = i break if attr_line is not None: # Docs is now everything *up to* this. docs = '\n'.join(docs_lines[:attr_line]) # Go through remaining lines creating attrs and docs for each. cur_attr: Optional[AttributeInfo] = None for i in range(attr_line + 1, len(docs_lines)): line = docs_lines[i].strip() # A line with a single alphanumeric word preceding a colon # is a new attr. splits = line.split(':') if (len(splits) in (1, 2) and splits[0] and splits[0].replace('_', '').isalnum()): if cur_attr is not None: attrs.append(cur_attr) cur_attr = AttributeInfo(name=splits[0]) if len(splits) == 2: cur_attr.attr_type = splits[1] # Any other line gets tacked onto the current attr. else: if cur_attr is not None: if cur_attr.docs is None: cur_attr.docs = '' cur_attr.docs += line + '\n' # Finish out last. if cur_attr is not None: attrs.append(cur_attr) for attr in attrs: if attr.docs is not None: attr.docs = attr.docs.strip() return docs def _get_defining_class(cls: type, name: str) -> Optional[type]: for i in cls.mro()[1:]: if hasattr(i, name): return i return None def _get_bases(cls: type) -> list[str]: bases = [] for par in cls.mro()[1:]: if par is not object: bases.append(par.__module__ + '.' + par.__name__) return bases def _split_into_paragraphs(docs: str, filter_type: str, indent: int) -> str: indent_str = str(indent) + 'px' # Ok, now break into paragraphs (2 newlines denotes a new paragraph). paragraphs = docs.split('\n\n') docs = '' for i, par in enumerate(paragraphs): # For function/method signatures, indent lines after the first so # our big multi-line function signatures are readable. if (filter_type in ['function', 'method'] and i == 0 and len(par.split('(')) > 1 and par.strip().split('(')[0].replace('.', '').replace( '_', '').isalnum()): style = (' style="padding-left: ' + str(indent + 50) + 'px; text-indent: -50px;"') if DO_STYLES else '' style2 = ' style="color: #666677;"' if DO_STYLES else '' docs += f'' # Also, signatures seem to have quotes around annotations. # Let's just strip them all out. This will look wrong if # we have a string as a default value though, so don't # in that case. if " = '" not in par and ' = "' not in par: par = par.replace("'", '') docs += par docs += '

\n\n' # Emphasize a few specific lines. elif par.strip() in [ 'Conditions:', 'Available Conditions:', 'Actions:', 'Available Actions:', 'Play Types:', 'Available Setting Options:', 'Available Values:', 'Usage:' ]: style = (' style="padding-left: ' + indent_str + ';"' if DO_STYLES else '') docs += f'' docs += par docs += '

\n\n' elif par.lower().strip().startswith(CATEGORY_STRING.lower()): style = (' style="padding-left: ' + indent_str + ';"' if DO_STYLES else '') docs += f'' docs += par docs += '

\n\n' elif par.strip().startswith('#'): p_lines = par.split('\n') for it2, line in enumerate(p_lines): if line.strip().startswith('#'): style = (' style="color: #008800;"' if DO_STYLES else '') p_lines[it2] = (f'' + line + '') par = '\n'.join(p_lines) style = (' style="padding-left: ' + indent_str + ';"' if DO_STYLES else '') docs += f'' docs += par docs += '\n\n' else: style = (' style="padding-left: ' + indent_str + ';"' if DO_STYLES else '') docs += f'' docs += par docs += '

\n\n' return docs def _filter_type_settings(filter_type: str) -> tuple[Optional[Callable], int]: get_category_href_func = None if filter_type == 'class': indent = 30 get_category_href_func = _get_class_category_href elif filter_type == 'method': indent = 60 elif filter_type == 'function': indent = 30 get_category_href_func = _get_function_category_href elif filter_type == 'attribute': indent = 60 else: raise Exception('invalid filter_type: ' + str(filter_type)) return get_category_href_func, indent def _get_defining_class_backwards(cls: type, name: str) -> Optional[type]: mro = cls.mro() mro.reverse() for i in mro: if hasattr(i, name): return i return None def _get_module_classes(module: ModuleType) -> list[tuple[str, type]]: names = dir(module) # Look for all public classes in the provided module. class_objs = [ getattr(module, name) for name in names if not name.startswith('_') ] class_objs = [ c for c in class_objs if str(c).startswith(' bool: method = getattr(cls, name) # Classmethods are already bound with the class as self. # we need to just look at the im_func in that case is_class_method = (inspect.ismethod(method) and method.__self__ is cls) if is_class_method: for mth in cls.mro()[1:]: mth2 = getattr(mth, name, None) if mth2 is not None: if method.__func__ == mth2.__func__: return True else: return any(method == getattr(i, name, None) for i in cls.mro()[1:]) return False def _get_function_category_href(c_name: str) -> str: """Return a href for linking to the specified function category.""" return 'function_category_' + c_name.replace('.', '_').replace(' ', '_') def _get_class_category_href(c_name: str) -> str: """Return a href for linking to the specified class category.""" return 'class_category_' + c_name.replace('.', '_').replace(' ', '_') def _get_class_href(c_name: str) -> str: """Return a href for linking to the specified class.""" return 'class_' + c_name.replace('.', '_') def _get_function_href(f_name: str) -> str: """Return a href for linking to the specified function.""" return 'function_' + f_name.replace('.', '_') def _get_method_href(c_name: str, f_name: str) -> str: """Return a href for linking to the specified method.""" return 'method_' + c_name.replace('.', '_') + '__' + f_name.replace( '.', '_') def _get_attribute_href(c_name: str, a_name: str) -> str: return 'attr_' + c_name.replace('.', '_') + '__' + a_name.replace('.', '_') def _get_category(docs: str, category_type: CategoryType) -> str: """Parse the category name from a docstring.""" category_lines = [ l for l in docs.splitlines() if l.lower().strip().startswith(CATEGORY_STRING.lower()) ] if category_lines: category = category_lines[0].strip()[len(CATEGORY_STRING):].strip() else: category = { CategoryType.CLASS: 'Misc Classes', CategoryType.FUNCTION: 'Misc Functions' }[category_type] return category def _print_child_classes(category_classes: list[ClassInfo], parent: str, indent: int) -> str: out = '' valid_classes = [] for cls in category_classes: if cls.parents: c_parent = cls.parents[0] else: c_parent = '' # If it has a parent not in this category, consider it to # have no parent. if c_parent != '': found = False for ctest in category_classes: if c_parent == ctest.name: found = True break if not found: c_parent = '' # Print only if its parent matches what we want. if c_parent == parent: valid_classes.append(cls) if valid_classes: out += ' ' * indent + '
    \n' for cls in valid_classes: out += (' ' * indent + '
  • ' + cls.name + '
  • \n') out += _print_child_classes(category_classes, cls.name, indent + 1) if valid_classes: out += ' ' * indent + '
\n' return out def _add_inner_classes(class_objs: Sequence[type], classes_by_name: list[tuple[str, type]]) -> None: # Ok, now go through all existing classes and look for classes # defined within. for cls in class_objs: for name in dir(cls): if name.startswith('_'): continue obj = getattr(cls, name) if not inspect.isclass(obj): continue if _get_defining_class_backwards(cls, name) != cls: continue classes_by_name.append( (cls.__module__ + '.' + cls.__name__ + '.' + name, obj)) class Generator: """class which handles docs generation.""" def __init__(self) -> None: self._index_keys: list[str] = [] # Make a list of missing stuff so we can warn about it in one # big chunk at the end (so the user can batch their corrections). self._errors: list[Any] = [] self._index: dict[str, tuple[str, Union[ClassInfo, FunctionInfo, AttributeInfo]]] = {} self._out = '' self._classes: list[ClassInfo] = [] self._functions: list[FunctionInfo] = [] self._merged_categories: list[tuple[str, str, list[Union[ClassInfo, FunctionInfo]]]] = [] def name_variants(self, name: str) -> list[str]: """Return variants of a word (such as plural) for linking.""" # Do 'ies' plural for words ending in y. # (but not things like foo.y or display or prey) if (len(name) > 1 and name.endswith('y') and name[-2].isalpha() and name[-2] not in {'a', 'e', 'i', 'o', 'u'}): return [name, f'{name[:-1]}ies'] # Otherwise assume plural just ends with s: return [name, f'{name}s'] def _add_index_links(self, docs: str, ignore_links: Optional[list[str]] = None) -> str: """Add links to indexed classes/methods/etc found in a docstr.""" sub_num = 0 subs = {} # Ok now replace any names found in our index with links. for index_entry in self._index_keys: if ignore_links is not None and index_entry in ignore_links: continue for index_entry_actual in self.name_variants(index_entry): bits = docs.split(index_entry_actual) docs = bits[0] # Look at the first char after each split; if its # not alphanumeric, lets replace. for i in range(1, len(bits)): bit = bits[i] if not bit: valid = True else: valid = not bit[:1].isalnum() if valid: # Strip out this name and replace it with a funky # string to prevent further replacements from # applying to it.. we'll then swap it back at the end. sub_name = '<__SUB' + str(sub_num) + '__>' subs[sub_name] = index_entry_actual sub_num += 1 # Sub in link. docs += ('' + sub_name + '') else: docs += index_entry_actual # Keep original. docs += bits[i] # Misc replacements: docs = docs.replace( 'General message handling; can be passed any message object.', 'General message handling; can be passed any message object.') for sub_name, sub_val in list(subs.items()): docs = docs.replace(sub_name, sub_val) return docs def _get_all_attrs_for_class(self, cls: type, docs: str) -> tuple[str, list[AttributeInfo]]: """ if there's an 'Attributes' section in the docs, strip it out and create attributes entries out of it. Returns the stripped down docs as well as detected attrs. """ attrs: list[AttributeInfo] = [] # Start by pulling any type info we find in the doc str. # (necessary in many non-property cases since there's no other way # to annotate attrs) docs = parse_docs_attrs(attrs, docs) # In some cases we document an attr in the class doc-string but # provide an annotation for it at the type level. # (this is the case for simple class/instance attributes since we # can't provide docstrings along with those) self._get_class_level_types_for_doc_attrs(cls, attrs) # Now pull info on properties, which can have doc-strings and # annotations all in the same place; yay! self._get_property_attrs_for_class(cls, attrs) return docs, attrs def _get_class_level_types_for_doc_attrs( self, cls: type, attrs: list[AttributeInfo]) -> None: # Take note of all the attrs that we're aware of already; # these are the ones we can potentially provide type info for. existing_attrs_by_name = {a.name: a for a in attrs} cls_annotations = getattr(cls, '__annotations__', {}) for aname, aval in cls_annotations.items(): # (we expect __future__ annotations to always be on, which makes # these strings) assert isinstance(aval, str) if aname in existing_attrs_by_name: # Complain if there's a type in both the docs and the type. if existing_attrs_by_name[aname].attr_type is not None: print('FOUND', existing_attrs_by_name[aname], aval) self._errors.append( f'attr {aname} for class {cls}' 'has both a docstring and class level annotation;' ' should just have one') existing_attrs_by_name[aname].attr_type = aval def _get_property_attrs_for_class(self, cls: type, attrs: list[AttributeInfo]) -> None: for attrname in dir(cls): attr = getattr(cls, attrname) if isinstance(attr, property): if any(a.name == attrname for a in attrs): raise Exception(f'attr "{attrname}" has both a' f' class docs and property entry') # Pull its docs. attrdocs = getattr(attr, '__doc__', None) if attrdocs is None: self._errors.append( f'property \'{attrname}\' on class {cls}') attrdocs = '(no docs)' else: attrdocs = attrdocs.strip() # Pull type annotations. attr_annotations = getattr(attr.fget, '__annotations__') if (not isinstance(attr_annotations, dict) or 'return' not in attr_annotations or not isinstance(attr_annotations['return'], str)): raise Exception('property type annotation not found') attrtype = attr_annotations['return'] if '(internal)' not in attrdocs: attrs.append( AttributeInfo(name=attrname, docs=attrdocs, attr_type=attrtype)) def _get_base_docs_for_class(self, cls: type) -> str: if cls.__doc__ is not None: docs = cls.__doc__ docs_lines = docs.splitlines() min_indent = 9999 for i, line in enumerate(docs_lines): if line != '': spaces = 0 while line and line[0] == ' ': line = line[1:] spaces += 1 if spaces < min_indent: min_indent = spaces if min_indent == 9999: min_indent = 0 for i, line in enumerate(docs_lines): if line != '': if not line.startswith(' ' * min_indent): raise Exception("expected opening whitespace: '" + line + "'; class " + str(cls)) docs_lines[i] = line[min_indent:] docs = '\n'.join(docs_lines) else: docs = '(no docs)' self._errors.append(f'base docs for class {cls}') return docs def _get_enum_values_for_class(self, cls: type) -> Optional[list[str]]: if issubclass(cls, Enum): return [val.name for val in cls] return None def _get_methods_for_class( self, cls: type) -> tuple[list[FunctionInfo], list[FunctionInfo]]: import types method_types = [ types.MethodDescriptorType, types.FunctionType, types.MethodType ] methods_raw = [ getattr(cls, name) for name in dir(cls) if any(isinstance(getattr(cls, name), t) for t in method_types) and ( not name.startswith('_') or name == '__init__') and '_no_init' not in name and '_no_init_or_replace_init' not in name ] methods: list[FunctionInfo] = [] inherited_methods: list[FunctionInfo] = [] for mth in methods_raw: # Protocols seem to give this... if mth.__name__ in {'_no_init', '_no_init_or_replace_init'}: continue # Keep a list of inherited methods but don't do a full # listing of them. if _is_inherited(cls, mth.__name__): dcls = _get_defining_class(cls, mth.__name__) assert dcls is not None inherited_methods.append( FunctionInfo(name=mth.__name__, method_class=dcls.__module__ + '.' + dcls.__name__)) continue # Use pydoc stuff for python methods since it includes args. # Its a c-defined method. if isinstance(mth, types.MethodDescriptorType): if mth.__doc__ is not None: mdocs = mth.__doc__ else: mdocs = '(no docs)' self._errors.append(mth) is_class_method = False # Its a python method. else: mdocs, is_class_method = self._python_method_docs(cls, mth) if '(internal)' not in mdocs: methods.append( FunctionInfo(name=mth.__name__, docs=mdocs, is_class_method=is_class_method)) return methods, inherited_methods def _python_method_docs(self, cls: type, mth: Callable) -> tuple[str, bool]: import pydoc mdocs_lines = pydoc.plain(pydoc.render_doc(mth)).splitlines()[2:] # Remove ugly 'method of builtins.type instance' # on classmethods. mdocs_lines = [ l.replace('method of builtins.type instance', '') for l in mdocs_lines ] # Pydoc indents all lines but the first 4 spaces; # undo that. for i, line in enumerate(mdocs_lines): if i != 0: if not line.startswith(' '): raise Exception('UNEXPECTED') mdocs_lines[i] = line[4:] # Class-methods will show up as bound methods when we pull # them out of the type (with the type as the object). # Regular methods just show up as normal functions in # python 3 (no more unbound methods). is_class_method = inspect.ismethod(mth) # If this only gave us 1 line, it means there's no docs # (the one line is just the call signature). # In that case lets try parent classes to see if they # have docs. if len(mdocs_lines) == 1: mdocs_lines = self._handle_single_line_method_docs( cls, mdocs_lines, mth) # Add an empty line after the first. mdocs_lines = [mdocs_lines[0]] + [''] + mdocs_lines[1:] if len(mdocs_lines) == 2: # Special case: we allow dataclass types to have no __init__ docs # since they generate their own init (and their attributes tell # pretty much the whole story about them anyway). if (hasattr(cls, '__dataclass_fields__') and mth.__name__ == '__init__'): pass else: self._errors.append((cls, mth)) mdocs = '\n'.join(mdocs_lines) return mdocs, is_class_method def _handle_single_line_method_docs(self, cls: type, mdocs_lines: list[str], mth: Callable) -> list[str]: import pydoc for testclass in cls.mro()[1:]: testm = getattr(testclass, mth.__name__, None) if testm is not None: mdocs_lines_test = pydoc.plain( pydoc.render_doc(testm)).splitlines()[2:] # Split before "unbound method" or "method". if 'unbound' in mdocs_lines_test[0]: if len(mdocs_lines_test[0].split('unbound')) > 2: raise Exception('multi-unbounds') mdocs_lines_test[0] = \ mdocs_lines_test[0].split('unbound')[0] else: if len(mdocs_lines_test[0].split('method')) > 2: raise Exception('multi-methods') mdocs_lines_test[0] = \ mdocs_lines_test[0].split('method')[0] # If this one has more info in it but its # first line (call signature) matches ours, # go ahead and use its docs in place of ours. if (len(mdocs_lines_test) > 1 and mdocs_lines_test[0] == mdocs_lines[0]): mdocs_lines = mdocs_lines_test return mdocs_lines def _create_index(self) -> None: # Create an index of everything we can link to in classes and # functions. for cls in self._classes: key = cls.name if key in self._index: print('duplicate index entry:', key) self._index[key] = (_get_class_href(cls.name), cls) self._index_keys.append(key) # Add in methods. for mth in cls.methods: key = cls.name + '.' + mth.name if key in self._index: print('duplicate index entry:', key) self._index[key] = (_get_method_href(cls.name, mth.name), mth) self._index_keys.append(key) # Add in attributes. for attr in cls.attributes: key = cls.name + '.' + attr.name if key in self._index: print('duplicate index entry:', key) self._index[key] = (_get_attribute_href(cls.name, attr.name), attr) self._index_keys.append(key) # Add in functions. for fnc in self._functions: key = fnc.name if key in self._index: print('duplicate index entry:', key) self._index[key] = (_get_function_href(fnc.name), fnc) self._index_keys.append(key) # Reverse this so when we replace things with links our longest # ones are searched first (such as nested classes like ba.Foo.Bar). self._index_keys.reverse() def _write_inherited_attrs(self, inherited_attrs: dict[str, str]) -> None: style = (' style="padding-left: 0px;"' if DO_STYLES else '') self._out += f'Attributes Inherited:\n' style = (' style="padding-left: 30px;"' if DO_STYLES else '') self._out += f'' inherited_attrs_sorted = list(inherited_attrs.items()) inherited_attrs_sorted.sort(key=lambda x: x[0].lower()) for i, attr in enumerate(inherited_attrs_sorted): if i != 0: self._out += ', ' aname = attr[0] self._out += ('' + aname + '') self._out += '\n' def _write_attrs(self, cls: ClassInfo, attributes: list[AttributeInfo]) -> None: # Include a block of links to our attrs if we have more # than one. if len(attributes) > 1: self._out += f'' for i, attr in enumerate(attributes): if i != 0: self._out += ', ' aname = attr.name self._out += ('' + aname + '') self._out += '\n' self._out += '
\n' for attr in attributes: cssclass = ' class="offsanchor"' if DO_CSS_CLASSES else '' self._out += (f'
' f'' + attr.name + '
\n') # If we've got a type for the attr, spit that out. if attr.attr_type is not None: # Add links to any types we cover. typestr = self._add_index_links(attr.attr_type) style2 = (' style="color: #666677;"' if DO_STYLES else '') self._out += (f'' f'' + typestr + '

\n') else: self._errors.append(f"Attr '{attr.name}' on {cls.name} " 'has no type annotation.') if attr.docs is not None: self._out += self._filter_docs(attr.docs, 'attribute') self._out += '
\n' self._out += '
\n' def _write_class_attrs_all(self, cls: ClassInfo, attributes: list[AttributeInfo], inherited_attrs: dict[str, str]) -> None: # If this class has no non-inherited attrs, just print a link to # the base class instead of repeating everything. # Nevermind for now; we never have many attrs so this isn't as # helpful as with methods. if bool(False): if cls.parents: self._out += f'Attributes:\n' par = cls.parents[0] self._out += (f'<' 'all attributes inherited from ' + '' + par + '' + '>

\n') else: # Dump inherited attrs. if inherited_attrs: self._write_inherited_attrs(inherited_attrs) # Dump attributes. if attributes: if inherited_attrs: self._out += (f'' 'Attributes Defined Here:\n') else: self._out += f'Attributes:\n' self._write_attrs(cls, attributes) def _write_enum_vals(self, cls: ClassInfo) -> None: if cls.enum_values is None: return self._out += f'Values:\n' self._out += '
    \n' for val in cls.enum_values: self._out += '
  • ' + val + '
  • \n' self._out += '
\n' def _write_inherited_methods_for_class(self, cls: ClassInfo) -> None: """Dump inherited methods for a class.""" if cls.inherited_methods: # If we inherit directly from a builtin class, # lets not print inherited methods at all since # we don't have docs for them. if (len(cls.parents) == 1 and cls.parents[0].startswith('builtins.')): pass else: self._out += f'Methods Inherited:\n' self._out += f'' for i, method in enumerate(cls.inherited_methods): if i != 0: self._out += ', ' mname = method.name + '()' if mname == '__init__()': mname = '<constructor>' assert method.method_class is not None self._out += ( '' + mname + '') self._out += '\n' def _write_methods_for_class(self, cls: ClassInfo, methods: list[FunctionInfo]) -> None: """Dump methods for a class.""" if cls.methods: # Just say "methods" if we had no inherited ones. if cls.inherited_methods: self._out += (f'' 'Methods Defined or Overridden:\n') else: self._out += f'Methods:\n' # Include a block of links to our methods if we have more # than one. if len(methods) > 1: self._out += f'' for i, method in enumerate(methods): if i != 0: self._out += ', ' mname = method.name + '()' if mname == '__init__()': mname = '<constructor>' self._out += ('' + mname + '') self._out += '\n' self._out += '
\n' for mth in cls.methods: self._write_method(cls, mth) self._out += '
\n' def _write_method(self, cls: ClassInfo, mth: FunctionInfo) -> None: name = mth.name + '()' ignore_links = [] if name == '__init__()': ignore_links.append(cls.name) name = '<constructor>' # If we have a 3 part name such as # 'ba.Spaz.DeathMessage', # ignore the first 2 components ('ba.Spaz'). dot_splits = cls.name.split('.') if len(dot_splits) == 3: ignore_links.append(dot_splits[0] + '.' + dot_splits[1]) cssclass = ' class="offsanchor"' if DO_CSS_CLASSES else '' self._out += (f'
' f'' + name + '
\n') if mth.docs is not None: mdocslines = mth.docs.splitlines() # Hmm should we be pulling from the class docs # as python suggests?.. hiding the suggestion to do so. mdocslines = [ l for l in mdocslines if 'Initialize self. See help(type(self))' ' for accurate signature' not in l ] # Kill any '-> None' on inits. if '__init__' in mdocslines[0]: mdocslines[0] = mdocslines[0].split(' -> ')[0] # Let's display '__init__(self, foo)' as 'Name(foo)'. mdocslines[0] = mdocslines[0].replace('__init__(self, ', cls.name + '(') mdocslines[0] = mdocslines[0].replace('__init__(self)', cls.name + '()') if mth.is_class_method: style2 = (' style="color: #CC6600;"' if DO_STYLES else '') self._out += (f'' f'' '<class method>' '\n') self._out += self._filter_docs('\n'.join(mdocslines), 'method', ignore_links=ignore_links) self._out += '
\n' def _get_type_display(self, name: str) -> str: """Given a string such as 'ba.ClassName', returns link/txt for it.""" # Special-case; don't do links for built in classes. if name.startswith('builtins.'): shortname = name.replace('builtins.', '') # Show handy links for some builtin python types if shortname in { 'Exception', 'BaseException', 'RuntimeError', 'ValueError' }: return (f'{shortname}') if name.startswith('ba.'): return ('' + name + '') # Show handy links for various standard library types. if name in {'typing.Generic', 'typing.Protocol'}: return (f'{name}') if name in {'enum.Enum'}: return (f'{name}') return name def _write_class_inheritance(self, cls: ClassInfo) -> None: if cls.parents: self._out += f'Inherits from: ' for i, par in enumerate(cls.parents): if i != 0: self._out += ', ' self._out += self._get_type_display(par) else: self._out += (f'' '<top level class>\n') def _get_inherited_attrs(self, cls: ClassInfo, attr_name_set: set[str]) -> dict[str, str]: inherited_attrs: dict[str, str] = {} for par in cls.parents: if par in self._index: parent_class = self._index[par][1] assert isinstance(parent_class, ClassInfo) for attr in parent_class.attributes: if (attr.name not in attr_name_set and attr.name not in inherited_attrs): inherited_attrs[attr.name] = (_get_attribute_href( parent_class.name, attr.name)) self._out += '

\n' return inherited_attrs def _write_classes(self) -> None: # Now write out the docs for each class. for cls in self._classes: self._out += '
\n' cssclass = ' class="offsanchor"' if DO_CSS_CLASSES else '' self._out += (f'

' + cls.name + '

\n') self._write_class_inheritance(cls) methods = cls.methods methods.sort(key=lambda x: x.name.lower()) attributes = cls.attributes attributes.sort(key=lambda x: x.name.lower()) attr_name_set = set() for attr in attributes: attr_name_set.add(attr.name) # Go through parent classes' attributes, including any # that aren't in our set in the 'inherited attributes' list. inherited_attrs = self._get_inherited_attrs(cls, attr_name_set) if cls.docs is not None: self._out += self._filter_docs(cls.docs, 'class') self._write_class_attrs_all(cls, attributes, inherited_attrs) self._write_enum_vals(cls) # If this class has no non-inherited methods, just print a link to # the base class instead of repeating everything. if cls.inherited_methods and not cls.methods: if cls.parents: self._out += f'Methods:\n' par = cls.parents[0] self._out += (f'<' 'all methods inherited from ' + self._get_type_display(par) + '>

\n') else: self._write_inherited_methods_for_class(cls) self._write_methods_for_class(cls, methods) def _gather_funcs(self, module: ModuleType) -> None: import types import pydoc # Function, build-in-function. func_types = [types.FunctionType, types.BuiltinMethodType] names = dir(module) funcs = [ getattr(module, name) for name in names if not name.startswith('_') ] funcs = [f for f in funcs if any(isinstance(f, t) for t in func_types)] for fnc in funcs: # For non-builtin funcs, use the pydoc rendering since it includes # args. # Chop off the first line which is just "Python Library # Documentation: and the second which is blank. docs = None if isinstance(fnc, types.FunctionType): docslines = pydoc.plain(pydoc.render_doc(fnc)).splitlines()[2:] # Pydoc indents all lines but the first 4 spaces; undo that. for i, line in enumerate(docslines): if i != 0: if not line.startswith(' '): raise Exception('UNEXPECTED') docslines[i] = line[4:] # Add an empty line after the first. docslines = [docslines[0]] + [''] + docslines[1:] if len(docslines) == 2: docslines.append('(no docs)') self._errors.append(fnc) docs = '\n'.join(docslines) else: if fnc.__doc__ is not None: docs = fnc.__doc__ else: fnc.__doc__ = '(no docs)' self._errors.append(fnc) assert docs is not None f_info = FunctionInfo(name=fnc.__module__ + '.' + fnc.__name__, category=_get_category( docs, CategoryType.FUNCTION), docs=docs) if '(internal)' not in docs: self._functions.append(f_info) def _process_classes(self, module: ModuleType) -> None: classes_by_name = _get_module_classes(module) for c_name, cls in classes_by_name: docs = self._get_base_docs_for_class(cls) bases = _get_bases(cls) methods, inherited_methods = self._get_methods_for_class(cls) enum_values = self._get_enum_values_for_class(cls) docs, attrs = self._get_all_attrs_for_class(cls, docs) c_info = ClassInfo(name=c_name, parents=bases, docs=docs, enum_values=enum_values, methods=methods, inherited_methods=inherited_methods, category=_get_category(docs, CategoryType.CLASS), attributes=attrs) self._classes.append(c_info) def _write_category_list(self) -> None: for cname, ctype, cmembers in self._merged_categories: if ctype == 'class': assert (isinstance(i, ClassInfo) for i in cmembers) cssclass = ' class="offsanchor"' if DO_CSS_CLASSES else '' self._out += (f'

' + cname + '

\n') classes_sorted = cast(list[ClassInfo], cmembers) classes_sorted.sort(key=lambda x: x.name.lower()) pcc = _print_child_classes(classes_sorted, '', 0) self._out += pcc elif ctype == 'function': assert (isinstance(i, FunctionInfo) for i in cmembers) cssclass = ' class="offsanchor"' if DO_CSS_CLASSES else '' self._out += (f'

' + cname + '

\n' '
    \n') funcs = cast(list[FunctionInfo], cmembers) funcs.sort(key=lambda x: x.name.lower()) for fnc in funcs: self._out += ('
  • ' + fnc.name + '()
  • \n') self._out += '
\n' else: raise Exception('invalid ctype') def _filter_docs(self, docs: str, filter_type: str, ignore_links: list[str] = None) -> str: get_category_href_func, indent = _filter_type_settings(filter_type) docs = docs.replace('>', '>') docs = docs.replace('<', '<') docs_lines = docs.splitlines() # Make sure empty lines are actually empty (so we can search for # '\n\n' and not get thrown off by something like '\n \n'). for i, line in enumerate(docs_lines): if line.strip() == '': docs_lines[i] = '' # If a line starts with 'Category:', make it a link to that category. for i, line in enumerate(docs_lines): if line.lower().strip().startswith(CATEGORY_STRING.lower()): if get_category_href_func is None: raise Exception('cant do category for filter_type ' + filter_type) cat = line.strip()[len(CATEGORY_STRING):].strip() docs_lines[i] = (CATEGORY_STRING + ' ' + cat + '') docs = '\n'.join(docs_lines) docs = _split_into_paragraphs(docs, filter_type, indent) docs = self._add_index_links(docs, ignore_links) return docs def run(self, outfilename: str) -> None: """Generate docs from within the game.""" import ba self._gather_funcs(ba) self._process_classes(ba) # Start with our list of classes and functions. app = ba.app self._out += (f'

last updated for Ballistica version ' f'{app.version} build {app.build_number}

\n') self._out += ( '

This page documents the Python classes' ' and functions in the \'ba\' module,\n' ' which are the ones most relevant to modding in Ballistica.' ' If you come across something you feel' ' should be included here or could' ' be better explained, please ' '' 'let me know. Happy modding!

\n') self._out += '
\n' self._out += '

Table of Contents

\n' self._create_index() # Build a sorted list of class categories. c_categories: dict[str, list[Union[ClassInfo, FunctionInfo]]] = {} self._classes.sort(key=lambda x: x.name.lower()) for cls in self._classes: assert cls.category is not None category = cls.category if category not in c_categories: c_categories[category] = [] c_categories[category].append(cls) self._merged_categories = [(cname, 'class', cval) for cname, cval in c_categories.items()] # Build sorted function category list. f_categories: dict[str, list[FunctionInfo]] = {} for fnc in self._functions: if fnc.category is not None: category = fnc.category else: category = 'Misc' if category not in f_categories: f_categories[category] = [] f_categories[category].append(fnc) self._merged_categories += [(cname, 'function', cast(list[Union[ClassInfo, FunctionInfo]], cval)) for cname, cval in f_categories.items()] def sort_func(entry: tuple[str, str, Any]) -> str: name = entry[0].lower() # Sort a few recognized categories somewhat manually. overrides = { 'gameplay classes': 'aaaa', 'gameplay functions': 'aaab', 'general utility classes': 'aaac', 'general utility functions': 'aaad', 'asset classes': 'aaae', 'asset functions': 'aaaf', 'message classes': 'aaag', 'app classes': 'aaah', 'app functions': 'aaai', 'user interface classes': 'aaaj', 'user interface functions': 'aaak', } return overrides.get(name, name) self._merged_categories.sort(key=sort_func) # Write out our category listings. self._write_category_list() self._write_classes() # Now write docs for each function. for fnc in self._functions: self._out += '
\n' cssclass = ' class="offsanchor"' if DO_CSS_CLASSES else '' self._out += (f'

' + fnc.name + '()

\n') if fnc.docs is not None: self._out += self._filter_docs(fnc.docs, 'function') # If we've hit any errors along the way, complain. if self._errors: max_displayed = 10 print( len(self._errors), 'ISSUES FOUND GENERATING DOCS:\n' + '\n'.join(self._errors[:max_displayed])) clipped = max(0, len(self._errors) - max_displayed) if clipped: print(f'(and {clipped} more)') raise Exception( str(len(self._errors)) + ' docs generation issues.') with open(outfilename, 'w', encoding='utf-8') as outfile: outfile.write(self._out) print(f"Generated docs file: '{Clr.BLU}{outfilename}.{Clr.RST}'") ba.quit() def generate(projroot: str) -> None: """Main entry point.""" toolsdir = os.path.abspath(os.path.join(projroot, 'tools')) # Make sure we're running from the dir above this script. os.chdir(projroot) outfilename = os.path.abspath('build/docs.html') # Let's build the cmake version; no sandboxing issues to contend # with there. Also going with the headless build; will need to revisit # if there's ever any functionality not available in that build. subprocess.run(['make', 'cmake-server-build'], check=True) # Launch ballisticacore and exec ourself from within it. print('Launching ballisticacore to generate docs...') try: subprocess.run( [ './ballisticacore', '-exec', f'try:\n' f' import sys\n' f' import ba\n' f' sys.path.append("{toolsdir}")\n' f' import batools.docs\n' f' batools.docs.Generator().run("{outfilename}")\n' f' ba.quit()\n' f'except Exception:\n' f' import sys\n' f' import traceback\n' f' print("ERROR GENERATING DOCS")\n' f' traceback.print_exc()\n' f' sys.exit(255)\n', ], cwd='build/cmake/server-debug/dist', check=True, ) except Exception as exc2: # Keep our error simple here; we want focus to be on what went # wrong withing BallisticaCore. raise CleanError('BallisticaCore docs generation failed.') from exc2 print('Docs generation complete.')