updating core files 1.7.10

This commit is contained in:
Ayush Saini 2022-10-01 14:51:35 +05:30
parent daff57f1e9
commit b110f8a12a
163 changed files with 4836 additions and 1960 deletions

View file

@ -188,7 +188,14 @@ def is_asyncio_streams_communication_error(exc: BaseException) -> bool:
# Let's still complain, however, if we get any SSL errors besides
# this one. https://bugs.python.org/issue39951
if isinstance(exc, ssl.SSLError):
if 'APPLICATION_DATA_AFTER_CLOSE_NOTIFY' in str(exc):
excstr = str(exc)
if 'APPLICATION_DATA_AFTER_CLOSE_NOTIFY' in excstr:
return True
# Also occasionally am getting WRONG_VERSION_NUMBER ssl errors;
# Assuming this just means client is attempting to connect from some
# outdated browser or whatnot.
if 'SSL: WRONG_VERSION_NUMBER' in excstr:
return True
return False

438
dist/ba_data/python/efro/log.py vendored Normal file
View file

@ -0,0 +1,438 @@
# Released under the MIT License. See LICENSE for details.
#
"""Logging functionality."""
from __future__ import annotations
import sys
import time
import asyncio
import logging
import datetime
from enum import Enum
from dataclasses import dataclass
from typing import TYPE_CHECKING, Annotated
from threading import Thread, current_thread, Lock
from efro.util import utc_now
from efro.call import tpartial
from efro.terminal import TerminalColor
from efro.dataclassio import ioprepped, IOAttrs, dataclass_to_json
if TYPE_CHECKING:
from pathlib import Path
from typing import Any, Callable, TextIO
class LogLevel(Enum):
"""Severity level for a log entry.
These enums have numeric values so they can be compared in severity.
Note that these values are not currently interchangeable with the
logging.ERROR, logging.DEBUG, etc. values.
"""
DEBUG = 0
INFO = 1
WARNING = 2
ERROR = 3
CRITICAL = 4
LEVELNO_LOG_LEVELS = {
logging.DEBUG: LogLevel.DEBUG,
logging.INFO: LogLevel.INFO,
logging.WARNING: LogLevel.WARNING,
logging.ERROR: LogLevel.ERROR,
logging.CRITICAL: LogLevel.CRITICAL
}
LEVELNO_COLOR_CODES: dict[int, tuple[str, str]] = {
logging.DEBUG: (TerminalColor.CYAN.value, TerminalColor.RESET.value),
logging.INFO: ('', ''),
logging.WARNING: (TerminalColor.YELLOW.value, TerminalColor.RESET.value),
logging.ERROR: (TerminalColor.RED.value, TerminalColor.RESET.value),
logging.CRITICAL:
(TerminalColor.STRONG_MAGENTA.value + TerminalColor.BOLD.value +
TerminalColor.BG_BLACK.value, TerminalColor.RESET.value),
}
@ioprepped
@dataclass
class LogEntry:
"""Single logged message."""
name: Annotated[str,
IOAttrs('n', soft_default='root', store_default=False)]
message: Annotated[str, IOAttrs('m')]
level: Annotated[LogLevel, IOAttrs('l')]
time: Annotated[datetime.datetime, IOAttrs('t')]
@ioprepped
@dataclass
class LogArchive:
"""Info and data for a log."""
# Total number of entries submitted to the log.
log_size: Annotated[int, IOAttrs('t')]
# Offset for the entries contained here.
# (10 means our first entry is the 10th in the log, etc.)
start_index: Annotated[int, IOAttrs('c')]
entries: Annotated[list[LogEntry], IOAttrs('e')]
class LogHandler(logging.Handler):
"""Fancy-pants handler for logging output.
Writes logs to disk in structured json format and echoes them
to stdout/stderr with pretty colors.
"""
_event_loop: asyncio.AbstractEventLoop
# IMPORTANT: Any debug prints we do here should ONLY go to echofile.
# Otherwise we can get infinite loops as those prints come back to us
# as new log entries.
def __init__(self,
path: str | Path | None,
echofile: TextIO | None,
suppress_non_root_debug: bool = False,
cache_size_limit: int = 0):
super().__init__()
# pylint: disable=consider-using-with
self._file = (None
if path is None else open(path, 'w', encoding='utf-8'))
self._echofile = echofile
self._callbacks_lock = Lock()
self._callbacks: list[Callable[[LogEntry], None]] = []
self._suppress_non_root_debug = suppress_non_root_debug
self._file_chunks: dict[str, list[str]] = {'stdout': [], 'stderr': []}
self._file_chunk_ship_task: dict[str, asyncio.Task | None] = {
'stdout': None,
'stderr': None
}
self._cache_size = 0
assert cache_size_limit >= 0
self._cache_size_limit = cache_size_limit
self._cache: list[tuple[int, LogEntry]] = []
self._cache_index_offset = 0
self._cache_lock = Lock()
self._printed_callback_error = False
self._thread_bootstrapped = False
self._thread = Thread(target=self._thread_main, daemon=True)
self._thread.start()
# Spin until our thread is up and running; otherwise we could
# wind up trying to push stuff to our event loop before the
# loop exists.
while not self._thread_bootstrapped:
time.sleep(0.001)
def add_callback(self, call: Callable[[LogEntry], None]) -> None:
"""Add a callback to be run for each LogEntry.
Note that this callback will always run in a background thread.
"""
with self._callbacks_lock:
self._callbacks.append(call)
def _thread_main(self) -> None:
self._event_loop = asyncio.new_event_loop()
# NOTE: if we ever use default threadpool at all we should allow
# setting it for our loop.
asyncio.set_event_loop(self._event_loop)
self._thread_bootstrapped = True
try:
self._event_loop.run_forever()
except BaseException:
# If this ever goes down we're in trouble.
# We won't be able to log about it though...
# Try to make some noise however we can.
print('LogHandler died!!!', file=sys.stderr)
import traceback
traceback.print_exc()
raise
def get_cached(self,
start_index: int = 0,
max_entries: int | None = None) -> LogArchive:
"""Build and return an archive of cached log entries.
This will only include entries that have been processed by the
background thread, so may not include just-submitted logs or
entries for partially written stdout/stderr lines.
Entries from the range [start_index:start_index+max_entries]
which are still present in the cache will be returned.
"""
assert start_index >= 0
if max_entries is not None:
assert max_entries >= 0
with self._cache_lock:
# Transform start_index to our present cache space.
start_index -= self._cache_index_offset
# Calc end-index in our present cache space.
end_index = (len(self._cache)
if max_entries is None else start_index + max_entries)
# Clamp both indexes to both ends of our present space.
start_index = max(0, min(start_index, len(self._cache)))
end_index = max(0, min(end_index, len(self._cache)))
return LogArchive(
log_size=self._cache_index_offset + len(self._cache),
start_index=start_index + self._cache_index_offset,
entries=[e[1] for e in self._cache[start_index:end_index]])
def emit(self, record: logging.LogRecord) -> None:
# Called by logging to send us records.
# We simply package them up and ship them to our thread.
# UPDATE: turns out we CAN get log messages from this thread
# (the C++ layer can spit out some performance metrics when
# calls take too long/etc.)
# assert current_thread() is not self._thread
# Special case - filter out this common extra-chatty category.
# TODO - should use a standard logging.Filter for this.
if (self._suppress_non_root_debug and record.name != 'root'
and record.levelname == 'DEBUG'):
return
# We want to forward as much as we can along without processing it
# (better to do so in a bg thread).
# However its probably best to flatten the message string here since
# it could cause problems stringifying things in threads where they
# didn't expect to be stringified.
msg = self.format(record)
# Also immediately print pretty colored output to our echo file
# (generally stderr). We do this part here instead of in our bg
# thread because the delay can throw off command line prompts or
# make tight debugging harder.
if self._echofile is not None:
ends = LEVELNO_COLOR_CODES.get(record.levelno)
if ends is not None:
self._echofile.write(f'{ends[0]}{msg}{ends[1]}\n')
else:
self._echofile.write(f'{msg}\n')
self._event_loop.call_soon_threadsafe(
tpartial(self._emit_in_thread, record.name, record.levelno,
record.created, msg))
def _emit_in_thread(self, name: str, levelno: int, created: float,
message: str) -> None:
try:
self._emit_entry(
LogEntry(name=name,
message=message,
level=LEVELNO_LOG_LEVELS.get(levelno, LogLevel.INFO),
time=datetime.datetime.fromtimestamp(
created, datetime.timezone.utc)))
except Exception:
import traceback
traceback.print_exc(file=self._echofile)
def file_write(self, name: str, output: str) -> None:
"""Send raw stdout/stderr output to the logger to be collated."""
self._event_loop.call_soon_threadsafe(
tpartial(self._file_write_in_thread, name, output))
def _file_write_in_thread(self, name: str, output: str) -> None:
try:
assert name in ('stdout', 'stderr')
# Here we try to be somewhat smart about breaking arbitrary
# print output into discrete log entries.
self._file_chunks[name].append(output)
# Individual parts of a print come across as separate writes,
# and the end of a print will be a standalone '\n' by default.
# Let's use that as a hint that we're likely at the end of
# a full print statement and ship what we've got.
if output == '\n':
self._ship_file_chunks(name, cancel_ship_task=True)
else:
# By default just keep adding chunks.
# However we keep a timer running anytime we've got
# unshipped chunks so that we can ship what we've got
# after a short bit if we never get a newline.
ship_task = self._file_chunk_ship_task[name]
if ship_task is None:
self._file_chunk_ship_task[name] = (
self._event_loop.create_task(
self._ship_chunks_task(name)))
except Exception:
import traceback
traceback.print_exc(file=self._echofile)
def file_flush(self, name: str) -> None:
"""Send raw stdout/stderr flush to the logger to be collated."""
self._event_loop.call_soon_threadsafe(
tpartial(self._file_flush_in_thread, name))
def _file_flush_in_thread(self, name: str) -> None:
try:
assert name in ('stdout', 'stderr')
# Immediately ship whatever chunks we've got.
if self._file_chunks[name]:
self._ship_file_chunks(name, cancel_ship_task=True)
except Exception:
import traceback
traceback.print_exc(file=self._echofile)
async def _ship_chunks_task(self, name: str) -> None:
await asyncio.sleep(0.1)
self._ship_file_chunks(name, cancel_ship_task=False)
def _ship_file_chunks(self, name: str, cancel_ship_task: bool) -> None:
# Note: Raw print input generally ends in a newline, but that is
# redundant when we break things into log entries and results
# in extra empty lines. So strip off a single trailing newline.
text = ''.join(self._file_chunks[name]).removesuffix('\n')
self._emit_entry(
LogEntry(name=name,
message=text,
level=LogLevel.INFO,
time=utc_now()))
self._file_chunks[name] = []
ship_task = self._file_chunk_ship_task[name]
if cancel_ship_task and ship_task is not None:
ship_task.cancel()
self._file_chunk_ship_task[name] = None
def _emit_entry(self, entry: LogEntry) -> None:
assert current_thread() is self._thread
# Store to our cache.
if self._cache_size_limit > 0:
with self._cache_lock:
# Do a rough calc of how many bytes this entry consumes.
entry_size = sum(
sys.getsizeof(x)
for x in (entry, entry.name, entry.message, entry.level,
entry.time))
self._cache.append((entry_size, entry))
self._cache_size += entry_size
# Prune old until we are back at or under our limit.
while self._cache_size > self._cache_size_limit:
popped = self._cache.pop(0)
self._cache_size -= popped[0]
self._cache_index_offset += 1
# Pass to callbacks.
with self._callbacks_lock:
for call in self._callbacks:
try:
call(entry)
except Exception:
# Only print one callback error to avoid insanity.
if not self._printed_callback_error:
import traceback
traceback.print_exc(file=self._echofile)
self._printed_callback_error = True
# Dump to our structured log file.
# TODO: set a timer for flushing; don't flush every line.
if self._file is not None:
entry_s = dataclass_to_json(entry)
assert '\n' not in entry_s # Make sure its a single line.
print(entry_s, file=self._file, flush=True)
class FileLogEcho:
"""A file-like object for forwarding stdout/stderr to a LogHandler."""
def __init__(self, original: TextIO, name: str,
handler: LogHandler) -> None:
assert name in ('stdout', 'stderr')
self._original = original
self._name = name
self._handler = handler
def write(self, output: Any) -> None:
"""Override standard write call."""
self._original.write(output)
self._handler.file_write(self._name, output)
def flush(self) -> None:
"""Flush the file."""
self._original.flush()
# We also use this as a hint to ship whatever file chunks
# we've accumulated (we have to try and be smart about breaking
# our arbitrary file output into discrete entries).
self._handler.file_flush(self._name)
def isatty(self) -> bool:
"""Are we a terminal?"""
return self._original.isatty()
def setup_logging(log_path: str | Path | None,
level: LogLevel,
suppress_non_root_debug: bool = False,
log_stdout_stderr: bool = False,
cache_size_limit: int = 0) -> LogHandler:
"""Set up our logging environment.
Returns the custom handler which can be used to fetch information
about logs that have passed through it. (worst log-levels, caches, etc.).
"""
lmap = {
LogLevel.DEBUG: logging.DEBUG,
LogLevel.INFO: logging.INFO,
LogLevel.WARNING: logging.WARNING,
LogLevel.ERROR: logging.ERROR,
LogLevel.CRITICAL: logging.CRITICAL,
}
# Wire logger output to go to a structured log file.
# Also echo it to stderr IF we're running in a terminal.
# UPDATE: Actually gonna always go to stderr. Is there a
# reason we shouldn't? This makes debugging possible if all
# we have is access to a non-interactive terminal or file dump.
# We could add a '--quiet' arg or whatnot to change this behavior.
# Note: by passing in the *original* stderr here before we
# (potentially) replace it, we ensure that our log echos
# won't themselves be intercepted and sent to the logger
# which would create an infinite loop.
loghandler = LogHandler(
log_path,
# echofile=sys.stderr if sys.stderr.isatty() else None,
echofile=sys.stderr,
suppress_non_root_debug=suppress_non_root_debug,
cache_size_limit=cache_size_limit)
# Note: going ahead with force=True here so that we replace any
# existing logger. Though we warn if it looks like we are doing
# that so we can try to avoid creating the first one.
had_previous_handlers = bool(logging.root.handlers)
logging.basicConfig(level=lmap[level],
format='%(message)s',
handlers=[loghandler],
force=True)
if had_previous_handlers:
logging.warning('setup_logging: force-replacing previous handlers.')
# Optionally intercept Python's stdout/stderr output and generate
# log entries from it.
if log_stdout_stderr:
sys.stdout = FileLogEcho( # type: ignore
sys.stdout, 'stdout', loghandler)
sys.stderr = FileLogEcho( # type: ignore
sys.stderr, 'stderr', loghandler)
return loghandler

View file

@ -11,15 +11,17 @@ from efro.message._protocol import MessageProtocol
from efro.message._sender import (MessageSender, BoundMessageSender)
from efro.message._receiver import (MessageReceiver, BoundMessageReceiver)
from efro.message._module import (create_sender_module, create_receiver_module)
from efro.message._message import (Message, Response, EmptyResponse,
ErrorResponse, StringResponse, BoolResponse,
from efro.message._message import (Message, Response, SysResponse,
EmptySysResponse, ErrorSysResponse,
StringResponse, BoolResponse,
UnregisteredMessageIDError)
__all__ = [
'Message', 'Response', 'EmptyResponse', 'ErrorResponse', 'StringResponse',
'BoolResponse', 'MessageProtocol', 'MessageSender', 'BoundMessageSender',
'MessageReceiver', 'BoundMessageReceiver', 'create_sender_module',
'create_receiver_module', 'UnregisteredMessageIDError'
'Message', 'Response', 'SysResponse', 'EmptySysResponse',
'ErrorSysResponse', 'StringResponse', 'BoolResponse', 'MessageProtocol',
'MessageSender', 'BoundMessageSender', 'MessageReceiver',
'BoundMessageReceiver', 'create_sender_module', 'create_receiver_module',
'UnregisteredMessageIDError'
]
# Have these things present themselves cleanly as 'thismodule.SomeClass'

View file

@ -24,52 +24,56 @@ class Message:
"""Base class for messages."""
@classmethod
def get_response_types(cls) -> list[type[Response]]:
"""Return all message types this Message can result in when sent.
def get_response_types(cls) -> list[type[Response] | None]:
"""Return all Response types this Message can return when sent.
The default implementation specifies EmptyResponse, so messages with
no particular response needs can leave this untouched.
Note that ErrorMessage is handled as a special case and does not
need to be specified here.
The default implementation specifies a None return type.
"""
return [EmptyResponse]
return [None]
class Response:
"""Base class for responses to messages."""
class SysResponse:
"""Base class for system-responses to messages.
These are only sent/handled by the messaging system itself;
users of the api never see them.
"""
# Some standard response types:
@ioprepped
@dataclass
class ErrorResponse(Response):
"""Message saying some error has occurred on the other end.
class ErrorSysResponse(SysResponse):
"""SysResponse saying some error has occurred for the send.
This type is unique in that it is not returned to the user; it
instead results in a local exception being raised.
This generally results in an Exception being raised for the caller.
"""
class ErrorType(Enum):
"""Type of error that occurred in remote message handling."""
OTHER = 0
CLEAN = 1
"""Type of error that occurred while sending a message."""
REMOTE = 0
REMOTE_CLEAN = 1
LOCAL = 2
COMMUNICATION = 3
error_message: Annotated[str, IOAttrs('m')]
error_type: Annotated[ErrorType, IOAttrs('e')] = ErrorType.OTHER
error_type: Annotated[ErrorType, IOAttrs('e')] = ErrorType.REMOTE
@ioprepped
@dataclass
class EmptyResponse(Response):
class EmptySysResponse(SysResponse):
"""The response equivalent of None."""
# TODO: could allow handlers to deal in raw values for these
# types similar to how we allow None in place of EmptyResponse.
# types similar to how we allow None in place of EmptySysResponse.
# Though not sure if they are widely used enough to warrant the
# extra code complexity.
@ioprepped

View file

@ -14,8 +14,9 @@ import json
from efro.error import CleanError
from efro.dataclassio import (is_ioprepped_dataclass, dataclass_to_dict,
dataclass_from_dict)
from efro.message._message import (Message, Response, ErrorResponse,
EmptyResponse, UnregisteredMessageIDError)
from efro.message._message import (Message, Response, SysResponse,
ErrorSysResponse, EmptySysResponse,
UnregisteredMessageIDError)
if TYPE_CHECKING:
from typing import Any, Literal
@ -33,37 +34,30 @@ class MessageProtocol:
def __init__(self,
message_types: dict[int, type[Message]],
response_types: dict[int, type[Response]],
preserve_clean_errors: bool = True,
receiver_logs_exceptions: bool = True,
receiver_returns_stack_traces: bool = False) -> None:
forward_clean_errors: bool = False,
remote_errors_include_stack_traces: bool = False) -> None:
"""Create a protocol with a given configuration.
Note that common response types are automatically registered
with (unchanging negative ids) so they don't need to be passed
explicitly (but can be if a different id is desired).
If 'preserve_clean_errors' is True, efro.error.CleanError
If 'forward_clean_errors' is True, efro.error.CleanError
exceptions raised on the receiver end will result in a matching
CleanError raised back on the sender. All other Exception types
come across as efro.error.RemoteError.
When 'receiver_logs_exceptions' is True, any uncaught Exceptions
on the receiver end will be logged there via logging.exception()
(in addition to the usual behavior of returning an ErrorResponse
to the sender). This is good to leave enabled if your
intention is to never return ErrorResponses. Looser setups
making routine use of CleanErrors or whatnot may want to
disable this, however.
If 'receiver_returns_stack_traces' is True, stringified stack
If 'remote_errors_include_stack_traces' is True, stringified stack
traces will be returned to the sender for exceptions occurring
on the receiver end. This can make debugging easier but should
only be used when the client is trusted to see such info.
"""
self.message_types_by_id: dict[int, type[Message]] = {}
self.message_ids_by_type: dict[type[Message], int] = {}
self.response_types_by_id: dict[int, type[Response]] = {}
self.response_ids_by_type: dict[type[Response], int] = {}
self.response_types_by_id: dict[int, type[Response]
| type[SysResponse]] = {}
self.response_ids_by_type: dict[type[Response] | type[SysResponse],
int] = {}
for m_id, m_type in message_types.items():
# Make sure only valid message types were passed and each
@ -85,32 +79,33 @@ class MessageProtocol:
self.response_types_by_id[r_id] = r_type
self.response_ids_by_type[r_type] = r_id
# Go ahead and auto-register a few common response types
# if the user has not done so explicitly. Use unique negative
# IDs which will never change or overlap with user ids.
def _reg_if_not(reg_tp: type[Response], reg_id: int) -> None:
if reg_tp in self.response_ids_by_type:
return
# Register our SysResponse types. These use negative
# IDs so as to never overlap with user Response types.
def _reg_sys(reg_tp: type[SysResponse], reg_id: int) -> None:
assert self.response_types_by_id.get(reg_id) is None
self.response_types_by_id[reg_id] = reg_tp
self.response_ids_by_type[reg_tp] = reg_id
_reg_if_not(ErrorResponse, -1)
_reg_if_not(EmptyResponse, -2)
_reg_sys(ErrorSysResponse, -1)
_reg_sys(EmptySysResponse, -2)
# Some extra-thorough validation in debug mode.
if __debug__:
# Make sure all Message types' return types are valid
# and have been assigned an ID as well.
all_response_types: set[type[Response]] = set()
all_response_types: set[type[Response] | None] = set()
for m_id, m_type in message_types.items():
m_rtypes = m_type.get_response_types()
assert isinstance(m_rtypes, list)
assert m_rtypes, (
f'Message type {m_type} specifies no return types.')
assert len(set(m_rtypes)) == len(m_rtypes) # check dups
all_response_types.update(m_rtypes)
for m_rtype in m_rtypes:
all_response_types.add(m_rtype)
for cls in all_response_types:
if cls is None:
continue
assert is_ioprepped_dataclass(cls)
assert issubclass(cls, Response)
if cls not in self.response_ids_by_type:
@ -127,9 +122,9 @@ class MessageProtocol:
'message_types contains duplicate __name__s;'
' all types are required to have unique names.')
self.preserve_clean_errors = preserve_clean_errors
self.receiver_logs_exceptions = receiver_logs_exceptions
self.receiver_returns_stack_traces = receiver_returns_stack_traces
self.forward_clean_errors = forward_clean_errors
self.remote_errors_include_stack_traces = (
remote_errors_include_stack_traces)
@staticmethod
def encode_dict(obj: dict) -> str:
@ -140,26 +135,27 @@ class MessageProtocol:
"""Encode a message to a json ready dict."""
return self._to_dict(message, self.message_ids_by_type, 'message')
def response_to_dict(self, response: Response) -> dict:
def response_to_dict(self, response: Response | SysResponse) -> dict:
"""Encode a response to a json ready dict."""
return self._to_dict(response, self.response_ids_by_type, 'response')
def error_to_response(self, exc: Exception) -> Response:
def error_to_response(self, exc: Exception) -> SysResponse:
"""Translate an error to a response."""
# Log any errors we got during handling if so desired.
if self.receiver_logs_exceptions:
logging.exception('Error handling message.')
# Log any errors we got during handling.
logging.exception('Error in efro.message handling.')
# If anything goes wrong, return a ErrorResponse instead.
if isinstance(exc, CleanError) and self.preserve_clean_errors:
return ErrorResponse(error_message=str(exc),
error_type=ErrorResponse.ErrorType.CLEAN)
return ErrorResponse(
# If anything goes wrong, return a ErrorSysResponse instead.
# (either CLEAN or generic REMOTE)
if isinstance(exc, CleanError) and self.forward_clean_errors:
return ErrorSysResponse(
error_message=str(exc),
error_type=ErrorSysResponse.ErrorType.REMOTE_CLEAN)
return ErrorSysResponse(
error_message=(traceback.format_exc()
if self.receiver_returns_stack_traces else
if self.remote_errors_include_stack_traces else
'An internal error has occurred.'),
error_type=ErrorResponse.ErrorType.OTHER)
error_type=ErrorSysResponse.ErrorType.REMOTE)
def _to_dict(self, message: Any, ids_by_type: dict[type, int],
opname: str) -> dict:
@ -185,10 +181,10 @@ class MessageProtocol:
assert isinstance(out, Message)
return out
def response_from_dict(self, data: dict) -> Response:
def response_from_dict(self, data: dict) -> Response | SysResponse:
"""Decode a response from a json string."""
out = self._from_dict(data, self.response_types_by_id, 'response')
assert isinstance(out, Response)
assert isinstance(out, Response | SysResponse)
return out
# Weeeird; we get mypy errors returning dict[int, type] but
@ -234,7 +230,7 @@ class MessageProtocol:
rsptypes.append(Response)
for rsp_tp in rsptypes:
# Skip these as they don't actually show up in code.
if rsp_tp is EmptyResponse or rsp_tp is ErrorResponse:
if rsp_tp is EmptySysResponse or rsp_tp is ErrorSysResponse:
continue
if (single_message_type and part == 'sender'
and rsp_tp is not Response):
@ -341,10 +337,8 @@ class MessageProtocol:
f'class {ppre}Bound{basename}(BoundMessageSender):\n'
f' """Protocol-specific bound sender."""\n')
def _filt_tp_name(rtype: type[Response]) -> str:
# We accept None to equal EmptyResponse so reflect that
# in the type annotation.
return 'None' if rtype is EmptyResponse else rtype.__name__
def _filt_tp_name(rtype: type[Response] | None) -> str:
return 'None' if rtype is None else rtype.__name__
# Define handler() overloads for all registered message types.
if msgtypes:
@ -381,6 +375,7 @@ class MessageProtocol:
for msgtype in msgtypes:
msgtypevar = msgtype.__name__
# rtypes = msgtype.get_response_types()
rtypes = msgtype.get_response_types()
if len(rtypes) > 1:
rtypevar = ' | '.join(
@ -438,10 +433,8 @@ class MessageProtocol:
# Define handler() overloads for all registered message types.
def _filt_tp_name(rtype: type[Response]) -> str:
# We accept None to equal EmptyResponse so reflect that
# in the type annotation.
return 'None' if rtype is EmptyResponse else rtype.__name__
def _filt_tp_name(rtype: type[Response] | None) -> str:
return 'None' if rtype is None else rtype.__name__
if msgtypes:
cbgn = 'Awaitable[' if is_async else ''

View file

@ -11,13 +11,14 @@ import inspect
import logging
from typing import TYPE_CHECKING
from efro.message._message import (Message, Response, EmptyResponse,
ErrorResponse, UnregisteredMessageIDError)
from efro.message._message import (Message, Response, EmptySysResponse,
UnregisteredMessageIDError)
if TYPE_CHECKING:
from typing import Any, Callable, Awaitable
from efro.message._protocol import MessageProtocol
from efro.message._message import SysResponse
class MessageReceiver:
@ -53,7 +54,8 @@ class MessageReceiver:
self._decode_filter_call: Callable[[Any, dict, Message],
None] | None = None
self._encode_filter_call: Callable[
[Any, Message | None, Response, dict], None] | None = None
[Any, Message | None, Response | SysResponse, dict],
None] | None = None
# TODO: don't currently have async encode equivalent
# or either for sender; can add as needed.
@ -106,26 +108,27 @@ class MessageReceiver:
assert issubclass(msgtype, Message)
ret = anns.get('return')
responsetypes: tuple[type[Any] | type[None], ...]
responsetypes: tuple[type[Any] | None, ...]
# Return types can be a single type or a union of types.
if isinstance(ret, (_GenericAlias, types.UnionType)):
targs = get_args(ret)
if not all(isinstance(a, type) for a in targs):
if not all(isinstance(a, (type, type(None))) for a in targs):
raise TypeError(f'expected only types for "return" annotation;'
f' got {targs}.')
responsetypes = targs
else:
if not isinstance(ret, type):
if not isinstance(ret, (type, type(None))):
raise TypeError(f'expected one or more types for'
f' "return" annotation; got a {type(ret)}.')
# This seems like maybe a mypy bug. Appeared after adding
# types.UnionType above.
responsetypes = (ret, ) # type: ignore
responsetypes = (ret, )
# Return type of None translates to EmptyResponse.
responsetypes = tuple(EmptyResponse if r is type(None) else r
for r in responsetypes) # noqa
# This will contain NoneType for empty return cases, but
# we expect it to be None.
responsetypes = tuple(None if r is type(None) else r
for r in responsetypes)
# Make sure our protocol has this message type registered and our
# return types exactly match. (Technically we could return a subset
@ -178,7 +181,9 @@ class MessageReceiver:
return call
def encode_filter_method(
self, call: Callable[[Any, Message | None, Response, dict], None]
self,
call: Callable[[Any, Message | None, Response | SysResponse, dict],
None]
) -> Callable[[Any, Message | None, Response, dict], None]:
"""Function decorator for defining an encode filter.
@ -236,17 +241,21 @@ class MessageReceiver:
response: Response | None) -> str:
"""Encode a response provided by the user for sending."""
# A return value of None equals EmptyResponse.
if response is None:
response = EmptyResponse()
assert isinstance(response, Response)
assert isinstance(response, Response | None)
# (user should never explicitly return error-responses)
assert not isinstance(response, ErrorResponse)
assert type(response) in message.get_response_types()
response_dict = self.protocol.response_to_dict(response)
assert (response is None
or type(response) in message.get_response_types())
# A return value of None equals EmptySysResponse.
out_response: Response | SysResponse
if response is None:
out_response = EmptySysResponse()
else:
out_response = response
response_dict = self.protocol.response_to_dict(out_response)
if self._encode_filter_call is not None:
self._encode_filter_call(bound_obj, message, response,
self._encode_filter_call(bound_obj, message, out_response,
response_dict)
return self.protocol.encode_dict(response_dict)
@ -280,7 +289,7 @@ class MessageReceiver:
if handler is None:
raise RuntimeError(f'Got unhandled message type: {msgtype}.')
response = handler(bound_obj, msg_decoded)
assert isinstance(response, (Response, type(None)))
assert isinstance(response, Response | None)
return self.encode_user_response(bound_obj, msg_decoded, response)
except Exception as exc:
@ -308,7 +317,7 @@ class MessageReceiver:
if handler is None:
raise RuntimeError(f'Got unhandled message type: {msgtype}.')
response = await handler(bound_obj, msg_decoded)
assert isinstance(response, (Response, type(None)))
assert isinstance(response, Response | None)
return self.encode_user_response(bound_obj, msg_decoded, response)
except Exception as exc:

View file

@ -7,19 +7,17 @@ Supports static typing for message types and possible return types.
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, TypeVar
from typing import TYPE_CHECKING
from efro.error import CleanError, RemoteError, CommunicationError
from efro.message._message import EmptyResponse, ErrorResponse
from efro.message._message import EmptySysResponse, ErrorSysResponse, Response
if TYPE_CHECKING:
from typing import Any, Callable, Awaitable
from efro.message._message import Message, Response
from efro.message._message import Message, SysResponse
from efro.message._protocol import MessageProtocol
TM = TypeVar('TM', bound='MessageSender')
class MessageSender:
"""Facilitates sending messages to a target and receiving responses.
@ -48,8 +46,8 @@ class MessageSender:
[Any, str], Awaitable[str]] | None = None
self._encode_filter_call: Callable[[Any, Message, dict],
None] | None = None
self._decode_filter_call: Callable[[Any, Message, dict, Response],
None] | None = None
self._decode_filter_call: Callable[
[Any, Message, dict, Response | SysResponse], None] | None = None
def send_method(
self, call: Callable[[Any, str],
@ -57,8 +55,9 @@ class MessageSender:
"""Function decorator for setting raw send method.
Send methods take strings and should return strings.
Any Exception raised during the send_method manifests as
a CommunicationError for the message sender.
CommunicationErrors raised here will be returned to the sender
as such; all other exceptions will result in a RuntimeError for
the sender.
"""
assert self._send_raw_message_call is None
self._send_raw_message_call = call
@ -70,8 +69,9 @@ class MessageSender:
"""Function decorator for setting raw send-async method.
Send methods take strings and should return strings.
Any Exception raised during the send_method manifests as
a CommunicationError for the message sender.
CommunicationErrors raised here will be returned to the sender
as such; all other exceptions will result in a RuntimeError for
the sender.
"""
assert self._send_async_raw_message_call is None
self._send_async_raw_message_call = call
@ -90,7 +90,8 @@ class MessageSender:
return call
def decode_filter_method(
self, call: Callable[[Any, Message, dict, Response], None]
self, call: Callable[[Any, Message, dict, Response | SysResponse],
None]
) -> Callable[[Any, Message, dict, Response], None]:
"""Function decorator for defining a decode filter.
@ -122,7 +123,8 @@ class MessageSender:
),
)
def send_split_part_1(self, bound_obj: Any, message: Message) -> Response:
def send_split_part_1(self, bound_obj: Any,
message: Message) -> Response | SysResponse:
"""Send a message synchronously.
Generally you can just call send(); these split versions are
@ -139,15 +141,16 @@ class MessageSender:
except Exception as exc:
# Any error in the raw send call gets recorded as either
# a local or communication error.
return ErrorResponse(
error_message=f'Error in send async method: {exc}',
error_type=(ErrorResponse.ErrorType.COMMUNICATION
return ErrorSysResponse(
error_message=
f'Error in MessageSender @send_method ({type(exc)}): {exc}',
error_type=(ErrorSysResponse.ErrorType.COMMUNICATION
if isinstance(exc, CommunicationError) else
ErrorResponse.ErrorType.LOCAL))
ErrorSysResponse.ErrorType.LOCAL))
return self._decode_raw_response(bound_obj, message, response_encoded)
async def send_split_part_1_async(self, bound_obj: Any,
message: Message) -> Response:
async def send_split_part_1_async(
self, bound_obj: Any, message: Message) -> Response | SysResponse:
"""Send a message asynchronously.
Generally you can just call send(); these split versions are
@ -165,15 +168,18 @@ class MessageSender:
except Exception as exc:
# Any error in the raw send call gets recorded as either
# a local or communication error.
return ErrorResponse(
error_message=f'Error in send async method: {exc}',
error_type=(ErrorResponse.ErrorType.COMMUNICATION
return ErrorSysResponse(
error_message=
f'Error in MessageSender @send_async_method ({type(exc)}):'
f' {exc}',
error_type=(ErrorSysResponse.ErrorType.COMMUNICATION
if isinstance(exc, CommunicationError) else
ErrorResponse.ErrorType.LOCAL))
ErrorSysResponse.ErrorType.LOCAL))
return self._decode_raw_response(bound_obj, message, response_encoded)
def send_split_part_2(self, message: Message,
raw_response: Response) -> Response | None:
def send_split_part_2(
self, message: Message,
raw_response: Response | SysResponse) -> Response | None:
"""Complete message sending (both sync and async).
Generally you can just call send(); these split versions are
@ -193,7 +199,7 @@ class MessageSender:
return self.protocol.encode_dict(msg_dict)
def _decode_raw_response(self, bound_obj: Any, message: Message,
response_encoded: str) -> Response:
response_encoded: str) -> Response | SysResponse:
"""Create a Response from returned data.
These Responses may encapsulate things like remote errors and
@ -201,6 +207,7 @@ class MessageSender:
should be used to translate to special values like None or raise
Exceptions. This function itself should never raise Exceptions.
"""
response: Response | SysResponse
try:
response_dict = self.protocol.decode_dict(response_encoded)
response = self.protocol.response_from_dict(response_dict)
@ -211,16 +218,17 @@ class MessageSender:
# If we got to this point, we successfully communicated
# with the other end so errors represent protocol mismatches
# or other invalid data. For now let's just log it but perhaps
# we'd want to somehow embed it in the ErrorResponse to be
# we'd want to somehow embed it in the ErrorSysResponse to be
# available directly to the user later.
logging.exception('Error decoding raw response')
response = ErrorResponse(
response = ErrorSysResponse(
error_message=
'Error decoding raw response; see log for details.',
error_type=ErrorResponse.ErrorType.LOCAL)
error_type=ErrorSysResponse.ErrorType.LOCAL)
return response
def _unpack_raw_response(self, raw_response: Response) -> Response | None:
def _unpack_raw_response(
self, raw_response: Response | SysResponse) -> Response | None:
"""Given a raw Response, unpacks to special values or Exceptions.
The result of this call is what should be passed to users.
@ -229,30 +237,31 @@ class MessageSender:
run such that any raised Exception is active when the callback
fires; not on the thread where the message was sent.
"""
# EmptyResponse translates to None
if isinstance(raw_response, EmptyResponse):
# EmptySysResponse translates to None
if isinstance(raw_response, EmptySysResponse):
return None
# Some error occurred. Raise a local Exception for it.
if isinstance(raw_response, ErrorResponse):
if isinstance(raw_response, ErrorSysResponse):
if (raw_response.error_type is
ErrorResponse.ErrorType.COMMUNICATION):
ErrorSysResponse.ErrorType.COMMUNICATION):
raise CommunicationError(raw_response.error_message)
# If something went wrong on our end of the connection,
# If something went wrong on *our* end of the connection,
# don't say it was a remote error.
if raw_response.error_type is ErrorResponse.ErrorType.LOCAL:
if raw_response.error_type is ErrorSysResponse.ErrorType.LOCAL:
raise RuntimeError(raw_response.error_message)
# If they want to support clean errors, do those.
if (self.protocol.preserve_clean_errors and
raw_response.error_type is ErrorResponse.ErrorType.CLEAN):
if (self.protocol.forward_clean_errors and raw_response.error_type
is ErrorSysResponse.ErrorType.REMOTE_CLEAN):
raise CleanError(raw_response.error_message)
# In all other cases, just say something went wrong 'out there'.
# Everything else gets lumped in as a remote error.
raise RemoteError(raw_response.error_message)
assert isinstance(raw_response, Response)
return raw_response
@ -289,15 +298,16 @@ class BoundMessageSender:
return await self._sender.send_async(bound_obj=self._obj,
message=message)
async def send_split_part_1_async_untyped(self,
message: Message) -> Response:
async def send_split_part_1_async_untyped(
self, message: Message) -> Response | SysResponse:
"""Split send (part 1 of 2)."""
assert self._obj is not None
return await self._sender.send_split_part_1_async(bound_obj=self._obj,
message=message)
def send_split_part_2_untyped(self, message: Message,
raw_response: Response) -> Response | None:
def send_split_part_2_untyped(
self, message: Message,
raw_response: Response | SysResponse) -> Response | None:
"""Split send (part 2 of 2)."""
return self._sender.send_split_part_2(message=message,
raw_response=raw_response)

View file

@ -441,8 +441,9 @@ class RPCEndpoint:
weakref.ref(
asyncio.create_task(
self._handle_raw_message(message_id=msgid, message=msg))))
self._debug_print_call(
f'{self._label}: done handling message at {self._tm()}.')
if self._debug_print:
self._debug_print_call(
f'{self._label}: done handling message at {self._tm()}.')
async def _handle_response_packet(self, big: bool) -> None:
assert self._peer_info is not None
@ -558,7 +559,7 @@ class RPCEndpoint:
# If that doesn't happen, make a fuss so we know to fix it.
# The other end will simply never get a response to this
# message.
logging.exception('Error handling message')
logging.exception('Error handling raw rpc message')
return
assert self._peer_info is not None

View file

@ -27,11 +27,11 @@ if TYPE_CHECKING:
from typing import Any, Callable, NoReturn
T = TypeVar('T')
TVAL = TypeVar('TVAL')
TARG = TypeVar('TARG')
TSELF = TypeVar('TSELF')
TRET = TypeVar('TRET')
TENUM = TypeVar('TENUM', bound=Enum)
ValT = TypeVar('ValT')
ArgT = TypeVar('ArgT')
SelfT = TypeVar('SelfT')
RetT = TypeVar('RetT')
EnumT = TypeVar('EnumT', bound=Enum)
class _EmptyObj:
@ -44,7 +44,7 @@ else:
Call = functools.partial
def enum_by_value(cls: type[TENUM], value: Any) -> TENUM:
def enum_by_value(cls: type[EnumT], value: Any) -> EnumT:
"""Create an enum from a value.
This is basically the same as doing 'obj = EnumType(value)' except
@ -251,15 +251,15 @@ class DirtyBit:
return False
class DispatchMethodWrapper(Generic[TARG, TRET]):
class DispatchMethodWrapper(Generic[ArgT, RetT]):
"""Type-aware standin for the dispatch func returned by dispatchmethod."""
def __call__(self, arg: TARG) -> TRET:
def __call__(self, arg: ArgT) -> RetT:
pass
@staticmethod
def register(
func: Callable[[Any, Any], TRET]) -> Callable[[Any, Any], TRET]:
func: Callable[[Any, Any], RetT]) -> Callable[[Any, Any], RetT]:
"""Register a new dispatch handler for this dispatch-method."""
registry: dict[Any, Callable]
@ -267,8 +267,8 @@ class DispatchMethodWrapper(Generic[TARG, TRET]):
# noinspection PyProtectedMember,PyTypeHints
def dispatchmethod(
func: Callable[[Any, TARG],
TRET]) -> DispatchMethodWrapper[TARG, TRET]:
func: Callable[[Any, ArgT],
RetT]) -> DispatchMethodWrapper[ArgT, RetT]:
"""A variation of functools.singledispatch for methods.
Note: as of Python 3.9 there is now functools.singledispatchmethod,
@ -307,7 +307,7 @@ def dispatchmethod(
return cast(DispatchMethodWrapper, wrapper)
def valuedispatch(call: Callable[[TVAL], TRET]) -> ValueDispatcher[TVAL, TRET]:
def valuedispatch(call: Callable[[ValT], RetT]) -> ValueDispatcher[ValT, RetT]:
"""Decorator for functions to allow dispatching based on a value.
This differs from functools.singledispatch in that it dispatches based
@ -318,21 +318,21 @@ def valuedispatch(call: Callable[[TVAL], TRET]) -> ValueDispatcher[TVAL, TRET]:
return ValueDispatcher(call)
class ValueDispatcher(Generic[TVAL, TRET]):
class ValueDispatcher(Generic[ValT, RetT]):
"""Used by the valuedispatch decorator"""
def __init__(self, call: Callable[[TVAL], TRET]) -> None:
def __init__(self, call: Callable[[ValT], RetT]) -> None:
self._base_call = call
self._handlers: dict[TVAL, Callable[[], TRET]] = {}
self._handlers: dict[ValT, Callable[[], RetT]] = {}
def __call__(self, value: TVAL) -> TRET:
def __call__(self, value: ValT) -> RetT:
handler = self._handlers.get(value)
if handler is not None:
return handler()
return self._base_call(value)
def _add_handler(self, value: TVAL,
call: Callable[[], TRET]) -> Callable[[], TRET]:
def _add_handler(self, value: ValT,
call: Callable[[], RetT]) -> Callable[[], RetT]:
if value in self._handlers:
raise RuntimeError(f'Duplicate handlers added for {value}')
self._handlers[value] = call
@ -340,42 +340,42 @@ class ValueDispatcher(Generic[TVAL, TRET]):
def register(
self,
value: TVAL) -> Callable[[Callable[[], TRET]], Callable[[], TRET]]:
value: ValT) -> Callable[[Callable[[], RetT]], Callable[[], RetT]]:
"""Add a handler to the dispatcher."""
from functools import partial
return partial(self._add_handler, value)
def valuedispatch1arg(
call: Callable[[TVAL, TARG],
TRET]) -> ValueDispatcher1Arg[TVAL, TARG, TRET]:
call: Callable[[ValT, ArgT],
RetT]) -> ValueDispatcher1Arg[ValT, ArgT, RetT]:
"""Like valuedispatch but for functions taking an extra argument."""
return ValueDispatcher1Arg(call)
class ValueDispatcher1Arg(Generic[TVAL, TARG, TRET]):
class ValueDispatcher1Arg(Generic[ValT, ArgT, RetT]):
"""Used by the valuedispatch1arg decorator"""
def __init__(self, call: Callable[[TVAL, TARG], TRET]) -> None:
def __init__(self, call: Callable[[ValT, ArgT], RetT]) -> None:
self._base_call = call
self._handlers: dict[TVAL, Callable[[TARG], TRET]] = {}
self._handlers: dict[ValT, Callable[[ArgT], RetT]] = {}
def __call__(self, value: TVAL, arg: TARG) -> TRET:
def __call__(self, value: ValT, arg: ArgT) -> RetT:
handler = self._handlers.get(value)
if handler is not None:
return handler(arg)
return self._base_call(value, arg)
def _add_handler(self, value: TVAL,
call: Callable[[TARG], TRET]) -> Callable[[TARG], TRET]:
def _add_handler(self, value: ValT,
call: Callable[[ArgT], RetT]) -> Callable[[ArgT], RetT]:
if value in self._handlers:
raise RuntimeError(f'Duplicate handlers added for {value}')
self._handlers[value] = call
return call
def register(
self, value: TVAL
) -> Callable[[Callable[[TARG], TRET]], Callable[[TARG], TRET]]:
self, value: ValT
) -> Callable[[Callable[[ArgT], RetT]], Callable[[ArgT], RetT]]:
"""Add a handler to the dispatcher."""
from functools import partial
return partial(self._add_handler, value)
@ -383,22 +383,22 @@ class ValueDispatcher1Arg(Generic[TVAL, TARG, TRET]):
if TYPE_CHECKING:
class ValueDispatcherMethod(Generic[TVAL, TRET]):
class ValueDispatcherMethod(Generic[ValT, RetT]):
"""Used by the valuedispatchmethod decorator."""
def __call__(self, value: TVAL) -> TRET:
def __call__(self, value: ValT) -> RetT:
...
def register(
self, value: TVAL
) -> Callable[[Callable[[TSELF], TRET]], Callable[[TSELF], TRET]]:
self, value: ValT
) -> Callable[[Callable[[SelfT], RetT]], Callable[[SelfT], RetT]]:
"""Add a handler to the dispatcher."""
...
def valuedispatchmethod(
call: Callable[[TSELF, TVAL],
TRET]) -> ValueDispatcherMethod[TVAL, TRET]:
call: Callable[[SelfT, ValT],
RetT]) -> ValueDispatcherMethod[ValT, RetT]:
"""Like valuedispatch but works with methods instead of functions."""
# NOTE: It seems that to wrap a method with a decorator and have self
@ -407,18 +407,18 @@ def valuedispatchmethod(
# in the function call dict and simply return a call.
_base_call = call
_handlers: dict[TVAL, Callable[[TSELF], TRET]] = {}
_handlers: dict[ValT, Callable[[SelfT], RetT]] = {}
def _add_handler(value: TVAL, addcall: Callable[[TSELF], TRET]) -> None:
def _add_handler(value: ValT, addcall: Callable[[SelfT], RetT]) -> None:
if value in _handlers:
raise RuntimeError(f'Duplicate handlers added for {value}')
_handlers[value] = addcall
def _register(value: TVAL) -> Callable[[Callable[[TSELF], TRET]], None]:
def _register(value: ValT) -> Callable[[Callable[[SelfT], RetT]], None]:
from functools import partial
return partial(_add_handler, value)
def _call_wrapper(self: TSELF, value: TVAL) -> TRET:
def _call_wrapper(self: SelfT, value: ValT) -> RetT:
handler = _handlers.get(value)
if handler is not None:
return handler(self)
@ -433,7 +433,7 @@ def valuedispatchmethod(
# In reality we just return a raw function call (for reasons listed above).
# pylint: disable=undefined-variable, no-else-return
if TYPE_CHECKING:
return ValueDispatcherMethod[TVAL, TRET]()
return ValueDispatcherMethod[ValT, RetT]()
else:
return _call_wrapper