Philipp Hörist pushed to branch soup3 at gajim / python-nbxmpp
Commits: ddb9c25e by Philipp Hörist at 2022-12-28T09:00:11+01:00 change: Port to libsoup3
- Port code to libsoup3 - Wrap Soup.Session and Soup.Message for easier usage
- - - - -
15 changed files:
- .gitlab-ci.yml - README.md - debian/control - nbxmpp/__init__.py - nbxmpp/addresses.py - nbxmpp/client.py - nbxmpp/const.py - nbxmpp/examples/client.py - + nbxmpp/http.py - nbxmpp/modules/muclumbus.py - nbxmpp/structs.py - nbxmpp/task.py - nbxmpp/websocket.py - + test/unit/test_http.py - + test/unit/test_muclumbus.py
Changes:
===================================== .gitlab-ci.yml ===================================== @@ -9,7 +9,7 @@ test-pylint: stage: test script: - python3 .ci/pylint-test.py - - coverage run --source=nbxmpp -m unittest discover -v + - NBXMPP_EXTERNAL_UNIT_TESTS=1 coverage run --source=nbxmpp -m unittest discover -v - coverage report -mi - coverage xml -i - pip install .
===================================== README.md ===================================== @@ -7,7 +7,7 @@ - python >= 3.9.0 - PyGObject - GLib >= 2.60 -- libsoup +- libsoup3 - precis-i18n - packaging - idna
===================================== debian/control ===================================== @@ -6,7 +6,7 @@ Build-Depends: debhelper-compat (= 13), dh-python, libglib2.0-0 (>= 2.60), - gir1.2-soup-2.4, + gir1.2-soup-3.0, python3-all, python3-gi, python3-idna, @@ -24,7 +24,7 @@ Architecture: all Depends: ${misc:Depends}, ${python3:Depends}, libglib2.0-0 (>= 2.60), - gir1.2-soup-2.4, + gir1.2-soup-3.0, python3-gi, python3-idna, python3-packaging,
===================================== nbxmpp/__init__.py ===================================== @@ -2,6 +2,6 @@ import gi
from .protocol import *
-gi.require_version('Soup', '2.4') +gi.require_version('Soup', '3.0')
__version__: str = '3.2.5'
===================================== nbxmpp/addresses.py ===================================== @@ -18,9 +18,8 @@ import logging from collections import namedtuple
-from gi.repository import Soup - -import nbxmpp +from nbxmpp.http import HTTPRequest +from nbxmpp.http import HTTPSession from nbxmpp.util import Observable from nbxmpp.const import ConnectionType from nbxmpp.const import ConnectionProtocol @@ -63,6 +62,7 @@ class ServerAddresses(Observable): Observable.__init__(self, log)
self._domain = domain + self._http_session = None self._custom_host = None self._proxy = None self._is_resolved = False @@ -136,32 +136,31 @@ class ServerAddresses(Observable): self._resolve_alternatives()
def _resolve_alternatives(self) -> None: - session = Soup.Session() - session.props.timeout = 5 - session.props.user_agent = f'nbxmpp/{nbxmpp.__version__}' - message = Soup.Message.new( - 'GET', f'https://%7Bself._domain%7D/.well-known/host-meta') - session.queue_message(message, self._on_alternatives_result) - - def _on_alternatives_result(self, - _session: Soup.Session, - message: Soup.Message) -> None: - - status = message.props.status_code - if status != Soup.Status.OK: - error = message.props.reason_phrase - log.info('Failed to retrieve host-meta file: %s %s', status, error) + if self._http_session is None: self._on_request_resolved() return
- response_body = message.props.response_body - if response_body is None or not response_body.data: + request = self._http_session.create_request() + request.send('GET', f'https://%7Bself._domain%7D/.well-known/host-meta', + timeout=5, + callback=self._on_alternatives_result) + + def _on_alternatives_result(self, request: HTTPRequest) -> None: + + if not request.is_complete(): + log.info('Failed to retrieve host-meta file: %s', + request.get_error_string()) + self._on_request_resolved() + return + + response_body = request.get_data() + if not response_body: log.info('No response body data found') self._on_request_resolved() return
try: - uri = parse_websocket_uri(response_body.data) + uri = parse_websocket_uri(response_body.decode()) except Exception as error: log.info('Error parsing websocket uri: %s', error) self._on_request_resolved() @@ -215,6 +214,9 @@ class ServerAddresses(Observable): def set_proxy(self, proxy): self._proxy = proxy
+ def set_http_session(self, session: HTTPSession) -> None: + self._http_session = session + def _on_request_resolved(self): self._is_resolved = True self.notify('resolved')
===================================== nbxmpp/client.py ===================================== @@ -22,6 +22,7 @@ from typing import Optional from gi.repository import GLib
from nbxmpp.namespaces import Namespace +from nbxmpp.http import HTTPSession from nbxmpp.protocol import Features from nbxmpp.protocol import StanzaMalformed from nbxmpp.protocol import SessionRequest @@ -93,6 +94,7 @@ class Client(Observable): self._client_cert = None self._client_cert_pass = None self._proxy = None + self._http_session = HTTPSession()
self._allowed_con_types = None self._allowed_protocols = None @@ -329,12 +331,18 @@ class Client(Observable):
def set_proxy(self, proxy): self._proxy = proxy - self._dispatcher.get_module('Muclumbus').set_proxy(proxy)
@property def proxy(self): return self._proxy
+ def set_http_session(self, session: HTTPSession) -> None: + self._http_session = session + + @property + def http_session(self) -> HTTPSession: + return self._http_session + def get_bound_jid(self): return self._jid
@@ -402,6 +410,7 @@ class Client(Observable): self.state = StreamState.RESOLVE
self._addresses = ServerAddresses(self._domain) + self._addresses.set_http_session(self._http_session) self._addresses.set_custom_host(self._custom_host) self._addresses.set_proxy(self._proxy) self._addresses.subscribe('resolved', self._on_addresses_resolved)
===================================== nbxmpp/const.py ===================================== @@ -453,6 +453,16 @@ class Mode(IntEnum): return self == Mode.ANONYMOUS_TEST
+class HTTPRequestError(IntEnum): + UNKNOWN = 0 + MISSING_CONTENT_LENGTH = 1 + INCOMPLETE = 2 + STATUS_NOT_OK = 3 + CANCELLED = 4 + CONTENT_OVERFLOW = 5 + TIMEOUT = 6 + + MOODS = [ 'afraid', 'amazed',
===================================== nbxmpp/examples/client.py ===================================== @@ -6,6 +6,7 @@ import json from pathlib import Path
import gi + gi.require_version('Gtk', '3.0') gi.require_version('GLib', '2.0') from gi.repository import Gtk @@ -14,6 +15,7 @@ from gi.repository import GLib import nbxmpp from nbxmpp.protocol import JID from nbxmpp.client import Client +from nbxmpp.http import HTTPSession from nbxmpp.structs import ProxyData from nbxmpp.structs import StanzaHandler from nbxmpp.const import ConnectionType @@ -83,6 +85,7 @@ class TestClient(Gtk.Window): self._client.set_domain(self.address.domain) self._client.set_username(self.address.localpart) self._client.set_resource('test') + self._client.set_http_session(HTTPSession())
proxy_ip = self._builder.proxy_ip.get_text() if proxy_ip:
===================================== nbxmpp/http.py ===================================== @@ -0,0 +1,473 @@ +# This file is part of nbxmpp. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 3 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; If not, see http://www.gnu.org/licenses/. + +from __future__ import annotations + +from pathlib import Path + +from typing import Any +from typing import cast +from typing import Literal +from typing import Callable +from typing import Optional + +import logging + +from gi.repository import Soup +from gi.repository import Gio +from gi.repository import GLib +from gi.repository import GObject + +import nbxmpp +from .const import HTTPRequestError + + +log = logging.getLogger('nbxmpp.http') + + +HTTP_METHODS_T = Literal[ + 'CONNECT', + 'DELETE', + 'GET', + 'HEAD', + 'OPTIONS', + 'PATCH', + 'POST', + 'PUT', + 'TRACE', +] +CHUNK_SIZE = 32768 +DEFAULT_USER_AGENT = f'nbxmpp/{nbxmpp.__version__}' +SIGNAL_ACTIONS = GObject.SignalFlags.RUN_LAST | GObject.SignalFlags.ACTION + + +class HTTPLogAdapter(logging.LoggerAdapter): + def process(self, msg: str, kwargs: Any) -> tuple[str, Any]: + return f'{self.extra["request"]}: {msg}', kwargs + + +class HTTPSession: + def __init__(self, + user_agent: str = DEFAULT_USER_AGENT, + sniffer: bool = False + ) -> None: + + self._session = Soup.Session() + self._session.set_user_agent(user_agent) + + if sniffer: + self._session.add_feature_by_type(Soup.ContentSniffer) + + def get_soup_session(self) -> Soup.Session: + return self._session + + def set_proxy_resolver(self, + resolver: Optional[Gio.SimpleProxyResolver] + ) -> None: + + self._session.set_proxy_resolver(resolver) + + def create_request(self) -> HTTPRequest: + return HTTPRequest(self) + + +class HTTPRequest(GObject.GObject): + + __gtype_name__ = "HTTPRequest" + + __gsignals__ = { + 'starting-response-body': (SIGNAL_ACTIONS, None, ()), + 'response-progress': (SIGNAL_ACTIONS, None, (float,)), + 'request-progress': (SIGNAL_ACTIONS, None, (float,)), + 'finished': (SIGNAL_ACTIONS, None, ()), + 'destroy': (SIGNAL_ACTIONS, None, ()), + } + + def __init__(self, session: HTTPSession) -> None: + GObject.GObject.__init__(self) + + self._log = HTTPLogAdapter(log, extra={'request': self}) + + self._session = session + + self._received_size = 0 + self._sent_size = 0 + + self._cancellable = Gio.Cancellable() + self._input_stream = cast(Gio.InputStream, None) + self._output_stream: Optional[Gio.OutputStream] = None + self._is_finished = False + self._error: Optional[HTTPRequestError] = None + self._is_complete = False + self._timeout_reached = False + self._timeout_id = None + + self._response_body_file: Optional[Gio.File] = None + self._response_body_data = b'' + + self._request_body_file: Optional[Gio.File] = None + self._request_body_data: Optional[bytes] = None + + self._request_content_type = '' + self._request_content_length = 0 + + self._response_content_type = '' + self._response_content_length = 0 + + self._emit_request_progress = False + self._emit_response_progress = False + + self._message = Soup.Message() + self._user_data = None + + self._log.info('Created') + + def is_finished(self) -> bool: + return self._is_finished + + def is_complete(self) -> bool: + return self._is_complete + + def get_error(self) -> Optional[HTTPRequestError]: + return self._error + + def get_error_string(self) -> str: + if self._error == HTTPRequestError.STATUS_NOT_OK: + return self._message.get_reason_phrase() + return f'{self._error}' + + def get_response_headers(self) -> Soup.MessageHeaders: + return self._message.get_response_headers() + + def get_request_headers(self) -> Soup.MessageHeaders: + return self._message.get_request_headers() + + def get_data(self) -> bytes: + if not self._is_finished: + raise ValueError('Process not finished, data not available') + return self._response_body_data + + def get_uri(self) -> Optional[GLib.Uri]: + return self._message.get_uri() + + def get_status(self) -> Soup.Status: + return self._message.get_status() + + def get_soup_message(self) -> Soup.Message: + return self._message + + def set_user_data(self, user_data: Any) -> None: + self._user_data = user_data + + def get_user_data(self) -> Any: + return self._user_data + + def cancel(self) -> None: + if self._is_finished: + raise ValueError('Session already finished') + + self._cancellable.cancel() + + def set_request_body_from_path(self, content_type: str, path: Path) -> None: + if not path.exists(): + raise ValueError('%s does not exist' % path) + + if not path.is_file(): + raise ValueError('%s is not a file' % path) + + self._request_body_file = Gio.File.new_for_path(str(path)) + self._request_content_type = content_type + self._request_content_length = path.stat().st_size + + def set_request_body(self, content_type: str, data: bytes) -> None: + self._request_body_data = data + self._request_content_type = content_type + self._request_content_length = len(data) + + def set_response_body_from_path(self, path: Path) -> None: + self._response_body_file = Gio.File.new_for_path(str(path)) + + def connect(self, + signal_name: str, + callback: Any, + *args: Any + ) -> None: + + if signal_name == 'response-progress': + self._emit_response_progress = True + + if signal_name == 'request-progress': + self._emit_request_progress = True + + if signal_name in GObject.signal_list_names(HTTPRequest): + GObject.GObject.connect(self, signal_name, callback, *args) + return + + user_data = (callback, args) + + self._message.connect(signal_name, + self._on_connect_callback, + user_data) + + def _on_connect_callback(self, _message: Soup.Message, *args: Any) -> None: + signal_args = args[:-1] + callback, user_data = args[-1] + callback(self, *signal_args, *user_data) + + def send(self, + method: HTTP_METHODS_T, + uri_string: str, + timeout: Optional[int] = None, + callback: Optional[Callable[[HTTPRequest], Any]] = None + ) -> None: + + if callback: + self.connect('finished', callback) + self._send(method, uri_string, timeout) + + def _send(self, + method: HTTP_METHODS_T, + uri_string: str, + timeout: Optional[int] = None + ) -> None: + + if self._is_finished: + raise ValueError('Session already finished') + + self._message.set_method(method) + + if self._response_body_file is not None: + self._output_stream = self._response_body_file.replace( + None, + False, + Gio.FileCreateFlags.REPLACE_DESTINATION, + self._cancellable) + + uri = GLib.Uri.parse(uri_string, GLib.UriFlags.HAS_PASSWORD) + self._message.set_uri(uri) + + if self._request_body_data is not None: + self._message.set_request_body_from_bytes( + self._request_content_type, + GLib.Bytes.new(self._request_body_data) + ) + if self._emit_request_progress: + self._message.connect('wrote-body-data', + self._on_request_body_progress) + + if self._request_body_file is not None: + request_input_stream = self._request_body_file.read( + self._cancellable) + self._message.set_request_body(self._request_content_type, + request_input_stream, + self._request_content_length) + if self._emit_request_progress: + self._message.connect('wrote-body-data', + self._on_request_body_progress) + + self._message.connect('finished', self._on_finished) + + soup_session = self._session.get_soup_session() + soup_session.send_async(self._message, + GLib.PRIORITY_DEFAULT, + self._cancellable, + self._on_response) + + if timeout is not None: + self._timeout_id = GLib.timeout_add_seconds( + timeout, self._on_timeout) + + self._log.info('Request sent') + + def _on_request_body_progress(self, + _message: Soup.Message, + chunk: int) -> None: + self._sent_size =+ chunk + self.emit('request-progress', + self._sent_size / self._request_content_length) + + def _on_timeout(self) -> None: + self._timeout_reached = True + self.cancel() + + def _on_response(self, + session: Soup.Session, + result: Gio.AsyncResult + ) -> None: + + self._log.info('Request response received') + try: + self._input_stream = session.send_finish(result) + except GLib.Error as error: + self._log.error(error) + quark = GLib.quark_try_string('g-io-error-quark') + if error.matches(quark, Gio.IOErrorEnum.CANCELLED): + self._set_failed(HTTPRequestError.CANCELLED) + else: + self._set_failed(HTTPRequestError.UNKNOWN) + return + + if self._message.get_status() not in (Soup.Status.OK, Soup.Status.CREATED): + self._set_failed(HTTPRequestError.STATUS_NOT_OK) + return + + headers = self.get_response_headers() + self._response_content_length = headers.get_content_length() + self._response_content_type, _params = headers.get_content_type() + if self._response_content_length == 0: + self._set_failed(HTTPRequestError.MISSING_CONTENT_LENGTH) + return + + self._log.info('Start downloading response body') + self.emit('starting-response-body') + + self._read_async() + + def _read_async(self) -> None: + self._input_stream.read_bytes_async(CHUNK_SIZE, + GLib.PRIORITY_LOW, + self._cancellable, + self._on_bytes_read_result) + + def _on_bytes_read_result(self, + input_stream: Gio.InputStream, + result: Gio.AsyncResult) -> None: + try: + data = input_stream.read_bytes_finish(result) + except GLib.Error as error: + self._log.error(error) + quark = GLib.quark_try_string('g-io-error-quark') + if error.matches(quark, Gio.IOErrorEnum.CANCELLED): + self._finish_read(HTTPRequestError.CANCELLED) + else: + self._finish_read(HTTPRequestError.UNKNOWN) + + return + + bytes_ = data.get_data() + if not bytes_: + self._finish_read() + return + + length = len(bytes_) + self._received_size += length + if self._received_size > self._response_content_length: + self._finish_read(HTTPRequestError.CONTENT_OVERFLOW) + return + + if self._output_stream is None: + self._response_body_data += bytes_ + + else: + try: + self._output_stream.write_all(bytes_, self._cancellable) + except GLib.Error as error: + self._log.error(error) + quark = GLib.quark_try_string('g-io-error-quark') + if error.matches(quark, Gio.IOErrorEnum.CANCELLED): + self._finish_read(HTTPRequestError.CANCELLED) + else: + self._finish_read(HTTPRequestError.UNKNOWN) + return + + self._read_async() + + if (self._emit_response_progress and + self._message.get_method() == 'GET'): + self.emit('response-progress', + self._received_size / self._response_content_length) + + + def _finish_read(self, error: Optional[HTTPRequestError] = None) -> None: + self._log.info('Finished reading') + if error is None: + self._close_all_streams() + return + + self._set_failed(error) + + def _on_finished(self, _message: Soup.Message) -> None: + self._set_finished() + + def _set_finished(self) -> None: + status = self._message.get_status() + if status == Soup.Status.NONE: + # Message has not been sent, can happen when we cancel the message + # before it is sent. The finished signal triggers before the + # cancelled exception. + return + + if self._is_finished: + return + + headers = self._message.get_response_headers() + content_length = headers.get_content_length() + if self._received_size != content_length: + self._set_failed(HTTPRequestError.INCOMPLETE) + return + + if status not in (Soup.Status.OK, Soup.Status.CREATED): + self._set_failed(HTTPRequestError.STATUS_NOT_OK) + return + + self._set_complete() + + def _set_failed(self, error: HTTPRequestError) -> None: + self._is_finished = True + if self._timeout_reached: + self._timeout_id = None + self._error = HTTPRequestError.TIMEOUT + else: + self._error = error + self._close_all_streams() + self.emit('finished') + self._cleanup() + + def _set_complete(self) -> None: + self._is_finished = True + self._is_complete = True + self._close_all_streams() + self.emit('finished') + self._cleanup() + + def _close_all_streams(self) -> None: + if self._input_stream is not None: + if not self._input_stream.is_closed(): + self._input_stream.close(None) + + if self._output_stream is not None: + if not self._output_stream.is_closed(): + self._output_stream.close(None) + + def _cleanup(self) -> None: + self._log.info('Run cleanup') + + self._response_body_data = b'' + self._message.run_dispose() + + del self._cancellable + del self._session + del self._input_stream + del self._output_stream + del self._user_data + + if self._timeout_id is not None: + GLib.source_remove(self._timeout_id) + + self.emit('destroy') + self.run_dispose() + + def __repr__(self) -> str: + return f'Request({id(self)})'
===================================== nbxmpp/modules/muclumbus.py ===================================== @@ -17,8 +17,6 @@
import json
-from gi.repository import Soup - from nbxmpp.namespaces import Namespace from nbxmpp.protocol import Node from nbxmpp.protocol import Iq @@ -44,15 +42,6 @@ class Muclumbus(BaseModule): self._client = client self.handlers = []
- self._proxy_resolver = None - self._soup_session = Soup.Session() - - def set_proxy(self, proxy): - if proxy is None: - return - self._proxy_resolver = proxy.get_resolver() - self._soup_session.props.proxy_resolver = self._proxy_resolver - @iq_request_task def request_parameters(self, jid): task = yield @@ -141,24 +130,25 @@ class Muclumbus(BaseModule): if after is not None: search['after'] = after
- message = Soup.Message.new('POST', uri) - message.set_request('application/json', - Soup.MemoryUse.COPY, - json.dumps(search).encode()) + body = json.dumps(search).encode()
- response_message = yield message + session = self._client.http_session + request = session.create_request() + request.set_request_body('application/json', body) + request.send('POST', uri)
- soup_body = response_message.get_property('response-body') + request = yield request
- if response_message.status_code != 200: - self._log.warning(soup_body.data) + if not request.is_complete(): + self._log.warning(request.get_error_string()) yield MuclumbusResult(first=None, last=None, max=None, end=True, items=[])
- response = json.loads(soup_body.data) + response_body = request.get_data() + response = json.loads(response_body)
result = response['result'] items = result.get('items')
===================================== nbxmpp/structs.py ===================================== @@ -30,8 +30,8 @@ from dataclasses import dataclass from dataclasses import field from datetime import datetime
-from gi.repository import Soup from gi.repository import Gio +from gi.repository import GLib
from nbxmpp.simplexml import Node from nbxmpp.namespaces import Namespace @@ -708,8 +708,9 @@ class ProxyData(NamedTuple):
def get_uri(self) -> str: if self.username is not None: - user_pass = Soup.uri_encode('%s:%s' % (self.username, - self.password)) + username = GLib.uri_escape_string(self.username, None, False) + password = GLib.uri_escape_string(self.password, None, False) + user_pass = f'{username}:{password}' return '%s://%s@%s' % (self.type, user_pass, self.host)
===================================== nbxmpp/task.py ===================================== @@ -27,14 +27,14 @@ import logging from enum import IntEnum from functools import wraps
-from gi.repository import Soup from gi.repository import GLib -from nbxmpp.modules.base import BaseModule
from nbxmpp.simplexml import Node from nbxmpp.errors import is_error from nbxmpp.errors import CancelledError from nbxmpp.errors import TimeoutStanzaError +from nbxmpp.http import HTTPRequest +from nbxmpp.modules.base import BaseModule from nbxmpp.modules.util import make_func_arguments_string
@@ -109,8 +109,7 @@ def http_request_task(func): @wraps(func) def func_wrapper(self, *args, callback=None, user_data=None, **kwargs): task = HTTPRequestTask(func(self, *args, **kwargs), - self._log, - self._soup_session) + self._log) return _setup_task(task, self._client, callback, user_data) return func_wrapper
@@ -251,7 +250,7 @@ class Task:
def _invoke_callbacks(self): for callback in self._done_callbacks: - if isinstance(callback, weakref.WeakMethod): + if isinstance(callback, (weakref.WeakMethod, weakref.ref)): callback = callback() if callback is None: return @@ -377,19 +376,21 @@ class HTTPRequestTask(Task):
'''
- _process_types = (Soup.Message,) + _process_types = (HTTPRequest,)
- def __init__(self, gen, logger, session): + def __init__(self, gen, logger): super().__init__(gen, logger) - self._session = session
- def _run_async(self, message): + def _run_async(self, request: HTTPRequest): # pylint: disable=arguments-renamed - self._session.queue_message(message, self._async_finished, None) + request.connect('finished', self._async_finished) + + def _async_finished(self, request: HTTPRequest): + + if self._state == TaskState.CANCELLED: + return
- def _async_finished(self, _session, message, _user_data): - if self._state != TaskState.CANCELLED: - self._next_step(message) + self._next_step(request)
def _finalize(self): self._session = None
===================================== nbxmpp/websocket.py ===================================== @@ -22,7 +22,6 @@ from gi.repository import GLib from gi.repository import Gio
from nbxmpp.const import TCPState -from nbxmpp.const import ConnectionType from nbxmpp.util import get_websocket_close_string from nbxmpp.util import convert_tls_error_flags from nbxmpp.connection import Connection @@ -35,11 +34,10 @@ class WebsocketConnection(Connection): Connection.__init__(self, *args, **kwargs)
self._session = Soup.Session() - self._session.props.ssl_strict = False
if self._log.getEffectiveLevel() == logging.INFO: self._session.add_feature( - Soup.Logger.new(Soup.LoggerLogLevel.BODY, -1)) + Soup.Logger.new(Soup.LoggerLogLevel.BODY))
self._websocket = None self._cancellable = Gio.Cancellable() @@ -53,11 +51,12 @@ class WebsocketConnection(Connection): self.state = TCPState.CONNECTING
message = Soup.Message.new('GET', self._address.uri) - message.connect('starting', self._check_certificate) + message.connect('accept-certificate', self._check_certificate) message.set_flags(Soup.MessageFlags.NO_REDIRECT) self._session.websocket_connect_async(message, None, ['xmpp'], + GLib.PRIORITY_DEFAULT, self._cancellable, self._on_connect, None) @@ -91,19 +90,20 @@ class WebsocketConnection(Connection): # Soup.Session does this automatically raise NotImplementedError
- def _check_certificate(self, message): - https_used, certificate, errors = message.get_https_status() - if not https_used and self._address.type == ConnectionType.PLAIN: - return + def _check_certificate(self, + _message, + certificate, + errors):
self._peer_certificate = certificate self._peer_certificate_errors = convert_tls_error_flags(errors)
if self._accept_certificate(): - return + return True
self.notify('bad-certificate') self._cancellable.cancel() + return False
def _on_websocket_message(self, _websocket, _type, message): data = message.get_data().decode()
===================================== test/unit/test_http.py ===================================== @@ -0,0 +1,247 @@ +from time import time +import tempfile +import unittest +from unittest.mock import call +from unittest.mock import Mock +from unittest.mock import patch +import os +from pathlib import Path + +from gi.repository import GLib +from gi.repository import Soup + +from nbxmpp.const import HTTPRequestError +from nbxmpp.http import HTTPSession + + +SMALL_FILE_URL = 'https://gajim.org/downloads/ci/unittest_small_file' # 200 KB +BIG_FILE_URL = 'https://gajim.org/downloads/ci/unittest_big_file' # 7 MB +LARGE_FILE_URL = 'https://gajim.org/downloads/ci/unittest_large_file' # 80 MB +NO_FILE_URL = 'https://gajim.org/downloads/ci/no-file' + + +# import logging +# consoleloghandler = logging.StreamHandler() +# log = logging.getLogger('nbxmpp.http') +# log.setLevel(logging.DEBUG) +# log.addHandler(consoleloghandler) + + +@unittest.skipUnless(os.environ.get('NBXMPP_EXTERNAL_UNIT_TESTS'), + 'ENV var for external tests not set') +class HTTP(unittest.TestCase): + + @classmethod + def setUpClass(cls): + pass + + @classmethod + def tearDownClass(cls): + pass + + def test_download_to_file(self): + + mainloop = GLib.MainLoop() + + session = HTTPSession() + request = session.create_request() + + callback_mock = Mock() + + temp_dir = tempfile.gettempdir() + request.set_response_body_from_path(Path(temp_dir) / 'file') + request.connect('response-progress', callback_mock.progress) + request.connect('starting-response-body', callback_mock.starting) + request.connect('finished', callback_mock.finished) + request.connect('destroy', lambda *args: mainloop.quit()) + + request.send('GET', SMALL_FILE_URL, timeout=10) + + mainloop.run() + + self.assertTrue(request.is_complete()) + self.assertTrue(request.is_finished()) + self.assertIsNone(request.get_error()) + + callback_mock.progress.assert_called() + callback_mock.starting.assert_called() + callback_mock.finished.assert_called() + + def test_in_memory_download(self): + + mainloop = GLib.MainLoop() + + session = HTTPSession() + request = session.create_request() + + callback_mock = Mock() + request.connect('response-progress', callback_mock.progress) + request.connect('starting-response-body', callback_mock.starting) + request.connect('finished', callback_mock.finished) + request.connect('destroy', lambda *args: mainloop.quit()) + request.send('GET', SMALL_FILE_URL, timeout=10) + + mainloop.run() + + self.assertTrue(request.is_complete()) + self.assertTrue(request.is_finished()) + self.assertIsNone(request.get_error()) + + callback_mock.progress.assert_called() + callback_mock.starting.assert_called() + callback_mock.finished.assert_called() + + def test_download_cancelled_before_start(self): + + mainloop = GLib.MainLoop() + + session = HTTPSession() + request = session.create_request() + + callback_mock = Mock() + request.connect('response-progress', callback_mock.progress) + request.connect('starting-response-body', callback_mock.starting) + request.connect('finished', callback_mock.finished) + request.connect('destroy', lambda *args: mainloop.quit()) + request.send('GET', SMALL_FILE_URL, timeout=10) + + GLib.timeout_add(10, request.cancel) + + mainloop.run() + + self.assertFalse(request.is_complete()) + self.assertTrue(request.is_finished()) + self.assertEqual(request.get_error(), HTTPRequestError.CANCELLED) + + callback_mock.progress.assert_not_called() + callback_mock.starting.assert_not_called() + callback_mock.finished.assert_called() + + def test_download_cancelled_after_start(self): + + mainloop = GLib.MainLoop() + + session = HTTPSession() + request = session.create_request() + + def _on_progress(req, progress): + self.assertIsInstance(progress, float) + req.cancel() + + callback_mock = Mock() + request.connect('starting-response-body', callback_mock.starting) + request.connect('response-progress', _on_progress) + request.connect('finished', callback_mock.finished) + request.connect('destroy', lambda *args: mainloop.quit()) + request.send('GET', LARGE_FILE_URL, timeout=10) + + mainloop.run() + + self.assertFalse(request.is_complete()) + self.assertTrue(request.is_finished()) + self.assertEqual(request.get_error(), HTTPRequestError.CANCELLED) + + callback_mock.starting.assert_called() + callback_mock.finished.assert_called() + + def test_download_failed_404(self): + + mainloop = GLib.MainLoop() + + session = HTTPSession() + request = session.create_request() + + callback_mock = Mock() + request.connect('finished', callback_mock.finished) + request.connect('response-progress', callback_mock.progress) + request.connect('destroy', lambda *args: mainloop.quit()) + request.send('GET', NO_FILE_URL, timeout=5) + + mainloop.run() + + self.assertFalse(request.is_complete()) + self.assertTrue(request.is_finished()) + self.assertEqual(request.get_error(), HTTPRequestError.STATUS_NOT_OK) + self.assertEqual(request.get_status(), Soup.Status.NOT_FOUND) + + callback_mock.progress.assert_not_called() + callback_mock.finished.assert_called() + + def test_cancel_with_timeout(self): + + mainloop = GLib.MainLoop() + + session = HTTPSession() + request = session.create_request() + + callback_mock = Mock() + request.connect('starting-response-body', callback_mock.starting) + request.connect('finished', callback_mock.finished) + request.connect('destroy', lambda *args: mainloop.quit()) + request.send('GET', LARGE_FILE_URL, timeout=1) + + mainloop.run() + + self.assertTrue(request.is_finished()) + self.assertFalse(request.is_complete()) + self.assertEqual(request.get_error(), HTTPRequestError.TIMEOUT) + + callback_mock.starting.assert_called() + callback_mock.finished.assert_called() + + def test_signal_propagation(self): + + mainloop = GLib.MainLoop() + + session = HTTPSession() + request = session.create_request() + + callback_mock = Mock() + request.connect('starting', callback_mock.starting, 1, 2, 3) + request.connect('got-body', callback_mock.got_body) + request.connect('destroy', lambda *args: mainloop.quit()) + request.send('GET', SMALL_FILE_URL, timeout=10) + + mainloop.run() + + self.assertTrue(request.is_finished()) + self.assertTrue(request.is_complete()) + + callback_mock.starting.assert_called_with(request, 1, 2, 3) + callback_mock.got_body.assert_called_with(request) + + def test_parallel_download(self): + + mainloop = GLib.MainLoop() + + request1 = HTTPSession().create_request() + request1.send('GET', SMALL_FILE_URL, timeout=5) + + request2 = HTTPSession().create_request() + request2.send('GET', SMALL_FILE_URL, timeout=5) + + request3 = HTTPSession().create_request() + request3.send('GET', SMALL_FILE_URL, timeout=5) + + request4 = HTTPSession().create_request() + request4.send('GET', SMALL_FILE_URL, timeout=5) + + GLib.timeout_add_seconds(5, mainloop.quit) + + mainloop.run() + + self.assertTrue(request1.is_finished()) + self.assertTrue(request1.is_complete()) + + self.assertTrue(request2.is_finished()) + self.assertTrue(request2.is_complete()) + + self.assertTrue(request3.is_finished()) + self.assertTrue(request3.is_complete()) + + self.assertTrue(request4.is_finished()) + self.assertTrue(request4.is_complete()) + + +if __name__ == '__main__': + unittest.main()
===================================== test/unit/test_muclumbus.py ===================================== @@ -0,0 +1,46 @@ +from __future__ import annotations + +from typing import Any + +from gi.repository import GLib + +import os +import unittest +from unittest.mock import Mock +from nbxmpp.client import Client +from nbxmpp.http import HTTPSession +from nbxmpp.structs import MuclumbusResult +from nbxmpp.modules.muclumbus import Muclumbus + +from nbxmpp.sasl import SCRAM_SHA_1 +from nbxmpp.util import b64encode + +# Test vector from https://wiki.xmpp.org/web/SASL_and_SCRAM-SHA-1 + +API_URL = 'https://search.jabber.network/api/1.0/search' + + +@unittest.skipUnless(os.environ.get('NBXMPP_EXTERNAL_UNIT_TESTS'), + 'ENV var for external tests not set') +class TestMuclumbus(unittest.TestCase): + def setUp(self): + self._client = Client() + self._client.set_http_session(HTTPSession()) + self._module = Muclumbus(self._client) + + def test_http_request(self): + + mainloop = GLib.MainLoop() + + def _result(task: Any) -> None: + result = task.finish() + assert isinstance(result, MuclumbusResult) + assert len(result.items) > 0 + mainloop.quit() + + self._module.set_http_search(API_URL, 'gajim', callback=_result) + + mainloop.run() + +if __name__ == '__main__': + unittest.main()
View it on GitLab: https://dev.gajim.org/gajim/python-nbxmpp/-/commit/ddb9c25e0e23bc5644e5289c0...