Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Switch from ws4py to websockets #626

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,12 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.8", "3.10", "3.12"]
python-version: ["3.10", "3.12", "3.13"] # Test the versions included in supported Ubuntu LTS's and the latest.
track: ["latest/edge", "5.21/edge", "5.0/edge"]
os: ["24.04"]
include:
# 4.0/* isn't supported on 24.04
- python-version: "3.8"
- python-version: "3.10"
track: "4.0/edge"
os: "22.04"

Expand Down
18 changes: 10 additions & 8 deletions doc/source/events.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,16 @@ web socket messages.
.. code-block:: python
>>> ws_client = client.events()
>>> ws_client.connect()
>>> ws_client.run()
>>> ws_client.recv() # receives one event
>>> ws_client.messages[-1] # get latest event
>>> for event in ws_client: print(event) # show all events as they come in
A default client class is provided, which will block indefinitely, and
collect all json messages in a `messages` attribute. An optional
`websocket_client` parameter can be provided when more functionality is
needed. The `ws4py` library is used to establish the connection; please
see the `ws4py` documentation for more information.
A default client class is provided, and collect all json messages in a `messages` attribute.
An optional `websocket_client` parameter can be provided when more functionality is needed.
To help older users, this parameter can also be used to provide a client from the
now deprecated `ws4py`.
The `websockets` library is used to establish the connection; please
see the `websockets` documentation for more information.

The stream of events can be filtered to include only specific types of
events, as defined in the LXD /endpoint `documentation <https://documentation.ubuntu.com/lxd/en/latest/events/>`_.
Expand All @@ -32,6 +34,6 @@ LXD server:
To receive only events pertaining to the lifecycle of the containers:

.. code-block:: python
>>> types = set([EventType.Lifecycle])
>>> ws_client = client.events(event_types=types)
73 changes: 68 additions & 5 deletions integration/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,18 @@
# License for the specific language governing permissions and limitations
# under the License.

import json
import os
import threading
import time

from websockets.sync.client import ClientConnection
from ws4py.client import WebSocketBaseClient

import pylxd
from integration.testing import IntegrationTestCase
from pylxd import exceptions
from pylxd.client import EventType


class TestClient(IntegrationTestCase):
Expand All @@ -37,11 +44,6 @@ def test_authenticate(self):
self.assertTrue(client.trusted)

def test_authenticate_with_project(self):
if self.client.has_api_extension("explicit_trust_token"):
self.skipTest(
"Required LXD support for password authentication not available!"
)

try:
client = pylxd.Client("https://127.0.0.1:8443/", project="test-project")
except exceptions.ClientConnectionFailed as e:
Expand All @@ -57,3 +59,64 @@ def test_authenticate_with_project(self):

client.authenticate(secret)
self.assertEqual(client.host_info["environment"]["project"], "test-project")

def _provoke_event(self):
time.sleep(1)
self.client.instances.all() # provoke an event

def test_events_default_client(self):
events_ws_client = self.client.events()

self.assertTrue(issubclass(type(events_ws_client), ClientConnection))
self.assertEqual(events_ws_client.protocol.wsuri.resource_name, "/1.0/events")

receiver = threading.Thread(target=self._provoke_event)
receiver.start()

message = events_ws_client.recv()
receiver.join()

self.assertEqual(len(events_ws_client.messages), 1)
self.assertEqual(type(events_ws_client.messages[0]), dict)
self.assertEqual(json.loads(message), events_ws_client.messages[0])
self.assertEqual(events_ws_client.messages[0]["type"], EventType.Logging.value)

events_ws_client.close()

def test_events_filters(self):
for eventType in EventType:
if eventType != EventType.All:
events_ws_client = self.client.events(event_types=[eventType])

self.assertEqual(
events_ws_client.protocol.wsuri.resource_name,
f"/1.0/events?type={eventType.value}",
)

events_ws_client.close()

def test_events_provided_client(self):
events_ws_client = self.client.events(websocket_client=ClientConnection)

self.assertEqual(type(events_ws_client), ClientConnection)
self.assertEqual(events_ws_client.protocol.wsuri.resource_name, "/1.0/events")

receiver = threading.Thread(target=self._provoke_event)
receiver.start()

message = events_ws_client.recv()
receiver.join()

self.assertEqual(type(message), str)
self.assertEqual(json.loads(message)["type"], EventType.Logging.value)

events_ws_client.close()

def test_events_ws4py_client(self):
events_ws_client = self.client.events(websocket_client=WebSocketBaseClient)

self.assertEqual(type(events_ws_client), WebSocketBaseClient)
self.assertEqual(events_ws_client.resource, "/1.0/events")

events_ws_client.connect()
events_ws_client.close()
120 changes: 108 additions & 12 deletions pylxd/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
import os
import re
import socket
import ssl
import warnings
from enum import Enum
from typing import NamedTuple
from urllib import parse
Expand All @@ -26,7 +28,7 @@
import urllib3.connection
from cryptography import x509
from cryptography.hazmat.primitives import hashes
from ws4py.client import WebSocketBaseClient
from websockets.sync.client import ClientConnection, connect, unix_connect

from pylxd import exceptions, managers

Expand Down Expand Up @@ -333,7 +335,7 @@
return response


class _WebsocketClient(WebSocketBaseClient):
class _WebsocketClient(ClientConnection):
"""A basic websocket client for the LXD API.

This client is intentionally barebones, and serves
Expand All @@ -342,12 +344,17 @@
then be read are parsed.
"""

def handshake_ok(self):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

Check warning on line 348 in pylxd/client.py

View check run for this annotation

Codecov / codecov/patch

pylxd/client.py#L348

Added line #L348 was not covered by tests
self.messages = []

def received_message(self, message):
json_message = json.loads(message.data.decode("utf-8"))
def recv(self):
message = super().recv()
if isinstance(message, bytes):
message = message.decode("utf-8")
json_message = json.loads(message)

Check warning on line 355 in pylxd/client.py

View check run for this annotation

Codecov / codecov/patch

pylxd/client.py#L352-L355

Added lines #L352 - L355 were not covered by tests
self.messages.append(json_message)
return message

Check warning on line 357 in pylxd/client.py

View check run for this annotation

Codecov / codecov/patch

pylxd/client.py#L357

Added line #L357 was not covered by tests


# Helper function used by Client.authenticate()
Expand Down Expand Up @@ -610,6 +617,58 @@
url = parse.urlunparse((scheme, host, "", "", "", ""))
return url

# Establishes a websocket client connection with the server.
# Accepts as parameter a websockets.sync.client.ClientConnection type class and returns a client object.
def create_websocket_client(
self,
websocket_client=ClientConnection,
unix_socket_path=None,
resource=None,
**kwargs, # Used to provide any additional arguments a custom websocket_client may need.
):
def create_client(*client_args, **client_kwargs):
client = websocket_client(

Check warning on line 630 in pylxd/client.py

View check run for this annotation

Codecov / codecov/patch

pylxd/client.py#L629-L630

Added lines #L629 - L630 were not covered by tests
*client_args,
**client_kwargs,
**kwargs,
)

# If resource name was provided, tweak client protocol accordingly.
if resource:
parsed = resource.split("?")
client.protocol.wsuri.path = parsed[0]
if len(parsed) > 1:
client.protocol.wsuri.query = parsed[1]

Check warning on line 641 in pylxd/client.py

View check run for this annotation

Codecov / codecov/patch

pylxd/client.py#L637-L641

Added lines #L637 - L641 were not covered by tests

return client

Check warning on line 643 in pylxd/client.py

View check run for this annotation

Codecov / codecov/patch

pylxd/client.py#L643

Added line #L643 was not covered by tests

ssl_context = None

Check warning on line 645 in pylxd/client.py

View check run for this annotation

Codecov / codecov/patch

pylxd/client.py#L645

Added line #L645 was not covered by tests

if self.api.scheme == "https" and self.cert:

Check warning on line 647 in pylxd/client.py

View check run for this annotation

Codecov / codecov/patch

pylxd/client.py#L647

Added line #L647 was not covered by tests
# PROTOCOL_TLS_CLIENT does not exist on older Python versions
protocol = getattr(ssl, "PROTOCOL_TLS_CLIENT", ssl.PROTOCOL_TLSv1_2)
ssl_context = ssl.SSLContext(protocol)

Check warning on line 650 in pylxd/client.py

View check run for this annotation

Codecov / codecov/patch

pylxd/client.py#L649-L650

Added lines #L649 - L650 were not covered by tests

ssl_context.load_cert_chain(certfile=self.cert[0], keyfile=self.cert[1])

Check warning on line 652 in pylxd/client.py

View check run for this annotation

Codecov / codecov/patch

pylxd/client.py#L652

Added line #L652 was not covered by tests

ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE

Check warning on line 655 in pylxd/client.py

View check run for this annotation

Codecov / codecov/patch

pylxd/client.py#L654-L655

Added lines #L654 - L655 were not covered by tests

# If path to unix socket was provided assume we are using a unix socket and create client object as such.
if unix_socket_path:
return unix_connect(

Check warning on line 659 in pylxd/client.py

View check run for this annotation

Codecov / codecov/patch

pylxd/client.py#L658-L659

Added lines #L658 - L659 were not covered by tests
unix_socket_path,
ssl=ssl_context,
create_connection=create_client,
)

# Otherwise create a regular websocket.
return connect(

Check warning on line 666 in pylxd/client.py

View check run for this annotation

Codecov / codecov/patch

pylxd/client.py#L666

Added line #L666 was not covered by tests
self.websocket_url,
ssl=ssl_context,
create_connection=create_client,
)

def events(self, websocket_client=None, event_types=None):
"""Get a websocket client for getting events.

Expand All @@ -624,7 +683,7 @@

:param websocket_client: Optional websocket client can be specified for
implementation-specific handling of events as they occur.
:type websocket_client: ws4py.client import WebSocketBaseClient
:type websocket_client: websockets.sync.client.ClientConnection or ws4py.client.WebSocketBaseClient

:param event_types: Optional set of event types to propagate. Omit this
argument or specify {EventTypes.All} to receive all events.
Expand All @@ -636,11 +695,6 @@
if websocket_client is None:
websocket_client = _WebsocketClient

use_ssl = self.api.scheme == "https" and self.cert
ssl_options = (
{"certfile": self.cert[0], "keyfile": self.cert[1]} if use_ssl else None
)
client = websocket_client(self.websocket_url, ssl_options=ssl_options)
parsed = parse.urlparse(self.api.events._api_endpoint)

resource = parsed.path
Expand All @@ -650,6 +704,48 @@
query.update({"type": ",".join(t.value for t in event_types)})
resource = f"{resource}?{parse.urlencode(query)}"

client.resource = resource
# First try handling it as a websockets' ClientConnection
if issubclass(websocket_client, ClientConnection):
is_unix_socket = "+unix" in parsed.scheme

Check warning on line 709 in pylxd/client.py

View check run for this annotation

Codecov / codecov/patch

pylxd/client.py#L708-L709

Added lines #L708 - L709 were not covered by tests

return self.create_websocket_client(

Check warning on line 711 in pylxd/client.py

View check run for this annotation

Codecov / codecov/patch

pylxd/client.py#L711

Added line #L711 was not covered by tests
websocket_client=websocket_client,
unix_socket_path=(
parsed.hostname.replace(
"%2F", "/"
) # urlparse fails to make sense of slashes in the hostname part.
if is_unix_socket
else None
),
resource=resource,
)

# If not a ClientConnection, assume it is a ws4py client and handle it accordingly, for backwards compatibility.
else:
use_ssl = self.api.scheme == "https" and self.cert

Check warning on line 725 in pylxd/client.py

View check run for this annotation

Codecov / codecov/patch

pylxd/client.py#L725

Added line #L725 was not covered by tests

try:
ssl_options = (

Check warning on line 728 in pylxd/client.py

View check run for this annotation

Codecov / codecov/patch

pylxd/client.py#L727-L728

Added lines #L727 - L728 were not covered by tests
{"certfile": self.cert[0], "keyfile": self.cert[1]}
if use_ssl
else None
)

client = websocket_client(

Check warning on line 734 in pylxd/client.py

View check run for this annotation

Codecov / codecov/patch

pylxd/client.py#L734

Added line #L734 was not covered by tests
self.websocket_url, resource, ssl_options=ssl_options
)

client.resource = resource

Check warning on line 738 in pylxd/client.py

View check run for this annotation

Codecov / codecov/patch

pylxd/client.py#L738

Added line #L738 was not covered by tests

warnings.warn(

Check warning on line 740 in pylxd/client.py

View check run for this annotation

Codecov / codecov/patch

pylxd/client.py#L740

Added line #L740 was not covered by tests
"The ws4py client API is deprecated and should not be supported in the future",
DeprecationWarning,
)
except (TypeError, ValueError, AttributeError):
warnings.warn(

Check warning on line 745 in pylxd/client.py

View check run for this annotation

Codecov / codecov/patch

pylxd/client.py#L744-L745

Added lines #L744 - L745 were not covered by tests
"Could not create client object since provided client follows neither websockets' nor ws4py's client APIs"
)

return None

Check warning on line 749 in pylxd/client.py

View check run for this annotation

Codecov / codecov/patch

pylxd/client.py#L749

Added line #L749 was not covered by tests

return client
Loading
Loading