diff --git a/Makefile b/Makefile
index c2b20402..91f01a26 100644
--- a/Makefile
+++ b/Makefile
@@ -1021,5 +1021,12 @@ _windows-wsl-rebuild:
$(VISUAL_STUDIO_VERSION)
@tools/pcommand echo BLU BLD Built build/windows/BallisticaCore$(WINPRJ).exe.
+# Generate docs.
+docs:
+ @tools/pcommand echo BLU GENERATING DOCS HTML...
+ @mkdir -p ${BUILD_DIR}
+ @tools/pcommand gendocs
+
# Tell make which of these targets don't represent files.
-.PHONY: _cmake-simple-ci-server-build _windows-wsl-build _windows-wsl-rebuild
+.PHONY: _cmake-simple-ci-server-build _windows-wsl-build _windows-wsl-rebuild \
+ docs
diff --git a/docs/ba_module.md b/docs/ba_module.md
index 2daee678..faec8050 100644
--- a/docs/ba_module.md
+++ b/docs/ba_module.md
@@ -1,5 +1,5 @@
-
last updated on 2021-06-09 for Ballistica version 1.6.4 build 20375
+last updated on 2021-06-10 for Ballistica version 1.6.4 build 20377
This page documents the Python classes and functions in the 'ba' module,
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!
diff --git a/tools/batools/build.py b/tools/batools/build.py
index e1d90ef6..48cb0ca9 100644
--- a/tools/batools/build.py
+++ b/tools/batools/build.py
@@ -784,7 +784,7 @@ def update_docs_md(check: bool) -> None:
# We store the hash in a separate file that only exists on private
# so public isn't full of constant hash change commits.
# (don't care so much on private)
- docs_hash_path = 'docs/ba_module_hash'
+ docs_hash_path = '.cache/ba_module_hash'
# Generate a hash from all c/c++ sources under the python subdir
# as well as all python scripts.
@@ -802,13 +802,18 @@ def update_docs_md(check: bool) -> None:
if any(fname.endswith(ext) for ext in exts):
pysources.append(os.path.join(root, fname))
pysources.sort()
+ storedhash: Optional[str]
curhash = get_files_hash(pysources)
# Extract the current embedded hash.
- with open(docs_hash_path) as infile:
- storedhash = infile.read()
+ if os.path.exists(docs_hash_path):
+ with open(docs_hash_path) as infile:
+ storedhash = infile.read()
+ else:
+ storedhash = None
- if curhash != storedhash or not os.path.exists(docs_path):
+ if (storedhash is None or curhash != storedhash
+ or not os.path.exists(docs_path)):
if check:
raise RuntimeError('Docs markdown is out of date.')
@@ -821,6 +826,7 @@ def update_docs_md(check: bool) -> None:
docs = infile.read()
docs = ('\n'
) + docs
+ os.makedirs(os.path.dirname(docs_path), exist_ok=True)
with open(docs_path, 'w') as outfile:
outfile.write(docs)
with open(docs_hash_path, 'w') as outfile:
diff --git a/tools/batools/gendocs.py b/tools/batools/gendocs.py
new file mode 100755
index 00000000..e7a0b33b
--- /dev/null
+++ b/tools/batools/gendocs.py
@@ -0,0 +1,1319 @@
+# Released under the MIT License. See LICENSE for details.
+#
+"""Documentation generation functionality."""
+
+# pylint: disable=too-many-lines
+
+from __future__ import annotations
+
+import os
+import datetime
+import inspect
+import subprocess
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, List, 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 (Type, Optional, Tuple, Callable, Dict, Any, Sequence,
+ Set)
+
+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
+ ]
+
+ methods: List[FunctionInfo] = []
+ inherited_methods: List[FunctionInfo] = []
+ for mth in methods_raw:
+
+ # Protocols seem to give this...
+ if mth.__name__ == '_no_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'- \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'\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'\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'\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 += ('last updated on ' + str(datetime.date.today()) +
+ ' for Ballistica version ' + app.version + ' build ' +
+ str(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') as outfile:
+ outfile.write(self._out)
+
+ print(f"Generated docs file: '{Clr.BLU}{outfilename}.{Clr.RST}'")
+
+ ba.quit()
+
+
+def run(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' from batools import gendocs\n'
+ f' gendocs.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.')
diff --git a/tools/batools/pcommand.py b/tools/batools/pcommand.py
index cf276136..0ab3651c 100644
--- a/tools/batools/pcommand.py
+++ b/tools/batools/pcommand.py
@@ -444,6 +444,12 @@ def warm_start_asset_build() -> None:
check=True)
+def gendocs() -> None:
+ """Generate docs html."""
+ import batools.gendocs
+ batools.gendocs.run(projroot=str(PROJROOT))
+
+
def update_docs_md() -> None:
"""Updates docs markdown files if necessary."""
import batools.build
diff --git a/tools/batools/updateproject.py b/tools/batools/updateproject.py
index 57e06309..0c228f4f 100755
--- a/tools/batools/updateproject.py
+++ b/tools/batools/updateproject.py
@@ -118,6 +118,10 @@ class Updater:
# (this will get filtered and be unequal in spinoff projects)
if 'ballistica' + 'core' == 'ballisticacore':
self._update_dummy_module()
+
+ # Docs checks/updates will only run if BA_ENABLE_DOCS_UPDATES=1
+ # is set in the environment.
+ if os.environ.get('BA_ENABLE_DOCS_UPDATES') == '1':
self._update_docs_md()
if self._check:
@@ -175,14 +179,12 @@ class Updater:
# We need to do this near the end because it may run the cmake build
# so its success may depend on the cmake build files having already
# been updated.
- # (only do this if gendocs is available)
- if os.path.exists('tools/gendocs.py'):
- try:
- subprocess.run(['tools/pcommand', 'update_docs_md'] +
- self._checkarglist,
- check=True)
- except Exception as exc:
- raise CleanError('Error checking/updating docs') from exc
+ try:
+ subprocess.run(['tools/pcommand', 'update_docs_md'] +
+ self._checkarglist,
+ check=True)
+ except Exception as exc:
+ raise CleanError('Error checking/updating docs') from exc
def _update_prereqs(self) -> None:
diff --git a/tools/pcommand b/tools/pcommand
index 3b858c97..0b002201 100755
--- a/tools/pcommand
+++ b/tools/pcommand
@@ -33,13 +33,13 @@ from batools.pcommand import (
python_build_apple, python_build_apple_debug, python_build_android,
python_build_android_debug, python_android_patch, python_gather,
python_winprune, capitalize, upper, efrocache_update, efrocache_get,
- get_modern_make, warm_start_asset_build, update_docs_md, list_pip_reqs,
- install_pip_reqs, checkenv, ensure_prefab_platform, prefab_run_var,
- make_prefab, update_makebob, lazybuild, android_archive_unstripped_libs,
- efro_gradle, stage_assets, update_assets_makefile, update_project,
- update_cmake_prefab_lib, cmake_prep_dir, gen_binding_code,
- gen_flat_data_code, wsl_path_to_win, wsl_build_check_win_drive,
- win_ci_binary_build, genchangelog)
+ get_modern_make, warm_start_asset_build, gendocs, update_docs_md,
+ list_pip_reqs, install_pip_reqs, checkenv, ensure_prefab_platform,
+ prefab_run_var, make_prefab, update_makebob, lazybuild,
+ android_archive_unstripped_libs, efro_gradle, stage_assets,
+ update_assets_makefile, update_project, update_cmake_prefab_lib,
+ cmake_prep_dir, gen_binding_code, gen_flat_data_code, wsl_path_to_win,
+ wsl_build_check_win_drive, win_ci_binary_build, genchangelog)
# pylint: enable=unused-import
if TYPE_CHECKING: