updated to 1.7.20

This commit is contained in:
Ayush Saini 2023-01-30 23:35:08 +05:30
parent 0dfe2de8e5
commit dab1db4141
67 changed files with 5236 additions and 2272 deletions

View file

@ -102,6 +102,7 @@ from ba._error import (
WidgetNotFoundError,
ActivityNotFoundError,
TeamNotFoundError,
MapNotFoundError,
SessionTeamNotFoundError,
SessionNotFoundError,
DelegateNotFoundError,
@ -282,6 +283,7 @@ __all__ = [
'Lobby',
'Lstr',
'Map',
'MapNotFoundError',
'Material',
'MetadataSubsystem',
'Model',

View file

@ -36,7 +36,7 @@ class AccountV2Subsystem:
# (or lack thereof) has completed. This includes things like
# workspace syncing. Completion of this is what flips the app
# into 'running' state.
self._initial_login_completed = False
self._initial_sign_in_completed = False
self._kicked_off_workspace_load = False
@ -98,7 +98,7 @@ class AccountV2Subsystem:
if account.workspaceid is not None:
assert account.workspacename is not None
if (
not self._initial_login_completed
not self._initial_sign_in_completed
and not self._kicked_off_workspace_load
):
self._kicked_off_workspace_load = True
@ -121,9 +121,9 @@ class AccountV2Subsystem:
return
# Ok; no workspace to worry about; carry on.
if not self._initial_login_completed:
self._initial_login_completed = True
_ba.app.on_initial_login_completed()
if not self._initial_sign_in_completed:
self._initial_sign_in_completed = True
_ba.app.on_initial_sign_in_completed()
def on_active_logins_changed(self, logins: dict[LoginType, str]) -> None:
"""Should be called when logins for the active account change."""
@ -156,9 +156,9 @@ class AccountV2Subsystem:
within a few seconds of app launch; the app can move forward
with the startup sequence at that point.
"""
if not self._initial_login_completed:
self._initial_login_completed = True
_ba.app.on_initial_login_completed()
if not self._initial_sign_in_completed:
self._initial_sign_in_completed = True
_ba.app.on_initial_sign_in_completed()
@staticmethod
def _hashstr(val: str) -> str:
@ -271,7 +271,7 @@ class AccountV2Subsystem:
self._implicit_state_changed = False
# Once we've made a move here we don't want to
# do any more automatic ones.
# do any more automatic stuff.
self._can_do_auto_sign_in = False
else:
@ -290,22 +290,23 @@ class AccountV2Subsystem:
' of implicit state change...',
)
self._implicit_signed_in_adapter.sign_in(
self._on_explicit_sign_in_completed
self._on_explicit_sign_in_completed,
description='implicit state change',
)
self._implicit_state_changed = False
# Once we've made a move here we don't want to
# do any more automatic ones.
# do any more automatic stuff.
self._can_do_auto_sign_in = False
if not self._can_do_auto_sign_in:
return
# If we're not currently signed in, we have connectivity, and
# we have an available implicit login, auto-sign-in with it.
# we have an available implicit login, auto-sign-in with it once.
# The implicit-state-change logic above should keep things
# mostly in-sync, but due to connectivity or other issues that
# might not always be the case. We prefer to keep people signed
# mostly in-sync, but that might not always be the case due to
# connectivity or other issues. We prefer to keep people signed
# in as a rule, even if there are corner cases where this might
# not be what they want (A user signing out and then restarting
# may be auto-signed back in).
@ -324,7 +325,7 @@ class AccountV2Subsystem:
)
self._can_do_auto_sign_in = False # Only ATTEMPT once
self._implicit_signed_in_adapter.sign_in(
self._on_implicit_sign_in_completed
self._on_implicit_sign_in_completed, description='auto-sign-in'
)
def _on_explicit_sign_in_completed(
@ -337,8 +338,8 @@ class AccountV2Subsystem:
del adapter # Unused.
# Make some noise on errors since the user knows
# a sign-in attempt is happening in this case.
# Make some noise on errors since the user knows a
# sign-in attempt is happening in this case (the 'explicit' part).
if isinstance(result, Exception):
# We expect the occasional communication errors;
# Log a full exception for anything else though.
@ -347,6 +348,8 @@ class AccountV2Subsystem:
'Error on explicit accountv2 sign in attempt.',
exc_info=result,
)
# For now just show 'error'. Should do better than this.
with _ba.Context('ui'):
_ba.screenmessage(
Lstr(resource='internal.signInErrorText'),
@ -395,9 +398,9 @@ class AccountV2Subsystem:
_ba.app.accounts_v2.set_primary_credentials(result.credentials)
def _on_set_active_workspace_completed(self) -> None:
if not self._initial_login_completed:
self._initial_login_completed = True
_ba.app.on_initial_login_completed()
if not self._initial_sign_in_completed:
self._initial_sign_in_completed = True
_ba.app.on_initial_sign_in_completed()
class AccountV2Handle:

View file

@ -20,6 +20,7 @@ from ba._meta import MetadataSubsystem
from ba._ads import AdsSubsystem
from ba._net import NetworkSubsystem
from ba._workspace import WorkspaceSubsystem
from ba._appcomponent import AppComponentSubsystem
from ba import _internal
if TYPE_CHECKING:
@ -58,20 +59,26 @@ class App:
class State(Enum):
"""High level state the app can be in."""
# Python-level systems being inited but should not interact.
LAUNCHING = 0
# The launch process has not yet begun.
INITIAL = 0
# Initial account logins, workspace & asset downloads, etc.
LOADING = 1
# Our app subsystems are being inited but should not yet interact.
LAUNCHING = 1
# Normal running state.
RUNNING = 2
# App subsystems are inited and interacting, but the app has not
# yet embarked on a high level course of action. It is doing initial
# account logins, workspace & asset downloads, etc. in order to
# prepare for this.
LOADING = 2
# App is backgrounded or otherwise suspended.
PAUSED = 3
# All pieces are in place and the app is now doing its thing.
RUNNING = 3
# App is shutting down.
SHUTTING_DOWN = 4
# The app is backgrounded or otherwise suspended.
PAUSED = 4
# The app is shutting down.
SHUTTING_DOWN = 5
@property
def aioloop(self) -> asyncio.AbstractEventLoop:
@ -128,7 +135,7 @@ class App:
@property
def debug_build(self) -> bool:
"""Whether the game was compiled in debug mode.
"""Whether the app was compiled in debug mode.
Debug builds generally run substantially slower than non-debug
builds due to compiler optimizations being disabled and extra
@ -232,11 +239,14 @@ class App:
"""
# pylint: disable=too-many-statements
self.state = self.State.LAUNCHING
self.state = self.State.INITIAL
self._bootstrapping_completed = False
self._called_on_app_launching = False
self._launch_completed = False
self._initial_login_completed = False
self._initial_sign_in_completed = False
self._meta_scan_completed = False
self._called_on_app_loading = False
self._called_on_app_running = False
self._app_paused = False
@ -294,6 +304,7 @@ class App:
# Server Mode.
self.server: ba.ServerController | None = None
self.components = AppComponentSubsystem()
self.meta = MetadataSubsystem()
self.accounts_v1 = AccountV1Subsystem()
self.plugins = PluginSubsystem()
@ -342,10 +353,8 @@ class App:
self.delegate: ba.AppDelegate | None = None
self._asyncio_timer: ba.Timer | None = None
def on_app_launch(self) -> None:
"""Runs after the app finishes low level bootstrapping.
(internal)"""
def on_app_launching(self) -> None:
"""Called when the app is first entering the launching state."""
# pylint: disable=cyclic-import
# pylint: disable=too-many-locals
from ba import _asyncio
@ -473,6 +482,9 @@ class App:
self._launch_completed = True
self._update_state()
def on_app_loading(self) -> None:
"""Called when initially entering the loading state."""
def on_app_running(self) -> None:
"""Called when initially entering the running state."""
@ -481,6 +493,13 @@ class App:
# from ba._dependency import test_depset
# test_depset()
def on_bootstrapping_completed(self) -> None:
"""Called by the C++ layer once its ready to rock."""
assert _ba.in_logic_thread()
assert not self._bootstrapping_completed
self._bootstrapping_completed = True
self._update_state()
def on_meta_scan_complete(self) -> None:
"""Called by meta-scan when it is done doing its thing."""
assert _ba.in_logic_thread()
@ -511,15 +530,25 @@ class App:
self.plugins.on_app_resume()
self.health_monitor.on_app_resume()
if self._initial_login_completed and self._meta_scan_completed:
# Handle initially entering or returning to other states.
if self._initial_sign_in_completed and self._meta_scan_completed:
self.state = self.State.RUNNING
if not self._called_on_app_running:
self._called_on_app_running = True
self.on_app_running()
elif self._launch_completed:
self.state = self.State.LOADING
if not self._called_on_app_loading:
self._called_on_app_loading = True
self.on_app_loading()
else:
# Only thing left is launching. We shouldn't be getting
# called before at least that is complete.
assert self._bootstrapping_completed
self.state = self.State.LAUNCHING
if not self._called_on_app_launching:
self._called_on_app_launching = True
self.on_app_launching()
def on_app_pause(self) -> None:
"""Called when the app goes to a suspended state."""
@ -724,8 +753,8 @@ class App:
_ba.screenmessage(Lstr(resource='errorText'), color=(1, 0, 0))
_ba.playsound(_ba.getsound('error'))
def on_initial_login_completed(self) -> None:
"""Callback to be run after initial login process (or lack thereof).
def on_initial_sign_in_completed(self) -> None:
"""Callback to be run after initial sign-in (or lack thereof).
This period includes things such as syncing account workspaces
or other data so it may take a substantial amount of time.
@ -736,5 +765,5 @@ class App:
# (account workspaces).
self.meta.start_extra_scan()
self._initial_login_completed = True
self._initial_sign_in_completed = True
self._update_state()

90
dist/ba_data/python/ba/_appcomponent.py vendored Normal file
View file

@ -0,0 +1,90 @@
# Released under the MIT License. See LICENSE for details.
#
"""Provides the AppComponent class."""
from __future__ import annotations
from typing import TYPE_CHECKING, TypeVar, cast
import _ba
if TYPE_CHECKING:
from typing import Callable, Any
T = TypeVar('T', bound=type)
class AppComponentSubsystem:
"""Subsystem for wrangling AppComponents.
Category: **App Classes**
This subsystem acts as a registry for classes providing particular
functionality for the app, and allows plugins or other custom code to
easily override said functionality.
Use ba.app.components to get the single shared instance of this class.
The general idea with this setup is that a base-class is defined to
provide some functionality and then anyone wanting that functionality
uses the getclass() method with that base class to return the current
registered implementation. The user should not know or care whether
they are getting the base class itself or some other implementation.
Change-callbacks can also be requested for base classes which will
fire in a deferred manner when particular base-classes are overridden.
"""
def __init__(self) -> None:
self._implementations: dict[type, type] = {}
self._prev_implementations: dict[type, type] = {}
self._dirty_base_classes: set[type] = set()
self._change_callbacks: dict[type, list[Callable[[Any], None]]] = {}
def setclass(self, baseclass: type, implementation: type) -> None:
"""Set the class providing an implementation of some base-class.
The provided implementation class must be a subclass of baseclass.
"""
# Currently limiting this to logic-thread use; can revisit if needed
# (would need to guard access to our implementations dict).
assert _ba.in_logic_thread()
if not issubclass(implementation, baseclass):
raise TypeError(
f'Implementation {implementation}'
f' is not a subclass of baseclass {baseclass}.'
)
self._implementations[baseclass] = implementation
# If we're the first thing getting dirtied, set up a callback to
# clean everything. And add ourself to the dirty list regardless.
if not self._dirty_base_classes:
_ba.pushcall(self._run_change_callbacks)
self._dirty_base_classes.add(baseclass)
def getclass(self, baseclass: T) -> T:
"""Given a base-class, return the currently set implementation class.
If no custom implementation has been set, the provided base-class
is returned.
"""
assert _ba.in_logic_thread()
del baseclass # Unused.
return cast(T, None)
def register_change_callback(
self, baseclass: T, callback: Callable[[T], None]
) -> None:
"""Register a callback to fire when a class implementation changes.
The callback will be scheduled to run in the logic thread event
loop. Note that any further setclass calls before the callback
runs will not result in additional callbacks.
"""
assert _ba.in_logic_thread()
self._change_callbacks.setdefault(baseclass, []).append(callback)
def _run_change_callbacks(self) -> None:
pass

View file

@ -476,35 +476,18 @@ def on_too_many_file_descriptors() -> None:
real_time = _ba.time(TimeType.REAL)
def _do_log() -> None:
import subprocess
pid = os.getpid()
out = f'TOO MANY FDS at {real_time}.\nWe are pid {pid}\n'
out += (
'FD Count: '
+ subprocess.run(
f'ls -l /proc/{pid}/fd | wc -l',
shell=True,
check=False,
capture_output=True,
).stdout.decode()
+ '\n'
try:
fdcount: int | str = len(os.listdir(f'/proc/{pid}/fd'))
except Exception as exc:
fdcount = f'? ({exc})'
logging.warning(
'TOO MANY FDS at %.2f. We are pid %d. FDCount is %s.',
real_time,
pid,
fdcount,
)
out += (
'lsof output:\n'
+ subprocess.run(
f'lsof -p {pid}',
shell=True,
check=False,
capture_output=True,
).stdout.decode()
+ '\n'
)
logging.warning(out)
Thread(target=_do_log, daemon=True).start()
# import io

View file

@ -47,7 +47,7 @@ def bootstrap() -> None:
# Give a soft warning if we're being used with a different binary
# version than we expect.
expected_build = 20982
expected_build = 21005
running_build: int = env['build_number']
if running_build != expected_build:
print(

View file

@ -69,6 +69,13 @@ class TeamNotFoundError(NotFoundError):
"""
class MapNotFoundError(NotFoundError):
"""Exception raised when an expected ba.Map does not exist.
Category: **Exception Classes**
"""
class DelegateNotFoundError(NotFoundError):
"""Exception raised when an expected delegate object does not exist.

View file

@ -14,7 +14,7 @@ from ba._activity import Activity
from ba._score import ScoreConfig
from ba._language import Lstr
from ba._messages import PlayerDiedMessage, StandMessage
from ba._error import NotFoundError, print_error, print_exception
from ba._error import MapNotFoundError, print_error, print_exception
from ba._general import Call, WeakCall
from ba._player import PlayerInfo
from ba import _map
@ -274,10 +274,10 @@ class GameActivity(Activity[PlayerType, TeamType]):
def map(self) -> ba.Map:
"""The map being used for this game.
Raises a ba.NotFoundError if the map does not currently exist.
Raises a ba.MapNotFoundError if the map does not currently exist.
"""
if self._map is None:
raise NotFoundError
raise MapNotFoundError
return self._map
def get_instance_display_string(self) -> ba.Lstr:

View file

@ -1,13 +1,13 @@
# Released under the MIT License. See LICENSE for details.
#
"""Snippets of code for use by the internal C++ layer.
"""Snippets of code for use by the internal layer.
History: originally I would dynamically compile/eval bits of Python text
from within C++ code, but the major downside there was that none of that was
type-checked so if names or arguments changed I would never catch code breakage
until the code was next run. By defining all snippets I use here and then
capturing references to them all at launch I can immediately verify everything
I'm looking for exists and pylint/mypy can do their magic on this file.
History: originally the engine would dynamically compile/eval various Python
code from within C++ code, but the major downside there was that none of it
was type-checked so if names or arguments changed it would go unnoticed
until it broke at runtime. By instead defining such snippets here and then
capturing references to them all at launch it is possible to allow linting
and type-checking magic to happen and most issues will be caught immediately.
"""
# (most of these are self-explanatory)
# pylint: disable=missing-function-docstring
@ -27,12 +27,8 @@ def finish_bootstrapping() -> None:
"""Do final bootstrapping related bits."""
assert _ba.in_logic_thread()
# Kick off our asyncio event handling, allowing us to use coroutines
# in our logic thread alongside our internal event handling.
# setup_asyncio()
# Ok, bootstrapping is done; time to get the show started.
_ba.app.on_app_launch()
# Ok, low level bootstrapping is done; time to get Python stuff started.
_ba.app.on_bootstrapping_completed()
def reset_to_main_menu() -> None:
@ -495,3 +491,33 @@ def login_adapter_get_sign_in_token_response(
adapter = _ba.app.accounts_v2.login_adapters[login_type]
assert isinstance(adapter, LoginAdapterNative)
adapter.on_sign_in_complete(attempt_id=attempt_id, result=result)
def show_client_too_old_error() -> None:
"""Called at launch if the server tells us we're too old to talk to it."""
from ba._language import Lstr
# If you are using an old build of the app and would like to stop
# seeing this error at launch, do:
# ba.app.config['SuppressClientTooOldErrorForBuild'] = ba.app.build_number
# ba.app.config.commit()
# Note that you will have to do that again later if you update to
# a newer build.
if (
_ba.app.config.get('SuppressClientTooOldErrorForBuild')
== _ba.app.build_number
):
return
_ba.playsound(_ba.getsound('error'))
_ba.screenmessage(
Lstr(
translate=(
'serverResponses',
'Server functionality is no longer supported'
' in this version of the game;\n'
'Please update to a newer version.',
)
),
color=(1, 0, 0),
)

View file

@ -4,6 +4,7 @@
from __future__ import annotations
import time
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING, final
@ -57,6 +58,9 @@ class LoginAdapter:
# current active primary account.
self._active_login_id: str | None = None
self._last_sign_in_time: float | None = None
self._last_sign_in_desc: str | None = None
def on_app_launch(self) -> None:
"""Should be called for each adapter in on_app_launch."""
@ -142,6 +146,7 @@ class LoginAdapter:
def sign_in(
self,
result_cb: Callable[[LoginAdapter, SignInResult | Exception], None],
description: str,
) -> None:
"""Attempt an explicit sign in via this adapter.
@ -151,6 +156,38 @@ class LoginAdapter:
"""
assert _ba.in_logic_thread()
from ba._general import Call
from ba._generated.enums import TimeType
# Have been seeing multiple sign-in attempts come through
# nearly simultaneously which can be problematic server-side.
# Let's error if a sign-in attempt is made within a few seconds
# of the last one to address this.
now = time.monotonic()
appnow = _ba.time(TimeType.REAL)
if self._last_sign_in_time is not None:
since_last = now - self._last_sign_in_time
if since_last < 1.0:
logging.warning(
'LoginAdapter: %s adapter sign_in() called too soon'
' (%.2fs) after last; this-desc="%s", last-desc="%s",'
' ba-real-time=%.2f.',
self.login_type.name,
since_last,
description,
self._last_sign_in_desc,
appnow,
)
_ba.pushcall(
Call(
result_cb,
self,
RuntimeError('sign_in called too soon after last.'),
)
)
return
self._last_sign_in_desc = description
self._last_sign_in_time = now
if DEBUG_LOG:
logging.debug(
@ -223,7 +260,12 @@ class LoginAdapter:
_ba.pushcall(Call(result_cb, self, result2))
_ba.app.cloud.send_message_cb(
bacommon.cloud.SignInMessage(self.login_type, result),
bacommon.cloud.SignInMessage(
self.login_type,
result,
description=description,
apptime=appnow,
),
on_response=_got_sign_in_response,
)

View file

@ -9,6 +9,8 @@ import copy
import logging
from typing import Any, TYPE_CHECKING
import _ba
if TYPE_CHECKING:
from typing import Sequence
from ba import _session
@ -34,6 +36,7 @@ def filter_playlist(
# pylint: disable=too-many-branches
# pylint: disable=too-many-statements
from ba._map import get_filtered_map_name
from ba._error import MapNotFoundError
from ba._store import get_unowned_maps, get_unowned_game_types
from ba._general import getclass
from ba._gameactivity import GameActivity
@ -160,7 +163,7 @@ def filter_playlist(
gameclass = getclass(entry['type'], GameActivity)
if entry['settings']['map'] not in available_maps:
raise ImportError(f"Map not found: '{entry['settings']['map']}'")
raise MapNotFoundError()
if remove_unowned and gameclass in unowned_game_types:
continue
@ -176,15 +179,22 @@ def filter_playlist(
for setting in neededsettings:
if setting.name not in entry['settings']:
entry['settings'][setting.name] = setting.default
goodlist.append(entry)
except MapNotFoundError:
logging.warning(
'Map \'%s\' not found while scanning playlist \'%s\'.',
name,
entry['settings']['map'],
)
except ImportError as exc:
logging.warning(
'Import failed while scanning playlist \'%s\': %s', name, exc
)
except Exception:
from ba import _error
logging.exception('Error in filter_playlist.')
_error.print_exception()
return goodlist

View file

@ -22,6 +22,9 @@ class PluginSubsystem:
Access the single shared instance of this class at `ba.app.plugins`.
"""
AUTO_ENABLE_NEW_PLUGINS_CONFIG_KEY = 'Auto Enable New Plugins'
AUTO_ENABLE_NEW_PLUGINS_DEFAULT = True
def __init__(self) -> None:
self.potential_plugins: list[ba.PotentialPlugin] = []
self.active_plugins: dict[str, ba.Plugin] = {}
@ -48,13 +51,20 @@ class PluginSubsystem:
available=True,
)
)
if class_path not in plugstates:
# Go ahead and enable new plugins by default, but we'll
# inform the user that they need to restart to pick them up.
# they can also disable them in settings so they never load.
plugstates[class_path] = {'enabled': True}
config_changed = True
found_new = True
if (
_ba.app.config.get(
self.AUTO_ENABLE_NEW_PLUGINS_CONFIG_KEY,
self.AUTO_ENABLE_NEW_PLUGINS_DEFAULT,
)
is True
):
if class_path not in plugstates:
# Go ahead and enable new plugins by default, but we'll
# inform the user that they need to restart to pick them up.
# they can also disable them in settings so they never load.
plugstates[class_path] = {'enabled': True}
config_changed = True
found_new = True
plugs.potential_plugins.sort(key=lambda p: p.class_path)

View file

@ -4,6 +4,7 @@
from __future__ import annotations
import threading
from collections import deque
from typing import TYPE_CHECKING
import _ba
@ -68,7 +69,7 @@ class _MacMusicAppThread(threading.Thread):
def __init__(self) -> None:
super().__init__()
self._commands_available = threading.Event()
self._commands: list[list] = []
self._commands = deque[list]()
self._volume = 1.0
self._current_playlist: str | None = None
self._orig_volume: int | None = None
@ -109,7 +110,7 @@ class _MacMusicAppThread(threading.Thread):
# We're not protecting this list with a mutex but we're
# just using it as a simple queue so it should be fine.
while self._commands:
cmd = self._commands.pop(0)
cmd = self._commands.popleft()
if cmd[0] == 'DIE':
self._handle_die_command()
done = True

View file

@ -183,6 +183,10 @@ class SignInMessage(Message):
login_type: Annotated[LoginType, IOAttrs('l')]
sign_in_token: Annotated[str, IOAttrs('t')]
# For debugging. Can remove soft_default once build 20988+ is ubiquitous.
description: Annotated[str, IOAttrs('d', soft_default='-')]
apptime: Annotated[float, IOAttrs('at', soft_default=-1.0)]
@classmethod
def get_response_types(cls) -> list[type[Response] | None]:
return [SignInResponse]

View file

@ -88,12 +88,12 @@ class Spaz(ba.Actor):
self.play_big_death_sound = False
# scales how much impacts affect us (most damage calcs)
# Scales how much impacts affect us (most damage calcs).
self.impact_scale = 1.0
self.source_player = source_player
self._dead = False
if self._demo_mode: # preserve old behavior
if self._demo_mode: # Preserve old behavior.
self._punch_power_scale = 1.2
else:
self._punch_power_scale = factory.punch_power_scale
@ -180,6 +180,7 @@ class Spaz(ba.Actor):
self._bomb_wear_off_flash_timer: ba.Timer | None = None
self._multi_bomb_wear_off_timer: ba.Timer | None = None
self._multi_bomb_wear_off_flash_timer: ba.Timer | None = None
self._curse_timer: ba.Timer | None = None
self.bomb_count = self.default_bomb_count
self._max_bomb_count = self.default_bomb_count
self.bomb_type_default = self.default_bomb_type
@ -262,7 +263,7 @@ class Spaz(ba.Actor):
def _turbo_filter_add_press(self, source: str) -> None:
"""
Can pass all button presses through here; if we see an obscene number
of them in a short time let's shame/pushish this guy for using turbo
of them in a short time let's shame/pushish this guy for using turbo.
"""
t_ms = ba.time(
timetype=ba.TimeType.BASE, timeformat=ba.TimeFormat.MILLISECONDS
@ -620,7 +621,9 @@ class Spaz(ba.Actor):
self.node.curse_death_time = int(
1000.0 * (tval + self.curse_time)
)
ba.timer(5.0, ba.WeakCall(self.curse_explode))
self._curse_timer = ba.Timer(
5.0, ba.WeakCall(self.curse_explode)
)
def equip_boxing_gloves(self) -> None:
"""

View file

@ -63,7 +63,7 @@ class MainMenuActivity(ba.Activity[ba.Player, ba.Team]):
'scale': scale,
'position': (0, 10),
'vr_depth': -10,
'text': '\xa9 2011-2022 Eric Froemling',
'text': '\xa9 2011-2023 Eric Froemling',
},
)
)

File diff suppressed because it is too large Load diff

View file

@ -1403,7 +1403,8 @@ class AccountSettingsWindow(ba.Window):
if adapter is not None:
self._signing_in_adapter = adapter
adapter.sign_in(
result_cb=ba.WeakCall(self._on_adapter_sign_in_result)
result_cb=ba.WeakCall(self._on_adapter_sign_in_result),
description='account settings button',
)
# Will get 'Signing in...' to show.
self._needs_refresh = True

View file

@ -234,6 +234,7 @@ class ManualGatherTab(GatherTab):
c_width = region_width
c_height = region_height - 20
last_addr = ba.app.config.get('Last Manual Party Connect Address', '')
last_port = ba.app.config.get('Last Manual Party Connect Port', 43210)
v = c_height - 70
v -= 70
ba.textwidget(
@ -256,6 +257,7 @@ class ManualGatherTab(GatherTab):
autoselect=True,
v_align='center',
scale=1.0,
maxwidth=380,
size=(420, 60),
)
ba.widget(edit=self._join_by_address_text, down_widget=txt)
@ -275,7 +277,7 @@ class ManualGatherTab(GatherTab):
parent=self._container,
editable=True,
description=ba.Lstr(resource='gatherWindow.' 'portText'),
text='43210',
text=str(last_port),
autoselect=True,
max_chars=5,
position=(c_width * 0.5 - 240 + 490, v - 30),
@ -811,6 +813,7 @@ class ManualGatherTab(GatherTab):
# Store for later.
config = ba.app.config
config['Last Manual Party Connect Address'] = resolved_address
config['Last Manual Party Connect Port'] = port
config.commit()
ba.internal.connect_to_party(resolved_address, port=port)

View file

@ -571,6 +571,7 @@ class PublicGatherTab(GatherTab):
h_align='left',
v_align='center',
editable=True,
maxwidth=310,
description=filter_txt,
)
ba.widget(edit=self._filter_text, up_widget=self._join_text)

View file

@ -211,9 +211,8 @@ class PartyWindow(ba.Window):
flatness=1.0,
)
self._chat_texts.append(txt)
if len(self._chat_texts) > 40:
first = self._chat_texts.pop(0)
first.delete()
while len(self._chat_texts) > 40:
self._chat_texts.pop(0).delete()
ba.containerwidget(edit=self._columnwidget, visible_child=txt)
def _on_menu_button_press(self) -> None:

View file

@ -473,7 +473,6 @@ class PlaylistEditGameWindow(ba.Window):
# Ok now wire up the column.
try:
# pylint: disable=unsubscriptable-object
prev_widgets: list[ba.Widget] | None = None
for cwdg in widget_column:
if prev_widgets is not None:

View file

@ -134,7 +134,7 @@ class AdvancedSettingsWindow(ba.Window):
parent=self._root_widget,
position=(0, self._height - 52),
size=(self._width, 25),
text=ba.Lstr(resource=self._r + '.titleText'),
text=ba.Lstr(resource=f'{self._r}.titleText'),
color=app.ui.title_color,
h_align='center',
v_align='top',
@ -203,10 +203,10 @@ class AdvancedSettingsWindow(ba.Window):
text=''
if ba.app.lang.language == 'Test'
else ba.Lstr(
resource=self._r + '.translationNoUpdateNeededText'
resource=f'{self._r}.translationNoUpdateNeededText'
)
if up_to_date
else ba.Lstr(resource=self._r + '.translationUpdateNeededText'),
else ba.Lstr(resource=f'{self._r}.translationUpdateNeededText'),
color=(0.2, 1.0, 0.2, 0.8)
if up_to_date
else (1.0, 0.2, 0.2, 0.8),
@ -214,10 +214,10 @@ class AdvancedSettingsWindow(ba.Window):
else:
ba.textwidget(
edit=self._lang_status_text,
text=ba.Lstr(resource=self._r + '.translationFetchErrorText')
text=ba.Lstr(resource=f'{self._r}.translationFetchErrorText')
if self._complete_langs_error
else ba.Lstr(
resource=self._r + '.translationFetchingStatusText'
resource=f'{self._r}.translationFetchingStatusText'
),
color=(1.0, 0.5, 0.2)
if self._complete_langs_error
@ -267,7 +267,7 @@ class AdvancedSettingsWindow(ba.Window):
)
ba.textwidget(
edit=self._title_text, text=ba.Lstr(resource=self._r + '.titleText')
edit=self._title_text, text=ba.Lstr(resource=f'{self._r}.titleText')
)
this_button_width = 410
@ -277,7 +277,7 @@ class AdvancedSettingsWindow(ba.Window):
position=(self._sub_width / 2 - this_button_width / 2, v - 14),
size=(this_button_width, 60),
autoselect=True,
label=ba.Lstr(resource=self._r + '.enterPromoCodeText'),
label=ba.Lstr(resource=f'{self._r}.enterPromoCodeText'),
text_scale=1.0,
on_activate_call=self._on_promo_code_press,
)
@ -293,7 +293,7 @@ class AdvancedSettingsWindow(ba.Window):
parent=self._subcontainer,
position=(200, v + 10),
size=(0, 0),
text=ba.Lstr(resource=self._r + '.languageText'),
text=ba.Lstr(resource=f'{self._r}.languageText'),
maxwidth=150,
scale=0.95,
color=ba.app.ui.title_color,
@ -371,7 +371,7 @@ class AdvancedSettingsWindow(ba.Window):
position=(self._sub_width * 0.5, v + 10),
size=(0, 0),
text=ba.Lstr(
resource=self._r + '.helpTranslateText',
resource=f'{self._r}.helpTranslateText',
subs=[('${APP_NAME}', ba.Lstr(resource='titleText'))],
),
maxwidth=self._sub_width * 0.9,
@ -389,7 +389,7 @@ class AdvancedSettingsWindow(ba.Window):
position=(self._sub_width / 2 - this_button_width / 2, v - 24),
size=(this_button_width, 60),
label=ba.Lstr(
resource=self._r + '.translationEditorButtonText',
resource=f'{self._r}.translationEditorButtonText',
subs=[('${APP_NAME}', ba.Lstr(resource='titleText'))],
),
autoselect=True,
@ -422,7 +422,7 @@ class AdvancedSettingsWindow(ba.Window):
maxwidth=430,
textcolor=(0.8, 0.8, 0.8),
value=lang_inform,
text=ba.Lstr(resource=self._r + '.translationInformMe'),
text=ba.Lstr(resource=f'{self._r}.translationInformMe'),
on_value_change_call=ba.WeakCall(self._on_lang_inform_value_change),
)
@ -439,7 +439,7 @@ class AdvancedSettingsWindow(ba.Window):
position=(50, v),
size=(self._sub_width - 100, 30),
configkey='Kick Idle Players',
displayname=ba.Lstr(resource=self._r + '.kickIdlePlayersText'),
displayname=ba.Lstr(resource=f'{self._r}.kickIdlePlayersText'),
scale=1.0,
maxwidth=430,
)
@ -450,7 +450,7 @@ class AdvancedSettingsWindow(ba.Window):
position=(50, v),
size=(self._sub_width - 100, 30),
configkey='Show Ping',
displayname=ba.Lstr(value='Show InGame Ping'),
displayname=ba.Lstr(resource=f'{self._r}.showInGamePingText'),
scale=1.0,
maxwidth=430,
)
@ -461,7 +461,7 @@ class AdvancedSettingsWindow(ba.Window):
position=(50, v),
size=(self._sub_width - 100, 30),
configkey='Disable Camera Shake',
displayname=ba.Lstr(resource=self._r + '.disableCameraShakeText'),
displayname=ba.Lstr(resource=f'{self._r}.disableCameraShakeText'),
scale=1.0,
maxwidth=430,
)
@ -475,7 +475,7 @@ class AdvancedSettingsWindow(ba.Window):
size=(self._sub_width - 100, 30),
configkey='Disable Camera Gyro',
displayname=ba.Lstr(
resource=self._r + '.disableCameraGyroscopeMotionText'
resource=f'{self._r}.disableCameraGyroscopeMotionText'
),
scale=1.0,
maxwidth=430,
@ -491,7 +491,7 @@ class AdvancedSettingsWindow(ba.Window):
configkey='Always Use Internal Keyboard',
autoselect=True,
displayname=ba.Lstr(
resource=self._r + '.alwaysUseInternalKeyboardText'
resource=f'{self._r}.alwaysUseInternalKeyboardText'
),
scale=1.0,
maxwidth=430,
@ -501,8 +501,9 @@ class AdvancedSettingsWindow(ba.Window):
position=(90, v - 10),
size=(0, 0),
text=ba.Lstr(
resource=self._r
+ '.alwaysUseInternalKeyboardDescriptionText'
resource=(
f'{self._r}.alwaysUseInternalKeyboardDescriptionText'
)
),
maxwidth=400,
flatness=1.0,
@ -523,7 +524,7 @@ class AdvancedSettingsWindow(ba.Window):
position=(self._sub_width / 2 - this_button_width / 2, v - 10),
size=(this_button_width, 60),
autoselect=True,
label=ba.Lstr(resource=self._r + '.moddingGuideText'),
label=ba.Lstr(resource=f'{self._r}.moddingGuideText'),
text_scale=1.0,
on_activate_call=ba.Call(
ba.open_url, 'https://ballistica.net/wiki/modding-guide'
@ -556,7 +557,7 @@ class AdvancedSettingsWindow(ba.Window):
position=(self._sub_width / 2 - this_button_width / 2, v - 10),
size=(this_button_width, 60),
autoselect=True,
label=ba.Lstr(resource=self._r + '.showUserModsText'),
label=ba.Lstr(resource=f'{self._r}.showUserModsText'),
text_scale=1.0,
on_activate_call=show_user_scripts,
)
@ -583,7 +584,7 @@ class AdvancedSettingsWindow(ba.Window):
position=(self._sub_width / 2 - this_button_width / 2, v - 14),
size=(this_button_width, 60),
autoselect=True,
label=ba.Lstr(resource=self._r + '.vrTestingText'),
label=ba.Lstr(resource=f'{self._r}.vrTestingText'),
text_scale=1.0,
on_activate_call=self._on_vr_test_press,
)
@ -598,7 +599,7 @@ class AdvancedSettingsWindow(ba.Window):
position=(self._sub_width / 2 - this_button_width / 2, v - 14),
size=(this_button_width, 60),
autoselect=True,
label=ba.Lstr(resource=self._r + '.netTestingText'),
label=ba.Lstr(resource=f'{self._r}.netTestingText'),
text_scale=1.0,
on_activate_call=self._on_net_test_press,
)
@ -611,7 +612,7 @@ class AdvancedSettingsWindow(ba.Window):
position=(self._sub_width / 2 - this_button_width / 2, v - 14),
size=(this_button_width, 60),
autoselect=True,
label=ba.Lstr(resource=self._r + '.benchmarksText'),
label=ba.Lstr(resource=f'{self._r}.benchmarksText'),
text_scale=1.0,
on_activate_call=self._on_benchmark_press,
)
@ -633,7 +634,7 @@ class AdvancedSettingsWindow(ba.Window):
def _show_restart_needed(self, value: Any) -> None:
del value # Unused.
ba.screenmessage(
ba.Lstr(resource=self._r + '.mustRestartText'), color=(1, 1, 0)
ba.Lstr(resource=f'{self._r}.mustRestartText'), color=(1, 1, 0)
)
def _on_lang_inform_value_change(self, val: bool) -> None:
@ -678,14 +679,12 @@ class AdvancedSettingsWindow(ba.Window):
appinvite.handle_app_invites_press()
def _on_plugins_button_press(self) -> None:
from bastd.ui.settings.plugins import PluginSettingsWindow
from bastd.ui.settings.plugins import PluginWindow
self._save_state()
ba.containerwidget(edit=self._root_widget, transition='out_left')
ba.app.ui.set_main_menu_window(
PluginSettingsWindow(
origin_widget=self._plugins_button
).get_root_widget()
PluginWindow(origin_widget=self._plugins_button).get_root_widget()
)
def _on_promo_code_press(self) -> None:

View file

@ -1,6 +1,6 @@
# Released under the MIT License. See LICENSE for details.
#
"""Plugin settings UI."""
"""Plugin Window UI."""
from __future__ import annotations
@ -12,7 +12,7 @@ if TYPE_CHECKING:
pass
class PluginSettingsWindow(ba.Window):
class PluginWindow(ba.Window):
"""Window for configuring plugins."""
def __init__(
@ -106,6 +106,27 @@ class PluginSettingsWindow(ba.Window):
size=(60, 60),
label=ba.charstr(ba.SpecialChar.BACK),
)
settings_button_x = 670 if uiscale is ba.UIScale.SMALL else 570
self._settings_button = ba.buttonwidget(
parent=self._root_widget,
position=(settings_button_x, self._height - 60),
size=(40, 40),
label='',
on_activate_call=self._open_settings,
)
ba.imagewidget(
parent=self._root_widget,
position=(settings_button_x + 3, self._height - 60),
size=(35, 35),
texture=ba.gettexture('settingsIcon'),
)
ba.widget(
edit=self._settings_button,
up_widget=self._settings_button,
right_widget=self._settings_button,
)
self._scrollwidget = ba.scrollwidget(
parent=self._root_widget,
@ -185,6 +206,7 @@ class PluginSettingsWindow(ba.Window):
edit=check,
up_widget=self._back_button,
left_widget=self._back_button,
right_widget=self._settings_button,
)
if button is not None:
ba.widget(edit=button, up_widget=self._back_button)
@ -212,6 +234,17 @@ class PluginSettingsWindow(ba.Window):
plugstate['enabled'] = value
ba.app.config.commit()
def _open_settings(self) -> None:
# pylint: disable=cyclic-import
from bastd.ui.settings.pluginsettings import PluginSettingsWindow
ba.playsound(ba.getsound('swish'))
ba.containerwidget(edit=self._root_widget, transition='out_left')
ba.app.ui.set_main_menu_window(
PluginSettingsWindow(transition='in_right').get_root_widget()
)
def _save_state(self) -> None:
pass

View file

@ -0,0 +1,174 @@
# Released under the MIT License. See LICENSE for details.
#
"""Plugin Settings UI."""
from __future__ import annotations
from typing import TYPE_CHECKING
import ba
from bastd.ui.confirm import ConfirmWindow
if TYPE_CHECKING:
pass
class PluginSettingsWindow(ba.Window):
"""Plugin Settings Window"""
def __init__(self, transition: str = 'in_right'):
scale_origin: tuple[float, float] | None
self._transition_out = 'out_right'
scale_origin = None
uiscale = ba.app.ui.uiscale
width = 470.0 if uiscale is ba.UIScale.SMALL else 470.0
height = (
365.0
if uiscale is ba.UIScale.SMALL
else 300.0
if uiscale is ba.UIScale.MEDIUM
else 370.0
)
top_extra = 10 if uiscale is ba.UIScale.SMALL else 0
super().__init__(
root_widget=ba.containerwidget(
size=(width, height + top_extra),
transition=transition,
toolbar_visibility='menu_minimal',
scale_origin_stack_offset=scale_origin,
scale=(
2.06
if uiscale is ba.UIScale.SMALL
else 1.4
if uiscale is ba.UIScale.MEDIUM
else 1.0
),
stack_offset=(0, -25)
if uiscale is ba.UIScale.SMALL
else (0, 0),
)
)
self._back_button = ba.buttonwidget(
parent=self._root_widget,
position=(53, height - 60),
size=(60, 60),
scale=0.8,
autoselect=True,
label=ba.charstr(ba.SpecialChar.BACK),
button_type='backSmall',
on_activate_call=self._do_back,
)
ba.containerwidget(
edit=self._root_widget, cancel_button=self._back_button
)
self._title_text = ba.textwidget(
parent=self._root_widget,
position=(0, height - 52),
size=(width, 25),
text=ba.Lstr(resource='pluginSettingsText'),
color=ba.app.ui.title_color,
h_align='center',
v_align='top',
)
self._y_position = 170 if uiscale is ba.UIScale.MEDIUM else 205
self._enable_plugins_button = ba.buttonwidget(
parent=self._root_widget,
position=(65, self._y_position),
size=(350, 60),
autoselect=True,
label=ba.Lstr(resource='pluginsEnableAllText'),
text_scale=1.0,
on_activate_call=lambda: ConfirmWindow(
action=self._enable_all_plugins,
),
)
self._y_position -= 70
self._disable_plugins_button = ba.buttonwidget(
parent=self._root_widget,
position=(65, self._y_position),
size=(350, 60),
autoselect=True,
label=ba.Lstr(resource='pluginsDisableAllText'),
text_scale=1.0,
on_activate_call=lambda: ConfirmWindow(
action=self._disable_all_plugins,
),
)
self._y_position -= 70
self._enable_new_plugins_check_box = ba.checkboxwidget(
parent=self._root_widget,
position=(65, self._y_position),
size=(350, 60),
value=ba.app.config.get(
ba.app.plugins.AUTO_ENABLE_NEW_PLUGINS_CONFIG_KEY,
ba.app.plugins.AUTO_ENABLE_NEW_PLUGINS_DEFAULT,
),
text=ba.Lstr(resource='pluginsAutoEnableNewText'),
scale=1.0,
maxwidth=308,
on_value_change_call=self._update_value,
)
ba.widget(
edit=self._back_button, down_widget=self._enable_plugins_button
)
ba.widget(
edit=self._disable_plugins_button,
left_widget=self._disable_plugins_button,
)
ba.widget(
edit=self._enable_new_plugins_check_box,
left_widget=self._enable_new_plugins_check_box,
right_widget=self._enable_new_plugins_check_box,
down_widget=self._enable_new_plugins_check_box,
)
def _enable_all_plugins(self) -> None:
cfg = ba.app.config
plugs: dict[str, dict] = cfg.setdefault('Plugins', {})
for plug in plugs.values():
plug['enabled'] = True
cfg.apply_and_commit()
ba.screenmessage(
ba.Lstr(resource='settingsWindowAdvanced.mustRestartText'),
color=(1.0, 0.5, 0.0),
)
def _disable_all_plugins(self) -> None:
cfg = ba.app.config
plugs: dict[str, dict] = cfg.setdefault('Plugins', {})
for plug in plugs.values():
plug['enabled'] = False
cfg.apply_and_commit()
ba.screenmessage(
ba.Lstr(resource='settingsWindowAdvanced.mustRestartText'),
color=(1.0, 0.5, 0.0),
)
def _update_value(self, val: bool) -> None:
cfg = ba.app.config
cfg[ba.app.plugins.AUTO_ENABLE_NEW_PLUGINS_CONFIG_KEY] = val
cfg.apply_and_commit()
def _do_back(self) -> None:
# pylint: disable=cyclic-import
from bastd.ui.settings.plugins import PluginWindow
ba.containerwidget(
edit=self._root_widget, transition=self._transition_out
)
ba.app.ui.set_main_menu_window(
PluginWindow(transition='in_left').get_root_widget()
)

View file

@ -1155,7 +1155,6 @@ class StoreBrowserWindow(ba.Window):
# Wire this button to the equivalent in the
# previous row.
if prev_row_buttons is not None:
# pylint: disable=unsubscriptable-object
if len(prev_row_buttons) > col:
ba.widget(
edit=btn,

49
dist/ba_data/python/efro/cloudshell.py vendored Normal file
View file

@ -0,0 +1,49 @@
# Released under the MIT License. See LICENSE for details.
#
"""My nifty ssh/mosh/rsync mishmash."""
from __future__ import annotations
from enum import Enum
from dataclasses import dataclass
from efro.dataclassio import ioprepped
class LockType(Enum):
"""Types of locks that can be acquired on a host."""
HOST = 'host'
WORKSPACE = 'workspace'
PYCHARM = 'pycharm'
CLION = 'clion'
@ioprepped
@dataclass
class HostConfig:
"""Config for a cloud machine to run commands on.
precommand, if set, will be run before the passed commands.
Note that it is not run in interactive mode (when no command is given).
"""
address: str | None = None
user: str = 'ubuntu'
port: int = 22
mosh_port: int | None = None
mosh_server_path: str | None = None
mosh_shell: str = 'sh'
workspaces_root: str = '/home/${USER}/cloudshell_workspaces'
sync_perms: bool = True
precommand: str | None = None
managed: bool = False
idle_minutes: int = 5
can_sudo_reboot: bool = False
max_sessions: int = 3
reboot_wait_seconds: int = 20
reboot_attempts: int = 1
def resolved_workspaces_root(self) -> str:
"""Returns workspaces_root with standard substitutions."""
return self.workspaces_root.replace('${USER}', self.user)

View file

@ -8,7 +8,9 @@ import time
import asyncio
import logging
import datetime
import itertools
from enum import Enum
from collections import deque
from dataclasses import dataclass
from typing import TYPE_CHECKING, Annotated
from threading import Thread, current_thread, Lock
@ -143,12 +145,14 @@ class LogHandler(logging.Handler):
assert cache_size_limit >= 0
self._cache_size_limit = cache_size_limit
self._cache_time_limit = cache_time_limit
self._cache: list[tuple[int, LogEntry]] = []
self._cache = deque[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._log_thread_main, daemon=True)
if __debug__:
self._last_slow_emit_warning_time: float | None = None
self._thread.start()
# Spin until our thread is up and running; otherwise we could
@ -167,6 +171,12 @@ class LogHandler(logging.Handler):
def _log_thread_main(self) -> None:
self._event_loop = asyncio.new_event_loop()
# In our background thread event loop we do a fair amount of
# slow synchronous stuff such as mucking with the log cache.
# Let's avoid getting tons of warnings about this in debug mode.
self._event_loop.slow_callback_duration = 2.0 # Default is 0.1
# NOTE: if we ever use default threadpool at all we should allow
# setting it for our loop.
asyncio.set_event_loop(self._event_loop)
@ -192,20 +202,15 @@ class LogHandler(logging.Handler):
now = utc_now()
with self._cache_lock:
# Quick out: if oldest cache entry is still valid,
# don't touch anything.
if (
# Prune the oldest entry as long as there is a first one that
# is too old.
while (
self._cache
and (now - self._cache[0][1].time) < self._cache_time_limit
and (now - self._cache[0][1].time) >= self._cache_time_limit
):
continue
# Ok; full prune.
self._cache = [
e
for e in self._cache
if (now - e[1].time) < self._cache_time_limit
]
popped = self._cache.popleft()
self._cache_size -= popped[0]
self._cache_index_offset += 1
def get_cached(
self, start_index: int = 0, max_entries: int | None = None
@ -239,19 +244,40 @@ class LogHandler(logging.Handler):
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]],
entries=self._cache_slice(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
def _cache_slice(
self, start: int, end: int, step: int = 1
) -> list[LogEntry]:
# Deque doesn't natively support slicing but we can do it manually.
# It sounds like rotating the deque and pulling from the beginning
# is the most efficient way to do this. The downside is the deque
# gets temporarily modified in the process so we need to make sure
# we're holding the lock.
assert self._cache_lock.locked()
cache = self._cache
cache.rotate(-start)
slc = [e[1] for e in itertools.islice(cache, 0, end - start, step)]
cache.rotate(start)
return slc
# Special case - filter out this common extra-chatty category.
# TODO - should use a standard logging.Filter for this.
@classmethod
def _is_immutable_log_data(cls, data: Any) -> bool:
if isinstance(data, (str, bool, int, float, bytes)):
return True
if isinstance(data, tuple):
return all(cls._is_immutable_log_data(x) for x in data)
return False
def emit(self, record: logging.LogRecord) -> None:
if __debug__:
starttime = time.monotonic()
# Called by logging to send us records.
# Special case: filter out this common extra-chatty category.
# TODO - perhaps should use a standard logging.Filter for this.
if (
self._suppress_non_root_debug
and record.name != 'root'
@ -259,38 +285,106 @@ class LogHandler(logging.Handler):
):
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,
)
# Optimization: if our log args are all simple immutable values,
# we can just kick the whole thing over to our background thread to
# be formatted there at our leisure. If anything is mutable and
# thus could possibly change between now and then or if we want
# to do immediate file echoing then we need to bite the bullet
# and do that stuff here at the call site.
fast_path = self._echofile is None and self._is_immutable_log_data(
record.args
)
if fast_path:
if __debug__:
formattime = echotime = time.monotonic()
self._event_loop.call_soon_threadsafe(
tpartial(
self._emit_in_thread,
record.name,
record.levelno,
record.created,
record,
)
)
else:
# Slow case; do formatting and echoing here at the log call
# site.
msg = self.format(record)
if __debug__:
formattime = time.monotonic()
# 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')
if __debug__:
echotime = time.monotonic()
self._event_loop.call_soon_threadsafe(
tpartial(
self._emit_in_thread,
record.name,
record.levelno,
record.created,
msg,
)
)
if __debug__:
# Make noise if we're taking a significant amount of time here.
# Limit the noise to once every so often though; otherwise we
# could get a feedback loop where every log emit results in a
# warning log which results in another, etc.
now = time.monotonic()
# noinspection PyUnboundLocalVariable
duration = now - starttime
# noinspection PyUnboundLocalVariable
format_duration = formattime - starttime
# noinspection PyUnboundLocalVariable
echo_duration = echotime - formattime
if duration > 0.05 and (
self._last_slow_emit_warning_time is None
or now > self._last_slow_emit_warning_time + 10.0
):
# Logging calls from *within* a logging handler
# sounds sketchy, so let's just kick this over to
# the bg event loop thread we've already got.
self._last_slow_emit_warning_time = now
self._event_loop.call_soon_threadsafe(
tpartial(
logging.warning,
'efro.log.LogHandler emit took too long'
' (%.2fs total; %.2fs format, %.2fs echo,'
' fast_path=%s).',
duration,
format_duration,
echo_duration,
fast_path,
)
)
def _emit_in_thread(
self, name: str, levelno: int, created: float, message: str
self,
name: str,
levelno: int,
created: float,
message: str | logging.LogRecord,
) -> None:
try:
# If they passed a raw record here, bake it down to a string.
if isinstance(message, logging.LogRecord):
message = self.format(message)
self._emit_entry(
LogEntry(
name=name,
@ -409,7 +503,7 @@ class LogHandler(logging.Handler):
# Prune old until we are back at or under our limit.
while self._cache_size > self._cache_size_limit:
popped = self._cache.pop(0)
popped = self._cache.popleft()
self._cache_size -= popped[0]
self._cache_index_offset += 1
@ -469,6 +563,7 @@ def setup_logging(
level: LogLevel,
suppress_non_root_debug: bool = False,
log_stdout_stderr: bool = False,
echo_to_stderr: bool = True,
cache_size_limit: int = 0,
cache_time_limit: datetime.timedelta | None = None,
) -> LogHandler:
@ -499,8 +594,7 @@ def setup_logging(
# which would create an infinite loop.
loghandler = LogHandler(
log_path,
# echofile=sys.stderr if sys.stderr.isatty() else None,
echofile=sys.stderr,
echofile=sys.stderr if echo_to_stderr else None,
suppress_non_root_debug=suppress_non_root_debug,
cache_size_limit=cache_size_limit,
cache_time_limit=cache_time_limit,

View file

@ -282,10 +282,13 @@ class MessageProtocol:
def _get_module_header(
self,
part: Literal['sender', 'receiver'],
extra_import_code: str | None = None,
extra_import_code: str | None,
enable_async_sends: bool,
) -> str:
"""Return common parts of generated modules."""
# pylint: disable=too-many-locals, too-many-branches
# pylint: disable=too-many-locals
# pylint: disable=too-many-branches
# pylint: disable=too-many-statements
import textwrap
tpimports: dict[str, list[str]] = {}
@ -342,7 +345,7 @@ class MessageProtocol:
if part == 'sender':
import_lines += (
'from efro.message import MessageSender,' ' BoundMessageSender'
'from efro.message import MessageSender, BoundMessageSender'
)
tpimport_typing_extras = ''
else:
@ -362,11 +365,18 @@ class MessageProtocol:
import_lines += f'\n{extra_import_code}\n'
ovld = ', overload' if not single_message_type else ''
ovld2 = (
', cast, Awaitable'
if (single_message_type and part == 'sender' and enable_async_sends)
else ''
)
tpimport_lines = textwrap.indent(tpimport_lines, ' ')
baseimps = ['Any']
if part == 'receiver':
baseimps.append('Callable')
if part == 'sender' and enable_async_sends:
baseimps.append('Awaitable')
baseimps_s = ', '.join(baseimps)
out = (
'# Released under the MIT License. See LICENSE for details.\n'
@ -375,7 +385,7 @@ class MessageProtocol:
f'\n'
f'from __future__ import annotations\n'
f'\n'
f'from typing import TYPE_CHECKING{ovld}\n'
f'from typing import TYPE_CHECKING{ovld}{ovld2}\n'
f'\n'
f'{import_lines}\n'
f'\n'
@ -399,13 +409,16 @@ class MessageProtocol:
) -> str:
"""Used by create_sender_module(); do not call directly."""
# pylint: disable=too-many-locals
# pylint: disable=too-many-branches
import textwrap
msgtypes = list(self.message_ids_by_type.keys())
ppre = '_' if private else ''
out = self._get_module_header(
'sender', extra_import_code=protocol_module_level_import_code
'sender',
extra_import_code=protocol_module_level_import_code,
enable_async_sends=enable_async_sends,
)
ccind = textwrap.indent(protocol_create_code, ' ')
out += (
@ -438,7 +451,8 @@ class MessageProtocol:
continue
pfx = 'async ' if async_pass else ''
sfx = '_async' if async_pass else ''
awt = 'await ' if async_pass else ''
# awt = 'await ' if async_pass else ''
awt = ''
how = 'asynchronously' if async_pass else 'synchronously'
if len(msgtypes) == 1:
@ -451,22 +465,29 @@ class MessageProtocol:
rtypevar = ' | '.join(_filt_tp_name(t) for t in rtypes)
else:
rtypevar = _filt_tp_name(rtypes[0])
if async_pass:
rtypevar = f'Awaitable[{rtypevar}]'
out += (
f'\n'
f' {pfx}def send{sfx}(self,'
f' def send{sfx}(self,'
f' message: {msgtypevar})'
f' -> {rtypevar}:\n'
f' """Send a message {how}."""\n'
f' out = {awt}self._sender.'
f'send{sfx}(self._obj, message)\n'
f' assert isinstance(out, {rtypevar})\n'
f' return out\n'
)
if not async_pass:
out += (
f' assert isinstance(out, {rtypevar})\n'
' return out\n'
)
else:
out += f' return cast({rtypevar}, out)\n'
else:
for msgtype in msgtypes:
msgtypevar = msgtype.__name__
# rtypes = msgtype.get_response_types()
rtypes = msgtype.get_response_types()
if len(rtypes) > 1:
rtypevar = ' | '.join(
@ -482,10 +503,13 @@ class MessageProtocol:
f' -> {rtypevar}:\n'
f' ...\n'
)
rtypevar = 'Response | None'
if async_pass:
rtypevar = f'Awaitable[{rtypevar}]'
out += (
f'\n'
f' {pfx}def send{sfx}(self, message: Message)'
f' -> Response | None:\n'
f' def send{sfx}(self, message: Message)'
f' -> {rtypevar}:\n'
f' """Send a message {how}."""\n'
f' return {awt}self._sender.'
f'send{sfx}(self._obj, message)\n'
@ -509,7 +533,9 @@ class MessageProtocol:
ppre = '_' if private else ''
msgtypes = list(self.message_ids_by_type.keys())
out = self._get_module_header(
'receiver', extra_import_code=protocol_module_level_import_code
'receiver',
extra_import_code=protocol_module_level_import_code,
enable_async_sends=False,
)
ccind = textwrap.indent(protocol_create_code, ' ')
out += (
@ -602,11 +628,11 @@ class MessageProtocol:
if is_async:
out += (
'\n'
' async def handle_raw_message(\n'
' def handle_raw_message(\n'
' self, message: str, raise_unregistered: bool = False\n'
' ) -> str:\n'
' ) -> Awaitable[str]:\n'
' """Asynchronously handle a raw incoming message."""\n'
' return await self._receiver.'
' return self._receiver.'
'handle_raw_message_async(\n'
' self._obj, message, raise_unregistered\n'
' )\n'

View file

@ -62,12 +62,6 @@ class MessageReceiver:
[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.
self._decode_filter_async_call: Callable[
[Any, dict, Message], Awaitable[None]
] | None = None
# noinspection PyProtectedMember
def register_handler(
self, call: Callable[[Any, Message], Response | None]
@ -96,14 +90,17 @@ class MessageReceiver:
# Make sure we are only given async methods if we are an async handler
# and sync ones otherwise.
is_async = inspect.iscoroutinefunction(call)
if self.is_async != is_async:
msg = (
'Expected a sync method; found an async one.'
if is_async
else 'Expected an async method; found a sync one.'
)
raise ValueError(msg)
# UPDATE - can't do this anymore since we now sometimes use
# regular functions which return awaitables instead of having
# the entire function be async.
# is_async = inspect.iscoroutinefunction(call)
# if self.is_async != is_async:
# msg = (
# 'Expected a sync method; found an async one.'
# if is_async
# else 'Expected an async method; found a sync one.'
# )
# raise ValueError(msg)
# Check annotation types to determine what message types we handle.
# Return-type annotation can be a Union, but we probably don't
@ -189,19 +186,6 @@ class MessageReceiver:
self._decode_filter_call = call
return call
def decode_filter_async_method(
self, call: Callable[[Any, dict, Message], Awaitable[None]]
) -> Callable[[Any, dict, Message], Awaitable[None]]:
"""Function decorator for defining a decode filter.
Decode filters can be used to extract extra data from incoming
message dicts. Note that this version will only work with
handle_raw_message_async().
"""
assert self._decode_filter_async_call is None
self._decode_filter_async_call = call
return call
def encode_filter_method(
self,
call: Callable[
@ -247,24 +231,6 @@ class MessageReceiver:
bound_obj, _msg_dict, msg_decoded = self._decode_incoming_message_base(
bound_obj=bound_obj, msg=msg
)
# If they've set an async filter but are calling sync
# handle_raw_message() its likely a bug.
assert self._decode_filter_async_call is None
return msg_decoded
async def _decode_incoming_message_async(
self, bound_obj: Any, msg: str
) -> Message:
bound_obj, msg_dict, msg_decoded = self._decode_incoming_message_base(
bound_obj=bound_obj, msg=msg
)
if self._decode_filter_async_call is not None:
await self._decode_filter_async_call(
bound_obj, msg_dict, msg_decoded
)
return msg_decoded
def encode_user_response(
@ -316,6 +282,7 @@ class MessageReceiver:
"""
assert not self.is_async, "can't call sync handler on async receiver"
msg_decoded: Message | None = None
msgtype: type[Message] | None = None
try:
msg_decoded = self._decode_incoming_message(bound_obj, msg)
msgtype = type(msg_decoded)
@ -335,41 +302,93 @@ class MessageReceiver:
bound_obj, msg_decoded, exc
)
if dolog:
logging.exception('Error in efro.message handling.')
if msgtype is not None:
logging.exception(
'Error handling %s.%s message.',
msgtype.__module__,
msgtype.__qualname__,
)
else:
logging.exception('Error in efro.message handling.')
return rstr
async def handle_raw_message_async(
def handle_raw_message_async(
self, bound_obj: Any, msg: str, raise_unregistered: bool = False
) -> str:
) -> Awaitable[str]:
"""Should be called when the receiver gets a message.
The return value is the raw response to the message.
"""
# Note: This call is synchronous so that the first part of it can
# happen synchronously. If the whole call were async we wouldn't be
# able to guarantee that messages handlers would be called in the
# order the messages were received.
assert self.is_async, "can't call async handler on sync receiver"
msg_decoded: Message | None = None
msgtype: type[Message] | None = None
try:
msg_decoded = await self._decode_incoming_message_async(
bound_obj, msg
)
msg_decoded = self._decode_incoming_message(bound_obj, msg)
msgtype = type(msg_decoded)
handler = self._handlers.get(msgtype)
if handler is None:
raise RuntimeError(f'Got unhandled message type: {msgtype}.')
response = await handler(bound_obj, msg_decoded)
assert isinstance(response, Response | None)
return self.encode_user_response(bound_obj, msg_decoded, response)
handler_awaitable = handler(bound_obj, msg_decoded)
except Exception as exc:
if raise_unregistered and isinstance(
exc, UnregisteredMessageIDError
):
raise
rstr, dolog = self.encode_error_response(
bound_obj, msg_decoded, exc
return self._handle_raw_message_async_error(
bound_obj, msg_decoded, msgtype, exc
)
if dolog:
# Return an awaitable to handle the rest asynchronously.
return self._handle_raw_message_async(
bound_obj, msg_decoded, msgtype, handler_awaitable
)
async def _handle_raw_message_async_error(
self,
bound_obj: Any,
msg_decoded: Message | None,
msgtype: type[Message] | None,
exc: Exception,
) -> str:
rstr, dolog = self.encode_error_response(bound_obj, msg_decoded, exc)
if dolog:
if msgtype is not None:
logging.exception(
'Error handling %s.%s message.',
msgtype.__module__,
msgtype.__qualname__,
)
else:
logging.exception('Error in efro.message handling.')
return rstr
return rstr
async def _handle_raw_message_async(
self,
bound_obj: Any,
msg_decoded: Message,
msgtype: type[Message] | None,
handler_awaitable: Awaitable[Response | None],
) -> str:
"""Should be called when the receiver gets a message.
The return value is the raw response to the message.
"""
try:
response = await handler_awaitable
assert isinstance(response, Response | None)
return self.encode_user_response(bound_obj, msg_decoded, response)
except Exception as exc:
return await self._handle_raw_message_async_error(
bound_obj, msg_decoded, msgtype, exc
)
class BoundMessageReceiver:

View file

@ -44,6 +44,9 @@ class MessageSender:
self._send_async_raw_message_call: Callable[
[Any, str], Awaitable[str]
] | None = None
self._send_async_raw_message_ex_call: Callable[
[Any, str, Message], Awaitable[str]
] | None = None
self._encode_filter_call: Callable[
[Any, Message, dict], None
] | None = None
@ -75,11 +78,32 @@ class MessageSender:
CommunicationErrors raised here will be returned to the sender
as such; all other exceptions will result in a RuntimeError for
the sender.
IMPORTANT: Generally async send methods should not be implemented
as 'async' methods, but instead should be regular methods that
return awaitable objects. This way it can be guaranteed that
outgoing messages are synchronously enqueued in the correct
order, and then async calls can be returned which finish each
send. If the entire call is async, they may be enqueued out of
order in rare cases.
"""
assert self._send_async_raw_message_call is None
self._send_async_raw_message_call = call
return call
def send_async_ex_method(
self, call: Callable[[Any, str, Message], Awaitable[str]]
) -> Callable[[Any, str, Message], Awaitable[str]]:
"""Function decorator for extended send-async method.
Version of send_async_method which is also is passed the original
unencoded message; can be useful for cases where metadata is sent
along with messages referring to their payloads/etc.
"""
assert self._send_async_raw_message_ex_call is None
self._send_async_raw_message_ex_call = call
return call
def encode_filter_method(
self, call: Callable[[Any, Message, dict], None]
) -> Callable[[Any, Message, dict], None]:
@ -126,17 +150,34 @@ class MessageSender:
),
)
async def send_async(
def send_async(
self, bound_obj: Any, message: Message
) -> Response | None:
) -> Awaitable[Response | None]:
"""Send a message asynchronously."""
# Note: This call is synchronous so that the first part of it can
# happen synchronously. If the whole call were async we wouldn't be
# able to guarantee that messages sent in order would actually go
# out in order.
raw_response_awaitable = self.fetch_raw_response_async(
bound_obj=bound_obj,
message=message,
)
# Now return an awaitable that will finish the send.
return self._send_async_awaitable(
bound_obj, message, raw_response_awaitable
)
async def _send_async_awaitable(
self,
bound_obj: Any,
message: Message,
raw_response_awaitable: Awaitable[Response | SysResponse],
) -> Response | None:
return self.unpack_raw_response(
bound_obj=bound_obj,
message=message,
raw_response=await self.fetch_raw_response_async(
bound_obj=bound_obj,
message=message,
),
raw_response=await raw_response_awaitable,
)
def fetch_raw_response(
@ -171,27 +212,68 @@ class MessageSender:
return response
return self._decode_raw_response(bound_obj, message, response_encoded)
async def fetch_raw_response_async(
def fetch_raw_response_async(
self, bound_obj: Any, message: Message
) -> Response | SysResponse:
"""Fetch a raw message response.
) -> Awaitable[Response | SysResponse]:
"""Fetch a raw message response awaitable.
The result of this should be passed to unpack_raw_response() to
produce the final message result.
The result of this should be awaited and then passed to
unpack_raw_response() to produce the final message result.
Generally you can just call send(); calling fetch and unpack
manually is for when message sending and response handling need
to happen in different contexts/threads.
"""
if self._send_async_raw_message_call is None:
# Note: This call is synchronous so that the first part of it can
# happen synchronously. If the whole call were async we wouldn't be
# able to guarantee that messages sent in order would actually go
# out in order.
if (
self._send_async_raw_message_call is None
and self._send_async_raw_message_ex_call is None
):
raise RuntimeError('send_async() is unimplemented for this type.')
msg_encoded = self._encode_message(bound_obj, message)
try:
response_encoded = await self._send_async_raw_message_call(
bound_obj, msg_encoded
)
if self._send_async_raw_message_ex_call is not None:
send_awaitable = self._send_async_raw_message_ex_call(
bound_obj, msg_encoded, message
)
else:
assert self._send_async_raw_message_call is not None
send_awaitable = self._send_async_raw_message_call(
bound_obj, msg_encoded
)
except Exception as exc:
return self._error_awaitable(exc)
# Now return an awaitable to finish the job.
return self._fetch_raw_response_awaitable(
bound_obj, message, send_awaitable
)
async def _error_awaitable(self, exc: Exception) -> SysResponse:
response = ErrorSysResponse(
error_message='Error in MessageSender @send_async_method.',
error_type=(
ErrorSysResponse.ErrorType.COMMUNICATION
if isinstance(exc, CommunicationError)
else ErrorSysResponse.ErrorType.LOCAL
),
)
# Can include the actual exception since we'll be looking at
# this locally; might be helpful.
response.set_local_exception(exc)
return response
async def _fetch_raw_response_awaitable(
self, bound_obj: Any, message: Message, send_awaitable: Awaitable[str]
) -> Response | SysResponse:
try:
response_encoded = await send_awaitable
except Exception as exc:
response = ErrorSysResponse(
error_message='Error in MessageSender @send_async_method.',
@ -354,23 +436,23 @@ class BoundMessageSender:
assert self._obj is not None
return self._sender.send(bound_obj=self._obj, message=message)
async def send_async_untyped(self, message: Message) -> Response | None:
def send_async_untyped(
self, message: Message
) -> Awaitable[Response | None]:
"""Send a message asynchronously.
Whenever possible, use the send_async() call provided by generated
subclasses instead of this; it will provide better type safety.
"""
assert self._obj is not None
return await self._sender.send_async(
bound_obj=self._obj, message=message
)
return self._sender.send_async(bound_obj=self._obj, message=message)
async def fetch_raw_response_async_untyped(
def fetch_raw_response_async_untyped(
self, message: Message
) -> Response | SysResponse:
) -> Awaitable[Response | SysResponse]:
"""Split send (part 1 of 2)."""
assert self._obj is not None
return await self._sender.fetch_raw_response_async(
return self._sender.fetch_raw_response_async(
bound_obj=self._obj, message=message
)

View file

@ -9,6 +9,7 @@ import asyncio
import logging
import weakref
from enum import Enum
from collections import deque
from dataclasses import dataclass
from threading import current_thread
from typing import TYPE_CHECKING, Annotated
@ -201,7 +202,7 @@ class RPCEndpoint:
self._closing = False
self._did_wait_closed = False
self._event_loop = asyncio.get_running_loop()
self._out_packets: list[bytes] = []
self._out_packets = deque[bytes]()
self._have_out_packets = asyncio.Event()
self._run_called = False
self._peer_info: _PeerInfo | None = None
@ -323,12 +324,12 @@ class RPCEndpoint:
if self.debug_print:
self.debug_print_call(f'{self._label}: finished.')
async def send_message(
def send_message(
self,
message: bytes,
timeout: float | None = None,
close_on_error: bool = True,
) -> bytes:
) -> Awaitable[bytes]:
"""Send a message to the peer and return a response.
If timeout is not provided, the default will be used.
@ -340,7 +341,10 @@ class RPCEndpoint:
respect to a given endpoint. Pass close_on_error=False to
override this for a particular message.
"""
# pylint: disable=too-many-branches
# Note: This call is synchronous so that the first part of it
# (enqueueing outgoing messages) happens synchronously. If it were
# a pure async call it could be possible for send order to vary
# based on how the async tasks get processed.
if self.debug_print_io:
self.debug_print_call(
@ -358,16 +362,6 @@ class RPCEndpoint:
f'{self._label}: have peerinfo? {self._peer_info is not None}.'
)
# We need to know their protocol, so if we haven't gotten a handshake
# from them yet, just wait.
while self._peer_info is None:
await asyncio.sleep(0.01)
assert self._peer_info is not None
if self._peer_info.protocol == 1:
if len(message) > 65535:
raise RuntimeError('Message cannot be larger than 65535 bytes')
# message_id is a 16 bit looping value.
message_id = self._next_message_id
self._next_message_id = (self._next_message_id + 1) % 65536
@ -420,8 +414,35 @@ class RPCEndpoint:
if timeout is None:
timeout = self.DEFAULT_MESSAGE_TIMEOUT
assert timeout is not None
bytes_awaitable = msgobj.wait_task
# Now complete the send asynchronously.
return self._send_message(
message, timeout, close_on_error, bytes_awaitable, message_id
)
async def _send_message(
self,
message: bytes,
timeout: float | None,
close_on_error: bool,
bytes_awaitable: asyncio.Task[bytes],
message_id: int,
) -> bytes:
# We need to know their protocol, so if we haven't gotten a handshake
# from them yet, just wait.
while self._peer_info is None:
await asyncio.sleep(0.01)
assert self._peer_info is not None
if self._peer_info.protocol == 1:
if len(message) > 65535:
raise RuntimeError('Message cannot be larger than 65535 bytes')
try:
return await asyncio.wait_for(msgobj.wait_task, timeout=timeout)
return await asyncio.wait_for(bytes_awaitable, timeout=timeout)
except asyncio.CancelledError as exc:
# Question: we assume this means the above wait_for() was
# cancelled; how do we distinguish between this and *us* being
@ -449,7 +470,7 @@ class RPCEndpoint:
)
# Stop waiting on the response.
msgobj.wait_task.cancel()
bytes_awaitable.cancel()
# Remove the record of this message.
del self._in_flight_messages[message_id]
@ -738,7 +759,7 @@ class RPCEndpoint:
await self._have_out_packets.wait()
assert self._out_packets
data = self._out_packets.pop(0)
data = self._out_packets.popleft()
# Important: only clear this once all packets are sent.
if not self._out_packets: