vh-bombsquad-modded-server-.../dist/ba_data/python/efro/entity/_field.py
2024-02-26 00:17:10 +05:30

602 lines
22 KiB
Python

# Released under the MIT License. See LICENSE for details.
#
"""Field types for the entity system."""
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, dict_key_to_raw, dict_key_from_raw
from efro.entity._support import (BoundCompoundValue, BoundListField,
BoundDictField, BoundCompoundListField,
BoundCompoundDictField)
from efro.entity.util import have_matching_fields
if TYPE_CHECKING:
from typing import Dict, Type, List, Any
from efro.entity._value import TypedValue, CompoundValue
T = TypeVar('T')
TK = TypeVar('TK')
TC = TypeVar('TC', bound='CompoundValue')
class Field(BaseField, Generic[T]):
"""Field consisting of a single value."""
def __init__(self,
d_key: str,
value: TypedValue[T],
store_default: bool = True) -> None:
super().__init__(d_key)
self.d_value = value
self._store_default = store_default
def __repr__(self) -> str:
return f'<Field "{self.d_key}" with {self.d_value}>'
def get_default_data(self) -> Any:
return self.d_value.get_default_data()
def filter_input(self, data: Any, error: bool) -> Any:
return self.d_value.filter_input(data, error)
def filter_output(self, data: Any) -> Any:
return self.d_value.filter_output(data)
def prune_data(self, data: Any) -> bool:
return self.d_value.prune_data(data)
if TYPE_CHECKING:
# Use default runtime get/set but let type-checker know our types.
# Note: we actually return a bound-field when accessed on
# a type instead of an instance, but we don't reflect that here yet
# (would need to write a mypy plugin so sub-field access works first)
@overload
def __get__(self, obj: None, cls: Any = None) -> Field[T]:
...
@overload
def __get__(self, obj: Any, cls: Any = None) -> T:
...
def __get__(self, obj: Any, cls: Any = None) -> Any:
...
def __set__(self, obj: Any, value: T) -> None:
...
class CompoundField(BaseField, Generic[TC]):
"""Field consisting of a single compound value."""
def __init__(self,
d_key: str,
value: TC,
store_default: bool = True) -> None:
super().__init__(d_key)
if __debug__:
from efro.entity._value import CompoundValue
assert isinstance(value, CompoundValue)
assert not hasattr(value, 'd_data')
self.d_value = value
self._store_default = store_default
def get_default_data(self) -> dict:
return self.d_value.get_default_data()
def filter_input(self, data: Any, error: bool) -> dict:
return self.d_value.filter_input(data, error)
def prune_data(self, data: Any) -> bool:
return self.d_value.prune_data(data)
# Note:
# Currently, to the type-checker we just return a simple instance
# of our CompoundValue so it can properly type-check access to its
# attrs. However at runtime we return a FieldInspector or
# BoundCompoundField which both use magic to provide the same attrs
# dynamically (but which the type-checker doesn't understand).
# Perhaps at some point we can write a mypy plugin to correct this.
if TYPE_CHECKING:
def __get__(self, obj: Any, cls: Any = None) -> TC:
...
# Theoretically this type-checking may be too tight;
# we can support assigning a parent class to a child class if
# their fields match. Not sure if that'll ever come up though;
# gonna leave this for now as I prefer to have *some* checking.
# Also once we get BoundCompoundValues working with mypy we'll
# need to accept those too.
def __set__(self: CompoundField[TC], obj: Any, value: TC) -> None:
...
def get_with_data(self, data: Any) -> Any:
assert self.d_key in data
return BoundCompoundValue(self.d_value, data[self.d_key])
def set_with_data(self, data: Any, value: Any, error: bool) -> Any:
from efro.entity._value import CompoundValue
# Ok here's the deal: our type checking above allows any subtype
# of our CompoundValue in here, but we want to be more picky than
# that. Let's check fields for equality. This way we'll allow
# assigning something like a Carentity to a Car field
# (where the data is the same), but won't allow assigning a Car
# to a Vehicle field (as Car probably adds more fields).
value1: CompoundValue
if isinstance(value, BoundCompoundValue):
value1 = value.d_value
elif isinstance(value, CompoundValue):
value1 = value
else:
raise ValueError(f"Can't assign from object type {type(value)}")
dataval = getattr(value, 'd_data', None)
if dataval is None:
raise ValueError(f"Can't assign from unbound object {value}")
if self.d_value.get_fields() != value1.get_fields():
raise ValueError(f"Can't assign to {self.d_value} from"
f' incompatible type {value.d_value}; '
f'sub-fields do not match.')
# If we're allowing this to go through, we can simply copy the
# data from the passed in value. The fields match so it should
# be in a valid state already.
data[self.d_key] = copy.deepcopy(dataval)
class ListField(BaseField, Generic[T]):
"""Field consisting of repeated values."""
def __init__(self,
d_key: str,
value: TypedValue[T],
store_default: bool = True) -> None:
super().__init__(d_key)
self.d_value = value
self._store_default = store_default
def get_default_data(self) -> list:
return []
def filter_input(self, data: Any, error: bool) -> Any:
# If we were passed a BoundListField, operate on its raw values
if isinstance(data, BoundListField):
data = data.d_data
if not isinstance(data, list):
if error:
raise TypeError(f'list value expected; got {type(data)}')
logging.error('Ignoring non-list data for %s: %s', self, data)
data = []
for i, entry in enumerate(data):
data[i] = self.d_value.filter_input(entry, error=error)
return data
def prune_data(self, data: Any) -> bool:
# We never prune individual values since that would fundamentally
# change the list, but we can prune completely if empty (and allowed).
return not data and not self._store_default
# When accessed on a FieldInspector we return a sub-field FieldInspector.
# When accessed on an instance we return a BoundListField.
if TYPE_CHECKING:
# Access via type gives our field; via an instance gives a bound field.
@overload
def __get__(self, obj: None, cls: Any = None) -> ListField[T]:
...
@overload
def __get__(self, obj: Any, cls: Any = None) -> BoundListField[T]:
...
def __get__(self, obj: Any, cls: Any = None) -> Any:
...
# Allow setting via a raw value list or a bound list field
@overload
def __set__(self, obj: Any, value: List[T]) -> None:
...
@overload
def __set__(self, obj: Any, value: BoundListField[T]) -> None:
...
def __set__(self, obj: Any, value: Any) -> None:
...
def get_with_data(self, data: Any) -> Any:
return BoundListField(self, data[self.d_key])
class DictField(BaseField, Generic[TK, T]):
"""A field of values in a dict with a specified index type."""
def __init__(self,
d_key: str,
keytype: Type[TK],
field: TypedValue[T],
store_default: bool = True) -> None:
super().__init__(d_key)
self.d_value = field
self._store_default = store_default
self._keytype = keytype
def get_default_data(self) -> dict:
return {}
def filter_input(self, data: Any, error: bool) -> Any:
# If we were passed a BoundDictField, operate on its raw values
if isinstance(data, BoundDictField):
data = data.d_data
if not isinstance(data, dict):
if error:
raise TypeError('dict value expected')
logging.error('Ignoring non-dict data for %s: %s', self, data)
data = {}
data_out = {}
for key, val in data.items():
# For enum keys, make sure its a valid enum.
if issubclass(self._keytype, Enum):
# Our input data can either be an enum or the underlying type.
if isinstance(key, self._keytype):
key = dict_key_to_raw(key, self._keytype)
# key = key.value
else:
try:
_enumval = dict_key_from_raw(key, self._keytype)
# _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(
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
def prune_data(self, data: Any) -> bool:
# We never prune individual values since that would fundamentally
# change the dict, but we can prune completely if empty (and allowed)
return not data and not self._store_default
if TYPE_CHECKING:
# Return our field if accessed via type and bound-dict-field
# if via instance.
@overload
def __get__(self, obj: None, cls: Any = None) -> DictField[TK, T]:
...
@overload
def __get__(self, obj: Any, cls: Any = None) -> BoundDictField[TK, T]:
...
def __get__(self, obj: Any, cls: Any = None) -> Any:
...
# Allow setting via matching dict values or BoundDictFields
@overload
def __set__(self, obj: Any, value: Dict[TK, T]) -> None:
...
@overload
def __set__(self, obj: Any, value: BoundDictField[TK, T]) -> None:
...
def __set__(self, obj: Any, value: Any) -> None:
...
def get_with_data(self, data: Any) -> Any:
return BoundDictField(self._keytype, self, data[self.d_key])
class CompoundListField(BaseField, Generic[TC]):
"""A field consisting of repeated instances of a compound-value.
Element access returns the sub-field, allowing nested field access.
ie: mylist[10].fieldattr = 'foo'
"""
def __init__(self,
d_key: str,
valuetype: TC,
store_default: bool = True) -> None:
super().__init__(d_key)
self.d_value = valuetype
# 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._store_default = store_default
def filter_input(self, data: Any, error: bool) -> list:
if not isinstance(data, list):
if error:
raise TypeError('list value expected')
logging.error('Ignoring non-list data for %s: %s', self, data)
data = []
assert isinstance(data, list)
# Ok we've got a list; now run everything in it through validation.
for i, subdata in enumerate(data):
data[i] = self.d_value.filter_input(subdata, error=error)
return data
def get_default_data(self) -> list:
return []
def prune_data(self, data: Any) -> bool:
# Run pruning on all individual entries' data through out child field.
# However we don't *completely* prune values from the list since that
# would change it.
for subdata in data:
self.d_value.prune_fields_data(subdata)
# We can also optionally prune the whole list if empty and allowed.
return not data and not self._store_default
if TYPE_CHECKING:
@overload
def __get__(self, obj: None, cls: Any = None) -> CompoundListField[TC]:
...
@overload
def __get__(self,
obj: Any,
cls: Any = None) -> BoundCompoundListField[TC]:
...
def __get__(self, obj: Any, cls: Any = None) -> Any:
...
# Note:
# When setting the list, we tell the type-checker that we also accept
# a raw list of CompoundValue objects, but at runtime we actually
# always deal with BoundCompoundValue objects (see note in
# BoundCompoundListField for why we accept CompoundValue objs)
@overload
def __set__(self, obj: Any, value: List[TC]) -> None:
...
@overload
def __set__(self, obj: Any, value: BoundCompoundListField[TC]) -> None:
...
def __set__(self, obj: Any, value: Any) -> None:
...
def get_with_data(self, data: Any) -> Any:
assert self.d_key in data
return BoundCompoundListField(self, data[self.d_key])
def set_with_data(self, data: Any, value: Any, error: bool) -> Any:
# If we were passed a BoundCompoundListField,
# simply convert it to a flat list of BoundCompoundValue objects which
# is what we work with natively here.
if isinstance(value, BoundCompoundListField):
value = list(value)
if not isinstance(value, list):
raise TypeError(f'CompoundListField expected list value on set;'
f' got {type(value)}.')
# Allow assigning only from a sequence of our existing children.
# (could look into expanding this to other children if we can
# be sure the underlying data will line up; for example two
# CompoundListFields with different child_field values should not
# be inter-assignable.
if not all(isinstance(i, BoundCompoundValue) for i in value):
raise ValueError('CompoundListField assignment must be a '
'list containing only BoundCompoundValue objs.')
# Make sure the data all has the same CompoundValue type and
# compare that type against ours once to make sure its fields match.
# (this will not allow passing CompoundValues from multiple sources
# but I don't know if that would ever come up..)
for i, val in enumerate(value):
if i == 0:
# Do the full field comparison on the first value only..
if not have_matching_fields(val.d_value, self.d_value):
raise ValueError(
'CompoundListField assignment must be a '
'list containing matching CompoundValues.')
else:
# For all remaining values, just ensure they match the first.
if val.d_value is not value[0].d_value:
raise ValueError(
'CompoundListField assignment cannot contain '
'multiple CompoundValue types as sources.')
data[self.d_key] = self.filter_input([i.d_data for i in value],
error=error)
class CompoundDictField(BaseField, Generic[TK, TC]):
"""A field consisting of key-indexed instances of a compound-value.
Element access returns the sub-field, allowing nested field access.
ie: mylist[10].fieldattr = 'foo'
"""
def __init__(self,
d_key: str,
keytype: Type[TK],
valuetype: TC,
store_default: bool = True) -> None:
super().__init__(d_key)
self.d_value = valuetype
# 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
def filter_input(self, data: Any, error: bool) -> dict:
if not isinstance(data, dict):
if error:
raise TypeError('dict value expected')
logging.error('Ignoring non-dict data for %s: %s', self, data)
data = {}
data_out = {}
for key, val in data.items():
# For enum keys, make sure its a valid enum.
if issubclass(self.d_keytype, Enum):
# Our input data can either be an enum or the underlying type.
if isinstance(key, self.d_keytype):
key = dict_key_to_raw(key, self.d_keytype)
# key = key.value
else:
try:
_enumval = dict_key_from_raw(key, self.d_keytype)
# _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(
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
def get_default_data(self) -> dict:
return {}
def prune_data(self, data: Any) -> bool:
# Run pruning on all individual entries' data through our child field.
# However we don't *completely* prune values from the list since that
# would change it.
for subdata in data.values():
self.d_value.prune_fields_data(subdata)
# We can also optionally prune the whole list if empty and allowed.
return not data and not self._store_default
# ONLY overriding these in type-checker land to clarify types.
# (see note in BaseField)
if TYPE_CHECKING:
@overload
def __get__(self,
obj: None,
cls: Any = None) -> CompoundDictField[TK, TC]:
...
@overload
def __get__(self,
obj: Any,
cls: Any = None) -> BoundCompoundDictField[TK, TC]:
...
def __get__(self, obj: Any, cls: Any = None) -> Any:
...
# Note:
# When setting the dict, we tell the type-checker that we also accept
# a raw dict of CompoundValue objects, but at runtime we actually
# always deal with BoundCompoundValue objects (see note in
# BoundCompoundDictField for why we accept CompoundValue objs)
@overload
def __set__(self, obj: Any, value: Dict[TK, TC]) -> None:
...
@overload
def __set__(self, obj: Any, value: BoundCompoundDictField[TK,
TC]) -> None:
...
def __set__(self, obj: Any, value: Any) -> None:
...
def get_with_data(self, data: Any) -> Any:
assert self.d_key in data
return BoundCompoundDictField(self, data[self.d_key])
def set_with_data(self, data: Any, value: Any, error: bool) -> Any:
# If we were passed a BoundCompoundDictField,
# simply convert it to a flat dict of BoundCompoundValue objects which
# is what we work with natively here.
if isinstance(value, BoundCompoundDictField):
value = dict(value.items())
if not isinstance(value, dict):
raise TypeError('CompoundDictField expected dict value on set.')
# Allow assigning only from a sequence of our existing children.
# (could look into expanding this to other children if we can
# be sure the underlying data will line up; for example two
# CompoundListFields with different child_field values should not
# be inter-assignable.
if (not all(isinstance(i, BoundCompoundValue)
for i in value.values())):
raise ValueError('CompoundDictField assignment must be a '
'dict containing only BoundCompoundValues.')
# Make sure the data all has the same CompoundValue type and
# compare that type against ours once to make sure its fields match.
# (this will not allow passing CompoundValues from multiple sources
# but I don't know if that would ever come up..)
first_value: Any = None
for i, val in enumerate(value.values()):
if i == 0:
first_value = val.d_value
# Do the full field comparison on the first value only..
if not have_matching_fields(val.d_value, self.d_value):
raise ValueError(
'CompoundListField assignment must be a '
'list containing matching CompoundValues.')
else:
# For all remaining values, just ensure they match the first.
if val.d_value is not first_value:
raise ValueError(
'CompoundListField assignment cannot contain '
'multiple CompoundValue types as sources.')
data[self.d_key] = self.filter_input(
{key: val.d_data
for key, val in value.items()}, error=error)