Philipp Hörist pushed to branch soup3 at gajim / python-nbxmpp

Commits:

15 changed files:

Changes:

  • .gitlab-ci.yml
    ... ... @@ -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 .
    

  • README.md
    ... ... @@ -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
    

  • debian/control
    ... ... @@ -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,
    

  • nbxmpp/__init__.py
    ... ... @@ -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'

  • nbxmpp/addresses.py
    ... ... @@ -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')
    

  • nbxmpp/client.py
    ... ... @@ -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)
    

  • nbxmpp/const.py
    ... ... @@ -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',
    

  • nbxmpp/examples/client.py
    ... ... @@ -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:
    

  • nbxmpp/http.py
    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)})'

  • nbxmpp/modules/muclumbus.py
    ... ... @@ -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')
    

  • nbxmpp/structs.py
    ... ... @@ -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)
    

  • nbxmpp/task.py
    ... ... @@ -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
    

  • nbxmpp/websocket.py
    ... ... @@ -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()
    

  • test/unit/test_http.py
    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()

  • test/unit/test_muclumbus.py
    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()