From 9733751716fd245eadfa404c3ea7ab04779a4894 Mon Sep 17 00:00:00 2001 From: Eric Froemling Date: Sun, 5 Sep 2021 19:37:54 -0500 Subject: [PATCH] efro.error.TransportError renamed to CommunicationError --- .idea/dictionaries/ericf.xml | 5 + .../.idea/dictionaries/ericf.xml | 5 + docs/ba_module.md | 2 +- tests/test_efro/test_message.py | 155 ++++++++++++++++-- tools/efro/error.py | 4 +- tools/efro/message.py | 89 +++++++--- tools/efro/net.py | 6 +- 7 files changed, 215 insertions(+), 51 deletions(-) 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.