diff --git a/assets/src/ba_data/python/_ba.py b/assets/src/ba_data/python/_ba.py index 1d4a6e84..bf1d64a5 100644 --- a/assets/src/ba_data/python/_ba.py +++ b/assets/src/ba_data/python/_ba.py @@ -12,7 +12,7 @@ Ideally this should be a stub (.pyi) file, but we'd need to make sure that it still works with all our tools (mypy, pylint, pycharm). -NOTE: This file was autogenerated by gendummymodule; do not edit by hand. +NOTE: This file was autogenerated by batools.dummymodule; do not edit by hand. """ # I'm sorry Pylint. I know this file saddens you. Be strong. diff --git a/tools/batools/dummymodule.py b/tools/batools/dummymodule.py new file mode 100755 index 00000000..d5d0e287 --- /dev/null +++ b/tools/batools/dummymodule.py @@ -0,0 +1,757 @@ +# Released under the MIT License. See LICENSE for details. +# +"""Generates a dummy _ba.py based on the game's real internal _ba module. + +This allows us to use code introspection tools such as pylint from outside +the game, and also allows external scripts to import game scripts successfully +(though with limited functionality) +""" + +from __future__ import annotations + +import os +import sys +import textwrap +import subprocess +from typing import TYPE_CHECKING + +from efro.error import CleanError +from efro.terminal import Clr +from efrotools import get_files_hash + +if TYPE_CHECKING: + from types import ModuleType + from typing import Sequence, Any, Tuple, List + from batools.gendocs import AttributeInfo + + +def _get_varying_func_info(sig_in: str) -> Tuple[str, str]: + """Return overloaded signatures and return statements for varying funcs.""" + returns = 'return None' + if sig_in == ('getdelegate(self, type: Type,' + ' doraise: bool = False) -> '): + sig = ('# Show that ur return type varies based on "doraise" value:\n' + '@overload\n' + 'def getdelegate(self, type: Type[_T],' + ' doraise: Literal[False] = False) -> Optional[_T]:\n' + ' ...\n' + '\n' + '@overload\n' + 'def getdelegate(self, type: Type[_T],' + ' doraise: Literal[True]) -> _T:\n' + ' ...\n' + '\n' + 'def getdelegate(self, type: Any,' + ' doraise: bool = False) -> Any:\n') + elif sig_in == ('getinputdevice(name: str, unique_id:' + ' str, doraise: bool = True) -> '): + sig = ('# Show that our return type varies based on "doraise" value:\n' + '@overload\n' + 'def getinputdevice(name: str, unique_id: str,' + ' doraise: Literal[True] = True) -> ba.InputDevice:\n' + ' ...\n' + '\n' + '@overload\n' + 'def getinputdevice(name: str, unique_id: str,' + ' doraise: Literal[False]) -> Optional[ba.InputDevice]:\n' + ' ...\n' + '\n' + 'def getinputdevice(name: str, unique_id: str,' + ' doraise: bool=True) -> Any:') + elif sig_in == ('time(timetype: ba.TimeType = TimeType.SIM,' + ' timeformat: ba.TimeFormat = TimeFormat.SECONDS)' + ' -> '): + sig = ( + '# Overloads to return a type based on requested format.\n' + '\n' + '@overload\n' + 'def time(timetype: ba.TimeType = TimeType.SIM,\n' + ' timeformat: Literal[TimeFormat.SECONDS]' + ' = TimeFormat.SECONDS) -> float:\n' + ' ...\n' + '\n' + '# This "*"' + ' keyword-only hack lets us accept 1 arg' + ' (timeformat=MILLISECS) forms.\n' + '@overload\n' + 'def time(timetype: ba.TimeType = TimeType.SIM, *,\n' + ' timeformat: Literal[TimeFormat.MILLISECONDS]) -> int:\n' + ' ...\n' + '\n' + '@overload\n' + 'def time(timetype: ba.TimeType,\n' + ' timeformat: Literal[TimeFormat.MILLISECONDS]) -> int:\n' + ' ...\n' + '\n' + '\n' + 'def time(timetype: ba.TimeType = TimeType.SIM,\n' + ' timeformat: ba.TimeFormat = TimeFormat.SECONDS)' + ' -> Any:\n') + elif sig_in == 'getactivity(doraise: bool = True) -> ': + sig = ( + '# Show that our return type varies based on "doraise" value:\n' + '@overload\n' + 'def getactivity(doraise: Literal[True] = True) -> ba.Activity:\n' + ' ...\n' + '\n' + '\n' + '@overload\n' + 'def getactivity(doraise: Literal[False])' + ' -> Optional[ba.Activity]:\n' + ' ...\n' + '\n' + '\n' + 'def getactivity(doraise: bool = True) -> Optional[ba.Activity]:') + elif sig_in == 'getsession(doraise: bool = True) -> ': + sig = ('# Show that our return type varies based on "doraise" value:\n' + '@overload\n' + 'def getsession(doraise: Literal[True] = True) -> ba.Session:\n' + ' ...\n' + '\n' + '\n' + '@overload\n' + 'def getsession(doraise: Literal[False])' + ' -> Optional[ba.Session]:\n' + ' ...\n' + '\n' + '\n' + 'def getsession(doraise: bool = True) -> Optional[ba.Session]:') + + else: + raise RuntimeError( + f'Unimplemented varying func: {Clr.RED}{sig_in}{Clr.RST}') + return sig, returns + + +def _writefuncs(parent: Any, funcnames: Sequence[str], indent: int, + spacing: int, as_method: bool) -> str: + # pylint: disable=too-many-branches + # pylint: disable=too-many-statements + # pylint: disable=too-many-locals + out = '' + spcstr = '\n' * spacing + indstr = ' ' * indent + for funcname in funcnames: + + # Skip some that are not in public builds. + if funcname in {'master_hash_dump'}: + continue + + func = getattr(parent, funcname) + docstr = func.__doc__ + + # We expect an empty line and take everything before that to be + # the function signature. + if '\n\n' not in docstr: + raise Exception(f'docstr missing empty line: {func}') + sig = docstr.split('\n\n')[0].replace('\n', ' ').strip() + + # Sanity check - make sure name is in the sig. + if funcname + '(' not in sig: + raise Exception(f'func name not found in sig for {funcname}') + + # If these are methods, add self. + if as_method: + if funcname + '()' in sig: + sig = sig.replace(funcname + '()', funcname + '(self)') + else: + sig = sig.replace(funcname + '(', funcname + '(self, ') + + # We expect sig to have a -> denoting return type. + if ' -> ' not in sig: + raise Exception(f'no "->" found in docstr for {funcname}') + returns = sig.split('->')[-1].strip() + + # Some functions don't have simple signatures; we need to hard-code + # those here with overloads and whatnot. + if '' in sig: + overloadsigs, returnstr = _get_varying_func_info(sig) + defsline = textwrap.indent(overloadsigs, indstr) + else: + defsline = f'{indstr}def {sig}:\n' + + # Types can be strings for forward-declaration cases. + if ((returns[0] == "'" and returns[-1] == "'") + or (returns[0] == '"' and returns[-1] == '"')): + returns = returns[1:-1] + if returns == 'None': + returnstr = 'return None' + elif returns == 'ba.Lstr': + returnstr = ('import ba # pylint: disable=cyclic-import\n' + "return ba.Lstr(value='')") + elif returns in ('ba.Activity', 'Optional[ba.Activity]'): + returnstr = ( + 'import ba # pylint: disable=cyclic-import\nreturn ' + + 'ba.Activity(settings={})') + elif returns in ('ba.Session', 'Optional[ba.Session]'): + returnstr = ( + 'import ba # pylint: disable=cyclic-import\nreturn ' + + 'ba.Session([])') + elif returns == 'Optional[ba.SessionPlayer]': + returnstr = ('import ba # pylint: disable=cyclic-import\n' + 'return ba.SessionPlayer()') + elif returns == 'Optional[ba.Player]': + returnstr = ('import ba # pylint: disable=cyclic-import\n' + 'return ba.Player()') + elif returns.startswith('ba.'): + + # We cant import ba at module level so let's + # do it within funcs as needed. + returnstr = ( + 'import ba # pylint: disable=cyclic-import\nreturn ' + + returns + '()') + + # we'll also have to go without a sig + # (could use the equivalent _ba class perhaps) + # sig = sig.split('->')[0] + + elif returns in ['object', 'Any']: + + # We use 'object' when we mean "can vary" + # don't want pylint making assumptions in this case. + returnstr = 'return _uninferrable()' + elif returns == 'Tuple[float, float]': + returnstr = 'return (0.0, 0.0)' + elif returns == 'Optional[str]': + returnstr = "return ''" + elif returns == 'Tuple[float, float, float, float]': + returnstr = 'return (0.0, 0.0, 0.0, 0.0)' + elif returns == 'Optional[ba.Widget]': + returnstr = 'return Widget()' + elif returns == 'Optional[ba.InputDevice]': + returnstr = 'return InputDevice()' + elif returns == 'List[ba.Widget]': + returnstr = 'return [Widget()]' + elif returns == 'Tuple[float, ...]': + returnstr = 'return (0.0, 0.0, 0.0)' + elif returns == 'List[str]': + returnstr = "return ['blah', 'blah2']" + elif returns == 'Union[float, int]': + returnstr = 'return 0.0' + elif returns == 'Dict[str, Any]': + returnstr = "return {'foo': 'bar'}" + elif returns in ('Optional[Tuple[int, int]]', 'Tuple[int, int]'): + returnstr = 'return (0, 0)' + elif returns == 'List[Dict[str, Any]]': + returnstr = "return [{'foo': 'bar'}]" + elif returns in [ + 'session.Session', 'team.Team', '_app.App', + 'appconfig.AppConfig' + ]: + returnstr = ('from ba import ' + returns.split('.')[0] + + '; return ' + returns + '()') + elif returns in [ + 'bool', 'str', 'int', 'list', 'dict', 'tuple', 'float', + 'SessionData', 'ActivityData', 'Player', 'SessionPlayer', + 'InputDevice', 'Sound', 'Texture', 'Model', 'CollideModel', + 'team.Team', 'Vec3', 'Widget', 'Node' + ]: + returnstr = 'return ' + returns + '()' + else: + raise Exception( + f'unknown returns value: {returns} for {funcname}') + returnspc = indstr + ' ' + returnstr = ('\n' + returnspc).join(returnstr.strip().splitlines()) + docstr_out = _formatdoc(docstr, indent + 4) + out += spcstr + defsline + docstr_out + f'{returnspc}{returnstr}\n' + return out + + +def _special_class_cases(classname: str) -> str: + out = '' + + # Special case: define a fallback attr getter with a random + # return type in cases where our class handles attrs itself. + if classname in ['Vec3']: + out += ('\n' + ' # pylint: disable=function-redefined\n' + '\n' + ' @overload\n' + ' def __init__(self) -> None:\n' + ' pass\n' + '\n' + ' @overload\n' + ' def __init__(self, value: float):\n' + ' pass\n' + '\n' + ' @overload\n' + ' def __init__(self, values: Sequence[float]):\n' + ' pass\n' + '\n' + ' @overload\n' + ' def __init__(self, x: float, y: float, z: float):\n' + ' pass\n' + '\n' + ' def __init__(self, *args: Any, **kwds: Any):\n' + ' pass\n' + '\n' + ' def __add__(self, other: Vec3) -> Vec3:\n' + ' return self\n' + '\n' + ' def __sub__(self, other: Vec3) -> Vec3:\n' + ' return self\n' + '\n' + ' @overload\n' + ' def __mul__(self, other: float) -> Vec3:\n' + ' return self\n' + '\n' + ' @overload\n' + ' def __mul__(self, other: Sequence[float]) -> Vec3:\n' + ' return self\n' + '\n' + ' def __mul__(self, other: Any) -> Any:\n' + ' return self\n' + '\n' + ' @overload\n' + ' def __rmul__(self, other: float) -> Vec3:\n' + ' return self\n' + '\n' + ' @overload\n' + ' def __rmul__(self, other: Sequence[float]) -> Vec3:\n' + ' return self\n' + '\n' + ' def __rmul__(self, other: Any) -> Any:\n' + ' return self\n' + '\n' + ' # (for index access)\n' + ' def __getitem__(self, typeargs: Any) -> Any:\n' + ' return 0.0\n' + '\n' + ' def __len__(self) -> int:\n' + ' return 3\n' + '\n' + ' # (for iterator access)\n' + ' def __iter__(self) -> Any:\n' + ' return self\n' + '\n' + ' def __next__(self) -> float:\n' + ' return 0.0\n' + '\n' + ' def __neg__(self) -> Vec3:\n' + ' return self\n' + '\n' + ' def __setitem__(self, index: int, val: float) -> None:\n' + ' pass\n') + if classname in ['Node']: + out += ( + '\n' + ' # Note attributes:\n' + ' # NOTE: I\'m just adding *all* possible node attrs here\n' + ' # now now since we have a single ba.Node type; in the\n' + ' # future I hope to create proper individual classes\n' + ' # corresponding to different node types with correct\n' + ' # attributes per node-type.\n' + ' color: Sequence[float] = (0.0, 0.0, 0.0)\n' + ' size: Sequence[float] = (0.0, 0.0, 0.0)\n' + ' position: Sequence[float] = (0.0, 0.0, 0.0)\n' + ' position_center: Sequence[float] = (0.0, 0.0, 0.0)\n' + ' position_forward: Sequence[float] = (0.0, 0.0, 0.0)\n' + ' punch_position: Sequence[float] = (0.0, 0.0, 0.0)\n' + ' punch_velocity: Sequence[float] = (0.0, 0.0, 0.0)\n' + ' velocity: Sequence[float] = (0.0, 0.0, 0.0)\n' + ' name_color: Sequence[float] = (0.0, 0.0, 0.0)\n' + ' tint_color: Sequence[float] = (0.0, 0.0, 0.0)\n' + ' tint2_color: Sequence[float] = (0.0, 0.0, 0.0)\n' + " text: Union[ba.Lstr, str] = ''\n" + ' texture: Optional[ba.Texture] = None\n' + ' tint_texture: Optional[ba.Texture] = None\n' + ' times: Sequence[int] = (1,2,3,4,5)\n' + ' values: Sequence[float] = (1.0, 2.0, 3.0, 4.0)\n' + ' offset: float = 0.0\n' + ' input0: float = 0.0\n' + ' input1: float = 0.0\n' + ' input2: float = 0.0\n' + ' input3: float = 0.0\n' + ' flashing: bool = False\n' + ' scale: Union[float, Sequence[float]] = 0.0\n' # FIXME + ' opacity: float = 0.0\n' + ' loop: bool = False\n' + ' time1: int = 0\n' + ' time2: int = 0\n' + ' timemax: int = 0\n' + ' client_only: bool = False\n' + ' materials: Sequence[Material] = ()\n' + ' roller_materials: Sequence[Material] = ()\n' + " name: str = ''\n" + ' punch_materials: Sequence[ba.Material] = ()\n' + ' pickup_materials: Sequence[ba.Material] = ()\n' + ' extras_material: Sequence[ba.Material] = ()\n' + ' rotate: float = 0.0\n' + ' hold_node: Optional[ba.Node] = None\n' + ' hold_body: int = 0\n' + ' host_only: bool = False\n' + ' premultiplied: bool = False\n' + ' source_player: Optional[ba.Player] = None\n' + ' model_opaque: Optional[ba.Model] = None\n' + ' model_transparent: Optional[ba.Model] = None\n' + ' damage_smoothed: float = 0.0\n' + ' gravity_scale: float = 1.0\n' + ' punch_power: float = 0.0\n' + ' punch_momentum_linear: Sequence[float] = ' + '(0.0, 0.0, 0.0)\n' + ' punch_momentum_angular: float = 0.0\n' + ' rate: int = 0\n' + ' vr_depth: float = 0.0\n' + ' is_area_of_interest: bool = False\n' + ' jump_pressed: bool = False\n' + ' pickup_pressed: bool = False\n' + ' punch_pressed: bool = False\n' + ' bomb_pressed: bool = False\n' + ' fly_pressed: bool = False\n' + ' hold_position_pressed: bool = False\n' + ' knockout: float = 0.0\n' + ' invincible: bool = False\n' + ' stick_to_owner: bool = False\n' + ' damage: int = 0\n' + ' run: float = 0.0\n' + ' move_up_down: float = 0.0\n' + ' move_left_right: float = 0.0\n' + ' curse_death_time: int = 0\n' + ' boxing_gloves: bool = False\n' + ' use_fixed_vr_overlay: bool = False\n' + ' allow_kick_idle_players: bool = False\n' + ' music_continuous: bool = False\n' + ' music_count: int = 0\n' + ' hurt: float = 0.0\n' + ' always_show_health_bar: bool = False\n' + ' mini_billboard_1_texture: Optional[ba.Texture] = None\n' + ' mini_billboard_1_start_time: int = 0\n' + ' mini_billboard_1_end_time: int = 0\n' + ' mini_billboard_2_texture: Optional[ba.Texture] = None\n' + ' mini_billboard_2_start_time: int = 0\n' + ' mini_billboard_2_end_time: int = 0\n' + ' mini_billboard_3_texture: Optional[ba.Texture] = None\n' + ' mini_billboard_3_start_time: int = 0\n' + ' mini_billboard_3_end_time: int = 0\n' + ' boxing_gloves_flashing: bool = False\n' + ' dead: bool = False\n' + ' floor_reflection: bool = False\n' + ' debris_friction: float = 0.0\n' + ' debris_kill_height: float = 0.0\n' + ' vr_near_clip: float = 0.0\n' + ' shadow_ortho: bool = False\n' + ' happy_thoughts_mode: bool = False\n' + ' shadow_offset: Sequence[float] = (0.0, 0.0)\n' + ' paused: bool = False\n' + ' time: int = 0\n' + ' ambient_color: Sequence[float] = (1.0, 1.0, 1.0)\n' + " camera_mode: str = 'rotate'\n" + ' frozen: bool = False\n' + ' area_of_interest_bounds: Sequence[float]' + ' = (-1, -1, -1, 1, 1, 1)\n' + ' shadow_range: Sequence[float] = (0, 0, 0, 0)\n' + " counter_text: str = ''\n" + ' counter_texture: Optional[ba.Texture] = None\n' + ' shattered: int = 0\n' + ' billboard_texture: Optional[ba.Texture] = None\n' + ' billboard_cross_out: bool = False\n' + ' billboard_opacity: float = 0.0\n' + ' slow_motion: bool = False\n' + " music: str = ''\n" + ' vr_camera_offset: Sequence[float] = (0.0, 0.0, 0.0)\n' + ' vr_overlay_center: Sequence[float] = (0.0, 0.0, 0.0)\n' + ' vr_overlay_center_enabled: bool = False\n' + ' vignette_outer: Sequence[float] = (0.0, 0.0)\n' + ' vignette_inner: Sequence[float] = (0.0, 0.0)\n' + ' tint: Sequence[float] = (1.0, 1.0, 1.0)\n') + + # Special case: need to be able to use the 'with' statement + # on some classes. + if classname in ['Context']: + out += ('\n' + ' def __enter__(self) -> None:\n' + ' """Support for "with" statement."""\n' + ' pass\n' + '\n' + ' def __exit__(self, exc_type: Any, exc_value: Any, ' + 'traceback: Any) -> Any:\n' + ' """Support for "with" statement."""\n' + ' pass\n') + return out + + +def _formatdoc(docstr: str, indent: int) -> str: + out = '' + indentstr = indent * ' ' + docslines = docstr.splitlines() + if len(docslines) == 1: + out += '\n' + indentstr + '"""' + docslines[0] + '"""\n' + else: + for i, line in enumerate(docslines): + if i != 0 and line != '': + docslines[i] = indentstr + line + out += ('\n' + indentstr + '"""' + '\n'.join(docslines) + '\n' + + indentstr + '"""\n') + return out + + +def _writeclasses(module: ModuleType, classnames: Sequence[str]) -> str: + # pylint: disable=too-many-branches + import types + from batools.gendocs import parse_docs_attrs + out = '' + for classname in classnames: + cls = getattr(module, classname) + if cls is None: + raise Exception('unexpected') + out += '\n' '\n' + + # Special case: + if classname == 'Vec3': + out += f'class {classname}(Sequence[float]):\n' + else: + out += f'class {classname}:\n' + + docstr = cls.__doc__ + out += _formatdoc(docstr, 4) + + # Create a public constructor if it has one. + # If the first docs line appears to be a function signature + # and not category or a usage statement ending with a period, + # assume it has a public constructor. + has_constructor = False + if ('category:' not in docstr.splitlines()[0].lower() + and not docstr.splitlines()[0].endswith('.') + and docstr != '(internal)'): + + # Ok.. looks like the first line is a signature. + # Make sure we've got a signature followed by a blank line. + if '\n\n' not in docstr: + raise Exception( + f'Constructor docstr missing empty line for {cls}.') + sig = docstr.split('\n\n')[0].replace('\n', ' ').strip() + + # Sanity check - make sure name is in the sig. + if classname + '(' not in sig: + raise Exception( + f'Class name not found in constructor sig for {cls}.') + sig = sig.replace(classname + '(', '__init__(self, ') + out += ' def ' + sig + ':\n pass\n' + has_constructor = True + + # Scan its doc-string for attribute info; drop in typed + # declarations for any that we find. + attrs: List[AttributeInfo] = [] + parse_docs_attrs(attrs, docstr) + if attrs: + for attr in attrs: + if attr.attr_type is not None: + out += f' {attr.name}: {attr.attr_type}\n' + else: + raise Exception(f'Found untyped attr in' + f' {classname} docs: {attr.name}') + + # Special cases such as attributes we add. + out += _special_class_cases(classname) + + # Print its methods. + funcnames = [] + for entry in (e for e in dir(cls) if not e.startswith('__')): + if isinstance(getattr(cls, entry), types.MethodDescriptorType): + funcnames.append(entry) + else: + raise Exception(f'Unhandled obj {entry} in {cls}') + funcnames.sort() + functxt = _writefuncs(cls, + funcnames, + indent=4, + spacing=1, + as_method=True) + if functxt == '' and not has_constructor: + out += ' pass\n' + else: + out += functxt + + return out + + +def generate(sources_hash: str, outfilename: str) -> None: + """Run the actual generation from within the game.""" + import _ba as module + from efrotools import get_public_license, PYVER + import types + funcnames = [] + classnames = [] + for entry in (e for e in dir(module) if not e.startswith('__')): + if isinstance(getattr(module, entry), types.BuiltinFunctionType): + funcnames.append(entry) + elif isinstance(getattr(module, entry), type): + classnames.append(entry) + elif entry == 'app': + # Ignore _ba.app. + continue + else: + raise Exception(f'found unknown obj {entry}') + funcnames.sort() + classnames.sort() + out = (get_public_license('python') + + '\n' + '#\n' + '"""A dummy stub module for the real _ba.\n' + '\n' + 'The real _ba is a compiled extension module and only available\n' + 'in the live game. This dummy module allows Pylint/Mypy/etc. to\n' + 'function reasonably well outside of the game.\n' + '\n' + 'Make sure this file is never included in an actual game distro!\n' + '\n' + 'Ideally this should be a stub (.pyi) file, but we\'d need\n' + 'to make sure that it still works with all our tools\n' + '(mypy, pylint, pycharm).\n' + '\n' + 'NOTE: This file was autogenerated by ' + __name__ + '; ' + 'do not edit by hand.\n' + '"""\n' + '\n' + # '# (hash we can use to see if this file is out of date)\n' + # '# SOURCES_HASH='+sources_hash+'\n' + # '\n' + '# I\'m sorry Pylint. I know this file saddens you. Be strong.\n' + '# pylint: disable=useless-suppression\n' + '# pylint: disable=unnecessary-pass\n' + '# pylint: disable=unused-argument\n' + '# pylint: disable=missing-docstring\n' + '# pylint: disable=too-many-locals\n' + '# pylint: disable=redefined-builtin\n' + '# pylint: disable=too-many-lines\n' + '# pylint: disable=redefined-outer-name\n' + '# pylint: disable=invalid-name\n' + '# pylint: disable=no-self-use\n' + '# pylint: disable=no-value-for-parameter\n' + '\n' + 'from __future__ import annotations\n' + '\n' + 'from typing import TYPE_CHECKING, overload, Sequence, TypeVar\n' + '\n' + 'from ba._enums import TimeFormat, TimeType\n' + '\n' + 'if TYPE_CHECKING:\n' + ' from typing import (Any, Dict, Callable, Tuple, ' + ' List, Optional, Union, List, Type, Literal)\n' + ' from ba._app import App\n' + ' import ba\n' + '\n' + '\n' + "_T = TypeVar('_T')\n" + '\n' + 'app: App\n' + '\n' + 'def _uninferrable() -> Any:\n' + ' """Get an "Any" in mypy and "uninferrable" in Pylint."""\n' + ' # pylint: disable=undefined-variable\n' + ' return _not_a_real_variable # type: ignore' + '\n' + '\n' + ) # yapf: disable + + out += _writeclasses(module, classnames) + out += _writefuncs(module, funcnames, indent=0, spacing=2, as_method=False) + + outhashpath = os.path.join(os.path.dirname(outfilename), + '._ba_sources_hash') + + with open(outfilename, 'w') as outfile: + outfile.write(out) + + with open(outhashpath, 'w') as outfile: + outfile.write(sources_hash) + + # Lastly, format it. + subprocess.run([f'python{PYVER}', '-m', 'yapf', '--in-place', outfilename], + check=True) + + +def _dummy_module_dirty() -> Tuple[bool, str]: + """Test hashes on the dummy module to see if it needs updates.""" + + # Let's generate a hash from all sources under the python source dir. + pysources = [] + exts = ['.cc', '.c', '.h'] + for root, _dirs, files in os.walk('src/ballistica/python'): + for fname in files: + if any(fname.endswith(ext) for ext in exts): + pysources.append(os.path.join(root, fname)) + + # Also lets add this script so we re-create when it changes. + pysources.append(__file__) + + outpath = 'assets/src/ba_data/python/._ba_sources_hash' + if not os.path.exists(outpath): + existing_hash = '' + else: + with open(outpath) as infile: + existing_hash = infile.read() + + pysources.sort() + + # Note: returning plain integers instead of hex so linters + # don't see words and give spelling errors. + pysources_hash = get_files_hash(pysources, int_only=True) + dirty = existing_hash != pysources_hash + return dirty, pysources_hash + + +def update(projroot: str, check: bool, force: bool) -> None: + """Update the dummy module as needed.""" + toolsdir = os.path.abspath(os.path.join(projroot, 'tools')) + + outfilename = os.path.abspath( + os.path.join(projroot, 'assets/src/ba_data/python/_ba.py')) + + # Make sure we're running from the project root dir. + os.chdir(projroot) + + # Force makes no sense in check mode. + if force and check: + raise Exception('cannot specify both force and check mode') + + dirty, sources_hash = _dummy_module_dirty() + + if dirty: + if check: + print(f'{Clr.RED}ERROR: dummy _ba module' + f' is out of date.{Clr.RST}') + sys.exit(255) + elif not force: + # Dummy module is clean and force is off; we're done here. + print('Dummy module _ba.py is up to date.') + sys.exit(0) + + print(f'{Clr.MAG}Updating _ba.py Dummy Module...{Clr.RST}') + + # 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 ballistica and exec ourself from within it. + print('Launching ballisticacore to generate dummy-module...') + try: + subprocess.run( + [ + './ballisticacore', + '-exec', + f'try:\n' + f' import sys\n' + f' sys.path.append("{toolsdir}")\n' + f' from batools import dummymodule\n' + f' dummymodule.generate(sources_hash="{sources_hash}",\n' + f' outfilename="{outfilename}")\n' + f' ba.quit()\n' + f'except Exception as exc:\n' + f' import sys\n' + f' import traceback\n' + f' print("ERROR GENERATING DUMMY MODULE")\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 dummy-module generation failed.') from exc2 + + print('Dummy-module generation complete.') diff --git a/tools/batools/pcommand.py b/tools/batools/pcommand.py index 3c1e55e8..fd19962e 100644 --- a/tools/batools/pcommand.py +++ b/tools/batools/pcommand.py @@ -901,3 +901,11 @@ def update_python_enums_module() -> None: """Update our procedurally generated python enums.""" from batools.pythonenumsmodule import update update(projroot=str(PROJROOT), check='--check' in sys.argv) + + +def update_dummy_module() -> None: + """Update our _ba dummy module.""" + from batools.dummymodule import update + update(projroot=str(PROJROOT), + check='--check' in sys.argv, + force='--force' in sys.argv) diff --git a/tools/batools/updateproject.py b/tools/batools/updateproject.py index 1587541a..1a8d96b9 100755 --- a/tools/batools/updateproject.py +++ b/tools/batools/updateproject.py @@ -163,29 +163,6 @@ class Updater: self._internal_source_dirs = set(sources) return self._internal_source_dirs - def _update_dummy_module(self) -> None: - # Update our dummy _ba module. - # 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. - if os.path.exists('tools/gendummymodule.py'): - if os.system('tools/gendummymodule.py' + self._checkarg) != 0: - print( - f'{Clr.RED}Error checking/updating dummy module.{Clr.RST}') - sys.exit(255) - - def _update_docs_md(self) -> None: - # Update our docs/*.md files. - # 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. - 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: # This will update our prereqs which may include compile-commands @@ -701,3 +678,31 @@ class Updater: except Exception as exc: raise CleanError( 'Error checking/updating python enums module.') from exc + + def _update_dummy_module(self) -> None: + # Update our dummy _ba module. + # Note: This should happen near the end because it may run the cmake + # build so its success may depend on the cmake build files having + # already been updated. + + # FIXME: should support running this in public too. + if not self._public: + try: + subprocess.run(['tools/pcommand', 'update_dummy_module'] + + self._checkarglist, + check=True) + except Exception as exc: + raise CleanError( + 'Error checking/updating dummy module.') from exc + + def _update_docs_md(self) -> None: + # Update our docs/*.md files. + # 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. + try: + subprocess.run(['tools/pcommand', 'update_docs_md'] + + self._checkarglist, + check=True) + except Exception as exc: + raise CleanError('Error checking/updating docs') from exc diff --git a/tools/pcommand b/tools/pcommand index b99589e9..0835f818 100755 --- a/tools/pcommand +++ b/tools/pcommand @@ -41,7 +41,7 @@ from batools.pcommand import ( cmake_prep_dir, gen_binding_code, gen_flat_data_code, wsl_path_to_win, wsl_build_check_win_drive, win_ci_binary_build, genchangelog, android_sdk_utils, update_resources_makefile, update_meta_makefile, - xcode_build_path, update_python_enums_module) + xcode_build_path, update_python_enums_module, update_dummy_module) # pylint: enable=unused-import if TYPE_CHECKING: