From abb42dcd4886705d6ba8af27f68ef605218ac67c Mon Sep 17 00:00:00 2001 From: Philip Paquette Date: Wed, 11 Sep 2019 12:58:45 -0400 Subject: Added ReadtheDocs documentation for the public API - Reformatted the docstring to be compatible - Added tests to make sure the documentation compiles properly - Added sphinx as a pip requirement Co-authored-by: Philip Paquette Co-authored-by: notoraptor --- diplomacy/client/channel.py | 134 +++--- diplomacy/client/connection.py | 668 ++++++++++++++++-------------- diplomacy/client/game_instances_set.py | 3 + diplomacy/client/network_game.py | 156 ++++--- diplomacy/client/notification_managers.py | 25 +- diplomacy/client/response_managers.py | 87 ++-- 6 files changed, 607 insertions(+), 466 deletions(-) (limited to 'diplomacy/client') diff --git a/diplomacy/client/channel.py b/diplomacy/client/channel.py index 0403e7f..146d5e3 100644 --- a/diplomacy/client/channel.py +++ b/diplomacy/client/channel.py @@ -15,6 +15,7 @@ # with this program. If not, see . # ============================================================================== """ Channel + - The channel object represents an authenticated connection over a socket. - It has a token that it sends with every request to authenticate itself. """ @@ -23,21 +24,26 @@ import logging from tornado import gen from diplomacy.communication import requests -from diplomacy.utils import strings +from diplomacy.utils import strings, common LOGGER = logging.getLogger(__name__) -def req_fn(request_class, local_req_fn=None, **request_args): +def _req_fn(request_class, local_req_fn=None, **request_args): """ Create channel request method that sends request with channel token. + :param request_class: class of request to send with channel request method. :param local_req_fn: (optional) Channel method to use locally to try retrieving a data instead of sending a request. If provided, local_req_fn is called with request args: + - if it returns anything else than None, then returned data is returned by channel request method. - else, request class is still sent and channel request method follows standard path (request sent, response received, response handler called and final handler result returned). + :param request_args: arguments to pass to request class to create the request object. :return: a Channel method. """ + str_params = (', '.join('%s=%s' % (key, common.to_string(value)) + for (key, value) in sorted(request_args.items()))) if request_args else '' @gen.coroutine def func(self, game_object=None, **kwargs): @@ -67,29 +73,51 @@ def req_fn(request_class, local_req_fn=None, **request_args): request = request_class(**kwargs) return (yield self.connection.send(request, game_object)) + func.__request_name__ = request_class.__name__ + func.__request_params__ = str_params + func.__doc__ = """ + Send request :class:`.%(request_name)s`%(with_params)s``kwargs``. + Return response data returned by server for this request. + See :class:`.%(request_name)s` about request parameters and response. + """ % {'request_name': request_class.__name__, + 'with_params': ' with forced parameters ``(%s)`` and additional request parameters ' + % str_params if request_args else ' with request parameters '} return func -class Channel(): +class Channel: """ Channel - Represents an authenticated connection over a physical socket """ + # pylint: disable=too-few-public-methods __slots__ = ['connection', 'token', 'game_id_to_instances', '__weakref__'] def __init__(self, connection, token): """ Initialize a channel. + + Properties: + + - **connection**: :class:`.Connection` object from which this channel originated. + - **token**: Channel token, used to identify channel on server. + - **game_id_to_instances**: Dictionary mapping a game ID to :class:`.NetworkGame` objects loaded for this + game. Each :class:`.NetworkGame` has a specific role, which is either an observer role, an omniscient + role, or a power (player) role. Network games for a specific game ID are managed within a + :class:`.GameInstancesSet`, which makes sure that there will be at most 1 :class:`.NetworkGame` instance + per possible role. + :param connection: a Connection object. :param token: Channel token. - :type connection: diplomacy.Connection + :type connection: diplomacy.client.connection.Connection + :type token: str """ self.connection = connection self.token = token self.game_id_to_instances = {} # {game id => GameInstances} - def local_join_game(self, **kwargs): + def _local_join_game(self, **kwargs): """ Look for a local game with given kwargs intended to be used to build a JoinGame request. Return None if no local game found, else local game found. - Game is identified with game ID and power name (optional). + Game is identified with game ID **(required)** and power name *(optional)*. If power name is None, we look for a "special" game (observer or omniscient game) - loaded locally. Note that there is at most 1 special game per channel + game ID: - either observer or omniscient, not both. + loaded locally. Note that there is at most 1 special game per (channel + game ID) + couple: either observer or omniscient, not both. """ game_id = kwargs[strings.GAME_ID] power_name = kwargs.get(strings.POWER_NAME, None) @@ -103,54 +131,54 @@ class Channel(): # Public channel API. # =================== - create_game = req_fn(requests.CreateGame) - get_available_maps = req_fn(requests.GetAvailableMaps) - get_playable_powers = req_fn(requests.GetPlayablePowers) - join_game = req_fn(requests.JoinGame, local_req_fn=local_join_game) - join_powers = req_fn(requests.JoinPowers) - list_games = req_fn(requests.ListGames) - get_games_info = req_fn(requests.GetGamesInfo) + create_game = _req_fn(requests.CreateGame) + get_available_maps = _req_fn(requests.GetAvailableMaps) + get_playable_powers = _req_fn(requests.GetPlayablePowers) + join_game = _req_fn(requests.JoinGame, local_req_fn=_local_join_game) + join_powers = _req_fn(requests.JoinPowers) + list_games = _req_fn(requests.ListGames) + get_games_info = _req_fn(requests.GetGamesInfo) + get_dummy_waiting_powers = _req_fn(requests.GetDummyWaitingPowers) # User Account API. - delete_account = req_fn(requests.DeleteAccount) - logout = req_fn(requests.Logout) + delete_account = _req_fn(requests.DeleteAccount) + logout = _req_fn(requests.Logout) # Admin / Moderator API. - make_omniscient = req_fn(requests.SetGrade, grade=strings.OMNISCIENT, grade_update=strings.PROMOTE) - remove_omniscient = req_fn(requests.SetGrade, grade=strings.OMNISCIENT, grade_update=strings.DEMOTE) - promote_administrator = req_fn(requests.SetGrade, grade=strings.ADMIN, grade_update=strings.PROMOTE) - demote_administrator = req_fn(requests.SetGrade, grade=strings.ADMIN, grade_update=strings.DEMOTE) - promote_moderator = req_fn(requests.SetGrade, grade=strings.MODERATOR, grade_update=strings.PROMOTE) - demote_moderator = req_fn(requests.SetGrade, grade=strings.MODERATOR, grade_update=strings.DEMOTE) - - # ================ - # Public game API. - # ================ - - get_dummy_waiting_powers = req_fn(requests.GetDummyWaitingPowers) - get_phase_history = req_fn(requests.GetPhaseHistory) - leave_game = req_fn(requests.LeaveGame) - send_game_message = req_fn(requests.SendGameMessage) - set_orders = req_fn(requests.SetOrders) - - clear_centers = req_fn(requests.ClearCenters) - clear_orders = req_fn(requests.ClearOrders) - clear_units = req_fn(requests.ClearUnits) - - wait = req_fn(requests.SetWaitFlag, wait=True) - no_wait = req_fn(requests.SetWaitFlag, wait=False) - vote = req_fn(requests.Vote) - save = req_fn(requests.SaveGame) - synchronize = req_fn(requests.Synchronize) + make_omniscient = _req_fn(requests.SetGrade, grade=strings.OMNISCIENT, grade_update=strings.PROMOTE) + remove_omniscient = _req_fn(requests.SetGrade, grade=strings.OMNISCIENT, grade_update=strings.DEMOTE) + promote_administrator = _req_fn(requests.SetGrade, grade=strings.ADMIN, grade_update=strings.PROMOTE) + demote_administrator = _req_fn(requests.SetGrade, grade=strings.ADMIN, grade_update=strings.DEMOTE) + promote_moderator = _req_fn(requests.SetGrade, grade=strings.MODERATOR, grade_update=strings.PROMOTE) + demote_moderator = _req_fn(requests.SetGrade, grade=strings.MODERATOR, grade_update=strings.DEMOTE) + + # ==================================================================== + # Game API. Intended to be called by NetworkGame object, not directly. + # ==================================================================== + + _get_phase_history = _req_fn(requests.GetPhaseHistory) + _leave_game = _req_fn(requests.LeaveGame) + _send_game_message = _req_fn(requests.SendGameMessage) + _set_orders = _req_fn(requests.SetOrders) + + _clear_centers = _req_fn(requests.ClearCenters) + _clear_orders = _req_fn(requests.ClearOrders) + _clear_units = _req_fn(requests.ClearUnits) + + _wait = _req_fn(requests.SetWaitFlag, wait=True) + _no_wait = _req_fn(requests.SetWaitFlag, wait=False) + _vote = _req_fn(requests.Vote) + _save = _req_fn(requests.SaveGame) + _synchronize = _req_fn(requests.Synchronize) # Admin / Moderator API. - delete_game = req_fn(requests.DeleteGame) - kick_powers = req_fn(requests.SetDummyPowers) - set_state = req_fn(requests.SetGameState) - process = req_fn(requests.ProcessGame) - query_schedule = req_fn(requests.QuerySchedule) - start = req_fn(requests.SetGameStatus, status=strings.ACTIVE) - pause = req_fn(requests.SetGameStatus, status=strings.PAUSED) - resume = req_fn(requests.SetGameStatus, status=strings.ACTIVE) - cancel = req_fn(requests.SetGameStatus, status=strings.CANCELED) - draw = req_fn(requests.SetGameStatus, status=strings.COMPLETED) + _delete_game = _req_fn(requests.DeleteGame) + _kick_powers = _req_fn(requests.SetDummyPowers) + _set_state = _req_fn(requests.SetGameState) + _process = _req_fn(requests.ProcessGame) + _query_schedule = _req_fn(requests.QuerySchedule) + _start = _req_fn(requests.SetGameStatus, status=strings.ACTIVE) + _pause = _req_fn(requests.SetGameStatus, status=strings.PAUSED) + _resume = _req_fn(requests.SetGameStatus, status=strings.ACTIVE) + _cancel = _req_fn(requests.SetGameStatus, status=strings.CANCELED) + _draw = _req_fn(requests.SetGameStatus, status=strings.COMPLETED) diff --git a/diplomacy/client/connection.py b/diplomacy/client/connection.py index d0d5902..f81d5b6 100644 --- a/diplomacy/client/connection.py +++ b/diplomacy/client/connection.py @@ -18,7 +18,7 @@ import logging import weakref from datetime import timedelta - +from typing import Dict from tornado import gen, ioloop from tornado.concurrent import Future from tornado.iostream import StreamClosedError @@ -34,245 +34,89 @@ from diplomacy.utils import exceptions, strings, constants LOGGER = logging.getLogger(__name__) -class MessageWrittenCallback(): - """ Helper class representing callback to call on a connection when a request is written in a websocket. """ - __slots__ = ['request_context'] - - def __init__(self, request_context): - """ Initialize the callback object. - :param request_context: a request context - :type request_context: RequestFutureContext - """ - self.request_context = request_context - - def callback(self, msg_future): - """ Called when request is effectively written on socket, and move the request - from `request to send` to `request assumed sent`. - """ - # Remove request context from `requests to send` in any case. - connection = self.request_context.connection # type: Connection - request_id = self.request_context.request_id - exception = msg_future.exception() - if exception is not None: - if isinstance(exception, (WebSocketClosedError, StreamClosedError)): - # Connection suddenly closed. - # Request context was stored in connection.requests_to_send - # and will be re-sent when reconnection succeeds. - # For more details, see method Connection.write_request(). - LOGGER.error('Connection was closed when sending a request. Silently waiting for a reconnection.') - else: - LOGGER.error('Fatal error occurred while writing a request.') - self.request_context.future.set_exception(exception) - else: - connection.requests_waiting_responses[request_id] = self.request_context - -class Reconnection(): - """ Class performing reconnection work for a given connection. - - Class properties: - ================= - - - connection: Connection object to reconnect. - - - games_phases: dictionary mapping each game address (game ID + game role) to server game info: - {game ID => {game role => responses.DataGamePhase}} - Server game info is a DataGamePhase response sent by server as response to a Synchronize request. - It contains 3 fields: game ID, current server game phase and current server game timestamp. - We currently use only game phase. - - - n_expected_games: number of games registered in games_phases. - - - n_synchronized_games: number of games already synchronized. - - Reconnection procedure: - ======================= - - - Mark all waiting responses as `re-sent` (may be useful on server-side) and - move them back to responses_to_send. - - - Remove all previous synchronization requests that are not yet sent. We will send new synchronization - requests with latest games timestamps. Future associated to removed requests will raise an exception. - - - Initialize games_phases associating None to each game object currently opened in connection. - - - Send synchronization request for each game object currently opened in connection. For each game: - - - server will send a response describing current server game phase (current phase and timestamp). This info - will be used to check local requests to send. Note that concrete synchronization is done via notifications. - Thus, when server responses is received, game synchronization may not be yet terminated, but at least - we will now current server game phase. - - - Server response is saved in games_phases (replacing None associated to game object). - - - n_synchronized_games is incremented. - - - When sync responses are received for all games registered in games_phases - (n_expected_games == n_synchronized_games), we can finalize reconnection: - - - Remove every phase-dependent game request not yet sent for which phase does not match - server game phase. Futures associated to removed request will raise an exception. - - - Finally send all remaining requests. - - These requests may be marked as re-sent. - For these requests, server is (currently) responsible for checking if they don't represent - a duplicated query. +@gen.coroutine +def connect(hostname, port): + """ Connect to given hostname and port. + :param hostname: a hostname + :param port: a port + :return: a Connection object connected. + :type hostname: str + :type port: int + :rtype: Connection """ + connection = Connection(hostname, port) + yield connection._connect('Trying to connect.') # pylint: disable=protected-access + return connection - __slots__ = ['connection', 'games_phases', 'n_expected_games', 'n_synchronized_games'] - - def __init__(self, connection): - """ Initialize reconnection data/ - :param connection: connection to reconnect. - :type connection: Connection - """ - self.connection = connection - self.games_phases = {} - self.n_expected_games = 0 - self.n_synchronized_games = 0 - - def reconnect(self): - """ Perform concrete reconnection work. """ +class Connection: + """ Connection class. + + The connection class should not be initiated directly, but through the connect method + + .. code-block:: python + + >>> from diplomacy.client.connection import connect + >>> connection = await connect(hostname, port) + + Properties: + + - **hostname**: :class:`str` hostname to connect (e.g. 'localhost') + - **port**: :class:`int` port to connect (e.g. 8888) + - **use_ssl**: :class:`bool` telling if connection should be securized (True) or not (False). + - **url**: (property) :class:`str` websocket url to connect (generated with hostname and port) + - **connection**: :class:`tornado.websocket.WebSocketClientConnection` a tornado websocket connection object + - **connection_count**: :class:`int` number of successful connections from this Connection object. + Used to check if message callbacks is already launched (if count > 0). + - **is_connecting**: :class:`tornado.locks.Event` a tornado Event used to keep connection status. + No request can be sent while is_connecting. + If connected, Synchronize requests can be sent immediately even if is_reconnecting. + Other requests must wait full reconnection. + - **is_reconnecting**: :class:`tornado.locks.Event` a tornado Event used to keep re-connection status. + Non-synchronize request cannot be sent while is_reconnecting. + If reconnected, all requests can be sent. + - **channels**: a :class:`weakref.WeakValueDictionary` mapping channel token to :class:`.Channel` object. + - **requests_to_send**: a :class:`Dict` mapping a request ID to the context of a request + **not sent**. If we are disconnected when trying to send a request, then request + context is added to this dictionary to be send later once reconnected. + - **requests_waiting_responses**: a :class:`Dict` mapping a request ID to the context of a + request **sent**. Contains requests that are waiting for a server response. + - **unknown_tokens**: :class:`set` a set of unknown tokens. We can safely ignore them, as the server has been + notified. + """ + __slots__ = ['hostname', 'port', 'use_ssl', 'connection', 'is_connecting', 'is_reconnecting', 'connection_count', + 'channels', 'requests_to_send', 'requests_waiting_responses', 'unknown_tokens'] - # Mark all waiting responses as `re-sent` and move them back to responses_to_send. - for waiting_context in self.connection.requests_waiting_responses.values(): # type: RequestFutureContext - waiting_context.request.re_sent = True - self.connection.requests_to_send.update(self.connection.requests_waiting_responses) - self.connection.requests_waiting_responses.clear() + def __init__(self, hostname, port, use_ssl=False): + """ Constructor - # Remove all previous synchronization requests. - requests_to_send_updated = {} - for context in self.connection.requests_to_send.values(): # type: RequestFutureContext - if isinstance(context.request, requests.Synchronize): - context.future.set_exception(exceptions.DiplomacyException( - 'Sync request invalidated for game ID %s.' % context.request.game_id)) - else: - requests_to_send_updated[context.request.request_id] = context - self.connection.requests_to_send = requests_to_send_updated + The connection class should not be initiated directly, but through the connect method - # Count games to synchronize. - for channel in self.connection.channels.values(): - for game_instance_set in channel.game_id_to_instances.values(): - for game in game_instance_set.get_games(): - self.games_phases.setdefault(game.game_id, {})[game.role] = None - self.n_expected_games += 1 + .. code-block:: python - if self.n_expected_games: - # Synchronize games. - for channel in self.connection.channels.values(): - for game_instance_set in channel.game_id_to_instances.values(): - for game in game_instance_set.get_games(): - game.synchronize().add_done_callback(self.generate_sync_callback(game)) - else: - # No game to sync, finish sync now. - self.sync_done() + >>> from diplomacy.client.connection import connect + >>> connection = await connect(hostname, port) - def generate_sync_callback(self, game): - """ Generate callback to call when response to sync request is received for given game. - :param game: game - :return: a callback. - :type game: diplomacy.client.network_game.NetworkGame + :param hostname: hostname to connect (e.g. 'localhost') + :param port: port to connect (e.g. 8888) + :param use_ssl: telling if connection should be securized (True) or not (False). + :type hostname: str + :type port: int + :type use_ssl: bool """ - - def on_sync(future): - """ Callback. If exception occurs, print it as logging error. Else, register server response, - and move forward to final reconnection work if all games received sync responses. - """ - exception = future.exception() - if exception is not None: - LOGGER.error(str(exception)) - else: - self.games_phases[game.game_id][game.role] = future.result() - self.n_synchronized_games += 1 - if self.n_synchronized_games == self.n_expected_games: - self.sync_done() - - return on_sync - - def sync_done(self): - """ Final reconnection work. Remove obsolete game requests and send remaining requests. """ - - # All sync requests sent have finished. - # Remove all obsolete game requests from connection. - # A game request is obsolete if it's phase-dependent and if its phase does not match current game phase. - - request_to_send_updated = {} - for context in self.connection.requests_to_send.values(): # type: RequestFutureContext - keep = True - if context.request.level == strings.GAME and context.request.phase_dependent: - request_phase = context.request.phase - server_phase = self.games_phases[context.request.game_id][context.request.game_role].phase - if request_phase != server_phase: - # Request is obsolete. - context.future.set_exception(exceptions.DiplomacyException( - 'Game %s: request %s: request phase %s does not match current server game phase %s.' - % (context.request.game_id, context.request.name, request_phase, server_phase))) - keep = False - if keep: - request_to_send_updated[context.request.request_id] = context - - LOGGER.debug('Keep %d/%d old requests to send.', - len(request_to_send_updated), len(self.connection.requests_to_send)) - - # All requests to send are stored in request_to_send_updated. - # Then we can empty connection.requests_to_send. - # If we fail to send a request, it will be re-added again. - self.connection.requests_to_send.clear() - - # Send requests. - for request_to_send in request_to_send_updated.values(): # type: RequestFutureContext - self.connection.write_request(request_to_send).add_done_callback( - MessageWrittenCallback(request_to_send).callback) - - # We are reconnected. - self.connection.is_reconnecting.set() - - LOGGER.info('Done reconnection work.') - -class Connection(): - """ Connection class. Properties: - - hostname: hostname to connect (e.g. 'localhost') - - port: port to connect (e.g. 8888) - - use_ssl: boolean telling if connection should be securized (True) or not (False). - - url (auto): websocket url to connect (generated with hostname and port) - - connection: a tornado websocket connection object - - connection_count: number of successful connections from this Connection object. - Used to check if message callbacks is already launched (if count > 0). - - connection_lock: a tornado lock used to access tornado websocket connection object - - is_connecting: a tornado Event used to keep connection status. - No request can be sent while is_connecting. - If connected, Synchronize requests can be sent immediately even if is_reconnecting. - Other requests must wait full reconnection. - - is_reconnecting: a tornado Event used to keep re-connection status. - Non-synchronize request cannot be sent while is_reconnecting. - If reconnected, all requests can be sent. - - channels: a WeakValueDictionary mapping channel token to Channel object. - - requests_to_send: a dictionary mapping a request ID to the context of a request - **not sent**. If we are disconnected when trying to send a request, then request - context is added to this dictionary to be send later once reconnected. - - requests_waiting_responses: a dictionary mapping a request ID to the context of a - request **sent**. Contains requests that are waiting for a server response. - - unknown_tokens: a set of unknown tokens. We can safely ignore them, as the server has been notified. - """ - __slots__ = ['hostname', 'port', 'use_ssl', 'connection', 'is_connecting', 'is_reconnecting', 'connection_count', - 'channels', 'requests_to_send', 'requests_waiting_responses', 'unknown_tokens'] - - def __init__(self, hostname, port, use_ssl=False): self.hostname = hostname self.port = port self.use_ssl = bool(use_ssl) self.connection = None + self.connection_count = 0 self.is_connecting = Event() self.is_reconnecting = Event() - self.connection_count = 0 - self.channels = weakref.WeakValueDictionary() # {token => Channel} - self.requests_to_send = {} # type: dict{str, RequestFutureContext} - self.requests_waiting_responses = {} # type: dict{str, RequestFutureContext} + self.requests_to_send = {} # type: Dict[str, RequestFutureContext] + self.requests_waiting_responses = {} # type: Dict[str, RequestFutureContext] self.unknown_tokens = set() # When connection is created, we are not yet connected, but reconnection does not matter @@ -281,12 +125,53 @@ class Connection(): url = property(lambda self: '%s://%s:%d' % ('wss' if self.use_ssl else 'ws', self.hostname, self.port)) + # =================== + # Public Methods. + # =================== + + @gen.coroutine + def authenticate(self, username, password, create_user=False): + """ Send a :class:`.SignIn` request. + + :param username: username + :param password: password + :param create_user: boolean indicating if you want to create a user or login to an existing user. + :return: a :class:`.Channel` object representing the authentication. + :type username: str + :type password: str + :type create_user: bool + :rtype: diplomacy.client.channel.Channel + """ + request = requests.SignIn(username=username, password=password, create_user=create_user) + return (yield self.send(request)) + + @gen.coroutine + def get_daide_port(self, game_id): + """ Send a :class:`.GetDaidePort` request. + + :param game_id: game id for which to retrieve the DAIDE port. + :return: the game DAIDE port + :type game_id: str + :rtype: int + """ + request = requests.GetDaidePort(game_id=game_id) + return (yield self.send(request)) + + # =================== + # Private Methods + # =================== + @gen.coroutine - def _connect(self): + def _connect(self, message=None): """ Create (force) a tornado websocket connection. Try NB_CONNECTION_ATTEMPTS attempts, waiting for ATTEMPT_DELAY_SECONDS seconds between 2 attempts. Raise an exception if it cannot connect. + + :param message: if provided, print this message as a logger info before starting to connect. + :type message: str, optional """ + if message: + LOGGER.info(message) # We are connecting. self.is_connecting.clear() @@ -319,71 +204,11 @@ class Connection(): @gen.coroutine def _reconnect(self): """ Reconnect. """ - LOGGER.info('Trying to reconnect.') # We are reconnecting. self.is_reconnecting.clear() - yield self._connect() + yield self._connect('Trying to reconnect.') # We will be reconnected when method Reconnection.sync_done() will finish. - Reconnection(self).reconnect() - - def _register_to_send(self, request_context): - """ Register given request context as a request to send as soon as possible. - :param request_context: context of request to send. - :type request_context: RequestFutureContext - """ - self.requests_to_send[request_context.request_id] = request_context - - def write_request(self, request_context): - """ Write a request into internal connection object. - :param request_context: context of request to send. - :type request_context: RequestFutureContext - """ - future = Future() - request = request_context.request - - def on_message_written(write_future): - """ 3) Writing returned, set future as done (with writing result) or with writing exception. """ - exception = write_future.exception() - if exception is not None: - future.set_exception(exception) - else: - future.set_result(write_future.result()) - - def on_connected(reconnected_future): - """ 2) Send request. """ - exception = reconnected_future.exception() - if exception is not None: - LOGGER.error('Fatal (re)connection error occurred while sending a request.') - future.set_exception(exception) - else: - try: - if self.connection is None: - raise WebSocketClosedError() - write_future = self.connection.write_message(request.json()) - except (WebSocketClosedError, StreamClosedError) as exc: - # We were disconnected. - # Save request context as a request to send. - # We will re-try to send it later once reconnected. - self._register_to_send(request_context) - # Transfer exception to returned future. - future.set_exception(exc) - else: - write_future.add_done_callback(on_message_written) - - # 1) Synchronize requests just wait for connection. - # Other requests wait for reconnection (which also implies connection). - if isinstance(request, requests.Synchronize): - self.is_connecting.wait().add_done_callback(on_connected) - else: - self.is_reconnecting.wait().add_done_callback(on_connected) - - return future - - @gen.coroutine - def connect(self): - """ Effectively connect this object. """ - LOGGER.info('Trying to connect.') - yield self._connect() + _Reconnection(self).reconnect() @gen.coroutine def _on_socket_message(self, socket_message): @@ -461,47 +286,264 @@ class Connection(): except (WebSocketClosedError, StreamClosedError): pass - # Public methods. - @gen.coroutine - def get_daide_port(self, game_id): - """ Send a GetDaidePort request. - :param game_id: game id - :return: int. the game DAIDE port - """ - request = requests.GetDaidePort(game_id=game_id) - return (yield self.send(request)) + def _register_to_send(self, request_context): + """ Register given request context as a request to send as soon as possible. - @gen.coroutine - def authenticate(self, username, password, create_user=False): - """ Send a SignIn request. - :param username: username - :param password: password - :param create_user: boolean indicating if you want to create a user or login to and existing user. - :return: a Channel object representing the authentication. + :param request_context: context of request to send. + :type request_context: RequestFutureContext """ - request = requests.SignIn(username=username, password=password, create_user=create_user) - return (yield self.send(request)) + self.requests_to_send[request_context.request_id] = request_context def send(self, request, for_game=None): """ Send a request. + :param request: request object. :param for_game: (optional) NetworkGame object (required for game requests). :return: a Future that returns the response handler result of this request. + :type request: diplomacy.communication.requests._AbstractRequest + :type for_game: diplomacy.client.network_game.NetworkGame, optional + :rtype: Future """ request_future = Future() request_context = RequestFutureContext(request=request, future=request_future, connection=self, game=for_game) - self.write_request(request_context).add_done_callback(MessageWrittenCallback(request_context).callback) + self.write_request(request_context).add_done_callback(_MessageWrittenCallback(request_context).callback) return gen.with_timeout(timedelta(seconds=constants.REQUEST_TIMEOUT_SECONDS), request_future) -@gen.coroutine -def connect(hostname, port): - """ Connect to given hostname and port. - :param hostname: a hostname - :param port: a port - :return: a Connection object connected. - :rtype: Connection + def write_request(self, request_context): + """ Write a request into internal connection object. + + :param request_context: context of request to send. + :type request_context: RequestFutureContext + """ + future = Future() + request = request_context.request + + def on_message_written(write_future): + """ 3) Writing returned, set future as done (with writing result) + or with writing exception. + """ + exception = write_future.exception() + if exception is not None: + future.set_exception(exception) + else: + future.set_result(write_future.result()) + + def on_connected(reconnected_future): + """ 2) Send request. """ + exception = reconnected_future.exception() + if exception is not None: + LOGGER.error('Fatal (re)connection error occurred while sending a request.') + future.set_exception(exception) + else: + try: + if self.connection is None: + raise WebSocketClosedError() + write_future = self.connection.write_message(request.json()) + except (WebSocketClosedError, StreamClosedError) as exc: + # We were disconnected. + # Save request context as a request to send. + # We will re-try to send it later once reconnected. + self._register_to_send(request_context) + # Transfer exception to returned future. + future.set_exception(exc) + else: + write_future.add_done_callback(on_message_written) + + # 1) Synchronize requests just wait for connection. + # Other requests wait for reconnection (which also implies connection). + if isinstance(request, requests.Synchronize): + self.is_connecting.wait().add_done_callback(on_connected) + else: + self.is_reconnecting.wait().add_done_callback(on_connected) + + return future + +class _Reconnection: + """ Class performing reconnection work for a given connection. + + Class properties: + + - connection: Connection object to reconnect. + - games_phases: dictionary mapping each game address (game ID + game role) to server game info: + {game ID => {game role => responses.DataGamePhase}}. + Server game info is a DataGamePhase response sent by server as response to a Synchronize request. + It contains 3 fields: game ID, current server game phase and current server game timestamp. + We currently use only game phase. + - n_expected_games: number of games registered in games_phases. + - n_synchronized_games: number of games already synchronized. + + Reconnection procedure: + + - Mark all waiting responses as `re-sent` (may be useful on server-side) and + move them back to responses_to_send. + - Remove all previous synchronization requests that are not yet sent. We will send new synchronization + requests with latest games timestamps. Future associated to removed requests will raise an exception. + - Initialize games_phases associating None to each game object currently opened in connection. + - Send synchronization request for each game object currently opened in connection. For each game: + + - server will send a response describing current server game phase (current phase and timestamp). This info + will be used to check local requests to send. Note that concrete synchronization is done via notifications. + Thus, when server responses is received, game synchronization may not be yet terminated, but at least + we will now current server game phase. + - Server response is saved in games_phases (replacing None associated to game object). + - n_synchronized_games is incremented. + + - When sync responses are received for all games registered in games_phases + (n_expected_games == n_synchronized_games), we can finalize reconnection: + + - Remove every phase-dependent game request not yet sent for which phase does not match + server game phase. Futures associated to removed request will raise an exception. + - Finally send all remaining requests. + + These requests may be marked as re-sent. + For these requests, server is (currently) responsible for checking if they don't represent + a duplicated query. """ - connection = Connection(hostname, port) - yield connection.connect() - return connection + + __slots__ = ['connection', 'games_phases', 'n_expected_games', 'n_synchronized_games'] + + def __init__(self, connection): + """ Initialize reconnection data/ + + :param connection: connection to reconnect. + :type connection: Connection + """ + self.connection = connection + self.games_phases = {} + self.n_expected_games = 0 + self.n_synchronized_games = 0 + + def reconnect(self): + """ Perform concrete reconnection work. """ + + # Mark all waiting responses as `re-sent` and move them back to responses_to_send. + for waiting_context in self.connection.requests_waiting_responses.values(): # type: RequestFutureContext + waiting_context.request.re_sent = True + self.connection.requests_to_send.update(self.connection.requests_waiting_responses) + self.connection.requests_waiting_responses.clear() + + # Remove all previous synchronization requests. + requests_to_send_updated = {} + for context in self.connection.requests_to_send.values(): # type: RequestFutureContext + if isinstance(context.request, requests.Synchronize): + context.future.set_exception(exceptions.DiplomacyException( + 'Sync request invalidated for game ID %s.' % context.request.game_id)) + else: + requests_to_send_updated[context.request.request_id] = context + self.connection.requests_to_send = requests_to_send_updated + + # Count games to synchronize. + for channel in self.connection.channels.values(): + for game_instance_set in channel.game_id_to_instances.values(): + for game in game_instance_set.get_games(): + self.games_phases.setdefault(game.game_id, {})[game.role] = None + self.n_expected_games += 1 + + if self.n_expected_games: + # Synchronize games. + for channel in self.connection.channels.values(): + for game_instance_set in channel.game_id_to_instances.values(): + for game in game_instance_set.get_games(): + game.synchronize().add_done_callback(self.generate_sync_callback(game)) + else: + # No game to sync, finish sync now. + self.sync_done() + + def generate_sync_callback(self, game): + """ Generate callback to call when response to sync request is received for given game. + + :param game: game + :return: a callback. + :type game: diplomacy.client.network_game.NetworkGame + """ + + def on_sync(future): + """ Callback. If exception occurs, print it as logging error. Else, register server response, + and move forward to final reconnection work if all games received sync responses. + """ + exception = future.exception() + if exception is not None: + LOGGER.error(str(exception)) + else: + self.games_phases[game.game_id][game.role] = future.result() + self.n_synchronized_games += 1 + if self.n_synchronized_games == self.n_expected_games: + self.sync_done() + + return on_sync + + def sync_done(self): + """ Final reconnection work. Remove obsolete game requests and send remaining requests. """ + + # All sync requests sent have finished. + # Remove all obsolete game requests from connection. + # A game request is obsolete if it's phase-dependent and if its phase does not match current game phase. + + request_to_send_updated = {} + for context in self.connection.requests_to_send.values(): # type: RequestFutureContext + keep = True + if context.request.level == strings.GAME and context.request.phase_dependent: + request_phase = context.request.phase + server_phase = self.games_phases[context.request.game_id][context.request.game_role].phase + if request_phase != server_phase: + # Request is obsolete. + context.future.set_exception(exceptions.DiplomacyException( + 'Game %s: request %s: request phase %s does not match current server game phase %s.' + % (context.request.game_id, context.request.name, request_phase, server_phase))) + keep = False + if keep: + request_to_send_updated[context.request.request_id] = context + + LOGGER.debug('Keep %d/%d old requests to send.', + len(request_to_send_updated), len(self.connection.requests_to_send)) + + # All requests to send are stored in request_to_send_updated. + # Then we can empty connection.requests_to_send. + # If we fail to send a request, it will be re-added again. + self.connection.requests_to_send.clear() + + # Send requests. + for request_to_send in request_to_send_updated.values(): # type: RequestFutureContext + self.connection.write_request(request_to_send).add_done_callback( + _MessageWrittenCallback(request_to_send).callback) + + # We are reconnected. + self.connection.is_reconnecting.set() + + LOGGER.info('Done reconnection work.') + +class _MessageWrittenCallback: + """ Helper class representing callback to call on a connection + when a request is written in a websocket. + """ + __slots__ = ['request_context'] + + def __init__(self, request_context): + """ Initialize the callback object. + + :param request_context: a request context + :type request_context: RequestFutureContext + """ + self.request_context = request_context + + def callback(self, msg_future): + """ Called when request is effectively written on socket, and move the request + from `request to send` to `request assumed sent`. + """ + # Remove request context from `requests to send` in any case. + connection = self.request_context.connection # type: Connection + request_id = self.request_context.request_id + exception = msg_future.exception() + if exception is not None: + if isinstance(exception, (WebSocketClosedError, StreamClosedError)): + # Connection suddenly closed. + # Request context was stored in connection.requests_to_send + # and will be re-sent when reconnection succeeds. + # For more details, see method Connection.write_request(). + LOGGER.error('Connection was closed when sending a request. Silently waiting for a reconnection.') + else: + LOGGER.error('Fatal error occurred while writing a request.') + self.request_context.future.set_exception(exception) + else: + connection.requests_waiting_responses[request_id] = self.request_context diff --git a/diplomacy/client/game_instances_set.py b/diplomacy/client/game_instances_set.py index f8c22b9..fd50507 100644 --- a/diplomacy/client/game_instances_set.py +++ b/diplomacy/client/game_instances_set.py @@ -32,7 +32,9 @@ class GameInstancesSet(): def __init__(self, game_id): """ Initialize a game instances set. + :param game_id: game ID of game instances to store. + :type game_id: str """ self.game_id = game_id self.games = weakref.WeakValueDictionary() # {power name => NetworkGame} @@ -60,6 +62,7 @@ class GameInstancesSet(): def add(self, game): """ Add given game. + :param game: a NetworkGame object. :type game: diplomacy.client.network_game.NetworkGame """ diff --git a/diplomacy/client/network_game.py b/diplomacy/client/network_game.py index 368d1d2..10b97d8 100644 --- a/diplomacy/client/network_game.py +++ b/diplomacy/client/network_game.py @@ -24,7 +24,7 @@ from diplomacy.utils.exceptions import DiplomacyException LOGGER = logging.getLogger(__name__) -def game_request_method(channel_method): +def _game_request_method(channel_method): """Create a game request method that calls channel counterpart.""" def func(self, **kwargs): @@ -34,40 +34,62 @@ def game_request_method(channel_method): raise DiplomacyException('Invalid client game.') return channel_method(self.channel, game_object=self, **kwargs) + func.__doc__ = """ + Send game request :class:`.%(request_name)s`%(with_params)s``kwargs``. + See :class:`.%(request_name)s` about request parameters and response. + """ % {'request_name': channel_method.__request_name__, + 'with_params': (' with forced parameters ``(%s)`` and additional request parameters ' + % (channel_method.__request_params__ + if channel_method.__request_params__ + else ' with request parameters '))} return func -def callback_setting_method(notification_class): +def _callback_setting_method(notification_class): """ Create a callback setting method for a given notification class. """ def func(self, notification_callback): """ Add given callback for this game notification class. """ self.add_notification_callback(notification_class, notification_callback) + func.__doc__ = """ + Add callback for notification :class:`.%(notification_name)s`. Callback signature: + ``callback(network_game, notification) -> None``. + """ % {'notification_name' : notification_class.__name__} + return func -def callback_clearing_method(notification_class): +def _callback_clearing_method(notification_class): """ Create a callback clearing method for a given notification class. """ def func(self): """ Clear user callbacks for this game notification class. """ self.clear_notification_callbacks(notification_class) + func.__doc__ = """ + Clear callbacks for notification :class:`.%(notification_name)s`.. + """ % {'notification_name': notification_class.__name__} + return func class NetworkGame(Game): - """ NetworkGame class. Properties: - - channel: associated Channel object. - - notification_callbacks: dict mapping a notification class name to a callback to be called + """ NetworkGame class. + + Properties: + + - **channel**: associated :class:`diplomacy.client.channel.Channel` object. + - **notification_callbacks**: :class:`Dict` mapping a notification class name to a callback to be called when a corresponding game notification is received. """ + # pylint: disable=protected-access __slots__ = ['channel', 'notification_callbacks', 'data', '__weakref__'] def __init__(self, channel, received_game): """ Initialize network game object with a channel and a game object sent by server. + :param channel: a Channel object. :param received_game: a Game object. :type channel: diplomacy.client.channel.Channel - :type received_game: diplomacy.Game + :type received_game: diplomacy.engine.game.Game """ self.channel = channel self.notification_callbacks = {} # {notification_class => [callback(game, notification)]} @@ -83,80 +105,83 @@ class NetworkGame(Game): # NB: Method get_all_possible_orders() is only local in Python code, # but is still a network call from web interface. - get_phase_history = game_request_method(Channel.get_phase_history) - leave = game_request_method(Channel.leave_game) - send_game_message = game_request_method(Channel.send_game_message) - set_orders = game_request_method(Channel.set_orders) + get_phase_history = _game_request_method(Channel._get_phase_history) + leave = _game_request_method(Channel._leave_game) + send_game_message = _game_request_method(Channel._send_game_message) + set_orders = _game_request_method(Channel._set_orders) - clear_centers = game_request_method(Channel.clear_centers) - clear_orders = game_request_method(Channel.clear_orders) - clear_units = game_request_method(Channel.clear_units) + clear_centers = _game_request_method(Channel._clear_centers) + clear_orders = _game_request_method(Channel._clear_orders) + clear_units = _game_request_method(Channel._clear_units) - wait = game_request_method(Channel.wait) - no_wait = game_request_method(Channel.no_wait) - vote = game_request_method(Channel.vote) - save = game_request_method(Channel.save) + wait = _game_request_method(Channel._wait) + no_wait = _game_request_method(Channel._no_wait) + vote = _game_request_method(Channel._vote) + save = _game_request_method(Channel._save) def synchronize(self): - """ Send a Synchronize request to synchronize this game with associated server game. """ + """ Send a :class:`.Synchronize` request to synchronize this game with associated server game. """ if not self.channel: raise DiplomacyException('Invalid client game.') - return self.channel.synchronize(game_object=self, timestamp=self.get_latest_timestamp()) + return self.channel._synchronize(game_object=self, timestamp=self.get_latest_timestamp()) # Admin / Moderator API. - delete = game_request_method(Channel.delete_game) - kick_powers = game_request_method(Channel.kick_powers) - set_state = game_request_method(Channel.set_state) - process = game_request_method(Channel.process) - query_schedule = game_request_method(Channel.query_schedule) - start = game_request_method(Channel.start) - pause = game_request_method(Channel.pause) - resume = game_request_method(Channel.resume) - cancel = game_request_method(Channel.cancel) - draw = game_request_method(Channel.draw) + delete = _game_request_method(Channel._delete_game) + kick_powers = _game_request_method(Channel._kick_powers) + set_state = _game_request_method(Channel._set_state) + process = _game_request_method(Channel._process) + query_schedule = _game_request_method(Channel._query_schedule) + start = _game_request_method(Channel._start) + pause = _game_request_method(Channel._pause) + resume = _game_request_method(Channel._resume) + cancel = _game_request_method(Channel._cancel) + draw = _game_request_method(Channel._draw) # =============================== # Notification callback settings. # =============================== - add_on_cleared_centers = callback_setting_method(notifications.ClearedCenters) - add_on_cleared_orders = callback_setting_method(notifications.ClearedOrders) - add_on_cleared_units = callback_setting_method(notifications.ClearedUnits) - add_on_game_deleted = callback_setting_method(notifications.GameDeleted) - add_on_game_message_received = callback_setting_method(notifications.GameMessageReceived) - add_on_game_processed = callback_setting_method(notifications.GameProcessed) - add_on_game_phase_update = callback_setting_method(notifications.GamePhaseUpdate) - add_on_game_status_update = callback_setting_method(notifications.GameStatusUpdate) - add_on_omniscient_updated = callback_setting_method(notifications.OmniscientUpdated) - add_on_power_orders_flag = callback_setting_method(notifications.PowerOrdersFlag) - add_on_power_orders_update = callback_setting_method(notifications.PowerOrdersUpdate) - add_on_power_vote_updated = callback_setting_method(notifications.PowerVoteUpdated) - add_on_power_wait_flag = callback_setting_method(notifications.PowerWaitFlag) - add_on_powers_controllers = callback_setting_method(notifications.PowersControllers) - add_on_vote_count_updated = callback_setting_method(notifications.VoteCountUpdated) - add_on_vote_updated = callback_setting_method(notifications.VoteUpdated) - - clear_on_cleared_centers = callback_clearing_method(notifications.ClearedCenters) - clear_on_cleared_orders = callback_clearing_method(notifications.ClearedOrders) - clear_on_cleared_units = callback_clearing_method(notifications.ClearedUnits) - clear_on_game_deleted = callback_clearing_method(notifications.GameDeleted) - clear_on_game_message_received = callback_clearing_method(notifications.GameMessageReceived) - clear_on_game_processed = callback_clearing_method(notifications.GameProcessed) - clear_on_game_phase_update = callback_clearing_method(notifications.GamePhaseUpdate) - clear_on_game_status_update = callback_clearing_method(notifications.GameStatusUpdate) - clear_on_omniscient_updated = callback_clearing_method(notifications.OmniscientUpdated) - clear_on_power_orders_flag = callback_clearing_method(notifications.PowerOrdersFlag) - clear_on_power_orders_update = callback_clearing_method(notifications.PowerOrdersUpdate) - clear_on_power_vote_updated = callback_clearing_method(notifications.PowerVoteUpdated) - clear_on_power_wait_flag = callback_clearing_method(notifications.PowerWaitFlag) - clear_on_powers_controllers = callback_clearing_method(notifications.PowersControllers) - clear_on_vote_count_updated = callback_clearing_method(notifications.VoteCountUpdated) - clear_on_vote_updated = callback_clearing_method(notifications.VoteUpdated) + add_on_cleared_centers = _callback_setting_method(notifications.ClearedCenters) + add_on_cleared_orders = _callback_setting_method(notifications.ClearedOrders) + add_on_cleared_units = _callback_setting_method(notifications.ClearedUnits) + add_on_game_deleted = _callback_setting_method(notifications.GameDeleted) + add_on_game_message_received = _callback_setting_method(notifications.GameMessageReceived) + add_on_game_processed = _callback_setting_method(notifications.GameProcessed) + add_on_game_phase_update = _callback_setting_method(notifications.GamePhaseUpdate) + add_on_game_status_update = _callback_setting_method(notifications.GameStatusUpdate) + add_on_omniscient_updated = _callback_setting_method(notifications.OmniscientUpdated) + add_on_power_orders_flag = _callback_setting_method(notifications.PowerOrdersFlag) + add_on_power_orders_update = _callback_setting_method(notifications.PowerOrdersUpdate) + add_on_power_vote_updated = _callback_setting_method(notifications.PowerVoteUpdated) + add_on_power_wait_flag = _callback_setting_method(notifications.PowerWaitFlag) + add_on_powers_controllers = _callback_setting_method(notifications.PowersControllers) + add_on_vote_count_updated = _callback_setting_method(notifications.VoteCountUpdated) + add_on_vote_updated = _callback_setting_method(notifications.VoteUpdated) + + clear_on_cleared_centers = _callback_clearing_method(notifications.ClearedCenters) + clear_on_cleared_orders = _callback_clearing_method(notifications.ClearedOrders) + clear_on_cleared_units = _callback_clearing_method(notifications.ClearedUnits) + clear_on_game_deleted = _callback_clearing_method(notifications.GameDeleted) + clear_on_game_message_received = _callback_clearing_method(notifications.GameMessageReceived) + clear_on_game_processed = _callback_clearing_method(notifications.GameProcessed) + clear_on_game_phase_update = _callback_clearing_method(notifications.GamePhaseUpdate) + clear_on_game_status_update = _callback_clearing_method(notifications.GameStatusUpdate) + clear_on_omniscient_updated = _callback_clearing_method(notifications.OmniscientUpdated) + clear_on_power_orders_flag = _callback_clearing_method(notifications.PowerOrdersFlag) + clear_on_power_orders_update = _callback_clearing_method(notifications.PowerOrdersUpdate) + clear_on_power_vote_updated = _callback_clearing_method(notifications.PowerVoteUpdated) + clear_on_power_wait_flag = _callback_clearing_method(notifications.PowerWaitFlag) + clear_on_powers_controllers = _callback_clearing_method(notifications.PowersControllers) + clear_on_vote_count_updated = _callback_clearing_method(notifications.VoteCountUpdated) + clear_on_vote_updated = _callback_clearing_method(notifications.VoteUpdated) def add_notification_callback(self, notification_class, notification_callback): """ Add a callback for a notification. - :param notification_class: a notification class - :param notification_callback: callback to add. + + :param notification_class: a notification class. + See :mod:`diplomacy.communication.notifications` about available notifications. + :param notification_callback: callback to add: + ``callback(network_game, notification) -> None``. """ assert callable(notification_callback) if notification_class not in self.notification_callbacks: @@ -166,6 +191,7 @@ class NetworkGame(Game): def clear_notification_callbacks(self, notification_class): """ Remove all user callbacks for a notification. + :param notification_class: a notification class """ self.notification_callbacks.pop(notification_class, None) diff --git a/diplomacy/client/notification_managers.py b/diplomacy/client/notification_managers.py index 317bd2e..05e6ab7 100644 --- a/diplomacy/client/notification_managers.py +++ b/diplomacy/client/notification_managers.py @@ -27,9 +27,10 @@ LOGGER = logging.getLogger(__name__) def _get_game_to_notify(connection, notification): """ Get notified game from connection using notification parameters. + :param connection: connection that receives the notification. :param notification: notification received. - :return: a NetWorkGame instance, or None if no game found. + :return: a NetworkGame instance, or None if no game found. :type connection: diplomacy.Connection :type notification: diplomacy.communication.notifications._GameNotification """ @@ -40,6 +41,7 @@ def _get_game_to_notify(connection, notification): def on_account_deleted(channel, notification): """ Manage notification AccountDeleted. + :param channel: channel associated to received notification. :param notification: received notification. :type channel: diplomacy.client.channel.Channel @@ -49,6 +51,7 @@ def on_account_deleted(channel, notification): def on_cleared_centers(game, notification): """ Manage notification ClearedCenters. + :param game: a Network game :param notification: notification received :type game: diplomacy.client.network_game.NetworkGame @@ -58,6 +61,7 @@ def on_cleared_centers(game, notification): def on_cleared_orders(game, notification): """ Manage notification ClearedOrders. + :param game: a Network game :param notification: notification received :type game: diplomacy.client.network_game.NetworkGame @@ -67,6 +71,7 @@ def on_cleared_orders(game, notification): def on_cleared_units(game, notification): """ Manage notification ClearedUnits. + :param game: a Network game :param notification: notification received :type game: diplomacy.client.network_game.NetworkGame @@ -76,6 +81,7 @@ def on_cleared_units(game, notification): def on_powers_controllers(game, notification): """ Manage notification PowersControllers. + :param game: a Network game :param notification: notification received :type game: diplomacy.client.network_game.NetworkGame @@ -90,6 +96,7 @@ def on_powers_controllers(game, notification): def on_game_deleted(game, notification): """ Manage notification GameDeleted. + :param game: a Network game :param notification: notification received :type game: diplomacy.client.network_game.NetworkGame @@ -101,7 +108,8 @@ def on_game_deleted(game, notification): game.channel.game_id_to_instances[game.game_id].remove_special() def on_game_message_received(game, notification): - """ Manage notification GameMessageReceived.. + """ Manage notification GameMessageReceived. + :param game: a Network game :param notification: notification received :type game: diplomacy.client.network_game.NetworkGame @@ -111,6 +119,7 @@ def on_game_message_received(game, notification): def on_game_processed(game, notification): """ Manage notification GamePhaseUpdate (for omniscient and observer games). + :param game: a Network game :param notification: notification received :type game: diplomacy.client.network_game.NetworkGame @@ -120,6 +129,7 @@ def on_game_processed(game, notification): def on_game_phase_update(game, notification): """ Manage notification GamePhaseUpdate. + :param game: a Network game :param notification: notification received :type game: diplomacy.client.network_game.NetworkGame @@ -132,6 +142,7 @@ def on_game_phase_update(game, notification): def on_game_status_update(game, notification): """ Manage notification GameStatusUpdate. + :param game: a Network game :param notification: notification received :type game: diplomacy.client.network_game.NetworkGame @@ -141,6 +152,7 @@ def on_game_status_update(game, notification): def on_omniscient_updated(game, notification): """ Manage notification OmniscientUpdated. + :param game: game associated to received notification. :param notification: received notification. :type game: diplomacy.client.network_game.NetworkGame @@ -153,10 +165,12 @@ def on_omniscient_updated(game, notification): else: assert notification.grade_update == strings.DEMOTE assert notification.game.is_observer_game() + # Save client game channel and invalidate client game. channel = game.channel game.channel = None channel.game_id_to_instances[notification.game_id].remove(game.role) + # Create a new client game with previous client game channel game sent by server. new_game = NetworkGame(channel, notification.game) new_game.notification_callbacks.update({key: value.copy() for key, value in game.notification_callbacks.items()}) @@ -165,6 +179,7 @@ def on_omniscient_updated(game, notification): def on_power_orders_update(game, notification): """ Manage notification PowerOrdersUpdate. + :param game: a Network game :param notification: notification received :type game: diplomacy.client.network_game.NetworkGame @@ -174,6 +189,7 @@ def on_power_orders_update(game, notification): def on_power_orders_flag(game, notification): """ Manage notification PowerOrdersFlag. + :param game: a Network game :param notification: notification received :type game: diplomacy.client.network_game.NetworkGame @@ -185,6 +201,7 @@ def on_power_orders_flag(game, notification): def on_power_vote_updated(game, notification): """ Manage notification PowerVoteUpdated (for power game). + :param game: a Network game :param notification: notification received :type game: diplomacy.client.network_game.NetworkGame @@ -195,6 +212,7 @@ def on_power_vote_updated(game, notification): def on_power_wait_flag(game, notification): """ Manage notification PowerWaitFlag. + :param game: a Network game :param notification: notification received :type game: diplomacy.client.network_game.NetworkGame @@ -204,6 +222,7 @@ def on_power_wait_flag(game, notification): def on_vote_count_updated(game, notification): """ Manage notification VoteCountUpdated (for observer game). + :param game: game associated to received notification. :param notification: received notification. :type game: diplomacy.client.network_game.NetworkGame @@ -212,6 +231,7 @@ def on_vote_count_updated(game, notification): def on_vote_updated(game, notification): """ Manage notification VoteUpdated (for omniscient game). + :param game: a Network game :param notification: notification received :type game: diplomacy.client.network_game.NetworkGame @@ -244,6 +264,7 @@ MAPPING = { def handle_notification(connection, notification): """ Call appropriate handler for given notification received by given connection. + :param connection: recipient connection. :param notification: received notification. :type connection: diplomacy.Connection diff --git a/diplomacy/client/response_managers.py b/diplomacy/client/response_managers.py index 991586f..0ecb453 100644 --- a/diplomacy/client/response_managers.py +++ b/diplomacy/client/response_managers.py @@ -28,7 +28,7 @@ from diplomacy.engine.game import Game from diplomacy.utils import exceptions from diplomacy.utils.game_phase_data import GamePhaseData -class RequestFutureContext(): +class RequestFutureContext: """ Helper class to store a context around a request (with future for response management, related connection and optional related game). """ @@ -36,6 +36,7 @@ class RequestFutureContext(): def __init__(self, request, future, connection, game=None): """ Initialize a request future context. + :param request: a request object (see diplomacy.communication.requests about possible classes). :param future: a tornado Future object. :param connection: a diplomacy.Connection object. @@ -63,6 +64,7 @@ class RequestFutureContext(): def new_game(self, received_game): """ Create, store (in associated connection) and return a new network game wrapping given game data. Returned game is already in appropriate type (observer game, omniscient game or power game). + :param received_game: game sent by server (Game object) :type received_game: Game """ @@ -90,6 +92,7 @@ def default_manager(context, response): Else, return response. Expect response to be either OK or a UniqueData (containing only 1 field intended to be returned by server for associated request). + :param context: request context :param response: response received :return: None, or data if response is a UniqueData. @@ -100,8 +103,42 @@ def default_manager(context, response): return None return response +def on_clear_centers(context, response): + """ Manage response for request ClearCenters. + + :param context: request context + :param response: response received + :return: None + :type context: RequestFutureContext + """ + request = context.request # type: requests.ClearCenters + Game.clear_centers(context.game, request.power_name) + +def on_clear_orders(context, response): + """ Manage response for request ClearOrders. + + :param context: request context + :param response: response received + :return: None + :type context: RequestFutureContext + """ + request = context.request # type: requests.ClearOrders + Game.clear_orders(context.game, request.power_name) + +def on_clear_units(context, response): + """ Manage response for request ClearUnits. + + :param context: request context + :param response: response received + :return: None + :type context: RequestFutureContext + """ + request = context.request # type: requests.ClearUnits + Game.clear_units(context.game, request.power_name) + def on_create_game(context, response): """ Manage response for request CreateGame. + :param context: request context :param response: response received :return: a new network game @@ -112,6 +149,7 @@ def on_create_game(context, response): def on_delete_account(context, response): """ Manage response for request DeleteAccount. + :param context: request context :param response: response received :return: None @@ -121,6 +159,7 @@ def on_delete_account(context, response): def on_delete_game(context, response): """ Manage response for request DeleteGame. + :param context: request context :param response: response received :return: None @@ -130,6 +169,7 @@ def on_delete_game(context, response): def on_get_phase_history(context, response): """ Manage response for request GetPhaseHistory. + :param context: request context :param response: response received :return: a list of game states @@ -143,6 +183,7 @@ def on_get_phase_history(context, response): def on_join_game(context, response): """ Manage response for request JoinGame. + :param context: request context :param response: response received :return: a new network game @@ -152,6 +193,7 @@ def on_join_game(context, response): def on_leave_game(context, response): """ Manage response for request LeaveGame. + :param context: request context :param response: response received :return: None @@ -161,6 +203,7 @@ def on_leave_game(context, response): def on_logout(context, response): """ Manage response for request Logout. + :param context: request context :param response: response received :return: None @@ -170,6 +213,7 @@ def on_logout(context, response): def on_send_game_message(context, response): """ Manage response for request SendGameMessage. + :param context: request context :param response: response received :return: None @@ -183,6 +227,7 @@ def on_send_game_message(context, response): def on_set_game_state(context, response): """ Manage response for request SetGameState. + :param context: request context :param response: response received :return: None @@ -197,6 +242,7 @@ def on_set_game_state(context, response): def on_set_game_status(context, response): """ Manage response for request SetGameStatus. + :param context: request context :param response: response received :return: None @@ -207,6 +253,7 @@ def on_set_game_status(context, response): def on_set_orders(context, response): """ Manage response for request SetOrders. + :param context: request context :param response: response received :return: None @@ -220,38 +267,9 @@ def on_set_orders(context, response): else: Game.set_orders(context.game, request.power_name, orders) -def on_clear_orders(context, response): - """ Manage response for request ClearOrders. - :param context: request context - :param response: response received - :return: None - :type context: RequestFutureContext - """ - request = context.request # type: requests.ClearOrders - Game.clear_orders(context.game, request.power_name) - -def on_clear_centers(context, response): - """ Manage response for request ClearCenters. - :param context: request context - :param response: response received - :return: None - :type context: RequestFutureContext - """ - request = context.request # type: requests.ClearCenters - Game.clear_centers(context.game, request.power_name) - -def on_clear_units(context, response): - """ Manage response for request ClearUnits. - :param context: request context - :param response: response received - :return: None - :type context: RequestFutureContext - """ - request = context.request # type: requests.ClearUnits - Game.clear_units(context.game, request.power_name) - def on_set_wait_flag(context, response): """ Manage response for request SetWaitFlag. + :param context: request context :param response: response received :return: None @@ -267,6 +285,7 @@ def on_set_wait_flag(context, response): def on_sign_in(context, response): """ Manage response for request SignIn. + :param context: request context :param response: response received :return: a new channel @@ -277,6 +296,7 @@ def on_sign_in(context, response): def on_vote(context, response): """ Manage response for request VoteAboutDraw. + :param context: request context :param response: response received :return: None @@ -300,13 +320,13 @@ MAPPING = { requests.GetAvailableMaps: default_manager, requests.GetDaidePort: default_manager, requests.GetDummyWaitingPowers: default_manager, - requests.GetPlayablePowers: default_manager, + requests.GetGamesInfo: default_manager, requests.GetPhaseHistory: on_get_phase_history, + requests.GetPlayablePowers: default_manager, requests.JoinGame: on_join_game, requests.JoinPowers: default_manager, requests.LeaveGame: on_leave_game, requests.ListGames: default_manager, - requests.GetGamesInfo: default_manager, requests.Logout: on_logout, requests.ProcessGame: default_manager, requests.QuerySchedule: default_manager, @@ -325,6 +345,7 @@ MAPPING = { def handle_response(context, response): """ Call appropriate handler for given response with given request context. + :param context: request context. :param response: response received. :return: value returned by handler. -- cgit v1.2.3