From 1c8e3fd01ed81af47b39ce7655f45d804b5d95a6 Mon Sep 17 00:00:00 2001 From: Eric Froemling Date: Thu, 11 Mar 2021 16:00:26 -0600 Subject: [PATCH] Added enum key support to ba.entity --- .idea/dictionaries/ericf.xml | 1 + .../.idea/dictionaries/ericf.xml | 1 + docs/ba_module.md | 2 +- tests/test_efro/test_entity.py | 49 ++++++++++++++- tools/efro/call.py | 2 + tools/efro/entity/_base.py | 20 ++++++- tools/efro/entity/_field.py | 51 ++++++++++++---- tools/efro/entity/_support.py | 59 ++++++++++--------- tools/efro/util.py | 8 +++ tools/efrotools/pybuild.py | 15 +++-- 10 files changed, 161 insertions(+), 47 deletions(-) diff --git a/.idea/dictionaries/ericf.xml b/.idea/dictionaries/ericf.xml index 03a9aeb3..6a62f7af 100644 --- a/.idea/dictionaries/ericf.xml +++ b/.idea/dictionaries/ericf.xml @@ -1098,6 +1098,7 @@ keepalives keepaway keeprefs + keyfilt keylayout keypresses keystr diff --git a/ballisticacore-cmake/.idea/dictionaries/ericf.xml b/ballisticacore-cmake/.idea/dictionaries/ericf.xml index b1e6c3ad..ebcc184b 100644 --- a/ballisticacore-cmake/.idea/dictionaries/ericf.xml +++ b/ballisticacore-cmake/.idea/dictionaries/ericf.xml @@ -479,6 +479,7 @@ jmessage keepalives keycode + keyfilt keysyms keywds khronos diff --git a/docs/ba_module.md b/docs/ba_module.md index 8a64610c..b1229d5b 100644 --- a/docs/ba_module.md +++ b/docs/ba_module.md @@ -1,5 +1,5 @@ -

last updated on 2021-03-03 for Ballistica version 1.6.0 build 20319

+

last updated on 2021-03-11 for Ballistica version 1.6.0 build 20323

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/tests/test_efro/test_entity.py b/tests/test_efro/test_entity.py index 88c32a57..1d47bc6d 100644 --- a/tests/test_efro/test_entity.py +++ b/tests/test_efro/test_entity.py @@ -28,6 +28,13 @@ class EnumTest(Enum): SECOND = 1 +@unique +class EnumTest2(Enum): + """Testing...""" + FIRST = 0 + SECOND = 1 + + class SubCompoundTest(entity.CompoundValue): """Testing...""" subval = entity.Field('b', entity.BoolValue()) @@ -58,12 +65,14 @@ class EntityTest(entity.Entity): slval = entity.ListField('sl', entity.StringValue()) tval2 = entity.Field('t2', entity.DateTimeValue()) str_int_dict = entity.DictField('sd', str, entity.IntValue()) + enum_int_dict = entity.DictField('ed', EnumTest, entity.IntValue()) compoundlist = entity.CompoundListField('l', CompoundTest()) compoundlist2 = entity.CompoundListField('l2', CompoundTest()) compoundlist3 = entity.CompoundListField('l3', CompoundTest2()) compounddict = entity.CompoundDictField('td', str, CompoundTest()) compounddict2 = entity.CompoundDictField('td2', str, CompoundTest()) compounddict3 = entity.CompoundDictField('td3', str, CompoundTest2()) + compounddict4 = entity.CompoundDictField('td4', EnumTest, CompoundTest()) fval2 = entity.Field('f2', entity.Float3Value()) @@ -117,6 +126,27 @@ def test_entity_values() -> None: assert static_type_equals(ent.str_int_dict['foo'], int) assert ent.str_int_dict['foo'] == 123 + # Simple dict with enum key. + ent.enum_int_dict[EnumTest.FIRST] = 234 + assert ent.enum_int_dict[EnumTest.FIRST] == 234 + # Set with incorrect key type should give TypeError. + with pytest.raises(TypeError): + ent.enum_int_dict[0] = 123 # type: ignore + with pytest.raises(TypeError): + ent.enum_int_dict[EnumTest2.FIRST] = 123 # type: ignore + # And set with incorrect value type should do same. + with pytest.raises(TypeError): + ent.enum_int_dict[EnumTest.FIRST] = 'bar' # type: ignore + # Make sure is stored as underlying type. + assert ent.d_data['ed'] == {0: 234} + + # Make sure invalid raw enum values are caught. + ent2 = EntityTest() + ent2.set_data({}) + ent2.set_data({'ed': {0: 111}}) + with pytest.raises(ValueError): + ent2.set_data({'ed': {5: 111}}) + # Waaah; this works at runtime, but it seems that we'd need # to have BoundDictField inherit from Mapping for mypy to accept this. # (which seems to get a bit ugly, but may be worth revisiting) @@ -164,7 +194,7 @@ def test_entity_values_2() -> None: with pytest.raises(TypeError): _cdval2 = ent.compounddict.add(1) # type: ignore # Hmm; should this throw a TypeError and not a KeyError?.. - with pytest.raises(KeyError): + with pytest.raises(TypeError): _cdval3 = ent.compounddict[1] # type: ignore assert static_type_equals(ent.compounddict['foo'], CompoundTest) @@ -172,7 +202,19 @@ def test_entity_values_2() -> None: with pytest.raises(ValueError): # noinspection PyTypeHints ent.enumval = None # type: ignore - assert ent.enumval == EnumTest.FIRST + assert ent.enumval is EnumTest.FIRST + + # Compound dict with enum key. + assert not ent.compounddict4 # bool operator + _cd4val = ent.compounddict4.add(EnumTest.FIRST) + assert ent.compounddict4 # bool operator + ent.compounddict4[EnumTest.FIRST].isubval = 222 + assert ent.compounddict4[EnumTest.FIRST].isubval == 222 + with pytest.raises(TypeError): + ent.compounddict4[0].isubval = 222 # type: ignore + assert static_type_equals(ent.compounddict4[EnumTest.FIRST], CompoundTest) + # Make sure enum keys are stored as underlying type. + assert ent.d_data['td4'] == {0: {'i': 222, 'l': []}} # Optional Enum value ent.enumval2 = None @@ -186,6 +228,9 @@ def test_entity_values_2() -> None: assert static_type_equals(ent.grp.compoundlist[0], SubCompoundTest) assert static_type_equals(ent.grp.compoundlist[0].subval, bool) + # Make sure we can digest the same data we spit out. + ent.set_data(ent.d_data) + def test_field_copies() -> None: """Test copying various values between fields.""" diff --git a/tools/efro/call.py b/tools/efro/call.py index 89080206..e83f22ad 100644 --- a/tools/efro/call.py +++ b/tools/efro/call.py @@ -264,3 +264,5 @@ if TYPE_CHECKING: # noinspection PyPep8Naming def Call(*_args: Any, **_keywds: Any) -> Any: ... + + Call = Call diff --git a/tools/efro/entity/_base.py b/tools/efro/entity/_base.py index 08789c39..9c6c7bd7 100644 --- a/tools/efro/entity/_base.py +++ b/tools/efro/entity/_base.py @@ -4,10 +4,28 @@ from __future__ import annotations +from enum import Enum from typing import TYPE_CHECKING if TYPE_CHECKING: - from typing import Any + from typing import Any, Type + + +def dict_key_to_raw(key: Any, keytype: Type) -> Any: + """Given a key value from the world, filter to stored key.""" + if not isinstance(key, keytype): + raise TypeError( + f'Invalid key type; expected {keytype}, got {type(key)}.') + if issubclass(keytype, Enum): + return key.value + return key + + +def dict_key_from_raw(key: Any, keytype: Type) -> Any: + """Given internal key, filter to world visible type.""" + if issubclass(keytype, Enum): + return keytype(key) + return key class DataHandler: diff --git a/tools/efro/entity/_field.py b/tools/efro/entity/_field.py index fbb1719a..33c26ffb 100644 --- a/tools/efro/entity/_field.py +++ b/tools/efro/entity/_field.py @@ -6,8 +6,10 @@ from __future__ import annotations import copy import logging +from enum import Enum from typing import TYPE_CHECKING, Generic, TypeVar, overload +from efro.util import enum_by_value from efro.entity._base import BaseField from efro.entity._support import (BoundCompoundValue, BoundListField, BoundDictField, BoundCompoundListField, @@ -186,7 +188,6 @@ class ListField(BaseField, Generic[T]): # When accessed on a FieldInspector we return a sub-field FieldInspector. # When accessed on an instance we return a BoundListField. - # noinspection DuplicatedCode if TYPE_CHECKING: # Access via type gives our field; via an instance gives a bound field. @@ -233,7 +234,6 @@ class DictField(BaseField, Generic[TK, T]): def get_default_data(self) -> dict: return {} - # noinspection DuplicatedCode def filter_input(self, data: Any, error: bool) -> Any: # If we were passed a BoundDictField, operate on its raw values @@ -247,12 +247,29 @@ class DictField(BaseField, Generic[TK, T]): data = {} data_out = {} for key, val in data.items(): - if not isinstance(key, self._keytype): + + # For enum keys, make sure its a valid enum. + if issubclass(self._keytype, Enum): + try: + _enumval = enum_by_value(self._keytype, key) + except Exception as exc: + if error: + raise ValueError(f'No enum of type {self._keytype}' + f' exists with value {key}') from exc + logging.error('Ignoring invalid key type for %s: %s', self, + data) + continue + + # For all other keys we can check for exact types. + elif not isinstance(key, self._keytype): if error: - raise TypeError('invalid key type') + raise TypeError( + f'Invalid key type; expected {self._keytype},' + f' got {type(key)}.') logging.error('Ignoring invalid key type for %s: %s', self, data) continue + data_out[key] = self.d_value.filter_input(val, error=error) return data_out @@ -261,7 +278,6 @@ class DictField(BaseField, Generic[TK, T]): # change the dict, but we can prune completely if empty (and allowed) return not data and not self._store_default - # noinspection DuplicatedCode if TYPE_CHECKING: # Return our field if accessed via type and bound-dict-field @@ -339,7 +355,6 @@ class CompoundListField(BaseField, Generic[TC]): # We can also optionally prune the whole list if empty and allowed. return not data and not self._store_default - # noinspection DuplicatedCode if TYPE_CHECKING: @overload @@ -436,10 +451,10 @@ class CompoundDictField(BaseField, Generic[TK, TC]): # This doesnt actually exist for us, but want the type-checker # to think it does (see TYPE_CHECKING note below). self.d_data: Any + self.d_keytype = keytype self._store_default = store_default - # noinspection DuplicatedCode def filter_input(self, data: Any, error: bool) -> dict: if not isinstance(data, dict): if error: @@ -448,12 +463,29 @@ class CompoundDictField(BaseField, Generic[TK, TC]): data = {} data_out = {} for key, val in data.items(): - if not isinstance(key, self.d_keytype): + + # For enum keys, make sure its a valid enum. + if issubclass(self.d_keytype, Enum): + try: + _enumval = enum_by_value(self.d_keytype, key) + except Exception as exc: + if error: + raise ValueError(f'No enum of type {self.d_keytype}' + f' exists with value {key}') from exc + logging.error('Ignoring invalid key type for %s: %s', self, + data) + continue + + # For all other keys we can check for exact types. + elif not isinstance(key, self.d_keytype): if error: - raise TypeError('invalid key type') + raise TypeError( + f'Invalid key type; expected {self.d_keytype},' + f' got {type(key)}.') logging.error('Ignoring invalid key type for %s: %s', self, data) continue + data_out[key] = self.d_value.filter_input(val, error=error) return data_out @@ -472,7 +504,6 @@ class CompoundDictField(BaseField, Generic[TK, TC]): # ONLY overriding these in type-checker land to clarify types. # (see note in BaseField) - # noinspection DuplicatedCode if TYPE_CHECKING: @overload diff --git a/tools/efro/entity/_support.py b/tools/efro/entity/_support.py index 43c034e5..d696079b 100644 --- a/tools/efro/entity/_support.py +++ b/tools/efro/entity/_support.py @@ -6,7 +6,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, TypeVar, Generic, overload -from efro.entity._base import BaseField +from efro.entity._base import (BaseField, dict_key_to_raw, dict_key_from_raw) if TYPE_CHECKING: from typing import (Optional, Tuple, Type, Any, Dict, List, Union) @@ -215,35 +215,30 @@ class BoundDictField(Generic[TKey, T]): def __repr__(self) -> str: return '{' + ', '.join( - repr(key) + ': ' + repr(self.d_field.d_value.filter_output(val)) + repr(dict_key_from_raw(key, self._keytype)) + ': ' + + repr(self.d_field.d_value.filter_output(val)) for key, val in self.d_data.items()) + '}' def __len__(self) -> int: return len(self.d_data) def __getitem__(self, key: TKey) -> T: - if not isinstance(key, self._keytype): - raise TypeError( - f'Invalid key type {type(key)}; expected {self._keytype}') - assert isinstance(key, self._keytype) - typedval: T = self.d_field.d_value.filter_output(self.d_data[key]) + keyfilt = dict_key_to_raw(key, self._keytype) + typedval: T = self.d_field.d_value.filter_output(self.d_data[keyfilt]) return typedval def get(self, key: TKey, default: Optional[T] = None) -> Optional[T]: """Get a value if present, or a default otherwise.""" - if not isinstance(key, self._keytype): - raise TypeError( - f'Invalid key type {type(key)}; expected {self._keytype}') - assert isinstance(key, self._keytype) - if key not in self.d_data: + keyfilt = dict_key_to_raw(key, self._keytype) + if keyfilt not in self.d_data: return default - typedval: T = self.d_field.d_value.filter_output(self.d_data[key]) + typedval: T = self.d_field.d_value.filter_output(self.d_data[keyfilt]) return typedval def __setitem__(self, key: TKey, value: T) -> None: - if not isinstance(key, self._keytype): - raise TypeError('Expected str index.') - self.d_data[key] = self.d_field.d_value.filter_input(value, error=True) + keyfilt = dict_key_to_raw(key, self._keytype) + self.d_data[keyfilt] = self.d_field.d_value.filter_input(value, + error=True) def __contains__(self, key: TKey) -> bool: return key in self.d_data @@ -253,7 +248,9 @@ class BoundDictField(Generic[TKey, T]): def keys(self) -> List[TKey]: """Return a list of our keys.""" - return list(self.d_data.keys()) + return [ + dict_key_from_raw(k, self._keytype) for k in self.d_data.keys() + ] def values(self) -> List[T]: """Return a list of our values.""" @@ -264,7 +261,8 @@ class BoundDictField(Generic[TKey, T]): def items(self) -> List[Tuple[TKey, T]]: """Return a list of item/value pairs.""" - return [(key, self.d_field.d_value.filter_output(value)) + return [(dict_key_from_raw(key, self._keytype), + self.d_field.d_value.filter_output(value)) for key, value in self.d_data.items()] @@ -413,13 +411,16 @@ class BoundCompoundDictField(Generic[TKey, TCompound]): def get(self, key): """return a value if present; otherwise None.""" - data = self.d_data.get(key) + keyfilt = dict_key_to_raw(key, self.d_field.d_keytype) + data = self.d_data.get(keyfilt) if data is not None: return BoundCompoundValue(self.d_field.d_value, data) return None def __getitem__(self, key): - return BoundCompoundValue(self.d_field.d_value, self.d_data[key]) + keyfilt = dict_key_to_raw(key, self.d_field.d_keytype) + return BoundCompoundValue(self.d_field.d_value, + self.d_data[keyfilt]) def values(self): """Return a list of our values.""" @@ -429,21 +430,22 @@ class BoundCompoundDictField(Generic[TKey, TCompound]): def items(self): """Return key/value pairs for all dict entries.""" - return [(key, BoundCompoundValue(self.d_field.d_value, value)) + return [(dict_key_from_raw(key, self.d_field.d_keytype), + BoundCompoundValue(self.d_field.d_value, value)) for key, value in self.d_data.items()] def add(self, key: TKey) -> TCompound: """Add an entry into the dict, returning it. Any existing value is replaced.""" - if not isinstance(key, self.d_field.d_keytype): - raise TypeError(f'expected key type {self.d_field.d_keytype};' - f' got {type(key)}') + keyfilt = dict_key_to_raw(key, self.d_field.d_keytype) + # Push the entity default into data and then let it fill in # any children/etc. - self.d_data[key] = (self.d_field.d_value.filter_input( + self.d_data[keyfilt] = (self.d_field.d_value.filter_input( self.d_field.d_value.get_default_data(), error=True)) - return BoundCompoundValue(self.d_field.d_value, self.d_data[key]) + return BoundCompoundValue(self.d_field.d_value, + self.d_data[keyfilt]) def __len__(self) -> int: return len(self.d_data) @@ -456,4 +458,7 @@ class BoundCompoundDictField(Generic[TKey, TCompound]): def keys(self) -> List[TKey]: """Return a list of our keys.""" - return list(self.d_data.keys()) + return [ + dict_key_from_raw(k, self.d_field.d_keytype) + for k in self.d_data.keys() + ] diff --git a/tools/efro/util.py b/tools/efro/util.py index c149ffe0..9a0a8c42 100644 --- a/tools/efro/util.py +++ b/tools/efro/util.py @@ -7,11 +7,13 @@ from __future__ import annotations import datetime import time import weakref +import functools from enum import Enum from typing import TYPE_CHECKING, cast, TypeVar, Generic if TYPE_CHECKING: import asyncio + from efro.call import Call as Call # 'as Call' so we re-export. from weakref import ReferenceType from typing import Any, Dict, Callable, Optional, Type @@ -27,6 +29,12 @@ class _EmptyObj: pass +if TYPE_CHECKING: + Call = Call +else: + Call = functools.partial + + def enum_by_value(cls: Type[TENUM], value: Any) -> TENUM: """Create an enum from a value. diff --git a/tools/efrotools/pybuild.py b/tools/efrotools/pybuild.py index 1a4a8b7b..d503c3e8 100644 --- a/tools/efrotools/pybuild.py +++ b/tools/efrotools/pybuild.py @@ -79,7 +79,7 @@ def build_apple(arch: str, debug: bool = False) -> None: # txt = replace_one(txt, '_lzma _', '#_lzma _') # Turn off bzip2 module. - txt = replace_one(txt, '_bz2 _b', '#_bz2 _b') + # txt = replace_one(txt, '_bz2 _b', '#_bz2 _b') # Turn off openssl module (only if not doing openssl). if not ENABLE_OPENSSL: @@ -150,11 +150,14 @@ def build_apple(arch: str, debug: bool = False) -> None: # libs we're not using. srctxt = '$$(PYTHON_DIR-$1)/dist/lib/libpython$(PYTHON_VER).a: ' if PY38: - txt = replace_one( - txt, srctxt, - '$$(PYTHON_DIR-$1)/dist/lib/libpython$(PYTHON_VER).a: ' + - ('build/$2/Support/OpenSSL ' if ENABLE_OPENSSL else '') + - 'build/$2/Support/XZ $$(PYTHON_DIR-$1)/Makefile\n#' + srctxt) + # Note: now just keeping everything on. + assert ENABLE_OPENSSL + if bool(False): + txt = replace_one( + txt, srctxt, + '$$(PYTHON_DIR-$1)/dist/lib/libpython$(PYTHON_VER).a: ' + + ('build/$2/Support/OpenSSL ' if ENABLE_OPENSSL else '') + + 'build/$2/Support/XZ $$(PYTHON_DIR-$1)/Makefile\n#' + srctxt) else: txt = replace_one( txt, srctxt,