Source code for batools.pcommands2

# Released under the MIT License. See LICENSE for details.
#
"""A nice collection of ready-to-use pcommands for this package."""
from __future__ import annotations

# Note: import as little as possible here at the module level to
# keep launch times fast for small snippets.
import sys

from efrotools import pcommand


[docs] def gen_monolithic_register_modules() -> None: """Generate .h file for registering py modules.""" import os import textwrap from efro.error import CleanError from batools.featureset import FeatureSet pcommand.disallow_in_batch() if len(sys.argv) != 3: raise CleanError('Expected 1 arg.') outpath = sys.argv[2] featuresets = FeatureSet.get_all_for_project(str(pcommand.PROJROOT)) # Filter out ones without native modules. featuresets = [f for f in featuresets if f.has_python_binary_module] pymodulenames = sorted(f.name_python_binary_module for f in featuresets) def initname(mname: str) -> str: # plus is a special case since we need to define that symbol # ourself. return f'DoPyInit_{mname}' if mname == '_baplus' else f'PyInit_{mname}' extern_def_code = '\n'.join( f'auto {initname(n)}() -> PyObject*;' for n in pymodulenames ) py_register_code = '\n'.join( f'PyImport_AppendInittab("{n}", &{initname(n)});' for n in pymodulenames ) if '_baplus' in pymodulenames: init_plus_code = ( '\n' '// Slight hack: because we are currently building baplus as a' ' static module\n' '// and linking it in, symbols exported there (namely' ' PyInit__baplus) do not\n' '// seem to be available through us when we are compiled as' ' a dynamic\n' '// library. This leads to Python being unable to load baplus.' ' While I\'m sure\n' '// there is some way to get those symbols exported, I\'m worried' ' it might be\n' '// a messy platform-specific affair. So instead we\'re just' ' defining that\n' '// function here when baplus is present and forwarding it through' ' to the\n' '// static library version.\n' 'extern "C" auto PyInit__baplus() -> PyObject* {\n' ' return DoPyInit__baplus();\n' '}\n' ) else: init_plus_code = '' base_code = """ // Released under the MIT License. See LICENSE for details. #ifndef BALLISTICA_CORE_MGEN_PYTHON_MODULES_MONOLITHIC_H_ #define BALLISTICA_CORE_MGEN_PYTHON_MODULES_MONOLITHIC_H_ // THIS CODE IS AUTOGENERATED BY META BUILD; DO NOT EDIT BY HAND. #include "ballistica/shared/ballistica.h" #include "ballistica/shared/python/python_sys.h" extern "C" { ${EXTERN_DEF_CODE} } namespace ballistica { /// Register init calls for all of our built-in Python modules. /// Should only be used in monolithic builds. In modular builds /// binary modules get located as .so files on disk as per regular /// Python behavior. void MonolithicRegisterPythonModules() { if (g_buildconfig.monolithic_build()) { ${PY_REGISTER_CODE} } else { FatalError( "MonolithicRegisterPythonModules should not be called" " in modular builds."); } } ${PY_INIT_PLUS} } // namespace ballistica #endif // BALLISTICA_CORE_MGEN_PYTHON_MODULES_MONOLITHIC_H_ """ out = ( textwrap.dedent(base_code) .replace('${EXTERN_DEF_CODE}', extern_def_code) .replace( '${PY_REGISTER_CODE}', textwrap.indent(py_register_code, ' ') ) .replace('${PY_INIT_PLUS}', init_plus_code) .strip() + '\n' ) os.makedirs(os.path.dirname(outpath), exist_ok=True) with open(outpath, 'w', encoding='utf-8') as outfile: outfile.write(out)
[docs] def py_examine() -> None: """Run a python examination at a given point in a given file.""" import os from pathlib import Path import efrotools.emacs pcommand.disallow_in_batch() if len(sys.argv) != 7: print('ERROR: expected 7 args') sys.exit(255) filename = Path(sys.argv[2]) line = int(sys.argv[3]) column = int(sys.argv[4]) selection: str | None = None if sys.argv[5] == '' else sys.argv[5] operation = sys.argv[6] # This stuff assumes it is being run from project root. os.chdir(pcommand.PROJROOT) # Set up pypaths so our main distro stuff works. scriptsdir = os.path.abspath( os.path.join( os.path.dirname(sys.argv[0]), '../src/assets/ba_data/python' ) ) toolsdir = os.path.abspath( os.path.join(os.path.dirname(sys.argv[0]), '../tools') ) if scriptsdir not in sys.path: sys.path.append(scriptsdir) if toolsdir not in sys.path: sys.path.append(toolsdir) efrotools.emacs.py_examine( pcommand.PROJROOT, filename, line, column, selection, operation )
[docs] def clean_orphaned_assets() -> None: """Remove asset files that are no longer part of the build.""" import os import json import subprocess pcommand.disallow_in_batch() # Operate from dist root.. os.chdir(pcommand.PROJROOT) # Our manifest is split into 2 files (public and private) with open( 'src/assets/.asset_manifest_public.json', encoding='utf-8' ) as infile: manifest = set(json.loads(infile.read())) with open( 'src/assets/.asset_manifest_private.json', encoding='utf-8' ) as infile: manifest.update(set(json.loads(infile.read()))) for root, _dirs, fnames in os.walk('build/assets'): for fname in fnames: fpath = os.path.join(root, fname) fpathrel = fpath[13:] # paths are relative to build/assets if fpathrel not in manifest: print(f'Removing orphaned asset file: {fpath}') os.unlink(fpath) # Lastly, clear empty dirs. subprocess.run( 'find build/assets -depth -empty -type d -delete', shell=True, check=True, )
[docs] def win_ci_install_prereqs() -> None: """Install bits needed for basic win ci.""" import json from efrotools.efrocache import get_target pcommand.disallow_in_batch() # We'll need to pull a handful of things out of efrocache for the # build to succeed. Normally this would happen through our Makefile # targets but we can't use them under raw Windows so we need to just # hard-code whatever we need here. lib_dbg_win32 = 'build/prefab/lib/windows/Debug_Win32' needed_targets: set[str] = { f'{lib_dbg_win32}/BallisticaKitGenericPlus.lib', f'{lib_dbg_win32}/BallisticaKitGenericPlus.pdb', 'ballisticakit-windows/Generic/BallisticaKit.ico', } # Look through everything that gets generated by our meta builds # and pick out anything we need for our basic builds/tests. with open( 'src/meta/.meta_manifest_public.json', encoding='utf-8' ) as infile: meta_public: list[str] = json.loads(infile.read()) with open( 'src/meta/.meta_manifest_private.json', encoding='utf-8' ) as infile: meta_private: list[str] = json.loads(infile.read()) for target in meta_public + meta_private: if (target.startswith('src/ballistica/') and '/mgen/' in target) or ( target.startswith('src/assets/ba_data/python/') and '/_mgen/' in target ): needed_targets.add(target) for target in needed_targets: get_target(target, batch=pcommand.is_batch(), clr=pcommand.clr())
[docs] def win_ci_binary_build() -> None: """Simple windows binary build for ci.""" import subprocess pcommand.disallow_in_batch() # Do the thing. subprocess.run( [ 'C:\\Program Files\\Microsoft Visual Studio\\2022\\' 'Enterprise\\MSBuild\\Current\\Bin\\MSBuild.exe', 'ballisticakit-windows\\Generic\\BallisticaKitGeneric.vcxproj', '-target:Build', '-property:Configuration=Debug', '-property:Platform=Win32', '-property:VisualStudioVersion=17', ], check=True, )
[docs] def update_cmake_prefab_lib() -> None: """Update prefab internal libs; run as part of a build.""" import subprocess import os from efro.error import CleanError from batools.build import PrefabPlatform pcommand.disallow_in_batch() if len(sys.argv) != 5: raise CleanError( 'Expected 3 args (standard/server, debug/release, build-dir)' ) buildtype = sys.argv[2] mode = sys.argv[3] builddir = sys.argv[4] if buildtype not in {'standard', 'server'}: raise CleanError(f'Invalid buildtype: {buildtype}') if mode not in {'debug', 'release'}: raise CleanError(f'Invalid mode: {mode}') # Our 'cmake' build targets use the Linux side of WSL; not native # Windows. platform = PrefabPlatform.get_current(wsl_targets_windows=False) suffix = '_server' if buildtype == 'server' else '_gui' target = ( f'build/prefab/lib/{platform.value}{suffix}/{mode}/libballisticaplus.a' ) # Build the target and then copy it to dst if it doesn't exist there # yet or the existing one is older than our target. subprocess.run(['make', target], check=True) libdir = os.path.join(builddir, 'prefablib') libpath = os.path.join(libdir, 'libballisticaplus.a') update = True time1 = os.path.getmtime(target) if os.path.exists(libpath): time2 = os.path.getmtime(libpath) if time1 <= time2: update = False if update: if not os.path.exists(libdir): os.makedirs(libdir, exist_ok=True) subprocess.run(['cp', target, libdir], check=True)
[docs] def android_archive_unstripped_libs() -> None: """Copy libs to a build archive.""" import subprocess from pathlib import Path from efro.error import CleanError from efro.terminal import Clr pcommand.disallow_in_batch() if len(sys.argv) != 4: raise CleanError('Expected 2 args; src-dir and dst-dir') src = Path(sys.argv[2]) dst = Path(sys.argv[3]) if dst.exists(): subprocess.run(['rm', '-rf', dst], check=True) dst.mkdir(parents=True, exist_ok=True) if not src.is_dir(): raise CleanError(f"Source dir not found: '{src}'") libname = 'libmain' libext = '.so' for abi, abishort in [ ('armeabi-v7a', 'arm'), ('arm64-v8a', 'arm64'), ('x86', 'x86'), ('x86_64', 'x86-64'), ]: srcpath = Path(src, abi, libname + libext) dstname = f'{libname}_{abishort}{libext}' dstpath = Path(dst, dstname) if srcpath.exists(): print(f'Archiving unstripped library: {Clr.BLD}{dstname}{Clr.RST}') subprocess.run(['cp', srcpath, dstpath], check=True) subprocess.run( ['tar', '-zcf', dstname + '.tgz', dstname], cwd=dst, check=True ) subprocess.run(['rm', dstpath], check=True)
[docs] def spinoff_test() -> None: """Test spinoff functionality.""" import batools.spinoff batools.spinoff.spinoff_test(sys.argv[2:])
[docs] def spinoff_check_submodule_parent() -> None: """Make sure this dst proj has a submodule parent.""" import os from efro.error import CleanError pcommand.disallow_in_batch() # Make sure we're a spinoff dst project. The spinoff command will be # a symlink if this is the case. if not os.path.exists('tools/spinoff'): raise CleanError( 'This does not appear to be a spinoff-enabled project.' ) if not os.path.islink('tools/spinoff'): raise CleanError('This project is a spinoff parent; we require a dst.') if not os.path.isdir('submodules/ballistica'): raise CleanError( 'This project is not using a submodule for its parent.\n' 'To set one up, run `tools/spinoff add-submodule-parent`' )
[docs] def gen_python_init_module() -> None: """Generate a basic __init__.py.""" import os from efro.error import CleanError from efro.terminal import Clr from batools.project import project_centric_path pcommand.disallow_in_batch() if len(sys.argv) != 3: raise CleanError('Expected an outfile arg.') outfilename = sys.argv[2] os.makedirs(os.path.dirname(outfilename), exist_ok=True) prettypath = project_centric_path( projroot=str(pcommand.PROJROOT), path=outfilename ) print(f'Meta-building {Clr.BLD}{prettypath}{Clr.RST}') with open(outfilename, 'w', encoding='utf-8') as outfile: outfile.write( '# Released under the MIT License.' ' See LICENSE for details.\n' '#\n' )
[docs] def tests_warm_start() -> None: """Warm-start some stuff needed by tests. This keeps logs clearer by showing any binary builds/downloads we need to do instead of having those silently happen as part of tests. """ from batools import apprun pcommand.disallow_in_batch() # We do lots of apprun.python_command() within test. Pre-build the # binary that they need to do their thing. if not apprun.test_runs_disabled(): apprun.acquire_binary_for_python_command(purpose='running tests')
[docs] def wsl_build_check_win_drive() -> None: """Make sure we're building on a windows drive.""" import os import subprocess import textwrap from efro.error import CleanError from efrotools.util import ( is_wsl_windows_build_path, wsl_windows_build_path_description, ) # We use env vars to influence our behavior and thus can't support # batch. pcommand.disallow_in_batch() if ( subprocess.run( ['which', 'wslpath'], check=False, capture_output=True ).returncode != 0 ): raise CleanError( "'wslpath' not found. This does not seem to be a WSL environment." ) if os.environ.get('WSL_BUILD_CHECK_WIN_DRIVE_IGNORE') == '1': return nativepath = os.getcwd() # Get a windows path to the current dir. winpath = ( subprocess.run( ['wslpath', '-w', '-a', nativepath], capture_output=True, check=True, ) .stdout.decode() .strip() ) def _wrap(txt: str) -> str: return textwrap.fill(txt, 76) # If we're sitting under the linux filesystem, our path will start # with '\\wsl$' or '\\wsl.localhost' or '\\wsl\'; fail in that case # and explain why. if any( winpath.startswith(x) for x in ['\\\\wsl$', '\\\\wsl.', '\\\\wsl\\'] ): raise CleanError( '\n\n'.join( [ _wrap( 'ERROR: This project appears to live' ' on the Linux filesystem.' ), _wrap( 'Visual Studio compiles will error here' ' for reasons related' ' to Linux filesystem case-sensitivity, and thus are' ' disallowed.' ' Clone the repo to a location that maps to a native' ' Windows drive such as \'/mnt/c/ballistica\'' ' and try again.' ), _wrap( 'Note that WSL2 filesystem performance' ' is poor when accessing' ' native Windows drives,' ' so if Visual Studio builds are not' ' needed it may be best to keep things here' ' on the Linux filesystem.' ' This behavior may differ under WSL1 (untested).' ), _wrap( 'Set env-var WSL_BUILD_CHECK_WIN_DRIVE_IGNORE=1 to skip' ' this check.' ), ] ) ) # We also now require this check to be true. We key off this same # check in other places to introduce various workarounds to deal # with funky permissions issues/etc. # # Note that we could rely on *only* this check, but it might be nice # to leave the above one in as well to better explain the Linux # filesystem situation. if not is_wsl_windows_build_path(nativepath): reqs = wsl_windows_build_path_description() raise CleanError( '\n\n'.join( [ _wrap( f'ERROR: This project\'s path ({nativepath})' f' is not valid for WSL Windows builds.' f' Path must be: {reqs}.' ) ] ) )
[docs] def wsl_path_to_win() -> None: """Forward escape slashes in a provided win path arg.""" import subprocess import logging import os from efro.error import CleanError pcommand.disallow_in_batch() try: create = False escape = False if len(sys.argv) < 3: raise CleanError('Expected at least 1 path arg.') wsl_path: str | None = None for arg in sys.argv[2:]: if arg == '--create': create = True elif arg == '--escape': escape = True else: if wsl_path is not None: raise CleanError('More than one path provided.') wsl_path = arg if wsl_path is None: raise CleanError('No path provided.') # wslpath fails on nonexistent paths; make it clear when that happens. if create: os.makedirs(wsl_path, exist_ok=True) if not os.path.exists(wsl_path): raise CleanError(f'Path \'{wsl_path}\' does not exist.') results = subprocess.run( ['wslpath', '-w', '-a', wsl_path], capture_output=True, check=True ) except Exception: # This gets used in a makefile so our returncode is ignored; # let's try to make our failure known in other ways. logging.exception('wsl_to_escaped_win_path failed.') print('wsl_to_escaped_win_path_error_occurred', end='') return out = results.stdout.decode().strip() # If our input ended with a slash, match in the output. if wsl_path.endswith('/') and not out.endswith('\\'): out += '\\' if escape: out = out.replace('\\', '\\\\') print(out, end='')
[docs] def get_modern_make() -> None: """Print name of a modern make command.""" import platform import subprocess pcommand.disallow_in_batch() # Mac gnu make is outdated (due to newer versions using GPL3 I believe). # so let's return 'gmake' there which will point to homebrew make which # should be up to date. if platform.system() == 'Darwin': if ( subprocess.run( ['which', 'gmake'], check=False, capture_output=True ).returncode != 0 ): print( 'WARNING: this requires gmake (mac system make is too old).' " Install it with 'brew install make'", file=sys.stderr, flush=True, ) print('gmake') else: print('make')
[docs] def asset_package_resolve() -> None: """Resolve exact asset-package-version we'll use (if any).""" import os from efro.error import CleanError from efrotools.project import getprojectconfig pcommand.disallow_in_batch() args = pcommand.get_args() if len(args) != 1: raise CleanError('Expected 1 arg.') resolve_path = args[0] apversion = getprojectconfig(pcommand.PROJROOT).get('assets') if apversion is None: raise CleanError("No 'assets' value found in projectconfig.") splits = apversion.split('.') if len(splits) != 3: raise CleanError( f"'{apversion}' is not a valid asset-package-version id." ) # 'dev' versions are a special case; in that case we don't create # a resolve file, which effectively causes our manifest fetch logic # to run each time. if splits[2] == 'dev': if os.path.exists(resolve_path): os.unlink(resolve_path) else: with open(resolve_path, 'w', encoding='utf-8') as outfile: outfile.write(apversion)
[docs] def asset_package_assemble() -> None: """Assemble asset package data and its manifest.""" import os import subprocess from efro.error import CleanError from efrotools.project import getprojectconfig pcommand.disallow_in_batch() args = pcommand.get_args() if len(args) != 2: raise CleanError('Expected 2 args.') resolve_path, flavor = args # If resolve path exists, it is the exact asset-package-version we # should use. apversion: str | None if os.path.exists(resolve_path): with open(resolve_path, encoding='utf-8') as infile: apversion = infile.read() else: # If there's no resolve file, look up the value directly from # project-config. Generally this means it's set to a dev # version. apversion = getprojectconfig(pcommand.PROJROOT).get('assets') if not isinstance(apversion, str): raise CleanError( f'Expected a string asset-package-version; got {type(apversion)}.' ) try: subprocess.run( [ f'{pcommand.PROJROOT}/tools/bacloud', 'assetpackage', '_assemble', apversion, flavor, ], check=True, ) except Exception as exc: raise CleanError( f'Failed to assemble {apversion}' f' ({flavor} flavor).' ) from exc