Philipp Hörist pushed to branch soup3 at gajim / python-nbxmpp
Commits: 18389912 by Philipp Hörist at 2022-12-11T13:44:25+01:00 ci: Use nbxmpp-master image for build-linux stage
- - - - - d86c0852 by Philipp Hörist at 2022-12-11T13:46:57+01:00 change: Port to libsoup3
- - - - -
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 ===================================== @@ -6,7 +6,6 @@ stages: - deploy
test-pylint: - stage: test script: - python3 .ci/pylint-test.py @@ -22,7 +21,6 @@ test-pylint: path: coverage.xml
build-linux: - image: nbxmpp-deb-build:latest stage: build dependencies: [] script:
===================================== 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,11 +18,11 @@ 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 HTTPRequestError from nbxmpp.const import ConnectionProtocol from nbxmpp.util import parse_websocket_uri
@@ -63,6 +63,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 +137,41 @@ 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) + if self._http_session is None: + self._on_request_resolved() + return + + request = self._http_session.create_request() + request.get(f'https://%7Bself._domain%7D/.well-known/host-meta', + timeout=5, + callback=self._on_alternatives_result)
def _on_alternatives_result(self, - _session: Soup.Session, - message: Soup.Message) -> None: + request: HTTPRequest, + _signal_name: str + ) -> None: + + if not request.is_complete(): + error = request.get_error() + if error == HTTPRequestError.STATUS_NOT_OK: + message = request.get_message() + phrase = message.get_reason_phrase() + log.info('Failed to retrieve host-meta file: %s %s', + request.get_status(), phrase) + else: + log.error('Failed to read response: %s', error)
- 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) self._on_request_resolved() return
- response_body = message.props.response_body - if response_body is None or not response_body.data: + 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 +225,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 = None
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) -> Optional[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,459 @@ +# 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 Literal +from typing import Callable +from typing import Optional + +import logging +from threading import Thread + +from gi.repository import Soup +from gi.repository import Gio +from gi.repository import GLib + +import nbxmpp +from nbxmpp.const import HTTPRequestError +from nbxmpp.util import Observable + + +log = logging.getLogger('nbxmpp.http') + + +HTTP_METHODS_T = Literal['GET', 'POST'] +CHUNK_SIZE = 32768 +DEFAULT_USER_AGENT = f'nbxmpp/{nbxmpp.__version__}' + + +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) -> None: + self._session = Soup.Session() + self._session.set_user_agent(user_agent) + + self._requests: list[HTTPRequest] = [] + + 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: + request = HTTPRequest(self) + request.subscribe('destroyed', self._on_request_destroyed) + self._requests.append(request) + return request + + def _on_request_destroyed(self, + request: HTTPRequest, + _signal_name: str + ) -> None: + + self._requests.remove(request) + + +class HTTPRequest(Observable): + + ''' + Signals + - started + - complete + - failed + - cancelled + - destroyed + ''' + + def __init__(self, session: HTTPSession) -> None: + Observable.__init__(self, HTTPLogAdapter(log, extra={'request': self})) + self._session = session + + self._received_size = 0 + self._sent_size = 0 + + self._uri: Optional[GLib.Uri] = None + + self._cancellable = Gio.Cancellable() + self._input_stream: Optional[Gio.InputStream] = None + self._output_stream: Optional[Gio.OutputStream] = None + self._file: Optional[Path] = 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._message = None + self._method = '' + self._progress_callback: Optional[Callable[..., None]] = 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_response_headers(self) -> int: + return self._message.get_response_headers() + + def get_request_headers(self) -> int: + 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._uri + + def get_file_path(self) -> Optional[Gio.File]: + return self._file + + def get_status(self) -> Soup.Status: + return self._message.get_status() + + def get_message(self) -> Soup.Message: + return self._message + + def _get_response_progress(self) -> float: + if self._received_size == 0: + return 0 + + return self._received_size / self._response_content_length + + def _get_request_progress(self) -> float: + if self._sent_size == 0: + return 0 + return self._sent_size / self._request_content_length + + def cancel(self) -> None: + if self._is_finished: + raise ValueError('Session already finished') + + self._cancellable.cancel() + + def set_progress_callback(self, func: Any) -> None: + self._progress_callback = func + + 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_path(self, path: Path) -> None: + self._response_body_file = Gio.File.new_for_path(str(path)) + + def post(self, + uri_string: str, + timeout: Optional[int] = None, + callback: Optional[Callable[[], Any]] = None + ) -> None: + + if callback is not None: + self.subscribe('finished', callback) + + self._send('POST', uri_string, timeout=timeout) + + def get(self, + uri_string: str, + timeout: Optional[int] = None, + callback: Optional[Callable[[], Any]] = None + ) -> None: + + if callback is not None: + self.subscribe('finished', callback) + + self._send('GET', uri_string, timeout=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._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) + + self._uri = GLib.Uri.parse(uri_string, GLib.UriFlags.HAS_PASSWORD) + self._message = Soup.Message.new_from_uri(method, self._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._progress_callback is not None: + 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._progress_callback is not None: + 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 + if self._progress_callback is not None: + self._progress_callback(self, self._get_request_progress()) + + 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 Exception 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() != Soup.Status.OK: + 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 = headers.get_content_type() + if self._response_content_length == 0: + self._set_failed(HTTPRequestError.MISSING_CONTENT_LENGTH) + return + + self.notify('started') + + thread = Thread(target=self._process_response_thread, + args=(self._response_content_length,)) + thread.start() + + def _process_response_thread(self, content_length: int) -> None: + received_size = 0 + + while True: + try: + data = self._input_stream.read_bytes(CHUNK_SIZE, + 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): + result = HTTPRequestError.CANCELLED + else: + result = HTTPRequestError.UNKNOWN + + GLib.idle_add(self._set_thread_result, result) + return + + bytes_ = data.get_data() + if not bytes_: + self._received_size = received_size + GLib.idle_add(self._set_thread_result) + return + + length = len(bytes_) + received_size += length + if received_size > content_length: + GLib.idle_add(self._set_thread_result, + 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 Exception as error: + self._log.error(error) + quark = GLib.quark_try_string('g-io-error-quark') + if error.matches(quark, Gio.IOErrorEnum.CANCELLED): + result = HTTPRequestError.CANCELLED + else: + result = HTTPRequestError.UNKNOWN + + GLib.idle_add(self._set_thread_result, result) + return + + if self._progress_callback is not None and self._method == 'GET': + GLib.idle_add(self._set_thread_progress, received_size) + + def _set_thread_progress(self, received_size: int) -> None: + self._received_size = received_size + self._progress_callback(self, self._get_response_progress()) + + def _set_thread_result(self, + error: Optional[HTTPRequestError] = None + ) -> None: + + 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 != Soup.Status.OK: + 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.notify('finished') + self._cleanup() + + def _set_complete(self) -> None: + self._is_finished = True + self._is_complete = True + self._close_all_streams() + self.notify('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() + + self._cancellable = None + self._session = None + self._progress_callback = None + self._input_stream = None + self._output_stream = None + self._file = None + + if self._timeout_id is not None: + GLib.source_remove(self._timeout_id) + + self.notify('destroyed') + self.remove_subscriptions() + + 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.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(f'{request.get_error()} {request.get_status()}') 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,23 @@ 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.subscribe('finished', self._async_finished) + + def _async_finished(self, + request: HTTPRequest, + _signal_name: str): + + 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,194 @@ +from time import time +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' +BIG_FILE_URL = 'https://gajim.org/downloads/ci/unittest_big_file' +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_in_memory_download_to_file(self): + + mainloop = GLib.MainLoop() + + session = HTTPSession() + request = session.create_request() + + with patch.object(request, 'notify', wraps=request.notify) as n_mock: + + progress_func = Mock() + request.set_progress_callback(progress_func) + request.set_response_body_path( + Path('/home/lovetox/Downloads/test123')) + request.subscribe('destroyed', lambda *args: mainloop.quit()) + request.get(SMALL_FILE_URL, timeout=10) + + mainloop.run() + + self.assertTrue(request.is_complete()) + self.assertTrue(request.is_finished()) + self.assertIsNone(request.get_error()) + + progress_func.assert_called() + n_mock.assert_has_calls([call('started'), + call('finished'), + call('destroyed')]) + + def test_in_memory_download(self): + + mainloop = GLib.MainLoop() + + session = HTTPSession() + request = session.create_request() + + with patch.object(request, 'notify', wraps=request.notify) as n_mock: + + progress_func = Mock() + request.set_progress_callback(progress_func) + request.subscribe('destroyed', lambda *args: mainloop.quit()) + request.get(SMALL_FILE_URL, timeout=10) + + mainloop.run() + + self.assertTrue(request.is_complete()) + self.assertTrue(request.is_finished()) + self.assertIsNone(request.get_error()) + + progress_func.assert_called() + n_mock.assert_has_calls([call('started'), + call('finished'), + call('destroyed')]) + + def test_download_cancelled_before_start(self): + + mainloop = GLib.MainLoop() + + session = HTTPSession() + request = session.create_request() + + with patch.object(request, 'notify', wraps=request.notify) as n_mock: + + progress_func = Mock() + request.set_progress_callback(progress_func) + request.subscribe('destroyed', lambda *args: mainloop.quit()) + request.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) + + progress_func.assert_not_called() + n_mock.assert_has_calls([call('finished'), + call('destroyed')]) + + def test_download_cancelled_after_start(self): + + mainloop = GLib.MainLoop() + + session = HTTPSession() + request = session.create_request() + + def _on_progress(req, progress): + req.cancel() + + with patch.object(request, 'notify', wraps=request.notify) as n_mock: + + request.set_progress_callback(_on_progress) + request.subscribe('destroyed', lambda *args: mainloop.quit()) + request.get(SMALL_FILE_URL, timeout=10) + + mainloop.run() + + self.assertFalse(request.is_complete()) + self.assertTrue(request.is_finished()) + self.assertEqual(request.get_error(), HTTPRequestError.CANCELLED) + + n_mock.assert_has_calls([call('started'), + call('finished'), + call('destroyed')]) + + def test_download_failed_404(self): + + mainloop = GLib.MainLoop() + + session = HTTPSession() + request = session.create_request() + + with patch.object(request, 'notify', wraps=request.notify) as n_mock: + + progress_func = Mock() + request.set_progress_callback(progress_func) + request.subscribe('destroyed', lambda *args: mainloop.quit()) + request.get(NO_FILE_URL, timeout=5) + + mainloop.run() + + progress_func.assert_not_called() + n_mock.assert_has_calls([call('finished'), + call('destroyed')]) + + 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) + + def test_cancel_with_timeout(self): + + mainloop = GLib.MainLoop() + + session = HTTPSession() + request = session.create_request() + + with patch.object(request, 'notify', wraps=request.notify) as n_mock: + + request.subscribe('destroyed', lambda *args: mainloop.quit()) + request.get(BIG_FILE_URL, timeout=1) + + mainloop.run() + + self.assertTrue(request.is_finished()) + self.assertFalse(request.is_complete()) + self.assertEqual(request.get_error(), HTTPRequestError.TIMEOUT) + + n_mock.assert_has_calls([call('started'), + call('finished'), + call('destroyed')]) + + + +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/-/compare/3b6cd299eb18cd6e8f6e95af...