diff --git a/docs/api.rst b/docs/api.rst index 0c685e6e..2993c735 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -6,6 +6,18 @@ API Reference .. module:: socketio +``SimpleClient`` class +---------------------- + +.. autoclass:: SimpleClient + :members: + +``AsyncSimpleClient`` class +--------------------------- + +.. autoclass:: AsyncSimpleClient + :members: + ``Client`` class ---------------- diff --git a/docs/client.rst b/docs/client.rst index 07d120e6..3344bd35 100644 --- a/docs/client.rst +++ b/docs/client.rst @@ -1,15 +1,16 @@ -The Socket.IO Client -==================== +The Socket.IO Clients +===================== This package contains two Socket.IO clients: -- The :func:`socketio.Client` class creates a client compatible with the - standard Python library. -- The :func:`socketio.AsyncClient` class creates a client compatible with - the ``asyncio`` package. +- a "simple" client, which provides a straightforward API that is sufficient + for most applications +- an "event-driven" client, which provides access to all the features of the + Socket.IO protocol -The methods in the two clients are the same, with the only difference that in -the ``asyncio`` client most methods are implemented as coroutines. +Each of these clients comes in two variants: one for the standard Python +library, and another for asynchronous applications built with the ``asyncio`` +package. Installation ------------ @@ -23,8 +24,174 @@ If instead you plan on using the ``asyncio`` client, then use this:: pip install "python-socketio[asyncio_client]" +Using the Simple Client +----------------------- + +The advantage of the simple client is that it abstracts away the logic required +to maintain a Socket.IO connection. This client handles disconnections and +reconnections in a completely transparent way, without adding any complexity to +the application. + +Creating a Client Instance +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +To instantiate a Socket.IO client, create an instance of the appropriate client +class:: + + import socketio + + # standard Python + sio = socketio.SimpleClient() + + # asyncio + sio = socketio.AsyncSimpleClient() + +Connecting to a Server +~~~~~~~~~~~~~~~~~~~~~~ + +The connection to a server is established by calling the ``connect()`` +method:: + + sio.connect('http://localhost:5000') + +In the case of the ``asyncio`` client, the method is a coroutine:: + + await sio.connect('http://localhost:5000') + +By default the client first connects to the server using the long-polling +transport, and then attempts to upgrade the connection to use WebSocket. To +connect directly using WebSocket, use the ``transports`` argument:: + + sio.connect('http://localhost:5000', transports=['websocket']) + +Upon connection, the server assigns the client a unique session identifier. +The application can find this identifier in the ``sid`` attribute:: + + print('my sid is', sio.sid) + +The Socket.IO transport that is used in the connection can be obtained from the +``transport`` attribute:: + + print('my transport is', sio.transport) + +The transport is given as a string, and can be either ``'websocket'`` or +``'polling'``. + +TLS/SSL Support +^^^^^^^^^^^^^^^ + +The client supports TLS/SSL connections. To enable it, use a ``https://`` +connection URL:: + + sio.connect('https://example.com') + +Or when using ``asyncio``:: + + await sio.connect('https://example.com') + +The client verifies server certificates by default. Consult the documentation +for the event-driven client for information on how to customize this behavior. + +Emitting Events +~~~~~~~~~~~~~~~ + +The client can emit an event to the server using the ``emit()`` method:: + + sio.emit('my message', {'foo': 'bar'}) + +Or in the case of ``asyncio``, as a coroutine:: + + await sio.emit('my message', {'foo': 'bar'}) + +The arguments provided to the method are the name of the event to emit and the +optional data that is passed on to the server. The data can be of type ``str``, +``bytes``, ``dict``, ``list`` or ``tuple``. When sending a ``list`` or a +``tuple``, the elements in it need to be of any allowed types except ``tuple``. +When a tuple is used, the elements of the tuple will be passed as individual +arguments to the server-side event handler function. + +Receiving Events +~~~~~~~~~~~~~~~~ + +The client can wait for the server to emit an event with the ``receive()`` +method:: + + event = sio.receive() + print(f'received event: "{event[0]}" with arguments {event[1:]}') + +When using ``asyncio``, this method needs to be awaited:: + + event = await sio.receive() + print(f'received event: "{event[0]}" with arguments {event[1:]}') + +The return value of ``receive()`` is a list. The first element of this list is +the event name, while the remaining elements are the arguments passed by the +server. + +With the usage shown above, the ``receive()`` method will return only when an +event is received from the server. An optional timeout in seconds can be passed +to prevent the client from waiting forever:: + + from socketio.exceptions import TimeoutError + + try: + event = sio.receive(timeout=5) + except TimeoutError: + print('timed out waiting for event') + else: + print('received event:', event) + +Or with ``asyncio``:: + + from socketio.exceptions import TimeoutError + + try: + event = await sio.receive(timeout=5) + except TimeoutError: + print('timed out waiting for event') + else: + print('received event:', event) + +Disconnecting from the Server +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +At any time the client can request to be disconnected from the server by +invoking the ``disconnect()`` method:: + + sio.disconnect() + +For the ``asyncio`` client this is a coroutine:: + + await sio.disconnect() + +Debugging and Troubleshooting +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +To help you debug issues, the client can be configured to output logs to the +terminal:: + + import socketio + + # standard Python + sio = socketio.Client(logger=True, engineio_logger=True) + + # asyncio + sio = socketio.AsyncClient(logger=True, engineio_logger=True) + +The ``logger`` argument controls logging related to the Socket.IO protocol, +while ``engineio_logger`` controls logs that originate in the low-level +Engine.IO transport. These arguments can be set to ``True`` to output logs to +``stderr``, or to an object compatible with Python's ``logging`` package +where the logs should be emitted to. A value of ``False`` disables logging. + +Logging can help identify the cause of connection problems, unexpected +disconnections and other issues. + +Using the Event-Driven Client +----------------------------- + Creating a Client Instance --------------------------- +~~~~~~~~~~~~~~~~~~~~~~~~~~ To instantiate an Socket.IO client, simply create an instance of the appropriate client class:: @@ -38,7 +205,7 @@ appropriate client class:: sio = socketio.AsyncClient() Defining Event Handlers ------------------------ +~~~~~~~~~~~~~~~~~~~~~~~ The Socket.IO protocol is event based. When a server wants to communicate with a client it *emits* an event. Each event has a name, and a list of @@ -69,7 +236,7 @@ If the server includes arguments with an event, those are passed to the handler function as arguments. Catch-All Event Handlers ------------------------- +~~~~~~~~~~~~~~~~~~~~~~~~ A "catch-all" event handler is invoked for any events that do not have an event handler. You can define a catch-all handler using ``'*'`` as event name:: @@ -88,9 +255,9 @@ A catch-all event handler receives the event name as a first argument. The remaining arguments are the same as for a regular event handler. Connect, Connect Error and Disconnect Event Handlers ----------------------------------------------------- +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -The ``connect``, ``connect_error`` and ``disconnect`` events are special; they +The ``connect``, ``connect_error`` and ``disconnect`` events are special; they are invoked automatically when a client connects or disconnects from the server:: @@ -122,7 +289,7 @@ The ``connect``, ``connect_error`` and ``disconnect`` events have to be defined explicitly and are not invoked on a catch-all event handler. Connecting to a Server ----------------------- +~~~~~~~~~~~~~~~~~~~~~~ The connection to a server is established by calling the ``connect()`` method:: @@ -138,8 +305,16 @@ The application can find this identifier in the ``sid`` attribute:: print('my sid is', sio.sid) +The Socket.IO transport that is used in the connection can be obtained from the +``transport`` attribute:: + + print('my transport is', sio.transport) + +The transport is given as a string, and can be either ``'websocket'`` or +``'polling'``. + TLS/SSL Support -~~~~~~~~~~~~~~~ +^^^^^^^^^^^^^^^ The client supports TLS/SSL connections. To enable it, use a ``https://`` connection URL:: @@ -206,7 +381,7 @@ And for ``asyncio``:: await sio.connect('https://example.com') Emitting Events ---------------- +~~~~~~~~~~~~~~~ The client can emit an event to the server using the ``emit()`` method:: @@ -216,18 +391,19 @@ Or in the case of ``asyncio``, as a coroutine:: await sio.emit('my message', {'foo': 'bar'}) -The single argument provided to the method is the data that is passed on -to the server. The data can be of type ``str``, ``bytes``, ``dict``, -``list`` or ``tuple``. When sending a ``tuple``, the elements in it need to -be of any of the other four allowed types. The elements of the tuple will be -passed as multiple arguments to the server-side event handler function. +The arguments provided to the method are the name of the event to emit and the +optional data that is passed on to the server. The data can be of type ``str``, +``bytes``, ``dict``, ``list`` or ``tuple``. When sending a ``list`` or a +``tuple``, the elements in it need to be of any allowed types except ``tuple``. +When a tuple is used, the elements of the tuple will be passed as individual +arguments to the server-side event handler function. The ``emit()`` method can be invoked inside an event handler as a response to a server event, or in any other part of the application, including in background tasks. Event Callbacks ---------------- +~~~~~~~~~~~~~~~ When a server emits an event to a client, it can optionally provide a callback function, to be invoked as a way of acknowledgment that the server @@ -249,7 +425,7 @@ the event, and any values returned by the server handler will be passed as arguments to this function. Namespaces ----------- +~~~~~~~~~~ The Socket.IO protocol supports multiple logical connections, all multiplexed on the same physical connection. Clients can open multiple connections by @@ -281,7 +457,7 @@ If the ``namespaces`` argument of the ``connect()`` call isn't given, any namespaces used in event handlers are automatically connected. Class-Based Namespaces ----------------------- +~~~~~~~~~~~~~~~~~~~~~~ As an alternative to the decorator-based event handlers, the event handlers that belong to a namespace can be created as methods of a subclass of @@ -332,7 +508,7 @@ decorator-based function handler, only the standalone function handler is invoked. Disconnecting from the Server ------------------------------ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ At any time the client can request to be disconnected from the server by invoking the ``disconnect()`` method:: @@ -344,7 +520,7 @@ For the ``asyncio`` client this is a coroutine:: await sio.disconnect() Managing Background Tasks -------------------------- +~~~~~~~~~~~~~~~~~~~~~~~~~ When a client connection to the server is established, a few background tasks will be spawned to keep the connection alive and handle incoming @@ -398,7 +574,7 @@ The single argument passed to the method is the number of seconds to sleep for. Debugging and Troubleshooting ------------------------------ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ To help you debug issues, the client can be configured to output logs to the terminal:: diff --git a/docs/conf.py b/docs/conf.py index e8398c3d..237eb821 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -42,6 +42,8 @@ 'sphinx.ext.autodoc', ] +autodoc_member_order = 'bysource' + # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] diff --git a/examples/README.rst b/examples/README.rst index 8651bba6..82b918e5 100644 --- a/examples/README.rst +++ b/examples/README.rst @@ -2,5 +2,5 @@ Socket.IO Examples ================== This directory contains several example Socket.IO applications. Look in the -`server` directory for Socket.IO servers, and in the `client` directory for -Socket.IO clients. \ No newline at end of file +`server` directory for Socket.IO servers, and in the `client` and +`simple-client` directories for Socket.IO clients. diff --git a/examples/client/README.rst b/examples/client/README.rst index 05346572..ce5dab04 100644 --- a/examples/client/README.rst +++ b/examples/client/README.rst @@ -4,13 +4,13 @@ Socket.IO Client Examples This directory contains several example Socket.IO client applications, organized by directory: -threads -------- +sync +---- Examples that use standard Python thread concurrency. -asyncio -------- +async +----- Examples that use Python's `asyncio` package for concurrency. diff --git a/examples/client/threads/README.rst b/examples/client/async/README.rst similarity index 57% rename from examples/client/threads/README.rst rename to examples/client/async/README.rst index 5333375e..57102139 100644 --- a/examples/client/threads/README.rst +++ b/examples/client/async/README.rst @@ -1,8 +1,8 @@ -Socket.IO Threading Examples -============================ +Socket.IO Async Client Examples +=============================== This directory contains example Socket.IO clients that work with the -`threading` package of the Python standard library. +``asyncio`` package of the Python standard library. latency_client.py ----------------- @@ -14,11 +14,19 @@ for each of these exchanges. This is an ideal application to measure the performance of the different asynchronous modes supported by the Socket.IO server. +fiddle_client.py +---------------- + +This is an extemely simple application based on the JavaScript example of the +same name. + Running the Examples -------------------- These examples work with the server examples of the same name. First run one -of the `latency.py` versions from the `examples/server/wsgi` directory. On -another terminal, then start the corresponding client:: +of the ``latency.py`` or ``fiddle.py`` versions from one of the +``examples/server`` subdirectories. On another terminal, then start the +corresponding client:: $ python latency_client.py + $ python fiddle_client.py diff --git a/examples/client/asyncio/fiddle_client.py b/examples/client/async/fiddle_client.py similarity index 100% rename from examples/client/asyncio/fiddle_client.py rename to examples/client/async/fiddle_client.py diff --git a/examples/client/asyncio/latency_client.py b/examples/client/async/latency_client.py similarity index 100% rename from examples/client/asyncio/latency_client.py rename to examples/client/async/latency_client.py diff --git a/examples/client/asyncio/README.rst b/examples/client/sync/README.rst similarity index 59% rename from examples/client/asyncio/README.rst rename to examples/client/sync/README.rst index c4ff8e2b..efc4d6f9 100644 --- a/examples/client/asyncio/README.rst +++ b/examples/client/sync/README.rst @@ -1,8 +1,8 @@ -Socket.IO Asyncio Examples -========================== +Socket.IO Client Examples +========================= This directory contains example Socket.IO clients that work with the -`asyncio` package of the Python standard library. +Python standard library. latency_client.py ----------------- @@ -14,11 +14,19 @@ for each of these exchanges. This is an ideal application to measure the performance of the different asynchronous modes supported by the Socket.IO server. +fiddle_client.py +---------------- + +This is an extemely simple application based on the JavaScript example of the +same name. + Running the Examples -------------------- These examples work with the server examples of the same name. First run one -of the `latency.py` versions from the `examples/server/wsgi` directory. On -another terminal, then start the corresponding client:: +of the ``latency.py`` or ``fiddle.py`` versions from one of the +``examples/server`` subdirectories. On another terminal, then start the +corresponding client:: $ python latency_client.py + $ python fiddle_client.py diff --git a/examples/client/threads/fiddle_client.py b/examples/client/sync/fiddle_client.py similarity index 100% rename from examples/client/threads/fiddle_client.py rename to examples/client/sync/fiddle_client.py diff --git a/examples/client/threads/latency_client.py b/examples/client/sync/latency_client.py similarity index 100% rename from examples/client/threads/latency_client.py rename to examples/client/sync/latency_client.py diff --git a/examples/simple-client/README.rst b/examples/simple-client/README.rst new file mode 100644 index 00000000..2f7c58ac --- /dev/null +++ b/examples/simple-client/README.rst @@ -0,0 +1,15 @@ +Socket.IO Simple Client Examples +================================ + +This directory contains several example Socket.IO client applications built +with the simplified client and organized by directory: + +sync +---- + +Examples that use standard Python. + +async +----- + +Examples that use Python's `asyncio` package. diff --git a/examples/simple-client/async/README.rst b/examples/simple-client/async/README.rst new file mode 100644 index 00000000..a5fe7c10 --- /dev/null +++ b/examples/simple-client/async/README.rst @@ -0,0 +1,33 @@ +Socket.IO Async Simple Client Examples +====================================== + +This directory contains example Socket.IO clients that work with the +`asyncio` package of the Python standard library, built with the simplified +client. + +latency_client.py +----------------- + +In this application the client sends *ping* messages to the server, which are +responded by the server with a *pong*. The client measures the time it takes +for each of these exchanges. + +This is an ideal application to measure the performance of the different +asynchronous modes supported by the Socket.IO server. + +fiddle_client.py +---------------- + +This is an extemely simple application based on the JavaScript example of the +same name. + +Running the Examples +-------------------- + +These examples work with the server examples of the same name. First run one +of the ``latency.py`` or ``fiddle.py`` versions from one of the +``examples/server`` subdirectories. On another terminal, then start the +corresponding client:: + + $ python latency_client.py + $ python fiddle_client.py diff --git a/examples/simple-client/async/fiddle_client.py b/examples/simple-client/async/fiddle_client.py new file mode 100644 index 00000000..305e71f6 --- /dev/null +++ b/examples/simple-client/async/fiddle_client.py @@ -0,0 +1,13 @@ +import asyncio +import socketio + + +async def main(): + sio = socketio.AsyncSimpleClient() + await sio.connect('http://localhost:5000', auth={'token': 'my-token'}) + print(await sio.receive()) + await sio.disconnect() + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/examples/simple-client/async/latency_client.py b/examples/simple-client/async/latency_client.py new file mode 100644 index 00000000..1139cccd --- /dev/null +++ b/examples/simple-client/async/latency_client.py @@ -0,0 +1,25 @@ +import asyncio +import time +import socketio + + +async def main(): + sio = socketio.AsyncSimpleClient() + await sio.connect('http://localhost:5000') + + try: + while True: + start_timer = time.time() + await sio.emit('ping_from_client') + while (await sio.receive()) != ['pong_from_server']: + pass + latency = time.time() - start_timer + print('latency is {0:.2f} ms'.format(latency * 1000)) + + await asyncio.sleep(1) + except (KeyboardInterrupt, asyncio.CancelledError): + await sio.disconnect() + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/examples/simple-client/sync/README.rst b/examples/simple-client/sync/README.rst new file mode 100644 index 00000000..d3f4f55d --- /dev/null +++ b/examples/simple-client/sync/README.rst @@ -0,0 +1,32 @@ +Socket.IO Simple Client Examples +================================ + +This directory contains example Socket.IO clients that are built using the +simplified client. + +latency_client.py +----------------- + +In this application the client sends *ping* messages to the server, which are +responded by the server with a *pong*. The client measures the time it takes +for each of these exchanges. + +This is an ideal application to measure the performance of the different +asynchronous modes supported by the Socket.IO server. + +fiddle_client.py +---------------- + +This is an extemely simple application based on the JavaScript example of the +same name. + +Running the Examples +-------------------- + +These examples work with the server examples of the same name. First run one +of the ``latency.py`` or ``fiddle.py`` versions from one of the +``examples/server`` subdirectories. On another terminal, then start the +corresponding client:: + + $ python latency_client.py + $ python fiddle_client.py diff --git a/examples/simple-client/sync/fiddle_client.py b/examples/simple-client/sync/fiddle_client.py new file mode 100644 index 00000000..2f79e97c --- /dev/null +++ b/examples/simple-client/sync/fiddle_client.py @@ -0,0 +1,12 @@ +import socketio + + +def main(): + sio = socketio.SimpleClient() + sio.connect('http://localhost:5000', auth={'token': 'my-token'}) + print(sio.receive()) + sio.disconnect() + + +if __name__ == '__main__': + main() diff --git a/examples/simple-client/sync/latency_client.py b/examples/simple-client/sync/latency_client.py new file mode 100644 index 00000000..2bf76577 --- /dev/null +++ b/examples/simple-client/sync/latency_client.py @@ -0,0 +1,24 @@ +import time +import socketio + + +def main(): + sio = socketio.SimpleClient() + sio.connect('http://localhost:5000') + + try: + while True: + start_timer = time.time() + sio.emit('ping_from_client') + while sio.receive() != ['pong_from_server']: + pass + latency = time.time() - start_timer + print('latency is {0:.2f} ms'.format(latency * 1000)) + + time.sleep(1) + except KeyboardInterrupt: + sio.disconnect() + + +if __name__ == '__main__': + main() diff --git a/src/socketio/__init__.py b/src/socketio/__init__.py index ad0a1c18..f5c851c9 100644 --- a/src/socketio/__init__.py +++ b/src/socketio/__init__.py @@ -1,6 +1,7 @@ import sys from .client import Client +from .simple_client import SimpleClient from .base_manager import BaseManager from .pubsub_manager import PubSubManager from .kombu_manager import KombuManager @@ -13,6 +14,7 @@ from .tornado import get_tornado_handler if sys.version_info >= (3, 5): # pragma: no cover from .asyncio_client import AsyncClient + from .asyncio_simple_client import AsyncSimpleClient from .asyncio_server import AsyncServer from .asyncio_manager import AsyncManager from .asyncio_namespace import AsyncNamespace, AsyncClientNamespace @@ -20,6 +22,7 @@ from .asyncio_aiopika_manager import AsyncAioPikaManager from .asgi import ASGIApp else: # pragma: no cover + AsyncSimpleClient = None AsyncClient = None AsyncServer = None AsyncManager = None @@ -27,10 +30,11 @@ AsyncRedisManager = None AsyncAioPikaManager = None -__all__ = ['Client', 'Server', 'BaseManager', 'PubSubManager', +__all__ = ['SimpleClient', 'Client', 'Server', 'BaseManager', 'PubSubManager', 'KombuManager', 'RedisManager', 'ZmqManager', 'KafkaManager', 'Namespace', 'ClientNamespace', 'WSGIApp', 'Middleware'] if AsyncServer is not None: # pragma: no cover - __all__ += ['AsyncClient', 'AsyncServer', 'AsyncNamespace', - 'AsyncClientNamespace', 'AsyncManager', 'AsyncRedisManager', - 'ASGIApp', 'get_tornado_handler', 'AsyncAioPikaManager'] + __all__ += ['AsyncSimpleClient', 'AsyncClient', 'AsyncServer', + 'AsyncNamespace', 'AsyncClientNamespace', 'AsyncManager', + 'AsyncRedisManager', 'ASGIApp', 'get_tornado_handler', + 'AsyncAioPikaManager'] diff --git a/src/socketio/asyncio_client.py b/src/socketio/asyncio_client.py index e68e270f..656b33e0 100644 --- a/src/socketio/asyncio_client.py +++ b/src/socketio/asyncio_client.py @@ -143,9 +143,10 @@ async def connect(self, url, headers={}, auth=None, transports=None, transports=transports, engineio_path=socketio_path) except engineio.exceptions.ConnectionError as exc: - await self._trigger_event( - 'connect_error', '/', - exc.args[1] if len(exc.args) > 1 else exc.args[0]) + for n in self.connection_namespaces: + await self._trigger_event( + 'connect_error', n, + exc.args[1] if len(exc.args) > 1 else exc.args[0]) raise exceptions.ConnectionError(exc.args[0]) from None if wait: @@ -271,7 +272,7 @@ async def call(self, event, data=None, namespace=None, timeout=60): argument is omitted the event is emitted to the default namespace. :param timeout: The waiting timeout. If the timeout is reached before - the client acknowledges the event, then a + the server acknowledges the event, then a ``TimeoutError`` exception is raised. Note: this method is not designed to be used concurrently. If multiple @@ -369,6 +370,7 @@ async def _handle_disconnect(self, namespace): return namespace = namespace or '/' await self._trigger_event('disconnect', namespace=namespace) + await self._trigger_event('__disconnect_final', namespace=namespace) if namespace in self.namespaces: del self.namespaces[namespace] if not self.namespaces: @@ -469,6 +471,9 @@ async def _handle_reconnect(self): try: await asyncio.wait_for(self._reconnect_abort.wait(), delay) self.logger.info('Reconnect task aborted') + for n in self.connection_namespaces: + await self._trigger_event('__disconnect_final', + namespace=n) break except (asyncio.TimeoutError, asyncio.CancelledError): pass @@ -490,6 +495,9 @@ async def _handle_reconnect(self): attempt_count >= self.reconnection_attempts: self.logger.info( 'Maximum reconnection attempts reached, giving up') + for n in self.connection_namespaces: + await self._trigger_event('__disconnect_final', + namespace=n) break client.reconnecting_clients.remove(self) @@ -533,15 +541,19 @@ async def _handle_eio_message(self, data): async def _handle_eio_disconnect(self): """Handle the Engine.IO disconnection event.""" self.logger.info('Engine.IO connection dropped') + will_reconnect = self.reconnection and self.eio.state == 'connected' if self.connected: for n in self.namespaces: await self._trigger_event('disconnect', namespace=n) + if not will_reconnect: + await self._trigger_event('__disconnect_final', + namespace=n) self.namespaces = {} self.connected = False self.callbacks = {} self._binary_packet = None self.sid = None - if self.eio.state == 'connected' and self.reconnection: + if will_reconnect: self._reconnect_task = self.start_background_task( self._handle_reconnect) diff --git a/src/socketio/asyncio_simple_client.py b/src/socketio/asyncio_simple_client.py new file mode 100644 index 00000000..f0066efa --- /dev/null +++ b/src/socketio/asyncio_simple_client.py @@ -0,0 +1,193 @@ +import asyncio +from socketio import AsyncClient +from socketio.exceptions import SocketIOError, TimeoutError, DisconnectedError + + +class AsyncSimpleClient: + """A Socket.IO client. + + This class implements a simple, yet fully compliant Socket.IO web client + with support for websocket and long-polling transports. + + Th positional and keyword arguments given in the constructor are passed + to the underlying :func:`socketio.AsyncClient` object. + """ + def __init__(self, *args, **kwargs): + self.client_args = args + self.client_kwargs = kwargs + self.client = None + self.namespace = '/' + self.connected_event = asyncio.Event() + self.connected = False + self.input_event = asyncio.Event() + self.input_buffer = [] + + async def connect(self, url, headers={}, auth=None, transports=None, + namespace='/', socketio_path='socket.io'): + """Connect to a Socket.IO server. + + :param url: The URL of the Socket.IO server. It can include custom + query string parameters if required by the server. If a + function is provided, the client will invoke it to obtain + the URL each time a connection or reconnection is + attempted. + :param headers: A dictionary with custom headers to send with the + connection request. If a function is provided, the + client will invoke it to obtain the headers dictionary + each time a connection or reconnection is attempted. + :param auth: Authentication data passed to the server with the + connection request, normally a dictionary with one or + more string key/value pairs. If a function is provided, + the client will invoke it to obtain the authentication + data each time a connection or reconnection is attempted. + :param transports: The list of allowed transports. Valid transports + are ``'polling'`` and ``'websocket'``. If not + given, the polling transport is connected first, + then an upgrade to websocket is attempted. + :param namespace: The namespace to connect to as a string. If not + given, the default namespace ``/`` is used. + :param socketio_path: The endpoint where the Socket.IO server is + installed. The default value is appropriate for + most cases. + + Note: this method is a coroutine. + """ + if self.connected: + raise RuntimeError('Already connected') + self.namespace = namespace + self.input_buffer = [] + self.input_event.clear() + self.client = AsyncClient(*self.client_args, **self.client_kwargs) + + @self.client.event + def connect(): # pragma: no cover + self.connected = True + self.connected_event.set() + + @self.client.event + def disconnect(): # pragma: no cover + self.connected_event.clear() + + @self.client.event + def __disconnect_final(): # pragma: no cover + self.connected = False + self.connected_event.set() + + @self.client.on('*') + def on_event(event, *args): # pragma: no cover + self.input_buffer.append([event, *args]) + self.input_event.set() + + await self.client.connect( + url, headers=headers, auth=auth, transports=transports, + namespaces=[namespace], socketio_path=socketio_path) + + @property + def sid(self): + """The session ID received from the server. + + The session ID is not guaranteed to remain constant throughout the life + of the connection, as reconnections can cause it to change. + """ + return self.client.sid if self.client else None + + @property + def transport(self): + """The name of the transport currently in use. + + The transport is returned as a string and can be one of ``polling`` + and ``websocket``. + """ + return self.client.transport if self.client else '' + + async def emit(self, event, data=None): + """Emit an event to the server. + + :param event: The event name. It can be any string. The event names + ``'connect'``, ``'message'`` and ``'disconnect'`` are + reserved and should not be used. + :param data: The data to send to the server. Data can be of + type ``str``, ``bytes``, ``list`` or ``dict``. To send + multiple arguments, use a tuple where each element is of + one of the types indicated above. + + Note: this method is a coroutine. + + This method schedules the event to be sent out and returns, without + actually waiting for its delivery. In cases where the client needs to + ensure that the event was received, :func:`socketio.SimpleClient.call` + should be used instead. + """ + while True: + await self.connected_event.wait() + if not self.connected: + raise DisconnectedError() + try: + return await self.client.emit(event, data, + namespace=self.namespace) + except SocketIOError: + pass + + async def call(self, event, data=None, timeout=60): + """Emit an event to the server and wait for a response. + + This method issues an emit and waits for the server to provide a + response or acknowledgement. If the response does not arrive before the + timeout, then a ``TimeoutError`` exception is raised. + + :param event: The event name. It can be any string. The event names + ``'connect'``, ``'message'`` and ``'disconnect'`` are + reserved and should not be used. + :param data: The data to send to the server. Data can be of + type ``str``, ``bytes``, ``list`` or ``dict``. To send + multiple arguments, use a tuple where each element is of + one of the types indicated above. + :param timeout: The waiting timeout. If the timeout is reached before + the server acknowledges the event, then a + ``TimeoutError`` exception is raised. + + Note: this method is a coroutine. + """ + while True: + await self.connected_event.wait() + if not self.connected: + raise DisconnectedError() + try: + return await self.client.call(event, data, + namespace=self.namespace, + timeout=timeout) + except SocketIOError: + pass + + async def receive(self, timeout=None): + """Wait for an event from the server. + + :param timeout: The waiting timeout. If the timeout is reached before + the server acknowledges the event, then a + ``TimeoutError`` exception is raised. + + Note: this method is a coroutine. + + The return value is a list with the event name as the first element. If + the server included arguments with the event, they are returned as + additional list elements. + """ + if not self.input_buffer: + await self.connected_event.wait() + if not self.connected: + raise DisconnectedError() + try: + await asyncio.wait_for(self.input_event.wait(), + timeout=timeout) + except asyncio.TimeoutError: + raise TimeoutError() + self.input_event.clear() + return self.input_buffer.pop(0) + + async def disconnect(self): + """Disconnect from the server. + + Note: this method is a coroutine. +i """ + await self.client.disconnect() + self.client = None diff --git a/src/socketio/client.py b/src/socketio/client.py index 2f645334..e6293f2b 100644 --- a/src/socketio/client.py +++ b/src/socketio/client.py @@ -92,7 +92,8 @@ class Client(object): fatal errors are logged even when ``engineio_logger`` is ``False``. """ - reserved_events = ['connect', 'connect_error', 'disconnect'] + reserved_events = ['connect', 'connect_error', 'disconnect', + '__disconnect_final'] def __init__(self, reconnection=True, reconnection_attempts=0, reconnection_delay=1, reconnection_delay_max=5, @@ -332,9 +333,10 @@ def connect(self, url, headers={}, auth=None, transports=None, transports=transports, engineio_path=socketio_path) except engineio.exceptions.ConnectionError as exc: - self._trigger_event( - 'connect_error', '/', - exc.args[1] if len(exc.args) > 1 else exc.args[0]) + for n in self.connection_namespaces: + self._trigger_event( + 'connect_error', n, + exc.args[1] if len(exc.args) > 1 else exc.args[0]) raise exceptions.ConnectionError(exc.args[0]) from None if wait: @@ -449,7 +451,7 @@ def call(self, event, data=None, namespace=None, timeout=60): argument is omitted the event is emitted to the default namespace. :param timeout: The waiting timeout. If the timeout is reached before - the client acknowledges the event, then a + the server acknowledges the event, then a ``TimeoutError`` exception is raised. Note: this method is not thread safe. If multiple threads are emitting @@ -569,6 +571,7 @@ def _handle_disconnect(self, namespace): return namespace = namespace or '/' self._trigger_event('disconnect', namespace=namespace) + self._trigger_event('__disconnect_final', namespace=namespace) if namespace in self.namespaces: del self.namespaces[namespace] if not self.namespaces: @@ -654,6 +657,8 @@ def _handle_reconnect(self): delay)) if self._reconnect_abort.wait(delay): self.logger.info('Reconnect task aborted') + for n in self.connection_namespaces: + self._trigger_event('__disconnect_final', namespace=n) break attempt_count += 1 try: @@ -673,6 +678,8 @@ def _handle_reconnect(self): attempt_count >= self.reconnection_attempts: self.logger.info( 'Maximum reconnection attempts reached, giving up') + for n in self.connection_namespaces: + self._trigger_event('__disconnect_final', namespace=n) break reconnecting_clients.remove(self) @@ -716,15 +723,18 @@ def _handle_eio_message(self, data): def _handle_eio_disconnect(self): """Handle the Engine.IO disconnection event.""" self.logger.info('Engine.IO connection dropped') + will_reconnect = self.reconnection and self.eio.state == 'connected' if self.connected: for n in self.namespaces: self._trigger_event('disconnect', namespace=n) + if not will_reconnect: + self._trigger_event('__disconnect_final', namespace=n) self.namespaces = {} self.connected = False self.callbacks = {} self._binary_packet = None self.sid = None - if self.eio.state == 'connected' and self.reconnection: + if will_reconnect: self._reconnect_task = self.start_background_task( self._handle_reconnect) diff --git a/src/socketio/exceptions.py b/src/socketio/exceptions.py index d9dae4a5..19d6e39e 100644 --- a/src/socketio/exceptions.py +++ b/src/socketio/exceptions.py @@ -32,3 +32,7 @@ class TimeoutError(SocketIOError): class BadNamespaceError(SocketIOError): pass + + +class DisconnectedError(SocketIOError): + pass diff --git a/src/socketio/simple_client.py b/src/socketio/simple_client.py new file mode 100644 index 00000000..9a58cba1 --- /dev/null +++ b/src/socketio/simple_client.py @@ -0,0 +1,177 @@ +from threading import Event +from socketio import Client +from socketio.exceptions import SocketIOError, TimeoutError, DisconnectedError + + +class SimpleClient: + """A Socket.IO client. + + This class implements a simple, yet fully compliant Socket.IO web client + with support for websocket and long-polling transports. + + Th positional and keyword arguments given in the constructor are passed + to the underlying :func:`socketio.Client` object. + """ + def __init__(self, *args, **kwargs): + self.client_args = args + self.client_kwargs = kwargs + self.client = None + self.namespace = '/' + self.connected_event = Event() + self.connected = False + self.input_event = Event() + self.input_buffer = [] + + def connect(self, url, headers={}, auth=None, transports=None, + namespace='/', socketio_path='socket.io'): + """Connect to a Socket.IO server. + + :param url: The URL of the Socket.IO server. It can include custom + query string parameters if required by the server. If a + function is provided, the client will invoke it to obtain + the URL each time a connection or reconnection is + attempted. + :param headers: A dictionary with custom headers to send with the + connection request. If a function is provided, the + client will invoke it to obtain the headers dictionary + each time a connection or reconnection is attempted. + :param auth: Authentication data passed to the server with the + connection request, normally a dictionary with one or + more string key/value pairs. If a function is provided, + the client will invoke it to obtain the authentication + data each time a connection or reconnection is attempted. + :param transports: The list of allowed transports. Valid transports + are ``'polling'`` and ``'websocket'``. If not + given, the polling transport is connected first, + then an upgrade to websocket is attempted. + :param namespace: The namespace to connect to as a string. If not + given, the default namespace ``/`` is used. + :param socketio_path: The endpoint where the Socket.IO server is + installed. The default value is appropriate for + most cases. + """ + if self.connected: + raise RuntimeError('Already connected') + self.namespace = namespace + self.input_buffer = [] + self.input_event.clear() + self.client = Client(*self.client_args, **self.client_kwargs) + + @self.client.event + def connect(): # pragma: no cover + self.connected = True + self.connected_event.set() + + @self.client.event + def disconnect(): # pragma: no cover + self.connected_event.clear() + + @self.client.event + def __disconnect_final(): # pragma: no cover + self.connected = False + self.connected_event.set() + + @self.client.on('*') + def on_event(event, *args): # pragma: no cover + self.input_buffer.append([event, *args]) + self.input_event.set() + + self.client.connect(url, headers=headers, auth=auth, + transports=transports, namespaces=[namespace], + socketio_path=socketio_path) + + @property + def sid(self): + """The session ID received from the server. + + The session ID is not guaranteed to remain constant throughout the life + of the connection, as reconnections can cause it to change. + """ + return self.client.sid if self.client else None + + @property + def transport(self): + """The name of the transport currently in use. + + The transport is returned as a string and can be one of ``polling`` + and ``websocket``. + """ + return self.client.transport if self.client else '' + + def emit(self, event, data=None): + """Emit an event to the server. + + :param event: The event name. It can be any string. The event names + ``'connect'``, ``'message'`` and ``'disconnect'`` are + reserved and should not be used. + :param data: The data to send to the server. Data can be of + type ``str``, ``bytes``, ``list`` or ``dict``. To send + multiple arguments, use a tuple where each element is of + one of the types indicated above. + + This method schedules the event to be sent out and returns, without + actually waiting for its delivery. In cases where the client needs to + ensure that the event was received, :func:`socketio.SimpleClient.call` + should be used instead. + """ + while True: + self.connected_event.wait() + if not self.connected: + raise DisconnectedError() + try: + return self.client.emit(event, data, namespace=self.namespace) + except SocketIOError: + pass + + def call(self, event, data=None, timeout=60): + """Emit an event to the server and wait for a response. + + This method issues an emit and waits for the server to provide a + response or acknowledgement. If the response does not arrive before the + timeout, then a ``TimeoutError`` exception is raised. + + :param event: The event name. It can be any string. The event names + ``'connect'``, ``'message'`` and ``'disconnect'`` are + reserved and should not be used. + :param data: The data to send to the server. Data can be of + type ``str``, ``bytes``, ``list`` or ``dict``. To send + multiple arguments, use a tuple where each element is of + one of the types indicated above. + :param timeout: The waiting timeout. If the timeout is reached before + the server acknowledges the event, then a + ``TimeoutError`` exception is raised. + """ + while True: + self.connected_event.wait() + if not self.connected: + raise DisconnectedError() + try: + return self.client.call(event, data, namespace=self.namespace, + timeout=timeout) + except SocketIOError: + pass + + def receive(self, timeout=None): + """Wait for an event from the server. + + :param timeout: The waiting timeout. If the timeout is reached before + the server acknowledges the event, then a + ``TimeoutError`` exception is raised. + + The return value is a list with the event name as the first element. If + the server included arguments with the event, they are returned as + additional list elements. + """ + if not self.input_buffer: + self.connected_event.wait() + if not self.connected: + raise DisconnectedError() + if not self.input_event.wait(timeout=timeout): + raise TimeoutError() + self.input_event.clear() + return self.input_buffer.pop(0) + + def disconnect(self): + """Disconnect from the server.""" + self.client.disconnect() + self.client = None diff --git a/tests/asyncio/test_asyncio_client.py b/tests/asyncio/test_asyncio_client.py index 96b998a0..25bdd795 100644 --- a/tests/asyncio/test_asyncio_client.py +++ b/tests/asyncio/test_asyncio_client.py @@ -1,5 +1,4 @@ import asyncio -from contextlib import contextmanager import sys import unittest from unittest import mock @@ -603,12 +602,15 @@ def test_handle_disconnect(self): c.connected = True c._trigger_event = AsyncMock() _run(c._handle_disconnect('/')) - c._trigger_event.mock.assert_called_once_with( + c._trigger_event.mock.assert_any_call( 'disconnect', namespace='/' ) + c._trigger_event.mock.assert_any_call( + '__disconnect_final', namespace='/' + ) assert not c.connected _run(c._handle_disconnect('/')) - assert c._trigger_event.mock.call_count == 1 + assert c._trigger_event.mock.call_count == 2 def test_handle_disconnect_namespace(self): c = asyncio_client.AsyncClient() @@ -616,11 +618,23 @@ def test_handle_disconnect_namespace(self): c.namespaces = {'/foo': '1', '/bar': '2'} c._trigger_event = AsyncMock() _run(c._handle_disconnect('/foo')) - c._trigger_event.mock.assert_called_once_with( + c._trigger_event.mock.assert_any_call( 'disconnect', namespace='/foo' ) + c._trigger_event.mock.assert_any_call( + '__disconnect_final', namespace='/foo' + ) assert c.namespaces == {'/bar': '2'} assert c.connected + _run(c._handle_disconnect('/bar')) + c._trigger_event.mock.assert_any_call( + 'disconnect', namespace='/bar' + ) + c._trigger_event.mock.assert_any_call( + '__disconnect_final', namespace='/bar' + ) + assert c.namespaces == {} + assert not c.connected def test_handle_disconnect_unknown_namespace(self): c = asyncio_client.AsyncClient() @@ -628,9 +642,12 @@ def test_handle_disconnect_unknown_namespace(self): c.namespaces = {'/foo': '1', '/bar': '2'} c._trigger_event = AsyncMock() _run(c._handle_disconnect('/baz')) - c._trigger_event.mock.assert_called_once_with( + c._trigger_event.mock.assert_any_call( 'disconnect', namespace='/baz' ) + c._trigger_event.mock.assert_any_call( + '__disconnect_final', namespace='/baz' + ) assert c.namespaces == {'/foo': '1', '/bar': '2'} assert c.connected @@ -640,7 +657,9 @@ def test_handle_disconnect_default_namespaces(self): c.namespaces = {'/foo': '1', '/bar': '2'} c._trigger_event = AsyncMock() _run(c._handle_disconnect('/')) - c._trigger_event.mock.assert_called_with('disconnect', namespace='/') + c._trigger_event.mock.assert_any_call('disconnect', namespace='/') + c._trigger_event.mock.assert_any_call('__disconnect_final', + namespace='/') assert c.namespaces == {'/foo': '1', '/bar': '2'} assert c.connected @@ -901,7 +920,9 @@ def test_handle_reconnect_max_delay(self, random, wait_for): @mock.patch('socketio.client.random.random', side_effect=[1, 0, 0.5]) def test_handle_reconnect_max_attempts(self, random, wait_for): c = asyncio_client.AsyncClient(reconnection_attempts=2, logger=True) + c.connection_namespaces = ['/'] c._reconnect_task = 'foo' + c._trigger_event = AsyncMock() c.connect = AsyncMock( side_effect=[ValueError, exceptions.ConnectionError, None] ) @@ -912,6 +933,8 @@ def test_handle_reconnect_max_attempts(self, random, wait_for): 1.5, ] assert c._reconnect_task == 'foo' + c._trigger_event.mock.assert_called_once_with('__disconnect_final', + namespace='/') @mock.patch( 'asyncio.wait_for', @@ -921,7 +944,9 @@ def test_handle_reconnect_max_attempts(self, random, wait_for): @mock.patch('socketio.client.random.random', side_effect=[1, 0, 0.5]) def test_handle_reconnect_aborted(self, random, wait_for): c = asyncio_client.AsyncClient(logger=True) + c.connection_namespaces = ['/'] c._reconnect_task = 'foo' + c._trigger_event = AsyncMock() c.connect = AsyncMock( side_effect=[ValueError, exceptions.ConnectionError, None] ) @@ -932,6 +957,8 @@ def test_handle_reconnect_aborted(self, random, wait_for): 1.5, ] assert c._reconnect_task == 'foo' + c._trigger_event.mock.assert_called_once_with('__disconnect_final', + namespace='/') def test_handle_eio_connect(self): c = asyncio_client.AsyncClient() @@ -1029,10 +1056,11 @@ def test_handle_eio_message(self): _run(c._handle_eio_message('9')) def test_eio_disconnect(self): - c = asyncio_client.AsyncClient(reconnection=False) + c = asyncio_client.AsyncClient() c.namespaces = {'/': '1'} c.connected = True c._trigger_event = AsyncMock() + c.start_background_task = mock.MagicMock() c.sid = 'foo' c.eio.state = 'connected' _run(c._handle_eio_disconnect()) @@ -1071,7 +1099,19 @@ def test_eio_disconnect_self_disconnect(self): def test_eio_disconnect_no_reconnect(self): c = asyncio_client.AsyncClient(reconnection=False) + c.namespaces = {'/': '1'} + c.connected = True + c._trigger_event = AsyncMock() c.start_background_task = mock.MagicMock() + c.sid = 'foo' c.eio.state = 'connected' _run(c._handle_eio_disconnect()) + c._trigger_event.mock.assert_any_call( + 'disconnect', namespace='/' + ) + c._trigger_event.mock.assert_any_call( + '__disconnect_final', namespace='/' + ) + assert c.sid is None + assert not c.connected c.start_background_task.assert_not_called() diff --git a/tests/asyncio/test_asyncio_manager.py b/tests/asyncio/test_asyncio_manager.py index 2d2768ad..c4247234 100644 --- a/tests/asyncio/test_asyncio_manager.py +++ b/tests/asyncio/test_asyncio_manager.py @@ -1,4 +1,3 @@ -import asyncio import sys import unittest from unittest import mock diff --git a/tests/asyncio/test_asyncio_namespace.py b/tests/asyncio/test_asyncio_namespace.py index b7f57313..0d9e6ce7 100644 --- a/tests/asyncio/test_asyncio_namespace.py +++ b/tests/asyncio/test_asyncio_namespace.py @@ -1,4 +1,3 @@ -import asyncio import sys import unittest from unittest import mock diff --git a/tests/asyncio/test_asyncio_simple_client.py b/tests/asyncio/test_asyncio_simple_client.py new file mode 100644 index 00000000..9188c4fe --- /dev/null +++ b/tests/asyncio/test_asyncio_simple_client.py @@ -0,0 +1,163 @@ +import asyncio +import unittest +from unittest import mock +import pytest +from socketio import AsyncSimpleClient +from socketio.exceptions import SocketIOError, TimeoutError, DisconnectedError +from .helpers import AsyncMock, _run + + +class TestAsyncAsyncSimpleClient(unittest.TestCase): + def test_constructor(self): + client = AsyncSimpleClient(1, '2', a='3', b=4) + assert client.client_args == (1, '2') + assert client.client_kwargs == {'a': '3', 'b': 4} + assert client.client is None + assert client.input_buffer == [] + assert not client.connected + + def test_connect(self): + client = AsyncSimpleClient(123, a='b') + with mock.patch('socketio.asyncio_simple_client.AsyncClient') \ + as mock_client: + mock_client.return_value.connect = AsyncMock() + + _run(client.connect('url', headers='h', auth='a', transports='t', + namespace='n', socketio_path='s')) + mock_client.assert_called_once_with(123, a='b') + assert client.client == mock_client() + mock_client().connect.mock.assert_called_once_with( + 'url', headers='h', auth='a', transports='t', + namespaces=['n'], socketio_path='s') + mock_client().event.call_count == 3 + mock_client().on.called_once_with('*') + assert client.namespace == 'n' + assert not client.input_event.is_set() + + def test_connect_twice(self): + client = AsyncSimpleClient(123, a='b') + client.client = mock.MagicMock() + client.connected = True + + with pytest.raises(RuntimeError): + _run(client.connect('url')) + + def test_properties(self): + client = AsyncSimpleClient() + client.client = mock.MagicMock(sid='sid', transport='websocket') + client.connected_event.set() + client.connected = True + + assert client.sid == 'sid' + assert client.transport == 'websocket' + + def test_emit(self): + client = AsyncSimpleClient() + client.client = mock.MagicMock() + client.client.emit = AsyncMock() + client.namespace = '/ns' + client.connected_event.set() + client.connected = True + + _run(client.emit('foo', 'bar')) + assert client.client.emit.mock.called_once_with('foo', 'bar', + namespace='/ns') + + def test_emit_disconnected(self): + client = AsyncSimpleClient() + client.connected_event.set() + client.connected = False + with pytest.raises(DisconnectedError): + _run(client.emit('foo', 'bar')) + + def test_emit_retries(self): + client = AsyncSimpleClient() + client.connected_event.set() + client.connected = True + client.client = mock.MagicMock() + client.client.emit = AsyncMock() + client.client.emit.mock.side_effect = [SocketIOError(), None] + + _run(client.emit('foo', 'bar')) + client.client.emit.mock.assert_called_with('foo', 'bar', namespace='/') + + def test_call(self): + client = AsyncSimpleClient() + client.client = mock.MagicMock() + client.client.call = AsyncMock() + client.client.call.mock.return_value = 'result' + client.namespace = '/ns' + client.connected_event.set() + client.connected = True + + assert _run(client.call('foo', 'bar')) == 'result' + assert client.client.call.mock.called_once_with('foo', 'bar', + namespace='/ns', + timeout=60) + + def test_call_disconnected(self): + client = AsyncSimpleClient() + client.connected_event.set() + client.connected = False + with pytest.raises(DisconnectedError): + _run(client.call('foo', 'bar')) + + def test_call_retries(self): + client = AsyncSimpleClient() + client.connected_event.set() + client.connected = True + client.client = mock.MagicMock() + client.client.call = AsyncMock() + client.client.call.mock.side_effect = [SocketIOError(), 'result'] + + assert _run(client.call('foo', 'bar')) == 'result' + client.client.call.mock.assert_called_with('foo', 'bar', namespace='/', + timeout=60) + + def test_receive_with_input_buffer(self): + client = AsyncSimpleClient() + client.input_buffer = ['foo', 'bar'] + assert _run(client.receive()) == 'foo' + assert _run(client.receive()) == 'bar' + + def test_receive_without_input_buffer(self): + client = AsyncSimpleClient() + client.connected_event.set() + client.connected = True + client.input_event = mock.MagicMock() + + async def fake_wait(timeout=None): + client.input_buffer = ['foo'] + return True + + client.input_event.wait = fake_wait + assert _run(client.receive()) == 'foo' + + def test_receive_with_timeout(self): + client = AsyncSimpleClient() + client.connected_event.set() + client.connected = True + client.input_event = mock.MagicMock() + + async def fake_wait(timeout=None): + await asyncio.sleep(1) + + client.input_event.wait = fake_wait + with pytest.raises(TimeoutError): + _run(client.receive(timeout=0.01)) + + def test_receive_disconnected(self): + client = AsyncSimpleClient() + client.connected_event.set() + client.connected = False + with pytest.raises(DisconnectedError): + _run(client.receive()) + + def test_disconnect(self): + client = AsyncSimpleClient() + mc = mock.MagicMock() + mc.disconnect = AsyncMock() + client.client = mc + _run(client.disconnect()) + mc.disconnect.mock.assert_called_once_with() + assert client.client is None diff --git a/tests/common/test_client.py b/tests/common/test_client.py index a9415efc..78dd1d80 100644 --- a/tests/common/test_client.py +++ b/tests/common/test_client.py @@ -752,10 +752,11 @@ def test_handle_disconnect(self): c.connected = True c._trigger_event = mock.MagicMock() c._handle_disconnect('/') - c._trigger_event.assert_called_once_with('disconnect', namespace='/') + c._trigger_event.assert_any_call('disconnect', namespace='/') + c._trigger_event.assert_any_call('__disconnect_final', namespace='/') assert not c.connected c._handle_disconnect('/') - assert c._trigger_event.call_count == 1 + assert c._trigger_event.call_count == 2 def test_handle_disconnect_namespace(self): c = client.Client() @@ -763,15 +764,21 @@ def test_handle_disconnect_namespace(self): c.namespaces = {'/foo': '1', '/bar': '2'} c._trigger_event = mock.MagicMock() c._handle_disconnect('/foo') - c._trigger_event.assert_called_once_with( + c._trigger_event.assert_any_call( 'disconnect', namespace='/foo' ) + c._trigger_event.assert_any_call( + '__disconnect_final', namespace='/foo' + ) assert c.namespaces == {'/bar': '2'} assert c.connected c._handle_disconnect('/bar') - c._trigger_event.assert_called_with( + c._trigger_event.assert_any_call( 'disconnect', namespace='/bar' ) + c._trigger_event.assert_any_call( + '__disconnect_final', namespace='/bar' + ) assert c.namespaces == {} assert not c.connected @@ -781,9 +788,12 @@ def test_handle_disconnect_unknown_namespace(self): c.namespaces = {'/foo': '1', '/bar': '2'} c._trigger_event = mock.MagicMock() c._handle_disconnect('/baz') - c._trigger_event.assert_called_once_with( + c._trigger_event.assert_any_call( 'disconnect', namespace='/baz' ) + c._trigger_event.assert_any_call( + '__disconnect_final', namespace='/baz' + ) assert c.namespaces == {'/foo': '1', '/bar': '2'} assert c.connected @@ -793,7 +803,9 @@ def test_handle_disconnect_default_namespace(self): c.namespaces = {'/foo': '1', '/bar': '2'} c._trigger_event = mock.MagicMock() c._handle_disconnect('/') - c._trigger_event.assert_called_with('disconnect', namespace='/') + print(c._trigger_event.call_args_list) + c._trigger_event.assert_any_call('disconnect', namespace='/') + c._trigger_event.assert_any_call('__disconnect_final', namespace='/') assert c.namespaces == {'/foo': '1', '/bar': '2'} assert c.connected @@ -1023,9 +1035,11 @@ def test_handle_reconnect_max_delay(self, random): @mock.patch('socketio.client.random.random', side_effect=[1, 0, 0.5]) def test_handle_reconnect_max_attempts(self, random): c = client.Client(reconnection_attempts=2) + c.connection_namespaces = ['/'] c._reconnect_task = 'foo' c._reconnect_abort = c.eio.create_event() c._reconnect_abort.wait = mock.MagicMock(return_value=False) + c._trigger_event = mock.MagicMock() c.connect = mock.MagicMock( side_effect=[ValueError, exceptions.ConnectionError, None] ) @@ -1036,13 +1050,17 @@ def test_handle_reconnect_max_attempts(self, random): mock.call(1.5), ] assert c._reconnect_task == 'foo' + c._trigger_event.assert_called_once_with('__disconnect_final', + namespace='/') @mock.patch('socketio.client.random.random', side_effect=[1, 0, 0.5]) def test_handle_reconnect_aborted(self, random): c = client.Client() + c.connection_namespaces = ['/'] c._reconnect_task = 'foo' c._reconnect_abort = c.eio.create_event() c._reconnect_abort.wait = mock.MagicMock(side_effect=[False, True]) + c._trigger_event = mock.MagicMock() c.connect = mock.MagicMock(side_effect=exceptions.ConnectionError) c._handle_reconnect() assert c._reconnect_abort.wait.call_count == 2 @@ -1051,6 +1069,8 @@ def test_handle_reconnect_aborted(self, random): mock.call(1.5), ] assert c._reconnect_task == 'foo' + c._trigger_event.assert_called_once_with('__disconnect_final', + namespace='/') def test_handle_eio_connect(self): c = client.Client() @@ -1189,7 +1209,15 @@ def test_eio_disconnect_self_disconnect(self): def test_eio_disconnect_no_reconnect(self): c = client.Client(reconnection=False) + c.namespaces = {'/': '1'} + c.connected = True + c._trigger_event = mock.MagicMock() c.start_background_task = mock.MagicMock() + c.sid = 'foo' c.eio.state = 'connected' c._handle_eio_disconnect() + c._trigger_event.assert_any_call('disconnect', namespace='/') + c._trigger_event.assert_any_call('__disconnect_final', namespace='/') + assert c.sid is None + assert not c.connected c.start_background_task.assert_not_called() diff --git a/tests/common/test_simple_client.py b/tests/common/test_simple_client.py new file mode 100644 index 00000000..f445ff85 --- /dev/null +++ b/tests/common/test_simple_client.py @@ -0,0 +1,146 @@ +import unittest +from unittest import mock +import pytest +from socketio import SimpleClient +from socketio.exceptions import SocketIOError, TimeoutError, DisconnectedError + + +class TestSimpleClient(unittest.TestCase): + def test_constructor(self): + client = SimpleClient(1, '2', a='3', b=4) + assert client.client_args == (1, '2') + assert client.client_kwargs == {'a': '3', 'b': 4} + assert client.client is None + assert client.input_buffer == [] + assert not client.connected + + def test_connect(self): + client = SimpleClient(123, a='b') + with mock.patch('socketio.simple_client.Client') as mock_client: + client.connect('url', headers='h', auth='a', transports='t', + namespace='n', socketio_path='s') + mock_client.assert_called_once_with(123, a='b') + assert client.client == mock_client() + mock_client().connect.assert_called_once_with( + 'url', headers='h', auth='a', transports='t', + namespaces=['n'], socketio_path='s') + mock_client().event.call_count == 3 + mock_client().on.called_once_with('*') + assert client.namespace == 'n' + assert not client.input_event.is_set() + + def test_connect_twice(self): + client = SimpleClient(123, a='b') + client.client = mock.MagicMock() + client.connected = True + + with pytest.raises(RuntimeError): + client.connect('url') + + def test_properties(self): + client = SimpleClient() + client.client = mock.MagicMock(sid='sid', transport='websocket') + client.connected_event.set() + client.connected = True + + assert client.sid == 'sid' + assert client.transport == 'websocket' + + def test_emit(self): + client = SimpleClient() + client.client = mock.MagicMock() + client.namespace = '/ns' + client.connected_event.set() + client.connected = True + + client.emit('foo', 'bar') + assert client.client.emit.called_once_with('foo', 'bar', + namespace='/ns') + + def test_emit_disconnected(self): + client = SimpleClient() + client.connected_event.set() + client.connected = False + with pytest.raises(DisconnectedError): + client.emit('foo', 'bar') + + def test_emit_retries(self): + client = SimpleClient() + client.connected_event.set() + client.connected = True + client.client = mock.MagicMock() + client.client.emit.side_effect = [SocketIOError(), None] + + client.emit('foo', 'bar') + client.client.emit.assert_called_with('foo', 'bar', namespace='/') + + def test_call(self): + client = SimpleClient() + client.client = mock.MagicMock() + client.client.call.return_value = 'result' + client.namespace = '/ns' + client.connected_event.set() + client.connected = True + + assert client.call('foo', 'bar') == 'result' + client.client.call.called_once_with('foo', 'bar', namespace='/ns', + timeout=60) + + def test_call_disconnected(self): + client = SimpleClient() + client.connected_event.set() + client.connected = False + with pytest.raises(DisconnectedError): + client.call('foo', 'bar') + + def test_call_retries(self): + client = SimpleClient() + client.connected_event.set() + client.connected = True + client.client = mock.MagicMock() + client.client.call.side_effect = [SocketIOError(), 'result'] + + assert client.call('foo', 'bar') == 'result' + client.client.call.assert_called_with('foo', 'bar', namespace='/', + timeout=60) + + def test_receive_with_input_buffer(self): + client = SimpleClient() + client.input_buffer = ['foo', 'bar'] + assert client.receive() == 'foo' + assert client.receive() == 'bar' + + def test_receive_without_input_buffer(self): + client = SimpleClient() + client.connected_event.set() + client.connected = True + client.input_event = mock.MagicMock() + + def fake_wait(timeout=None): + client.input_buffer = ['foo'] + return True + + client.input_event.wait = fake_wait + assert client.receive() == 'foo' + + def test_receive_with_timeout(self): + client = SimpleClient() + client.connected_event.set() + client.connected = True + with pytest.raises(TimeoutError): + client.receive(timeout=0.01) + + def test_receive_disconnected(self): + client = SimpleClient() + client.connected_event.set() + client.connected = False + with pytest.raises(DisconnectedError): + client.receive() + + def test_disconnect(self): + client = SimpleClient() + mc = mock.MagicMock() + client.client = mc + client.disconnect() + mc.disconnect.assert_called_once_with() + assert client.client is None