diff --git a/.idea/dictionaries/roman.xml b/.idea/dictionaries/roman.xml index 58cf7470..941fd83d 100644 --- a/.idea/dictionaries/roman.xml +++ b/.idea/dictionaries/roman.xml @@ -3,7 +3,9 @@ gamename maxlen + outdirname pagename + pythondir unhashable diff --git a/assets/src/ba_data/python/bastd/ui/popup.py b/assets/src/ba_data/python/bastd/ui/popup.py index d89374b2..8a50f8fb 100644 --- a/assets/src/ba_data/python/bastd/ui/popup.py +++ b/assets/src/ba_data/python/bastd/ui/popup.py @@ -15,7 +15,9 @@ if TYPE_CHECKING: class PopupWindow: - """A transient window that positions and scales itself for visibility.""" + """A transient window that positions and scales itself for visibility. + + Category: UI Classes""" def __init__(self, position: tuple[float, float], diff --git a/tools/batools/docs.py b/tools/batools/docs.py index 2653d528..a436eab1 100755 --- a/tools/batools/docs.py +++ b/tools/batools/docs.py @@ -2,20 +2,15 @@ # """Documentation generation functionality.""" -# pylint: disable=too-many-lines - from __future__ import annotations -import importlib +import sys import os -import datetime import inspect -import subprocess from dataclasses import dataclass -from typing import TYPE_CHECKING, Union, cast +from pathlib import Path +from typing import TYPE_CHECKING from enum import Enum -import types -import pydoc from efro.error import CleanError from efro.terminal import Clr @@ -367,1059 +362,23 @@ def _add_inner_classes(class_objs: Sequence[type], (cls.__module__ + '.' + cls.__name__ + '.' + name, obj)) -class BaseGenerator: - """Base class which handles docs generation. - - Some generation options and target module set up in inherited classes.""" - - # This should be overridden by inherited classes. - - # Top-level module (e.g. 'ba'). - top_module_name: str - - # Modules NOT to generate docs for. Example: ['ba.internal']. - ignored_modules: list[str] - - # Docs file header. - header: str = '' - - def __init__(self) -> None: - self._index_keys: list[str] = [] - - # All modules where we will search functions and classes to - # generate docs for (including top-level module). - self._submodules: set[ModuleType] = set() - - # 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._class_names: set[str] = set() - self._functions: list[FunctionInfo] = [] - self._function_names: set[str] = set() - 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] - - 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]]: - - 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]: - 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]: - 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(f'WARNING: 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(f'WARNING: 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(f'WARNING: 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(f'WARNING: 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 _collect_submodules(self, module: ModuleType) -> None: - # In case we somehow met one module twice. - if module in self._submodules: - return - - if module.__name__ in self.ignored_modules: - return - - if not module.__name__.startswith(self.top_module_name): - return - - self._submodules.add(module) - - names = dir(module) - objects = [ - getattr(module, name) for name in names if not name.startswith('_') - ] - submodules = [ - obj for obj in objects if isinstance(obj, types.ModuleType) - ] - for submodule in submodules: - self._collect_submodules(submodule) - - def _gather_funcs(self, module: ModuleType) -> None: - func_types = [types.FunctionType, types.BuiltinMethodType] - - names = dir(module) - objects = [ - getattr(module, name) for name in names if not name.startswith('_') - ] - funcs = [ - obj for obj in objects if any( - isinstance(obj, t) for t in func_types) - ] - # Or should we take care of this in modules itself?.. - funcs = [ - f for f in funcs if f.__name__.startswith(self.top_module_name) - ] - - 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 and f_info.name not in self._function_names: - self._functions.append(f_info) - self._function_names.add(f_info.name) - - 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) - if c_info.name not in self._class_names: - self._classes.append(c_info) - self._class_names.add(c_info.name) - - 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 get_top_module(self) -> ModuleType: - """Returns top-level module we generating docs for""" - return importlib.import_module(self.top_module_name) - - def run(self, outfilename: str) -> None: - """Generate docs from within the game.""" - # pylint: disable=too-many-locals - - import ba - - print(f"Generating docs file: '{Clr.BLU}{outfilename}{Clr.RST}'...") - - # Collect everything we want to generate docs for. - self._collect_submodules(self.get_top_module()) - submodules = list(sorted(self._submodules, key=lambda x: x.__name__)) - print(f'{self.top_module_name} submodules:\n -->', - '\n --> '.join([sm.__name__ for sm in submodules])) - for module in submodules: - self._gather_funcs(module) - self._process_classes(module) - - # Remove duplicates. This probably should be handled at - # self._gather_funcs/self._process_classes level (i.e. just not to add - # already existing things), but the main problem is unhashable - # ClassInfo dataclass which leads to O(N^2) asymptotic (though I didn't - # check how exactly slow it would be). - # funcs = self._functions - # funcs.sort(key=lambda x: x.name) - # f_last: Optional[FunctionInfo] = None - # self._functions = [] - # for func in funcs: - # if func.name == f_last.name: - # continue - # f_last = func - # self._functions.append(func) - - self._functions.sort(key=lambda x: x.name) - self._classes.sort(key=lambda x: x.name) - - # Start with our list of classes and functions. - app = ba.app - self._out += ('

last updated on ' + str(datetime.date.today()) + - ' for Ballistica version ' + app.version + ' build ' + - str(app.build_number) + '

\n') - self._out += self.header - 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}'.") - - -class BaModuleGenerator(BaseGenerator): - """Generates docs for 'ba' module.""" - - top_module_name = 'ba' - ignored_modules = ['ba.internal', 'ba.ui'] - header = ('

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') - - def _add_index_links(self, - docs: str, - ignore_links: Optional[list[str]] = None) -> str: - docs = super()._add_index_links(docs, ignore_links=ignore_links) - - # Misc replacements: - docs = docs.replace( - 'General message handling; can be passed any message object.', - 'General message handling; can be passed any message object.') - - return docs - - -class BastdModuleGenerator(BaseGenerator): - """Generates docs for 'bastd' module and all submodules.""" - - top_module_name = 'bastd' - # Mypy complains if there is no type annotation - # (though it is in base class). - ignored_modules: list[str] = [] - header = ('

This page documents the Python classes' - ' and functions in the \'bastd\' module,\n' - ' which are also often needed to modding.' - ' If you come across something you feel' - ' should be included here or could' - ' be better explained, please ' - '' - 'let me know. Happy modding!

\n') - - def generate(projroot: str) -> None: """Main entry point.""" - toolsdir = os.path.abspath(os.path.join(projroot, 'tools')) + import pdoc # Make sure we're running from the dir above this script. os.chdir(projroot) - ba_out_file_name = os.path.abspath('build/docs_ba.html') - bastd_out_file_name = os.path.abspath('build/docs_bastd.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...') + pythondir = str( + Path(projroot, 'assets', 'src', 'ba_data', 'python').absolute()) + sys.path.append(pythondir) + outdirname = Path('build', 'docs_html').absolute() 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.BaModuleGenerator().run("{ba_out_file_name}")\n' - f' batools.docs.BastdModuleGenerator().run("{bastd_out_file_name}")\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 + pdoc.pdoc('ba', 'bastd', output_directory=outdirname) + except Exception as exc: + import traceback + traceback.print_exc() + raise CleanError('Docs generation failed') from exc - print('Docs generation complete.') + print(f'{Clr.GRN}Docs generation complete.{Clr.RST}')