diff --git a/.idea/dictionaries/ericf.xml b/.idea/dictionaries/ericf.xml
index 94289c2c..8a4c77a9 100644
--- a/.idea/dictionaries/ericf.xml
+++ b/.idea/dictionaries/ericf.xml
@@ -1341,6 +1341,7 @@
megalint
memfunctions
menubar
+ metamakefile
metaprogramming
metascan
meteorshower
diff --git a/assets/src/ba_data/python/ba/_enums.py b/assets/src/ba_data/python/ba/_enums.py
index 7940bcfd..ea937b50 100644
--- a/assets/src/ba_data/python/ba/_enums.py
+++ b/assets/src/ba_data/python/ba/_enums.py
@@ -1,5 +1,5 @@
# Released under the MIT License. See LICENSE for details.
-"""Enums generated by tools/update_python_enums_module in ba-internal."""
+"""Enum vals generated by batools.pythonenumsmodule; do not edit by hand."""
from enum import Enum
diff --git a/ballisticacore-cmake/.idea/dictionaries/ericf.xml b/ballisticacore-cmake/.idea/dictionaries/ericf.xml
index eb2adef3..584d9868 100644
--- a/ballisticacore-cmake/.idea/dictionaries/ericf.xml
+++ b/ballisticacore-cmake/.idea/dictionaries/ericf.xml
@@ -607,6 +607,7 @@
memcpy
meshdata
messagebox
+ metamakefile
meth
mhbegin
mhend
diff --git a/tools/batools/pcommand.py b/tools/batools/pcommand.py
index 682859c4..3c1e55e8 100644
--- a/tools/batools/pcommand.py
+++ b/tools/batools/pcommand.py
@@ -895,3 +895,9 @@ def xcode_build_path() -> None:
project_path=project_path,
configuration=configuration)
print(path)
+
+
+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)
diff --git a/tools/batools/pythonenumsmodule.py b/tools/batools/pythonenumsmodule.py
new file mode 100755
index 00000000..c7aa690a
--- /dev/null
+++ b/tools/batools/pythonenumsmodule.py
@@ -0,0 +1,172 @@
+#!/usr/bin/env python3.8
+# Released under the MIT License. See LICENSE for details.
+#
+"""Procedurally regenerates our python enums module.
+
+This scans core/types.h for tagged C++ enum types and generates corresponding
+python enums for them.
+"""
+from __future__ import annotations
+
+import os
+import re
+import sys
+from typing import TYPE_CHECKING
+
+from efro.terminal import Clr
+from efrotools import get_public_license
+
+if TYPE_CHECKING:
+ from typing import Optional, List, Tuple
+
+OUTPUT_FILENAME = 'assets/src/ba_data/python/ba/_enums.py'
+
+
+def camel_case_convert(name: str) -> str:
+ """Convert camel-case text to upcase-with-underscores."""
+ str1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name)
+ return re.sub('([a-z0-9])([A-Z])', r'\1_\2', str1).upper()
+
+
+def _gen_enums() -> str:
+ out = ''
+ enum_lnums: List[int] = []
+ with open('src/ballistica/core/types.h') as infile:
+ lines = infile.read().splitlines()
+
+ # Tally up all places tagged for exporting python enums.
+ for i, line in enumerate(lines):
+ if '// BA_EXPORT_PYTHON_ENUM' in line:
+ enum_lnums.append(i + 1)
+
+ # Now export each of them.
+ for lnum in enum_lnums:
+
+ doclines, lnum = _parse_doc_lines(lines, lnum)
+ enum_name = _parse_name(lines, lnum)
+
+ out += f'\n\nclass {enum_name}(Enum):\n """'
+ out += '\n '.join(doclines)
+ if len(doclines) > 1:
+ out += '\n """\n'
+ else:
+ out += '"""\n'
+
+ lnumend = _find_enum_end(lines, lnum)
+ out = _parse_values(lines, lnum, lnumend, out)
+
+ # Clear lines with only spaces.
+ return ('\n'.join('' if line == ' ' else line
+ for line in out.splitlines()) + '\n')
+
+
+def _parse_name(lines: List[str], lnum: int) -> str:
+ bits = lines[lnum].split(' ')
+ if (len(bits) != 4 or bits[0] != 'enum' or bits[1] != 'class'
+ or bits[3] != '{'):
+ raise Exception(f'Unexpected format for enum on line {lnum + 1}.')
+ enum_name = bits[2]
+ return enum_name
+
+
+def _parse_values(lines: List[str], lnum: int, lnumend: int, out: str) -> str:
+ val = 0
+ for i in range(lnum + 1, lnumend):
+ line = lines[i]
+ if line.strip().startswith('//'):
+ continue
+
+ # Strip off any trailing comment.
+ if '//' in line:
+ line = line.split('//')[0].strip()
+
+ # Strip off any trailing comma.
+ if line.endswith(','):
+ line = line[:-1].strip()
+
+ # If they're explicitly assigning a value, parse it.
+ if '=' in line:
+ splits = line.split()
+ if (len(splits) != 3 or splits[1] != '='
+ or not splits[2].isnumeric()):
+ raise RuntimeError(f'Unable to parse enum value for: {line}')
+ name = splits[0]
+ val = int(splits[2])
+ else:
+ name = line
+
+ # name = line.split(',')[0].split('//')[0].strip()
+ if not name.startswith('k') or len(name) < 2:
+ raise RuntimeError(f"Expected name to start with 'k'; got {name}")
+
+ # We require kLast to be the final value
+ # (C++ requires this for bounds checking)
+ if i == lnumend - 1:
+ if name != 'kLast':
+ raise RuntimeError(
+ f'Expected last enum value of kLast; found {name}.')
+ continue
+ name = camel_case_convert(name[1:])
+ out += f' {name} = {val}\n'
+ val += 1
+ return out
+
+
+def _find_enum_end(lines: List[str], lnum: int) -> int:
+ lnumend = lnum + 1
+ while True:
+ if lnumend > len(lines) - 1:
+ raise Exception(f'No end found for enum on line {lnum + 1}.')
+ if '};' in lines[lnumend]:
+ break
+ lnumend += 1
+ return lnumend
+
+
+def _parse_doc_lines(lines: List[str], lnum: int) -> Tuple[List[str], int]:
+
+ # First parse the doc-string
+ doclines: List[str] = []
+ lnumorig = lnum
+ while True:
+ if lnum > len(lines) - 1:
+ raise Exception(
+ f'No end found for enum docstr line {lnumorig + 1}.')
+ if lines[lnum].startswith('enum class '):
+ break
+ if not lines[lnum].startswith('///'):
+ raise Exception(f'Invalid docstr at line {lnum + 1}.')
+ doclines.append(lines[lnum][4:])
+ lnum += 1
+ return doclines, lnum
+
+
+def update(projroot: str, check: bool) -> None:
+ """Main script entry point."""
+
+ # Operate out of root dist dir for consistency.
+ os.chdir(projroot)
+
+ fname = OUTPUT_FILENAME
+ existing: Optional[str]
+ try:
+ with open(fname, 'r') as infile:
+ existing = infile.read()
+ except Exception:
+ existing = None
+
+ out = (get_public_license('python') +
+ f'\n"""Enum vals generated by {__name__}; do not edit by hand."""'
+ f'\n\nfrom enum import Enum\n')
+
+ out += _gen_enums()
+
+ if out == existing:
+ print('Python enums module is up to date.')
+ else:
+ if check:
+ print(Clr.SRED + 'ERROR: file out of date: ' + fname + Clr.RST)
+ sys.exit(255)
+ print(Clr.SBLU + 'Generating: ' + fname + Clr.RST)
+ with open(fname, 'w') as outfile:
+ outfile.write(out)
diff --git a/tools/batools/updateproject.py b/tools/batools/updateproject.py
index 3f0795e0..1587541a 100755
--- a/tools/batools/updateproject.py
+++ b/tools/batools/updateproject.py
@@ -691,9 +691,13 @@ class Updater:
'Error checking/updating resources Makefile.') from exc
def _update_python_enums_module(self) -> None:
- if os.path.exists('tools/update_python_enums_module'):
- if os.system('tools/update_python_enums_module' +
- self._checkarg) != 0:
- print(f'{Clr.RED}Error checking/updating'
- f' python enums module.{Clr.RST}')
- sys.exit(255)
+ # FIXME: should support running this in public too.
+ if not self._public:
+ try:
+ subprocess.run(
+ ['tools/pcommand', 'update_python_enums_module'] +
+ self._checkarglist,
+ check=True)
+ except Exception as exc:
+ raise CleanError(
+ 'Error checking/updating python enums module.') from exc
diff --git a/tools/pcommand b/tools/pcommand
index 55b6e3f1..b99589e9 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)
+ xcode_build_path, update_python_enums_module)
# pylint: enable=unused-import
if TYPE_CHECKING: