diff --git a/.idea/dictionaries/ericf.xml b/.idea/dictionaries/ericf.xml
index ac3ab77e..17471111 100644
--- a/.idea/dictionaries/ericf.xml
+++ b/.idea/dictionaries/ericf.xml
@@ -69,6 +69,7 @@
animcurve
annarg
annargs
+ anns
anntype
anota
anroid
@@ -1402,6 +1403,7 @@
mrmaxmeier
msbuild
msgdict
+ msgtype
mshell
msvccompiler
msvcp
@@ -1881,6 +1883,7 @@
respawned
respawnicon
responsetype
+ responsetypes
resultstr
retrysecs
returncode
@@ -2118,6 +2121,7 @@
startercache
startscan
starttime
+ statictest
statictestfiles
statictype
stayin
@@ -2217,6 +2221,7 @@
targetname
targetpath
targetpractice
+ targs
tbtcolor
tbtn
tbttxt
diff --git a/ballisticacore-cmake/.idea/dictionaries/ericf.xml b/ballisticacore-cmake/.idea/dictionaries/ericf.xml
index 3913bb4c..64cfb863 100644
--- a/ballisticacore-cmake/.idea/dictionaries/ericf.xml
+++ b/ballisticacore-cmake/.idea/dictionaries/ericf.xml
@@ -44,6 +44,7 @@
aniso
annarg
annargs
+ anns
anntype
ansiwrap
anyofallof
@@ -639,6 +640,7 @@
mqrspec
msaa
msgdict
+ msgtype
mult
multing
multipass
@@ -867,6 +869,7 @@
resends
resetbtn
resetinput
+ responsetypes
resync
retrysecs
retval
@@ -980,6 +983,7 @@
startx
starty
staticdata
+ statictest
stdint
stepfast
stephane
@@ -1014,6 +1018,7 @@
tabtype
tabtypes
talloc
+ targs
tegra
telefonaktiebolaget
teleported
diff --git a/docs/ba_module.md b/docs/ba_module.md
index 66b9096b..44eaafed 100644
--- a/docs/ba_module.md
+++ b/docs/ba_module.md
@@ -1,5 +1,5 @@
-
last updated on 2021-09-02 for Ballistica version 1.6.5 build 20391
+last updated on 2021-09-05 for Ballistica version 1.6.5 build 20391
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_message.py b/tests/test_efro/test_message.py
index a542edad..418903ef 100644
--- a/tests/test_efro/test_message.py
+++ b/tests/test_efro/test_message.py
@@ -4,7 +4,7 @@
from __future__ import annotations
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, overload
from dataclasses import dataclass
import pytest
@@ -12,9 +12,10 @@ import pytest
from efro.dataclassio import ioprepped
from efro.message import (Message, MessageProtocol, MessageSender,
MessageReceiver)
+# from efrotools.statictest import static_type_equals
if TYPE_CHECKING:
- from typing import List, Type
+ from typing import List, Type, Any, Callable, Union
@ioprepped
@@ -25,42 +26,147 @@ class _TestMessage1(Message):
@classmethod
def get_response_types(cls) -> List[Type[Message]]:
- return [_TestMessage2]
+ return [_TestMessageR1]
@ioprepped
@dataclass
class _TestMessage2(Message):
+ """Just testing."""
+ sval: str
+
+ @classmethod
+ def get_response_types(cls) -> List[Type[Message]]:
+ return [_TestMessageR1, _TestMessageR2]
+
+
+@ioprepped
+@dataclass
+class _TestMessageR1(Message):
"""Just testing."""
bval: bool
+@ioprepped
+@dataclass
+class _TestMessageR2(Message):
+ """Just testing."""
+ fval: float
+
+
+@ioprepped
+@dataclass
+class _TestMessageR3(Message):
+ """Just testing."""
+ fval: float
+
+
+class _TestMessageSender(MessageSender):
+ """Testing type overrides for message sending.
+ Normally this would be auto-generated based on the protocol.
+ """
+
+ def __get__(self,
+ obj: Any,
+ type_in: Any = None) -> _BoundTestMessageSender:
+ return _BoundTestMessageSender(obj, self)
+
+
+class _BoundTestMessageSender:
+ """Testing type overrides for message sending.
+ Normally this would be auto-generated based on the protocol.
+ """
+
+ def __init__(self, obj: Any, sender: _TestMessageSender) -> None:
+ assert obj is not None
+ self._obj = obj
+ self._sender = sender
+
+ @overload
+ def send(self, message: _TestMessage1) -> _TestMessageR1:
+ ...
+
+ @overload
+ def send(self,
+ message: _TestMessage2) -> Union[_TestMessageR1, _TestMessageR2]:
+ ...
+
+ def send(self, message: Any) -> Any:
+ """Send a particular message type."""
+ return self._sender.send(self._obj, message)
+
+
+class _TestMessageReceiver(MessageReceiver):
+ """Testing type overrides for message receiving.
+ Normally this would be auto-generated based on the protocol.
+ """
+
+ def __get__(self,
+ obj: Any,
+ type_in: Any = None) -> _BoundTestMessageReceiver:
+ return _BoundTestMessageReceiver(obj, self)
+
+ @overload
+ def handler(
+ self, call: Callable[[Any, _TestMessage1], _TestMessageR1]
+ ) -> Callable[[Any, _TestMessage1], _TestMessageR1]:
+ ...
+
+ @overload
+ def handler(
+ self, call: Callable[[Any, _TestMessage2], Union[_TestMessageR1,
+ _TestMessageR2]]
+ ) -> Callable[[Any, _TestMessage2], Union[_TestMessageR1, _TestMessageR2]]:
+ ...
+
+ def handler(self, call: Callable) -> Callable:
+ """Decorator to register a handler for a particular message type."""
+ self.register_handler(call)
+ return call
+
+
+class _BoundTestMessageReceiver:
+ """Testing type overrides for message receiving.
+ Normally this would be auto-generated based on the protocol.
+ """
+
+ def __init__(self, obj: Any, receiver: _TestMessageReceiver) -> None:
+ assert obj is not None
+ self._obj = obj
+ self._receiver = receiver
+
+
+TEST_PROTOCOL = MessageProtocol(message_types={
+ 1: _TestMessage1,
+ 2: _TestMessage2,
+ 3: _TestMessageR1,
+ 4: _TestMessageR2,
+})
+
+
def test_protocol_creation() -> None:
"""Test protocol creation."""
- # This should fail because _TestMessage1 can return _TestMessage2 which
+ # This should fail because _TestMessage1 can return _TestMessageR1 which
# is not given an id here.
with pytest.raises(ValueError):
_protocol = MessageProtocol(message_types={1: _TestMessage1})
+ # Now it should work.
_protocol = MessageProtocol(message_types={
1: _TestMessage1,
- 2: _TestMessage2
+ 2: _TestMessageR1
})
def test_message_sending() -> None:
"""Test simple message sending."""
- protocol = MessageProtocol(message_types={
- 1: _TestMessage1,
- 2: _TestMessage2
- })
-
+ # Define a class that can send messages and one that can receive them.
class TestClassS:
- """For testing send functionality."""
+ """Test class incorporating send functionality."""
- msg = MessageSender(protocol)
+ msg = _TestMessageSender(TEST_PROTOCOL)
def __init__(self, receiver: TestClassR) -> None:
self._receiver = receiver
@@ -72,18 +178,31 @@ def test_message_sending() -> None:
return b''
class TestClassR:
- """For testing receive functionality."""
+ """Test class incorporating receive functionality."""
- receiver = MessageReceiver(protocol)
+ receiver = _TestMessageReceiver(TEST_PROTOCOL)
@receiver.handler
- def handle_test_message(self, msg: Message) -> Message:
+ def handle_test_message_1(self, msg: _TestMessage1) -> _TestMessageR1:
"""Test."""
del msg # Unused
print('Hello from test message 1 handler!')
- return _TestMessage2(bval=True)
+ return _TestMessageR1(bval=True)
+
+ @receiver.handler
+ def handle_test_message_2(
+ self,
+ msg: _TestMessage2) -> Union[_TestMessageR1, _TestMessageR2]:
+ """Test."""
+ del msg # Unused
+ print('Hello from test message 1 handler!')
+ return _TestMessageR2(fval=1.2)
obj_r = TestClassR()
obj_s = TestClassS(receiver=obj_r)
- print(f'MADE TEST OBJS {obj_s} and {obj_r}')
- obj_s.msg.send(_TestMessage1(ival=0))
+
+ _result = obj_s.msg.send(_TestMessage1(ival=0))
+ _result2 = obj_s.msg.send(_TestMessage2(sval='rah'))
+ print('SKIPPING STATIC CHECK')
+ # assert static_type_equals(result, _TestMessageR1)
+ # assert isinstance(result, _TestMessageR1)
diff --git a/tools/efro/error.py b/tools/efro/error.py
index 7adc4cc8..9fd7bbfb 100644
--- a/tools/efro/error.py
+++ b/tools/efro/error.py
@@ -35,8 +35,8 @@ class CleanError(Exception):
print(f'{Clr.SRED}{errstr}{Clr.RST}', flush=flush)
-class TransportError(Exception):
- """A transport-related communication error has occurred.
+class CommunicationError(Exception):
+ """A communication related error has occurred.
This covers anything network-related going wrong in the sending
of data or receiving of a response. This error does not imply
diff --git a/tools/efro/message.py b/tools/efro/message.py
index a6e41332..29f144a9 100644
--- a/tools/efro/message.py
+++ b/tools/efro/message.py
@@ -17,8 +17,9 @@ from efro.dataclassio import (ioprepped, is_ioprepped_dataclass, IOAttrs,
dataclass_to_dict)
if TYPE_CHECKING:
- from typing import Dict, Type, Tuple, List, Any, Callable, Optional, Set
- from efro.error import TransportError
+ from typing import (Dict, Type, Tuple, List, Any, Callable, Optional, Set,
+ Sequence)
+ from efro.error import CommunicationError
TM = TypeVar('TM', bound='MessageSender')
@@ -101,6 +102,7 @@ class MessageProtocol:
for m_id, m_type in message_types.items():
m_rtypes = m_type.get_response_types()
assert isinstance(m_rtypes, list)
+ assert len(set(m_rtypes)) == len(m_rtypes) # check for dups
all_response_types.update(m_rtypes)
for cls in all_response_types:
assert is_ioprepped_dataclass(cls) and issubclass(cls, Message)
@@ -117,7 +119,7 @@ class MessageProtocol:
m_id = self._message_ids_by_type.get(type(message))
if m_id is None:
- raise TypeError(f'Message type is not registered in protocol:'
+ raise TypeError(f'Message type is not registered in Protocol:'
f' {type(message)}')
msgdict = dataclass_to_dict(message)
@@ -154,6 +156,23 @@ class MessageProtocol:
the protocol.
"""
+ def validate_message_type(self, msgtype: Type,
+ responsetypes: Sequence[Type]) -> None:
+ """Ensure message type associated response types are valid.
+ Raises an exception if not.
+ """
+ if msgtype not in self._message_ids_by_type:
+ raise TypeError(f'Message type {msgtype} is not registered'
+ f' in this Protocol.')
+
+ # Make sure the responses exactly matches what the message expects.
+ assert len(set(responsetypes)) == len(responsetypes)
+
+ for responsetype in responsetypes:
+ if responsetype not in self._message_ids_by_type:
+ raise TypeError(f'Response message type {responsetype} is'
+ f' not registered in this Protocol.')
+
class MessageSender:
"""Facilitates sending messages to a target and receiving responses.
@@ -179,18 +198,6 @@ class MessageSender:
self._protocol = protocol
self._send_raw_message_call: Optional[Callable[[Any, bytes],
bytes]] = None
- self._bound_obj: Any = None
-
- def __get__(self: TM, obj: Any, type_in: Any = None) -> TM:
- if obj is None:
- raise RuntimeError('Must be called on an instance, not a type.')
-
- # Return a copy of ourself bound to the instance we were called from.
- bound_sender = type(self)(self._protocol)
- bound_sender._send_raw_message_call = self._send_raw_message_call
- bound_sender._bound_obj = obj
-
- return bound_sender
def send_raw_handler(
self, call: Callable[[Any, bytes],
@@ -200,25 +207,23 @@ class MessageSender:
self._send_raw_message_call = call
return call
- def send(self, message: Message) -> Any:
+ def send(self, bound_obj: Any, message: Message) -> Any:
"""Send a message and receive a response.
Will encode the message for transport and call dispatch_raw_message()
"""
if self._send_raw_message_call is None:
raise RuntimeError('send() is unimplemented for this type.')
- if self._bound_obj is None:
- raise RuntimeError('Cannot call on an unbound instance.')
encoded = self._protocol.message_encode(message)
- return self._send_raw_message_call(None, encoded)
+ return self._send_raw_message_call(bound_obj, encoded)
- def send_bg(self, message: Any) -> Any:
+ def send_bg(self, bound_obj: Any, message: Message) -> Message:
"""Send a message asynchronously and receive a future.
The message will be encoded for transport and passed to
dispatch_raw_message from a background thread.
"""
raise RuntimeError('Unimplemented!')
- def send_async(self, message: Any) -> Any:
+ def send_async(self, bound_obj: Any, message: Message) -> Message:
"""Send a message asynchronously using asyncio.
The message will be encoded for transport and passed to
dispatch_raw_message_async.
@@ -253,12 +258,42 @@ class MessageReceiver:
def __init__(self, protocol: MessageProtocol) -> None:
self._protocol = protocol
- def handler(
- self, call: Callable[[Any, Message], Message]
- ) -> Callable[[Any, Message], Message]:
- """Decorator for registering calls to handle message types."""
- print('WOULD REGISTER HANDLER TYPE')
- return call
+ # noinspection PyProtectedMember
+ def register_handler(self, call: Callable) -> None:
+ """Register a handler call.
+ The message type handled by the call is determined by its
+ type annotation.
+ """
+ # TODO: can use types.GenericAlias in 3.9.
+ from typing import _GenericAlias # type: ignore
+ from typing import Union, get_type_hints, get_args
+
+ # Return-type annotation can be a Union, but we probably don't
+ # have it available at runtime. Explicitly pull it in.
+ anns = get_type_hints(call, localns={'Union': Union})
+ msg = anns.get('msg')
+ if not isinstance(msg, type):
+ raise TypeError(
+ f'expected a type for "msg" annotation; got {type(msg)}.')
+ ret = anns.get('return')
+ rets: Tuple[Type, ...]
+
+ # Return types can be a single type or a union of types.
+ if isinstance(ret, _GenericAlias):
+ targs = get_args(ret)
+ if not all(isinstance(a, type) for a in targs):
+ raise TypeError(f'expected only types for "return" annotation;'
+ f' got {targs}.')
+ rets = targs
+
+ print(f'LOOKED AT GENERIC ALIAS {targs}')
+ else:
+ if not isinstance(ret, type):
+ raise TypeError(f'expected one or more types for'
+ f' "return" annotation; got a {type(ret)}.')
+ rets = (ret, )
+
+ print(f'WOULD REGISTER HANDLER! (got {msg} and {rets})')
def handle_raw_message(self, msg: bytes) -> bytes:
"""Should be called when the receiver gets a message.
diff --git a/tools/efro/net.py b/tools/efro/net.py
index 2c8bda21..50c2fdd8 100644
--- a/tools/efro/net.py
+++ b/tools/efro/net.py
@@ -10,11 +10,11 @@ if TYPE_CHECKING:
def is_urllib_network_error(exc: BaseException) -> bool:
- """Is the provided exception a network-related error?
+ """Is the provided exception from urllib a network-related error?
This should be passed an exception which resulted from opening or
- reading a urllib Request. It should return True for any errors that
- could conceivably arise due to unavailable/poor network connections,
+ reading a urllib Request. It returns True for any errors that could
+ conceivably arise due to unavailable/poor network connections,
firewall/connectivity issues, etc. These issues can often be safely
ignored or presented to the user as general 'network-unavailable'
states.