mirror of
https://github.com/RYDE-WORK/ballistica.git
synced 2026-01-19 21:37:57 +08:00
771 lines
31 KiB
Python
Executable File
771 lines
31 KiB
Python
Executable File
# 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, Optional
|
|
from batools.docs 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) -> <varies>'):
|
|
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) -> <varies>'):
|
|
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)'
|
|
' -> <varies>'):
|
|
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) -> <varies>':
|
|
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) -> <varies>':
|
|
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 '<varies>' 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, funcname=funcname)
|
|
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,
|
|
funcname: Optional[str] = None) -> str:
|
|
out = ''
|
|
indentstr = indent * ' '
|
|
docslines = docstr.splitlines()
|
|
|
|
if (funcname and docslines and docslines[0]
|
|
and docslines[0].startswith(funcname)):
|
|
# Remove this signature from python docstring
|
|
# as not to repeat ourselves.
|
|
_, docstr = docstr.split('\n\n', maxsplit=1)
|
|
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.docs 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__
|
|
# classname is constructor name
|
|
out += _formatdoc(docstr, 4, funcname=classname)
|
|
|
|
# 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=use-dict-literal\n'
|
|
'# pylint: disable=use-list-literal\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._generated.enums import TimeFormat, TimeType\n'
|
|
'\n'
|
|
'if TYPE_CHECKING:\n'
|
|
' from typing import Any, Callable, Optional, Union, 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', encoding='utf-8') as outfile:
|
|
outfile.write(out)
|
|
|
|
with open(outhashpath, 'w', encoding='utf-8') 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, encoding='utf-8') as infile:
|
|
existing_hash = infile.read()
|
|
|
|
# Important to keep this deterministic...
|
|
pysources.sort()
|
|
|
|
# Note: going with plain integers instead of hex so linters
|
|
# don't see words and whine about 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.')
|