Philipp Hörist pushed to branch soup3 at gajim / python-nbxmpp
Commits:
-
9805acef
by Philipp Hörist at 2022-12-12T19:19:12+01:00
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:
| ... | ... | @@ -9,7 +9,7 @@ test-pylint: |
| 9 | 9 | stage: test
|
| 10 | 10 | script:
|
| 11 | 11 | - python3 .ci/pylint-test.py
|
| 12 | - - coverage run --source=nbxmpp -m unittest discover -v
|
|
| 12 | + - NBXMPP_EXTERNAL_UNIT_TESTS=1 coverage run --source=nbxmpp -m unittest discover -v
|
|
| 13 | 13 | - coverage report -mi
|
| 14 | 14 | - coverage xml -i
|
| 15 | 15 | - pip install .
|
| ... | ... | @@ -7,7 +7,7 @@ |
| 7 | 7 | - python >= 3.9.0
|
| 8 | 8 | - PyGObject
|
| 9 | 9 | - GLib >= 2.60
|
| 10 | -- libsoup
|
|
| 10 | +- libsoup3
|
|
| 11 | 11 | - precis-i18n
|
| 12 | 12 | - packaging
|
| 13 | 13 | - idna
|
| ... | ... | @@ -6,7 +6,7 @@ Build-Depends: |
| 6 | 6 | debhelper-compat (= 13),
|
| 7 | 7 | dh-python,
|
| 8 | 8 | libglib2.0-0 (>= 2.60),
|
| 9 | - gir1.2-soup-2.4,
|
|
| 9 | + gir1.2-soup-3.0,
|
|
| 10 | 10 | python3-all,
|
| 11 | 11 | python3-gi,
|
| 12 | 12 | python3-idna,
|
| ... | ... | @@ -24,7 +24,7 @@ Architecture: all |
| 24 | 24 | Depends: ${misc:Depends},
|
| 25 | 25 | ${python3:Depends},
|
| 26 | 26 | libglib2.0-0 (>= 2.60),
|
| 27 | - gir1.2-soup-2.4,
|
|
| 27 | + gir1.2-soup-3.0,
|
|
| 28 | 28 | python3-gi,
|
| 29 | 29 | python3-idna,
|
| 30 | 30 | python3-packaging,
|
| ... | ... | @@ -2,6 +2,6 @@ import gi |
| 2 | 2 | |
| 3 | 3 | from .protocol import *
|
| 4 | 4 | |
| 5 | -gi.require_version('Soup', '2.4')
|
|
| 5 | +gi.require_version('Soup', '3.0')
|
|
| 6 | 6 | |
| 7 | 7 | __version__: str = '3.2.5' |
| ... | ... | @@ -18,9 +18,8 @@ |
| 18 | 18 | import logging
|
| 19 | 19 | from collections import namedtuple
|
| 20 | 20 | |
| 21 | -from gi.repository import Soup
|
|
| 22 | - |
|
| 23 | -import nbxmpp
|
|
| 21 | +from nbxmpp.http import HTTPRequest
|
|
| 22 | +from nbxmpp.http import HTTPSession
|
|
| 24 | 23 | from nbxmpp.util import Observable
|
| 25 | 24 | from nbxmpp.const import ConnectionType
|
| 26 | 25 | from nbxmpp.const import ConnectionProtocol
|
| ... | ... | @@ -63,6 +62,7 @@ class ServerAddresses(Observable): |
| 63 | 62 | Observable.__init__(self, log)
|
| 64 | 63 | |
| 65 | 64 | self._domain = domain
|
| 65 | + self._http_session = None
|
|
| 66 | 66 | self._custom_host = None
|
| 67 | 67 | self._proxy = None
|
| 68 | 68 | self._is_resolved = False
|
| ... | ... | @@ -136,32 +136,31 @@ class ServerAddresses(Observable): |
| 136 | 136 | self._resolve_alternatives()
|
| 137 | 137 | |
| 138 | 138 | def _resolve_alternatives(self) -> None:
|
| 139 | - session = Soup.Session()
|
|
| 140 | - session.props.timeout = 5
|
|
| 141 | - session.props.user_agent = f'nbxmpp/{nbxmpp.__version__}'
|
|
| 142 | - message = Soup.Message.new(
|
|
| 143 | - 'GET', f'https://{self._domain}/.well-known/host-meta')
|
|
| 144 | - session.queue_message(message, self._on_alternatives_result)
|
|
| 145 | - |
|
| 146 | - def _on_alternatives_result(self,
|
|
| 147 | - _session: Soup.Session,
|
|
| 148 | - message: Soup.Message) -> None:
|
|
| 149 | - |
|
| 150 | - status = message.props.status_code
|
|
| 151 | - if status != Soup.Status.OK:
|
|
| 152 | - error = message.props.reason_phrase
|
|
| 153 | - log.info('Failed to retrieve host-meta file: %s %s', status, error)
|
|
| 139 | + if self._http_session is None:
|
|
| 154 | 140 | self._on_request_resolved()
|
| 155 | 141 | return
|
| 156 | 142 | |
| 157 | - response_body = message.props.response_body
|
|
| 158 | - if response_body is None or not response_body.data:
|
|
| 143 | + request = self._http_session.create_request()
|
|
| 144 | + request.get(f'https://{self._domain}/.well-known/host-meta',
|
|
| 145 | + timeout=5,
|
|
| 146 | + callback=self._on_alternatives_result)
|
|
| 147 | + |
|
| 148 | + def _on_alternatives_result(self, request: HTTPRequest) -> None:
|
|
| 149 | + |
|
| 150 | + if not request.is_complete():
|
|
| 151 | + log.info('Failed to retrieve host-meta file: %s',
|
|
| 152 | + request.get_error_string())
|
|
| 153 | + self._on_request_resolved()
|
|
| 154 | + return
|
|
| 155 | + |
|
| 156 | + response_body = request.get_data()
|
|
| 157 | + if not response_body:
|
|
| 159 | 158 | log.info('No response body data found')
|
| 160 | 159 | self._on_request_resolved()
|
| 161 | 160 | return
|
| 162 | 161 | |
| 163 | 162 | try:
|
| 164 | - uri = parse_websocket_uri(response_body.data)
|
|
| 163 | + uri = parse_websocket_uri(response_body.decode())
|
|
| 165 | 164 | except Exception as error:
|
| 166 | 165 | log.info('Error parsing websocket uri: %s', error)
|
| 167 | 166 | self._on_request_resolved()
|
| ... | ... | @@ -215,6 +214,9 @@ class ServerAddresses(Observable): |
| 215 | 214 | def set_proxy(self, proxy):
|
| 216 | 215 | self._proxy = proxy
|
| 217 | 216 | |
| 217 | + def set_http_session(self, session: HTTPSession) -> None:
|
|
| 218 | + self._http_session = session
|
|
| 219 | + |
|
| 218 | 220 | def _on_request_resolved(self):
|
| 219 | 221 | self._is_resolved = True
|
| 220 | 222 | self.notify('resolved')
|
| ... | ... | @@ -22,6 +22,7 @@ from typing import Optional |
| 22 | 22 | from gi.repository import GLib
|
| 23 | 23 | |
| 24 | 24 | from nbxmpp.namespaces import Namespace
|
| 25 | +from nbxmpp.http import HTTPSession
|
|
| 25 | 26 | from nbxmpp.protocol import Features
|
| 26 | 27 | from nbxmpp.protocol import StanzaMalformed
|
| 27 | 28 | from nbxmpp.protocol import SessionRequest
|
| ... | ... | @@ -93,6 +94,7 @@ class Client(Observable): |
| 93 | 94 | self._client_cert = None
|
| 94 | 95 | self._client_cert_pass = None
|
| 95 | 96 | self._proxy = None
|
| 97 | + self._http_session = None
|
|
| 96 | 98 | |
| 97 | 99 | self._allowed_con_types = None
|
| 98 | 100 | self._allowed_protocols = None
|
| ... | ... | @@ -329,12 +331,18 @@ class Client(Observable): |
| 329 | 331 | |
| 330 | 332 | def set_proxy(self, proxy):
|
| 331 | 333 | self._proxy = proxy
|
| 332 | - self._dispatcher.get_module('Muclumbus').set_proxy(proxy)
|
|
| 333 | 334 | |
| 334 | 335 | @property
|
| 335 | 336 | def proxy(self):
|
| 336 | 337 | return self._proxy
|
| 337 | 338 | |
| 339 | + def set_http_session(self, session: HTTPSession) -> None:
|
|
| 340 | + self._http_session = session
|
|
| 341 | + |
|
| 342 | + @property
|
|
| 343 | + def http_session(self) -> Optional[HTTPSession]:
|
|
| 344 | + return self._http_session
|
|
| 345 | + |
|
| 338 | 346 | def get_bound_jid(self):
|
| 339 | 347 | return self._jid
|
| 340 | 348 | |
| ... | ... | @@ -402,6 +410,7 @@ class Client(Observable): |
| 402 | 410 | self.state = StreamState.RESOLVE
|
| 403 | 411 | |
| 404 | 412 | self._addresses = ServerAddresses(self._domain)
|
| 413 | + self._addresses.set_http_session(self._http_session)
|
|
| 405 | 414 | self._addresses.set_custom_host(self._custom_host)
|
| 406 | 415 | self._addresses.set_proxy(self._proxy)
|
| 407 | 416 | self._addresses.subscribe('resolved', self._on_addresses_resolved)
|
| ... | ... | @@ -453,6 +453,16 @@ class Mode(IntEnum): |
| 453 | 453 | return self == Mode.ANONYMOUS_TEST
|
| 454 | 454 | |
| 455 | 455 | |
| 456 | +class HTTPRequestError(IntEnum):
|
|
| 457 | + UNKNOWN = 0
|
|
| 458 | + MISSING_CONTENT_LENGTH = 1
|
|
| 459 | + INCOMPLETE = 2
|
|
| 460 | + STATUS_NOT_OK = 3
|
|
| 461 | + CANCELLED = 4
|
|
| 462 | + CONTENT_OVERFLOW = 5
|
|
| 463 | + TIMEOUT = 6
|
|
| 464 | + |
|
| 465 | + |
|
| 456 | 466 | MOODS = [
|
| 457 | 467 | 'afraid',
|
| 458 | 468 | 'amazed',
|
| ... | ... | @@ -6,6 +6,7 @@ import json |
| 6 | 6 | from pathlib import Path
|
| 7 | 7 | |
| 8 | 8 | import gi
|
| 9 | + |
|
| 9 | 10 | gi.require_version('Gtk', '3.0')
|
| 10 | 11 | gi.require_version('GLib', '2.0')
|
| 11 | 12 | from gi.repository import Gtk
|
| ... | ... | @@ -14,6 +15,7 @@ from gi.repository import GLib |
| 14 | 15 | import nbxmpp
|
| 15 | 16 | from nbxmpp.protocol import JID
|
| 16 | 17 | from nbxmpp.client import Client
|
| 18 | +from nbxmpp.http import HTTPSession
|
|
| 17 | 19 | from nbxmpp.structs import ProxyData
|
| 18 | 20 | from nbxmpp.structs import StanzaHandler
|
| 19 | 21 | from nbxmpp.const import ConnectionType
|
| ... | ... | @@ -83,6 +85,7 @@ class TestClient(Gtk.Window): |
| 83 | 85 | self._client.set_domain(self.address.domain)
|
| 84 | 86 | self._client.set_username(self.address.localpart)
|
| 85 | 87 | self._client.set_resource('test')
|
| 88 | + self._client.set_http_session(HTTPSession())
|
|
| 86 | 89 | |
| 87 | 90 | proxy_ip = self._builder.proxy_ip.get_text()
|
| 88 | 91 | if proxy_ip:
|
| 1 | +# This file is part of nbxmpp.
|
|
| 2 | +#
|
|
| 3 | +# This program is free software; you can redistribute it and/or
|
|
| 4 | +# modify it under the terms of the GNU General Public License
|
|
| 5 | +# as published by the Free Software Foundation; either version 3
|
|
| 6 | +# of the License, or (at your option) any later version.
|
|
| 7 | +#
|
|
| 8 | +# This program is distributed in the hope that it will be useful,
|
|
| 9 | +# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
| 10 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
| 11 | +# GNU General Public License for more details.
|
|
| 12 | +#
|
|
| 13 | +# You should have received a copy of the GNU General Public License
|
|
| 14 | +# along with this program; If not, see <http://www.gnu.org/licenses/>.
|
|
| 15 | + |
|
| 16 | +from __future__ import annotations
|
|
| 17 | + |
|
| 18 | +from pathlib import Path
|
|
| 19 | + |
|
| 20 | +from typing import Any
|
|
| 21 | +from typing import Literal
|
|
| 22 | +from typing import Callable
|
|
| 23 | +from typing import Optional
|
|
| 24 | + |
|
| 25 | +import logging
|
|
| 26 | +from threading import Thread
|
|
| 27 | + |
|
| 28 | +from gi.repository import Soup
|
|
| 29 | +from gi.repository import Gio
|
|
| 30 | +from gi.repository import GLib
|
|
| 31 | +from gi.repository import GObject
|
|
| 32 | + |
|
| 33 | +import nbxmpp
|
|
| 34 | +from nbxmpp.const import HTTPRequestError
|
|
| 35 | + |
|
| 36 | + |
|
| 37 | +log = logging.getLogger('nbxmpp.http')
|
|
| 38 | + |
|
| 39 | + |
|
| 40 | +HTTP_METHODS_T = Literal['GET', 'POST']
|
|
| 41 | +CHUNK_SIZE = 32768
|
|
| 42 | +DEFAULT_USER_AGENT = f'nbxmpp/{nbxmpp.__version__}'
|
|
| 43 | + |
|
| 44 | + |
|
| 45 | +SIGNAL_ACTIONS = GObject.SignalFlags.RUN_LAST | GObject.SignalFlags.ACTION
|
|
| 46 | + |
|
| 47 | + |
|
| 48 | +class HTTPLogAdapter(logging.LoggerAdapter):
|
|
| 49 | + def process(self, msg: str, kwargs: Any) -> tuple[str, Any]:
|
|
| 50 | + return f'{self.extra["request"]}: {msg}', kwargs
|
|
| 51 | + |
|
| 52 | + |
|
| 53 | +class HTTPSession:
|
|
| 54 | + def __init__(self, user_agent: str = DEFAULT_USER_AGENT) -> None:
|
|
| 55 | + self._session = Soup.Session()
|
|
| 56 | + self._session.set_user_agent(user_agent)
|
|
| 57 | + |
|
| 58 | + self._requests: list[HTTPRequest] = []
|
|
| 59 | + |
|
| 60 | + def get_soup_session(self) -> Soup.Session:
|
|
| 61 | + return self._session
|
|
| 62 | + |
|
| 63 | + def set_proxy_resolver(self,
|
|
| 64 | + resolver: Optional[Gio.SimpleProxyResolver]
|
|
| 65 | + ) -> None:
|
|
| 66 | + |
|
| 67 | + self._session.set_proxy_resolver(resolver)
|
|
| 68 | + |
|
| 69 | + def create_request(self) -> HTTPRequest:
|
|
| 70 | + request = HTTPRequest(self)
|
|
| 71 | + request.connect('destroy', self._on_request_destroyed)
|
|
| 72 | + self._requests.append(request)
|
|
| 73 | + return request
|
|
| 74 | + |
|
| 75 | + def _on_request_destroyed(self, request: HTTPRequest) -> None:
|
|
| 76 | + self._requests.remove(request)
|
|
| 77 | + |
|
| 78 | + |
|
| 79 | +class HTTPRequest(GObject.GObject):
|
|
| 80 | + |
|
| 81 | + __gtype_name__ = "HTTPRequest"
|
|
| 82 | + |
|
| 83 | + __gsignals__ = {
|
|
| 84 | + 'starting-response-body': (SIGNAL_ACTIONS, None, ()),
|
|
| 85 | + 'response-progress': (SIGNAL_ACTIONS, None, (float,)),
|
|
| 86 | + 'request-progress': (SIGNAL_ACTIONS, None, (float,)),
|
|
| 87 | + 'finished': (SIGNAL_ACTIONS, None, ()),
|
|
| 88 | + 'destroy': (SIGNAL_ACTIONS, None, ()),
|
|
| 89 | + }
|
|
| 90 | + |
|
| 91 | + def __init__(self, session: HTTPSession) -> None:
|
|
| 92 | + GObject.GObject.__init__(self)
|
|
| 93 | + |
|
| 94 | + self._log = HTTPLogAdapter(log, extra={'request': self})
|
|
| 95 | + |
|
| 96 | + self._session = session
|
|
| 97 | + |
|
| 98 | + self._received_size = 0
|
|
| 99 | + self._sent_size = 0
|
|
| 100 | + |
|
| 101 | + self._cancellable = Gio.Cancellable()
|
|
| 102 | + self._input_stream: Optional[Gio.InputStream] = None
|
|
| 103 | + self._output_stream: Optional[Gio.OutputStream] = None
|
|
| 104 | + self._is_finished = False
|
|
| 105 | + self._error: Optional[HTTPRequestError] = None
|
|
| 106 | + self._is_complete = False
|
|
| 107 | + self._timeout_reached = False
|
|
| 108 | + self._timeout_id = None
|
|
| 109 | + |
|
| 110 | + self._response_body_file: Optional[Gio.File] = None
|
|
| 111 | + self._response_body_data = b''
|
|
| 112 | + |
|
| 113 | + self._request_body_file: Optional[Gio.File] = None
|
|
| 114 | + self._request_body_data: Optional[bytes] = None
|
|
| 115 | + |
|
| 116 | + self._request_content_type = ''
|
|
| 117 | + self._request_content_length = 0
|
|
| 118 | + |
|
| 119 | + self._response_content_type = ''
|
|
| 120 | + self._response_content_length = 0
|
|
| 121 | + |
|
| 122 | + self._emit_request_progress = False
|
|
| 123 | + self._emit_response_progress = False
|
|
| 124 | + |
|
| 125 | + self._message = Soup.Message()
|
|
| 126 | + self._user_data = None
|
|
| 127 | + |
|
| 128 | + self._log.info('Created')
|
|
| 129 | + |
|
| 130 | + def is_finished(self) -> bool:
|
|
| 131 | + return self._is_finished
|
|
| 132 | + |
|
| 133 | + def is_complete(self) -> bool:
|
|
| 134 | + return self._is_complete
|
|
| 135 | + |
|
| 136 | + def get_error(self) -> Optional[HTTPRequestError]:
|
|
| 137 | + return self._error
|
|
| 138 | + |
|
| 139 | + def get_error_string(self) -> str:
|
|
| 140 | + if self._error == HTTPRequestError.STATUS_NOT_OK:
|
|
| 141 | + return self._message.get_reason_phrase()
|
|
| 142 | + return f'{self._error}'
|
|
| 143 | + |
|
| 144 | + def get_response_headers(self) -> int:
|
|
| 145 | + return self._message.get_response_headers()
|
|
| 146 | + |
|
| 147 | + def get_request_headers(self) -> int:
|
|
| 148 | + return self._message.get_request_headers()
|
|
| 149 | + |
|
| 150 | + def get_data(self) -> bytes:
|
|
| 151 | + if not self._is_finished:
|
|
| 152 | + raise ValueError('Process not finished, data not available')
|
|
| 153 | + return self._response_body_data
|
|
| 154 | + |
|
| 155 | + def get_uri(self) -> Optional[GLib.Uri]:
|
|
| 156 | + return self._message.get_uri()
|
|
| 157 | + |
|
| 158 | + def get_status(self) -> Soup.Status:
|
|
| 159 | + return self._message.get_status()
|
|
| 160 | + |
|
| 161 | + def get_soup_message(self) -> Soup.Message:
|
|
| 162 | + return self._message
|
|
| 163 | + |
|
| 164 | + def set_user_data(self, user_data: Any) -> None:
|
|
| 165 | + self._user_data = user_data
|
|
| 166 | + |
|
| 167 | + def get_user_data(self) -> Any:
|
|
| 168 | + return self._user_data
|
|
| 169 | + |
|
| 170 | + def cancel(self) -> None:
|
|
| 171 | + if self._is_finished:
|
|
| 172 | + raise ValueError('Session already finished')
|
|
| 173 | + |
|
| 174 | + self._cancellable.cancel()
|
|
| 175 | + |
|
| 176 | + def set_request_body_from_path(self, content_type: str, path: Path) -> None:
|
|
| 177 | + if not path.exists():
|
|
| 178 | + raise ValueError('%s does not exist' % path)
|
|
| 179 | + |
|
| 180 | + if not path.is_file():
|
|
| 181 | + raise ValueError('%s is not a file' % path)
|
|
| 182 | + |
|
| 183 | + self._request_body_file = Gio.File.new_for_path(str(path))
|
|
| 184 | + self._request_content_type = content_type
|
|
| 185 | + self._request_content_length = path.stat().st_size
|
|
| 186 | + |
|
| 187 | + def set_request_body(self, content_type: str, data: bytes) -> None:
|
|
| 188 | + self._request_body_data = data
|
|
| 189 | + self._request_content_type = content_type
|
|
| 190 | + self._request_content_length = len(data)
|
|
| 191 | + |
|
| 192 | + def set_response_body_path(self, path: Path) -> None:
|
|
| 193 | + self._response_body_file = Gio.File.new_for_path(str(path))
|
|
| 194 | + |
|
| 195 | + def connect(self,
|
|
| 196 | + signal_name: str,
|
|
| 197 | + callback: Any,
|
|
| 198 | + *args: Any
|
|
| 199 | + ) -> None:
|
|
| 200 | + |
|
| 201 | + if signal_name == 'response-progress':
|
|
| 202 | + self._emit_response_progress = True
|
|
| 203 | +
|
|
| 204 | + if signal_name == 'request-progress':
|
|
| 205 | + self._emit_request_progress = True
|
|
| 206 | + |
|
| 207 | + if signal_name in GObject.signal_list_names(HTTPRequest):
|
|
| 208 | + GObject.GObject.connect(self, signal_name, callback, *args)
|
|
| 209 | + return
|
|
| 210 | + |
|
| 211 | + user_data = (callback, args)
|
|
| 212 | + |
|
| 213 | + self._message.connect(signal_name,
|
|
| 214 | + self._on_connect_callback,
|
|
| 215 | + user_data)
|
|
| 216 | + |
|
| 217 | + def _on_connect_callback(self, message: Soup.Message, *args: Any) -> None:
|
|
| 218 | + signal_args = args[:-1]
|
|
| 219 | + callback, user_data = args[-1]
|
|
| 220 | + callback(self, *signal_args, *user_data)
|
|
| 221 | + |
|
| 222 | + def post(self,
|
|
| 223 | + uri_string: str,
|
|
| 224 | + timeout: Optional[int] = None,
|
|
| 225 | + callback: Optional[Callable[[HTTPRequest], Any]] = None,
|
|
| 226 | + ) -> None:
|
|
| 227 | + |
|
| 228 | + if callback is not None:
|
|
| 229 | + self.connect('finished', callback)
|
|
| 230 | + self._send('POST', uri_string, timeout=timeout)
|
|
| 231 | + |
|
| 232 | + def get(self,
|
|
| 233 | + uri_string: str,
|
|
| 234 | + timeout: Optional[int] = None,
|
|
| 235 | + callback: Optional[Callable[[HTTPRequest], Any]] = None,
|
|
| 236 | + ) -> None:
|
|
| 237 | + |
|
| 238 | + if callback is not None:
|
|
| 239 | + self.connect('finished', callback)
|
|
| 240 | + self._send('GET', uri_string, timeout=timeout)
|
|
| 241 | + |
|
| 242 | + def _send(self,
|
|
| 243 | + method: HTTP_METHODS_T,
|
|
| 244 | + uri_string: str,
|
|
| 245 | + timeout: Optional[int] = None
|
|
| 246 | + ) -> None:
|
|
| 247 | + |
|
| 248 | + if self._is_finished:
|
|
| 249 | + raise ValueError('Session already finished')
|
|
| 250 | + |
|
| 251 | + self._message.set_method(method)
|
|
| 252 | + |
|
| 253 | + if self._response_body_file is not None:
|
|
| 254 | + self._output_stream = self._response_body_file.replace(
|
|
| 255 | + None,
|
|
| 256 | + False,
|
|
| 257 | + Gio.FileCreateFlags.REPLACE_DESTINATION,
|
|
| 258 | + self._cancellable)
|
|
| 259 | + |
|
| 260 | + uri = GLib.Uri.parse(uri_string, GLib.UriFlags.HAS_PASSWORD)
|
|
| 261 | + self._message.set_uri(uri)
|
|
| 262 | + |
|
| 263 | + if self._request_body_data is not None:
|
|
| 264 | + self._message.set_request_body_from_bytes(
|
|
| 265 | + self._request_content_type,
|
|
| 266 | + GLib.Bytes.new(self._request_body_data)
|
|
| 267 | + )
|
|
| 268 | + if self._emit_request_progress:
|
|
| 269 | + self._message.connect('wrote-body-data',
|
|
| 270 | + self._on_request_body_progress)
|
|
| 271 | + |
|
| 272 | + if self._request_body_file is not None:
|
|
| 273 | + request_input_stream = self._request_body_file.read(
|
|
| 274 | + self._cancellable)
|
|
| 275 | + self._message.set_request_body(self._request_content_type,
|
|
| 276 | + request_input_stream,
|
|
| 277 | + self._request_content_length)
|
|
| 278 | + if self._emit_request_progress:
|
|
| 279 | + self._message.connect('wrote-body-data',
|
|
| 280 | + self._on_request_body_progress)
|
|
| 281 | + |
|
| 282 | + self._message.connect('finished', self._on_finished)
|
|
| 283 | + |
|
| 284 | + soup_session = self._session.get_soup_session()
|
|
| 285 | + soup_session.send_async(self._message,
|
|
| 286 | + GLib.PRIORITY_DEFAULT,
|
|
| 287 | + self._cancellable,
|
|
| 288 | + self._on_response)
|
|
| 289 | + |
|
| 290 | + if timeout is not None:
|
|
| 291 | + self._timeout_id = GLib.timeout_add_seconds(
|
|
| 292 | + timeout, self._on_timeout)
|
|
| 293 | + |
|
| 294 | + self._log.info('Request sent')
|
|
| 295 | + |
|
| 296 | + def _on_request_body_progress(self,
|
|
| 297 | + _message: Soup.Message,
|
|
| 298 | + chunk: int) -> None:
|
|
| 299 | + self._sent_size =+ chunk
|
|
| 300 | + self.emit('request-progress',
|
|
| 301 | + self._sent_size / self._request_content_length)
|
|
| 302 | + |
|
| 303 | + def _on_timeout(self) -> None:
|
|
| 304 | + self._timeout_reached = True
|
|
| 305 | + self.cancel()
|
|
| 306 | + |
|
| 307 | + def _on_response(self,
|
|
| 308 | + session: Soup.Session,
|
|
| 309 | + result: Gio.AsyncResult
|
|
| 310 | + ) -> None:
|
|
| 311 | + |
|
| 312 | + self._log.info('Request response received')
|
|
| 313 | + try:
|
|
| 314 | + self._input_stream = session.send_finish(result)
|
|
| 315 | + except Exception as error:
|
|
| 316 | + self._log.error(error)
|
|
| 317 | + quark = GLib.quark_try_string('g-io-error-quark')
|
|
| 318 | + if error.matches(quark, Gio.IOErrorEnum.CANCELLED):
|
|
| 319 | + self._set_failed(HTTPRequestError.CANCELLED)
|
|
| 320 | + else:
|
|
| 321 | + self._set_failed(HTTPRequestError.UNKNOWN)
|
|
| 322 | + return
|
|
| 323 | + |
|
| 324 | + if self._message.get_status() != Soup.Status.OK:
|
|
| 325 | + self._set_failed(HTTPRequestError.STATUS_NOT_OK)
|
|
| 326 | + return
|
|
| 327 | + |
|
| 328 | + headers = self.get_response_headers()
|
|
| 329 | + self._response_content_length = headers.get_content_length()
|
|
| 330 | + self._response_content_type = headers.get_content_type()
|
|
| 331 | + if self._response_content_length == 0:
|
|
| 332 | + self._set_failed(HTTPRequestError.MISSING_CONTENT_LENGTH)
|
|
| 333 | + return
|
|
| 334 | + |
|
| 335 | + self.emit('starting-response-body')
|
|
| 336 | + |
|
| 337 | + thread = Thread(target=self._process_response_thread,
|
|
| 338 | + args=(self._response_content_length,))
|
|
| 339 | + thread.start()
|
|
| 340 | + |
|
| 341 | + def _process_response_thread(self, content_length: int) -> None:
|
|
| 342 | + received_size = 0
|
|
| 343 | + |
|
| 344 | + while True:
|
|
| 345 | + try:
|
|
| 346 | + data = self._input_stream.read_bytes(CHUNK_SIZE,
|
|
| 347 | + self._cancellable)
|
|
| 348 | + except GLib.Error as error:
|
|
| 349 | + self._log.error(error)
|
|
| 350 | + quark = GLib.quark_try_string('g-io-error-quark')
|
|
| 351 | + if error.matches(quark, Gio.IOErrorEnum.CANCELLED):
|
|
| 352 | + result = HTTPRequestError.CANCELLED
|
|
| 353 | + else:
|
|
| 354 | + result = HTTPRequestError.UNKNOWN
|
|
| 355 | + |
|
| 356 | + GLib.idle_add(self._set_thread_result, result)
|
|
| 357 | + return
|
|
| 358 | + |
|
| 359 | + bytes_ = data.get_data()
|
|
| 360 | + if not bytes_:
|
|
| 361 | + self._received_size = received_size
|
|
| 362 | + GLib.idle_add(self._set_thread_result)
|
|
| 363 | + return
|
|
| 364 | + |
|
| 365 | + length = len(bytes_)
|
|
| 366 | + received_size += length
|
|
| 367 | + if received_size > content_length:
|
|
| 368 | + GLib.idle_add(self._set_thread_result,
|
|
| 369 | + HTTPRequestError.CONTENT_OVERFLOW)
|
|
| 370 | + return
|
|
| 371 | + |
|
| 372 | + if self._output_stream is None:
|
|
| 373 | + self._response_body_data += bytes_
|
|
| 374 | + |
|
| 375 | + else:
|
|
| 376 | + |
|
| 377 | + try:
|
|
| 378 | + self._output_stream.write_all(bytes_, self._cancellable)
|
|
| 379 | + except Exception as error:
|
|
| 380 | + self._log.error(error)
|
|
| 381 | + quark = GLib.quark_try_string('g-io-error-quark')
|
|
| 382 | + if error.matches(quark, Gio.IOErrorEnum.CANCELLED):
|
|
| 383 | + result = HTTPRequestError.CANCELLED
|
|
| 384 | + else:
|
|
| 385 | + result = HTTPRequestError.UNKNOWN
|
|
| 386 | + |
|
| 387 | + GLib.idle_add(self._set_thread_result, result)
|
|
| 388 | + return
|
|
| 389 | + |
|
| 390 | + if (self._emit_response_progress and
|
|
| 391 | + self._message.get_method() == 'GET'):
|
|
| 392 | + self._received_size = received_size
|
|
| 393 | + GLib.idle_add(self.emit,
|
|
| 394 | + 'response-progress',
|
|
| 395 | + received_size / content_length)
|
|
| 396 | + |
|
| 397 | + def _set_thread_result(self,
|
|
| 398 | + error: Optional[HTTPRequestError] = None
|
|
| 399 | + ) -> None:
|
|
| 400 | + |
|
| 401 | + if error is None:
|
|
| 402 | + self._close_all_streams()
|
|
| 403 | + return
|
|
| 404 | + |
|
| 405 | + self._set_failed(error)
|
|
| 406 | + |
|
| 407 | + def _on_finished(self, _message: Soup.Message) -> None:
|
|
| 408 | + self._set_finished()
|
|
| 409 | + |
|
| 410 | + def _set_finished(self) -> None:
|
|
| 411 | + status = self._message.get_status()
|
|
| 412 | + if status == Soup.Status.NONE:
|
|
| 413 | + # Message has not been sent, can happen when we cancel the message
|
|
| 414 | + # before it is sent. The finished signal triggers before the
|
|
| 415 | + # cancelled exception.
|
|
| 416 | + return
|
|
| 417 | + |
|
| 418 | + if self._is_finished:
|
|
| 419 | + return
|
|
| 420 | + |
|
| 421 | + headers = self._message.get_response_headers()
|
|
| 422 | + content_length = headers.get_content_length()
|
|
| 423 | + if self._received_size != content_length:
|
|
| 424 | + self._set_failed(HTTPRequestError.INCOMPLETE)
|
|
| 425 | + return
|
|
| 426 | + |
|
| 427 | + if status != Soup.Status.OK:
|
|
| 428 | + self._set_failed(HTTPRequestError.STATUS_NOT_OK)
|
|
| 429 | + return
|
|
| 430 | + |
|
| 431 | + self._set_complete()
|
|
| 432 | + |
|
| 433 | + def _set_failed(self, error: HTTPRequestError) -> None:
|
|
| 434 | + self._is_finished = True
|
|
| 435 | + if self._timeout_reached:
|
|
| 436 | + self._timeout_id = None
|
|
| 437 | + self._error = HTTPRequestError.TIMEOUT
|
|
| 438 | + else:
|
|
| 439 | + self._error = error
|
|
| 440 | + self._close_all_streams()
|
|
| 441 | + self.emit('finished')
|
|
| 442 | + self._cleanup()
|
|
| 443 | + |
|
| 444 | + def _set_complete(self) -> None:
|
|
| 445 | + self._is_finished = True
|
|
| 446 | + self._is_complete = True
|
|
| 447 | + self._close_all_streams()
|
|
| 448 | + self.emit('finished')
|
|
| 449 | + self._cleanup()
|
|
| 450 | + |
|
| 451 | + def _close_all_streams(self) -> None:
|
|
| 452 | + if self._input_stream is not None:
|
|
| 453 | + if not self._input_stream.is_closed():
|
|
| 454 | + self._input_stream.close(None)
|
|
| 455 | + |
|
| 456 | + if self._output_stream is not None:
|
|
| 457 | + if not self._output_stream.is_closed():
|
|
| 458 | + self._output_stream.close(None)
|
|
| 459 | + |
|
| 460 | + def _cleanup(self) -> None:
|
|
| 461 | + self._log.info('Run cleanup')
|
|
| 462 | + |
|
| 463 | + self._response_body_data = b''
|
|
| 464 | + self._message.run_dispose()
|
|
| 465 | + |
|
| 466 | + self._cancellable = None
|
|
| 467 | + self._session = None
|
|
| 468 | + self._input_stream = None
|
|
| 469 | + self._output_stream = None
|
|
| 470 | + self._user_data = None
|
|
| 471 | + |
|
| 472 | + if self._timeout_id is not None:
|
|
| 473 | + GLib.source_remove(self._timeout_id)
|
|
| 474 | + |
|
| 475 | + self.emit('destroy')
|
|
| 476 | + self.run_dispose()
|
|
| 477 | + |
|
| 478 | + def __repr__(self) -> str:
|
|
| 479 | + return f'Request({id(self)})' |
| ... | ... | @@ -17,8 +17,6 @@ |
| 17 | 17 | |
| 18 | 18 | import json
|
| 19 | 19 | |
| 20 | -from gi.repository import Soup
|
|
| 21 | - |
|
| 22 | 20 | from nbxmpp.namespaces import Namespace
|
| 23 | 21 | from nbxmpp.protocol import Node
|
| 24 | 22 | from nbxmpp.protocol import Iq
|
| ... | ... | @@ -44,15 +42,6 @@ class Muclumbus(BaseModule): |
| 44 | 42 | self._client = client
|
| 45 | 43 | self.handlers = []
|
| 46 | 44 | |
| 47 | - self._proxy_resolver = None
|
|
| 48 | - self._soup_session = Soup.Session()
|
|
| 49 | - |
|
| 50 | - def set_proxy(self, proxy):
|
|
| 51 | - if proxy is None:
|
|
| 52 | - return
|
|
| 53 | - self._proxy_resolver = proxy.get_resolver()
|
|
| 54 | - self._soup_session.props.proxy_resolver = self._proxy_resolver
|
|
| 55 | - |
|
| 56 | 45 | @iq_request_task
|
| 57 | 46 | def request_parameters(self, jid):
|
| 58 | 47 | task = yield
|
| ... | ... | @@ -141,24 +130,25 @@ class Muclumbus(BaseModule): |
| 141 | 130 | if after is not None:
|
| 142 | 131 | search['after'] = after
|
| 143 | 132 | |
| 144 | - message = Soup.Message.new('POST', uri)
|
|
| 145 | - message.set_request('application/json',
|
|
| 146 | - Soup.MemoryUse.COPY,
|
|
| 147 | - json.dumps(search).encode())
|
|
| 133 | + body = json.dumps(search).encode()
|
|
| 148 | 134 | |
| 149 | - response_message = yield message
|
|
| 135 | + session = self._client.http_session
|
|
| 136 | + request = session.create_request()
|
|
| 137 | + request.set_request_body('application/json', body)
|
|
| 138 | + request.post(uri)
|
|
| 150 | 139 | |
| 151 | - soup_body = response_message.get_property('response-body')
|
|
| 140 | + request = yield request
|
|
| 152 | 141 | |
| 153 | - if response_message.status_code != 200:
|
|
| 154 | - self._log.warning(soup_body.data)
|
|
| 142 | + if not request.is_complete():
|
|
| 143 | + self._log.warning(request.get_error_string())
|
|
| 155 | 144 | yield MuclumbusResult(first=None,
|
| 156 | 145 | last=None,
|
| 157 | 146 | max=None,
|
| 158 | 147 | end=True,
|
| 159 | 148 | items=[])
|
| 160 | 149 | |
| 161 | - response = json.loads(soup_body.data)
|
|
| 150 | + response_body = request.get_data()
|
|
| 151 | + response = json.loads(response_body)
|
|
| 162 | 152 | |
| 163 | 153 | result = response['result']
|
| 164 | 154 | items = result.get('items')
|
| ... | ... | @@ -30,8 +30,8 @@ from dataclasses import dataclass |
| 30 | 30 | from dataclasses import field
|
| 31 | 31 | from datetime import datetime
|
| 32 | 32 | |
| 33 | -from gi.repository import Soup
|
|
| 34 | 33 | from gi.repository import Gio
|
| 34 | +from gi.repository import GLib
|
|
| 35 | 35 | |
| 36 | 36 | from nbxmpp.simplexml import Node
|
| 37 | 37 | from nbxmpp.namespaces import Namespace
|
| ... | ... | @@ -708,8 +708,9 @@ class ProxyData(NamedTuple): |
| 708 | 708 | |
| 709 | 709 | def get_uri(self) -> str:
|
| 710 | 710 | if self.username is not None:
|
| 711 | - user_pass = Soup.uri_encode('%s:%s' % (self.username,
|
|
| 712 | - self.password))
|
|
| 711 | + username = GLib.uri_escape_string(self.username, None, False)
|
|
| 712 | + password = GLib.uri_escape_string(self.password, None, False)
|
|
| 713 | + user_pass = f'{username}:{password}'
|
|
| 713 | 714 | return '%s://%s@%s' % (self.type,
|
| 714 | 715 | user_pass,
|
| 715 | 716 | self.host)
|
| ... | ... | @@ -27,14 +27,14 @@ import logging |
| 27 | 27 | from enum import IntEnum
|
| 28 | 28 | from functools import wraps
|
| 29 | 29 | |
| 30 | -from gi.repository import Soup
|
|
| 31 | 30 | from gi.repository import GLib
|
| 32 | -from nbxmpp.modules.base import BaseModule
|
|
| 33 | 31 | |
| 34 | 32 | from nbxmpp.simplexml import Node
|
| 35 | 33 | from nbxmpp.errors import is_error
|
| 36 | 34 | from nbxmpp.errors import CancelledError
|
| 37 | 35 | from nbxmpp.errors import TimeoutStanzaError
|
| 36 | +from nbxmpp.http import HTTPRequest
|
|
| 37 | +from nbxmpp.modules.base import BaseModule
|
|
| 38 | 38 | from nbxmpp.modules.util import make_func_arguments_string
|
| 39 | 39 | |
| 40 | 40 | |
| ... | ... | @@ -109,8 +109,7 @@ def http_request_task(func): |
| 109 | 109 | @wraps(func)
|
| 110 | 110 | def func_wrapper(self, *args, callback=None, user_data=None, **kwargs):
|
| 111 | 111 | task = HTTPRequestTask(func(self, *args, **kwargs),
|
| 112 | - self._log,
|
|
| 113 | - self._soup_session)
|
|
| 112 | + self._log)
|
|
| 114 | 113 | return _setup_task(task, self._client, callback, user_data)
|
| 115 | 114 | return func_wrapper
|
| 116 | 115 | |
| ... | ... | @@ -251,7 +250,7 @@ class Task: |
| 251 | 250 | |
| 252 | 251 | def _invoke_callbacks(self):
|
| 253 | 252 | for callback in self._done_callbacks:
|
| 254 | - if isinstance(callback, weakref.WeakMethod):
|
|
| 253 | + if isinstance(callback, (weakref.WeakMethod, weakref.ref)):
|
|
| 255 | 254 | callback = callback()
|
| 256 | 255 | if callback is None:
|
| 257 | 256 | return
|
| ... | ... | @@ -377,19 +376,21 @@ class HTTPRequestTask(Task): |
| 377 | 376 | |
| 378 | 377 | '''
|
| 379 | 378 | |
| 380 | - _process_types = (Soup.Message,)
|
|
| 379 | + _process_types = (HTTPRequest,)
|
|
| 381 | 380 | |
| 382 | - def __init__(self, gen, logger, session):
|
|
| 381 | + def __init__(self, gen, logger):
|
|
| 383 | 382 | super().__init__(gen, logger)
|
| 384 | - self._session = session
|
|
| 385 | 383 | |
| 386 | - def _run_async(self, message):
|
|
| 384 | + def _run_async(self, request: HTTPRequest):
|
|
| 387 | 385 | # pylint: disable=arguments-renamed
|
| 388 | - self._session.queue_message(message, self._async_finished, None)
|
|
| 386 | + request.connect('finished', self._async_finished)
|
|
| 387 | + |
|
| 388 | + def _async_finished(self, request: HTTPRequest):
|
|
| 389 | + |
|
| 390 | + if self._state == TaskState.CANCELLED:
|
|
| 391 | + return
|
|
| 389 | 392 | |
| 390 | - def _async_finished(self, _session, message, _user_data):
|
|
| 391 | - if self._state != TaskState.CANCELLED:
|
|
| 392 | - self._next_step(message)
|
|
| 393 | + self._next_step(request)
|
|
| 393 | 394 | |
| 394 | 395 | def _finalize(self):
|
| 395 | 396 | self._session = None
|
| ... | ... | @@ -22,7 +22,6 @@ from gi.repository import GLib |
| 22 | 22 | from gi.repository import Gio
|
| 23 | 23 | |
| 24 | 24 | from nbxmpp.const import TCPState
|
| 25 | -from nbxmpp.const import ConnectionType
|
|
| 26 | 25 | from nbxmpp.util import get_websocket_close_string
|
| 27 | 26 | from nbxmpp.util import convert_tls_error_flags
|
| 28 | 27 | from nbxmpp.connection import Connection
|
| ... | ... | @@ -35,11 +34,10 @@ class WebsocketConnection(Connection): |
| 35 | 34 | Connection.__init__(self, *args, **kwargs)
|
| 36 | 35 | |
| 37 | 36 | self._session = Soup.Session()
|
| 38 | - self._session.props.ssl_strict = False
|
|
| 39 | 37 | |
| 40 | 38 | if self._log.getEffectiveLevel() == logging.INFO:
|
| 41 | 39 | self._session.add_feature(
|
| 42 | - Soup.Logger.new(Soup.LoggerLogLevel.BODY, -1))
|
|
| 40 | + Soup.Logger.new(Soup.LoggerLogLevel.BODY))
|
|
| 43 | 41 | |
| 44 | 42 | self._websocket = None
|
| 45 | 43 | self._cancellable = Gio.Cancellable()
|
| ... | ... | @@ -53,11 +51,12 @@ class WebsocketConnection(Connection): |
| 53 | 51 | self.state = TCPState.CONNECTING
|
| 54 | 52 | |
| 55 | 53 | message = Soup.Message.new('GET', self._address.uri)
|
| 56 | - message.connect('starting', self._check_certificate)
|
|
| 54 | + message.connect('accept-certificate', self._check_certificate)
|
|
| 57 | 55 | message.set_flags(Soup.MessageFlags.NO_REDIRECT)
|
| 58 | 56 | self._session.websocket_connect_async(message,
|
| 59 | 57 | None,
|
| 60 | 58 | ['xmpp'],
|
| 59 | + GLib.PRIORITY_DEFAULT,
|
|
| 61 | 60 | self._cancellable,
|
| 62 | 61 | self._on_connect,
|
| 63 | 62 | None)
|
| ... | ... | @@ -91,19 +90,20 @@ class WebsocketConnection(Connection): |
| 91 | 90 | # Soup.Session does this automatically
|
| 92 | 91 | raise NotImplementedError
|
| 93 | 92 | |
| 94 | - def _check_certificate(self, message):
|
|
| 95 | - https_used, certificate, errors = message.get_https_status()
|
|
| 96 | - if not https_used and self._address.type == ConnectionType.PLAIN:
|
|
| 97 | - return
|
|
| 93 | + def _check_certificate(self,
|
|
| 94 | + _message,
|
|
| 95 | + certificate,
|
|
| 96 | + errors):
|
|
| 98 | 97 | |
| 99 | 98 | self._peer_certificate = certificate
|
| 100 | 99 | self._peer_certificate_errors = convert_tls_error_flags(errors)
|
| 101 | 100 | |
| 102 | 101 | if self._accept_certificate():
|
| 103 | - return
|
|
| 102 | + return True
|
|
| 104 | 103 | |
| 105 | 104 | self.notify('bad-certificate')
|
| 106 | 105 | self._cancellable.cancel()
|
| 106 | + return False
|
|
| 107 | 107 | |
| 108 | 108 | def _on_websocket_message(self, _websocket, _type, message):
|
| 109 | 109 | data = message.get_data().decode()
|
| 1 | +from time import time
|
|
| 2 | +import unittest
|
|
| 3 | +from unittest.mock import call
|
|
| 4 | +from unittest.mock import Mock
|
|
| 5 | +from unittest.mock import patch
|
|
| 6 | +import os
|
|
| 7 | +from pathlib import Path
|
|
| 8 | + |
|
| 9 | +from gi.repository import GLib
|
|
| 10 | +from gi.repository import Soup
|
|
| 11 | + |
|
| 12 | +from nbxmpp.const import HTTPRequestError
|
|
| 13 | +from nbxmpp.http import HTTPSession
|
|
| 14 | + |
|
| 15 | + |
|
| 16 | +SMALL_FILE_URL = 'https://gajim.org/downloads/ci/unittest_small_file'
|
|
| 17 | +BIG_FILE_URL = 'https://gajim.org/downloads/ci/unittest_big_file'
|
|
| 18 | +NO_FILE_URL = 'https://gajim.org/downloads/ci/no-file'
|
|
| 19 | + |
|
| 20 | + |
|
| 21 | +# import logging
|
|
| 22 | +# consoleloghandler = logging.StreamHandler()
|
|
| 23 | +# log = logging.getLogger('nbxmpp.http')
|
|
| 24 | +# log.setLevel(logging.DEBUG)
|
|
| 25 | +# log.addHandler(consoleloghandler)
|
|
| 26 | + |
|
| 27 | + |
|
| 28 | +@unittest.skipUnless(os.environ.get('NBXMPP_EXTERNAL_UNIT_TESTS'),
|
|
| 29 | + 'ENV var for external tests not set')
|
|
| 30 | +class HTTP(unittest.TestCase):
|
|
| 31 | + |
|
| 32 | + @classmethod
|
|
| 33 | + def setUpClass(cls):
|
|
| 34 | + pass
|
|
| 35 | + |
|
| 36 | + @classmethod
|
|
| 37 | + def tearDownClass(cls):
|
|
| 38 | + pass
|
|
| 39 | + |
|
| 40 | + def test_in_memory_download_to_file(self):
|
|
| 41 | + |
|
| 42 | + mainloop = GLib.MainLoop()
|
|
| 43 | + |
|
| 44 | + session = HTTPSession()
|
|
| 45 | + request = session.create_request()
|
|
| 46 | + |
|
| 47 | + callback_mock = Mock()
|
|
| 48 | + |
|
| 49 | + request.set_response_body_path(
|
|
| 50 | + Path('/home/lovetox/Downloads/test123'))
|
|
| 51 | + request.connect('response-progress', callback_mock.progress)
|
|
| 52 | + request.connect('starting-response-body', callback_mock.starting)
|
|
| 53 | + request.connect('finished', callback_mock.finished)
|
|
| 54 | + request.connect('destroy', lambda *args: mainloop.quit())
|
|
| 55 | + |
|
| 56 | + request.get(SMALL_FILE_URL, timeout=10)
|
|
| 57 | + |
|
| 58 | + mainloop.run()
|
|
| 59 | + |
|
| 60 | + self.assertTrue(request.is_complete())
|
|
| 61 | + self.assertTrue(request.is_finished())
|
|
| 62 | + self.assertIsNone(request.get_error())
|
|
| 63 | + |
|
| 64 | + callback_mock.progress.assert_called()
|
|
| 65 | + callback_mock.starting.assert_called()
|
|
| 66 | + callback_mock.finished.assert_called()
|
|
| 67 | + |
|
| 68 | + def test_in_memory_download(self):
|
|
| 69 | + |
|
| 70 | + mainloop = GLib.MainLoop()
|
|
| 71 | + |
|
| 72 | + session = HTTPSession()
|
|
| 73 | + request = session.create_request()
|
|
| 74 | + |
|
| 75 | + callback_mock = Mock()
|
|
| 76 | + request.connect('response-progress', callback_mock.progress)
|
|
| 77 | + request.connect('starting-response-body', callback_mock.starting)
|
|
| 78 | + request.connect('finished', callback_mock.finished)
|
|
| 79 | + request.connect('destroy', lambda *args: mainloop.quit())
|
|
| 80 | + request.get(SMALL_FILE_URL, timeout=10)
|
|
| 81 | + |
|
| 82 | + mainloop.run()
|
|
| 83 | + |
|
| 84 | + self.assertTrue(request.is_complete())
|
|
| 85 | + self.assertTrue(request.is_finished())
|
|
| 86 | + self.assertIsNone(request.get_error())
|
|
| 87 | + |
|
| 88 | + callback_mock.progress.assert_called()
|
|
| 89 | + callback_mock.starting.assert_called()
|
|
| 90 | + callback_mock.finished.assert_called()
|
|
| 91 | + |
|
| 92 | + def test_download_cancelled_before_start(self):
|
|
| 93 | + |
|
| 94 | + mainloop = GLib.MainLoop()
|
|
| 95 | + |
|
| 96 | + session = HTTPSession()
|
|
| 97 | + request = session.create_request()
|
|
| 98 | + |
|
| 99 | + callback_mock = Mock()
|
|
| 100 | + request.connect('response-progress', callback_mock.progress)
|
|
| 101 | + request.connect('starting-response-body', callback_mock.starting)
|
|
| 102 | + request.connect('finished', callback_mock.finished)
|
|
| 103 | + request.connect('destroy', lambda *args: mainloop.quit())
|
|
| 104 | + request.get(SMALL_FILE_URL, timeout=10)
|
|
| 105 | + |
|
| 106 | + GLib.timeout_add(10, request.cancel)
|
|
| 107 | + |
|
| 108 | + mainloop.run()
|
|
| 109 | + |
|
| 110 | + self.assertFalse(request.is_complete())
|
|
| 111 | + self.assertTrue(request.is_finished())
|
|
| 112 | + self.assertEqual(request.get_error(), HTTPRequestError.CANCELLED)
|
|
| 113 | + |
|
| 114 | + callback_mock.progress.assert_not_called()
|
|
| 115 | + callback_mock.starting.assert_not_called()
|
|
| 116 | + callback_mock.finished.assert_called()
|
|
| 117 | + |
|
| 118 | + def test_download_cancelled_after_start(self):
|
|
| 119 | + |
|
| 120 | + mainloop = GLib.MainLoop()
|
|
| 121 | + |
|
| 122 | + session = HTTPSession()
|
|
| 123 | + request = session.create_request()
|
|
| 124 | + |
|
| 125 | + def _on_progress(req, progress):
|
|
| 126 | + self.assertIsInstance(progress, float)
|
|
| 127 | + req.cancel()
|
|
| 128 | + |
|
| 129 | + callback_mock = Mock()
|
|
| 130 | + request.connect('starting-response-body', callback_mock.starting)
|
|
| 131 | + request.connect('response-progress', _on_progress)
|
|
| 132 | + request.connect('finished', callback_mock.finished)
|
|
| 133 | + request.connect('destroy', lambda *args: mainloop.quit())
|
|
| 134 | + request.get(BIG_FILE_URL, timeout=10)
|
|
| 135 | + |
|
| 136 | + mainloop.run()
|
|
| 137 | + |
|
| 138 | + self.assertFalse(request.is_complete())
|
|
| 139 | + self.assertTrue(request.is_finished())
|
|
| 140 | + self.assertEqual(request.get_error(), HTTPRequestError.CANCELLED)
|
|
| 141 | + |
|
| 142 | + callback_mock.starting.assert_called()
|
|
| 143 | + callback_mock.finished.assert_called()
|
|
| 144 | + |
|
| 145 | + def test_download_failed_404(self):
|
|
| 146 | + |
|
| 147 | + mainloop = GLib.MainLoop()
|
|
| 148 | + |
|
| 149 | + session = HTTPSession()
|
|
| 150 | + request = session.create_request()
|
|
| 151 | + |
|
| 152 | + callback_mock = Mock()
|
|
| 153 | + request.connect('finished', callback_mock.finished)
|
|
| 154 | + request.connect('response-progress', callback_mock.progress)
|
|
| 155 | + request.connect('destroy', lambda *args: mainloop.quit())
|
|
| 156 | + request.get(NO_FILE_URL, timeout=5)
|
|
| 157 | + |
|
| 158 | + mainloop.run()
|
|
| 159 | + |
|
| 160 | + self.assertFalse(request.is_complete())
|
|
| 161 | + self.assertTrue(request.is_finished())
|
|
| 162 | + self.assertEqual(request.get_error(), HTTPRequestError.STATUS_NOT_OK)
|
|
| 163 | + self.assertEqual(request.get_status(), Soup.Status.NOT_FOUND)
|
|
| 164 | + |
|
| 165 | + callback_mock.progress.assert_not_called()
|
|
| 166 | + callback_mock.finished.assert_called()
|
|
| 167 | + |
|
| 168 | + def test_cancel_with_timeout(self):
|
|
| 169 | + |
|
| 170 | + mainloop = GLib.MainLoop()
|
|
| 171 | + |
|
| 172 | + session = HTTPSession()
|
|
| 173 | + request = session.create_request()
|
|
| 174 | + |
|
| 175 | + callback_mock = Mock()
|
|
| 176 | + request.connect('starting-response-body', callback_mock.starting)
|
|
| 177 | + request.connect('finished', callback_mock.finished)
|
|
| 178 | + request.connect('destroy', lambda *args: mainloop.quit())
|
|
| 179 | + request.get(BIG_FILE_URL, timeout=1)
|
|
| 180 | + |
|
| 181 | + mainloop.run()
|
|
| 182 | + |
|
| 183 | + self.assertTrue(request.is_finished())
|
|
| 184 | + self.assertFalse(request.is_complete())
|
|
| 185 | + self.assertEqual(request.get_error(), HTTPRequestError.TIMEOUT)
|
|
| 186 | + |
|
| 187 | + callback_mock.starting.assert_called()
|
|
| 188 | + callback_mock.finished.assert_called()
|
|
| 189 | + |
|
| 190 | + def test_signal_propagation(self):
|
|
| 191 | + |
|
| 192 | + mainloop = GLib.MainLoop()
|
|
| 193 | + |
|
| 194 | + session = HTTPSession()
|
|
| 195 | + request = session.create_request()
|
|
| 196 | + |
|
| 197 | + callback_mock = Mock()
|
|
| 198 | + request.connect('starting', callback_mock.starting, 1, 2, 3)
|
|
| 199 | + request.connect('got-body', callback_mock.got_body)
|
|
| 200 | + request.connect('destroy', lambda *args: mainloop.quit())
|
|
| 201 | + request.get(SMALL_FILE_URL, timeout=10)
|
|
| 202 | + |
|
| 203 | + mainloop.run()
|
|
| 204 | + |
|
| 205 | + self.assertTrue(request.is_finished())
|
|
| 206 | + self.assertTrue(request.is_complete())
|
|
| 207 | + |
|
| 208 | + callback_mock.starting.assert_called_with(request, 1, 2, 3)
|
|
| 209 | + callback_mock.got_body.assert_called_with(request)
|
|
| 210 | + |
|
| 211 | + |
|
| 212 | +if __name__ == '__main__':
|
|
| 213 | + unittest.main() |
| 1 | +from __future__ import annotations
|
|
| 2 | + |
|
| 3 | +from typing import Any
|
|
| 4 | + |
|
| 5 | +from gi.repository import GLib
|
|
| 6 | + |
|
| 7 | +import os
|
|
| 8 | +import unittest
|
|
| 9 | +from unittest.mock import Mock
|
|
| 10 | +from nbxmpp.client import Client
|
|
| 11 | +from nbxmpp.http import HTTPSession
|
|
| 12 | +from nbxmpp.structs import MuclumbusResult
|
|
| 13 | +from nbxmpp.modules.muclumbus import Muclumbus
|
|
| 14 | + |
|
| 15 | +from nbxmpp.sasl import SCRAM_SHA_1
|
|
| 16 | +from nbxmpp.util import b64encode
|
|
| 17 | + |
|
| 18 | +# Test vector from https://wiki.xmpp.org/web/SASL_and_SCRAM-SHA-1
|
|
| 19 | + |
|
| 20 | +API_URL = 'https://search.jabber.network/api/1.0/search'
|
|
| 21 | + |
|
| 22 | + |
|
| 23 | +@unittest.skipUnless(os.environ.get('NBXMPP_EXTERNAL_UNIT_TESTS'),
|
|
| 24 | + 'ENV var for external tests not set')
|
|
| 25 | +class TestMuclumbus(unittest.TestCase):
|
|
| 26 | + def setUp(self):
|
|
| 27 | + self._client = Client()
|
|
| 28 | + self._client.set_http_session(HTTPSession())
|
|
| 29 | + self._module = Muclumbus(self._client)
|
|
| 30 | + |
|
| 31 | + def test_http_request(self):
|
|
| 32 | + |
|
| 33 | + mainloop = GLib.MainLoop()
|
|
| 34 | + |
|
| 35 | + def _result(task: Any) -> None:
|
|
| 36 | + result = task.finish()
|
|
| 37 | + assert isinstance(result, MuclumbusResult)
|
|
| 38 | + assert len(result.items) > 0
|
|
| 39 | + mainloop.quit()
|
|
| 40 | + |
|
| 41 | + self._module.set_http_search(API_URL, 'gajim', callback=_result)
|
|
| 42 | + |
|
| 43 | + mainloop.run()
|
|
| 44 | + |
|
| 45 | +if __name__ == '__main__':
|
|
| 46 | + unittest.main() |