• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

tcalmant / ipopo / 30269241893

27 Jul 2026 01:12PM UTC coverage: 86.366% (-0.02%) from 86.381%
30269241893

Pull #207

github

web-flow
Merge e910fafba into 343fa08e0
Pull Request #207: Python 3.16 compatbility fix

10 of 13 new or added lines in 1 file covered. (76.92%)

2 existing lines in 1 file now uncovered.

15235 of 17640 relevant lines covered (86.37%)

5.13 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

82.39
/pelix/http/basic_async.py
1
#!/usr/bin/env python
2
# -- Content-Encoding: UTF-8 --
3
"""
4
Pelix basic asynchronous HTTP service bundle.
5

6
Provides an implementation of the Pelix HTTP service based on aiohttp.
7

8
:author: Thomas Calmant
9
:copyright: Copyright 2026, Thomas Calmant
10
:license: Apache License 2.0
11
:version: 3.2.2
12

13
..
14

15
    Copyright 2026 Thomas Calmant
16

17
    Licensed under the Apache License, Version 2.0 (the "License");
18
    you may not use this file except in compliance with the License.
19
    You may obtain a copy of the License at
20

21
        https://www.apache.org/licenses/LICENSE-2.0
22

23
    Unless required by applicable law or agreed to in writing, software
24
    distributed under the License is distributed on an "AS IS" BASIS,
25
    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
26
    See the License for the specific language governing permissions and
27
    limitations under the License.
28
"""
29

30
import asyncio
6✔
31
import concurrent.futures
6✔
32
import io
6✔
33
import logging
6✔
34
import ssl
6✔
35
import sys
6✔
36
import threading
6✔
37
import traceback
6✔
38
from typing import IO, TYPE_CHECKING, Any, cast
6✔
39

40
import aiohttp.client_exceptions
6✔
41
import aiohttp.web
6✔
42

43
import pelix.constants as fw_constants
6✔
44
from pelix import http, utilities
6✔
45
from pelix.http._base import (
6✔
46
    DEFAULT_BIND_ADDRESS,
47
    HTTP_SERVICE_EXTRA,
48
    LOCALHOST_ADDRESS,
49
    AbstractHttpService,
50
    compute_sub_path,
51
)
52
from pelix.internals.registry import ServiceReference
6✔
53
from pelix.ipopo.decorators import (
6✔
54
    BindField,
55
    ComponentFactory,
56
    Invalidate,
57
    Provides,
58
    Requires,
59
    UnbindField,
60
    UpdateField,
61
    Validate,
62
)
63

64
if TYPE_CHECKING:
65
    from pelix.framework import BundleContext
66

67

68
# ------------------------------------------------------------------------------
69

70
# Module version
71
__version_info__ = (3, 2, 2)
6✔
72
__version__ = ".".join(str(x) for x in __version_info__)
6✔
73

74
# Documentation strings format
75
__docformat__ = "restructuredtext en"
6✔
76

77
# ------------------------------------------------------------------------------
78

79
# Kept for backward compatibility: those constants are now defined in _base
80
__all__ = [
6✔
81
    "DEFAULT_BIND_ADDRESS",
82
    "HTTP_SERVICE_EXTRA",
83
    "LOCALHOST_ADDRESS",
84
    "AsyncHttpServiceImpl",
85
    "WSSession",
86
]
87

88

89
class _SyncHTTPServletRequest(http.AbstractHTTPServletRequest):
6✔
90
    """
91
    HTTP Servlet request helper
92
    """
93

94
    def __init__(self, request: aiohttp.web.Request, full_path: str, prefix: str, content: bytes) -> None:
6✔
95
        """
96
        Sets up the request helper
97

98
        :param request: The aiohttp Request object
99
        :param full_path: The full request path, including the prefix
100
        :param prefix: The path to the servlet root
101
        :param content: The request content
102
        """
103
        self._request = request
6✔
104
        self._prefix = prefix
6✔
105
        self._content = content
6✔
106

107
        # Compute the sub path
108
        self._sub_path = compute_sub_path(full_path, prefix)
6✔
109

110
    def get_command(self) -> str:
6✔
111
        """
112
        Returns the HTTP verb (GET, POST, ...) used for the request
113
        """
114
        return self._request.method.upper()
6✔
115

116
    def get_client_address(self) -> tuple[str, int]:
6✔
117
        """
118
        Retrieves the address of the client
119

120
        :return: A (host, port) tuple
121
        """
122
        if self._request.transport is None:
6✔
123
            # No transport, no address
124
            raise OSError("No transport available for the request")
×
125

126
        peer_name = self._request.transport.get_extra_info("peername")
6✔
127
        if not peer_name:
6✔
128
            raise OSError("No peer name available for the request")
×
129
        return peer_name[:2]
6✔
130

131
    def get_header(self, name: str, default: Any | None = None) -> Any:
6✔
132
        """
133
        Retrieves the value of a header
134
        """
135
        return self._request.headers.get(name, default)
6✔
136

137
    def get_headers(self) -> dict[str, Any]:
6✔
138
        """
139
        Retrieves all headers
140
        """
141
        return cast(dict[str, Any], self._request.headers)
6✔
142

143
    def get_path(self) -> str:
6✔
144
        """
145
        Retrieves the request full path
146
        """
147
        return self._request.path
×
148

149
    def get_prefix_path(self) -> str:
6✔
150
        """
151
        Returns the path to the servlet root
152

153
        :return: A request path (string)
154
        """
155
        return self._prefix
6✔
156

157
    def get_sub_path(self) -> str:
6✔
158
        """
159
        Returns the servlet-relative path, i.e. after the prefix
160

161
        :return: A request path (string)
162
        """
163
        return self._sub_path
6✔
164

165
    def get_rfile(self) -> IO[bytes]:
6✔
166
        """
167
        Retrieves the input as a file stream
168
        """
169
        return io.BytesIO(self._content)
×
170

171

172
class _WriteWrapper(IO[bytes]):
6✔
173
    def __init__(self):
6✔
174
        self._buffer = io.BytesIO()
6✔
175
        self._closed = False
6✔
176

177
    def get(self) -> bytes:
6✔
178
        """
179
        Retrieves the written data as bytes.
180
        This method should be called after the response has been sent.
181

182
        :return: The written data
183
        """
184
        return self._buffer.getvalue()
6✔
185

186
    def read(self, size: int = -1) -> bytes:
6✔
187
        raise OSError("This stream is not readable")
×
188

189
    def write(self, b: bytes) -> int:
6✔
190
        return self._buffer.write(b)
6✔
191

192
    def seek(self, offset: int, whence: int = io.SEEK_SET) -> int:
6✔
193
        raise OSError("This stream is not seekable")
×
194

195
    def tell(self) -> int:
6✔
196
        raise OSError("This stream is not seekable")
×
197

198
    def close(self) -> None:
6✔
199
        self._closed = True
6✔
200

201
    def flush(self) -> None:
6✔
202
        pass
×
203

204
    def readable(self) -> bool:
6✔
205
        return False
×
206

207
    def writable(self) -> bool:
6✔
208
        return True
×
209

210
    def seekable(self) -> bool:
6✔
211
        return False
×
212

213
    @property
6✔
214
    def closed(self) -> bool:
6✔
215
        return self._closed
×
216

217

218
class _SyncHTTPServletResponse(http.AbstractHTTPServletResponse):
6✔
219
    """
220
    HTTP Servlet response helper
221
    """
222

223
    def __init__(self, request: aiohttp.web.Request, loop: asyncio.AbstractEventLoop) -> None:
6✔
224
        """
225
        Sets up the response helper
226

227
        :param request: The aiohttp Request object
228
        :param loop: The asyncio event loop
229
        """
230
        self._request = request
6✔
231
        self._loop = loop
6✔
232
        self._headers: dict[str, str] = {}
6✔
233
        self._headers_set: bool = False
6✔
234
        self._code: int = 200
6✔
235
        self._message: str | None = None
6✔
236
        self._writer = _WriteWrapper()
6✔
237

238
    def to_aiohttp_response(self) -> aiohttp.web.StreamResponse:
6✔
239
        """
240
        Converts the response to an aiohttp StreamResponse object.
241
        This method should be called after all headers have been set.
242

243
        :return: The aiohttp StreamResponse object
244
        """
245
        return aiohttp.web.Response(
6✔
246
            body=self._writer.get(), status=self._code, reason=self._message, headers=self._headers
247
        )
248

249
    def set_response(self, code: int, message: str | None = None) -> None:
6✔
250
        """
251
        Sets the response line.
252
        This method should be the first called when sending an answer.
253

254
        :param code: HTTP result code
255
        :param message: Associated message
256
        """
257
        if self._headers_set:
6✔
258
            raise OSError("Headers have already been set, cannot change the response code")
×
259

260
        self._code = code
6✔
261
        self._message = message
6✔
262

263
    def set_header(self, name: str, value: Any) -> None:
6✔
264
        """
265
        Sets the value of a header.
266
        This method should not be called after ``end_headers()``.
267

268
        :param name: Header name
269
        :param value: Header value
270
        """
271
        if self._headers_set:
6✔
272
            raise OSError("Headers have already been set, cannot change them")
×
273

274
        if value is None:
6✔
275
            self._headers.pop(name.lower(), None)
×
276
        else:
277
            self._headers[name.lower()] = str(value)
6✔
278

279
    def is_header_set(self, name: str) -> bool:
6✔
280
        """
281
        Checks if the given header has already been set
282

283
        :param name: Header name
284
        :return: True if it has already been set
285
        """
286
        return name.lower() in self._headers
6✔
287

288
    def end_headers(self) -> None:
6✔
289
        """
290
        Ends the headers part
291
        """
292
        self._headers_set = True
6✔
293

294
    def get_wfile(self) -> IO[bytes]:
6✔
295
        """
296
        Retrieves the output as a file stream.
297
        ``end_headers()`` should have been called before, except if you want
298
        to write your own headers.
299

300
        :return: The output file-like object
301
        """
302
        if not self._headers_set:
6✔
303
            self.end_headers()
×
304

305
        return self._writer
6✔
306

307
    def write(self, data: bytes) -> None:
6✔
308
        """
309
        Writes the given data.
310
        ``end_headers()`` should have been called before, except if you want
311
        to write your own headers.
312

313
        :param data: Data to be written
314
        """
315
        writer = self.get_wfile()
6✔
316
        writer.write(data)
6✔
317
        writer.close()
6✔
318

319

320
# ------------------------------------------------------------------------------
321

322

323
class _AsyncHTTPServletRequest(http.AbstractAsyncHTTPServletRequest):
6✔
324
    """
325
    HTTP Servlet request helper
326
    """
327

328
    def __init__(self, request: aiohttp.web.Request, full_path: str, prefix: str) -> None:
6✔
329
        """
330
        Sets up the request helper
331

332
        :param request: The aiohttp Request object
333
        :param full_path: The full request path, including the prefix
334
        :param prefix: The path to the servlet root
335
        """
336
        self._request = request
6✔
337
        self._prefix = prefix
6✔
338

339
        # Compute the sub path
340
        self._sub_path = compute_sub_path(full_path, prefix)
6✔
341

342
    def get_command(self) -> str:
6✔
343
        """
344
        Returns the HTTP verb (GET, POST, ...) used for the request
345
        """
346
        return self._request.method.upper()
×
347

348
    def get_client_address(self) -> tuple[str, int]:
6✔
349
        """
350
        Retrieves the address of the client
351

352
        :return: A (host, port) tuple
353
        """
354
        if self._request.transport is None:
6✔
355
            # No transport, no address
356
            raise OSError("No transport available for the request")
×
357

358
        peer_name = self._request.transport.get_extra_info("peername")
6✔
359
        if not peer_name:
6✔
360
            raise OSError("No peer name available for the request")
×
361
        return peer_name[:2]
6✔
362

363
    async def get_header(self, name: str, default: Any | None = None) -> Any:
6✔
364
        """
365
        Retrieves the value of a header
366
        """
367
        return self._request.headers.get(name, default)
6✔
368

369
    async def get_headers(self) -> dict[str, Any]:
6✔
370
        """
371
        Retrieves all headers
372
        """
373
        return cast(dict[str, Any], self._request.headers)
6✔
374

375
    def get_path(self) -> str:
6✔
376
        """
377
        Retrieves the request full path
378
        """
379
        return self._request.path
×
380

381
    def get_prefix_path(self) -> str:
6✔
382
        """
383
        Returns the path to the servlet root
384

385
        :return: A request path (string)
386
        """
387
        return self._prefix
×
388

389
    def get_sub_path(self) -> str:
6✔
390
        """
391
        Returns the servlet-relative path, i.e. after the prefix
392

393
        :return: A request path (string)
394
        """
395
        return self._sub_path
×
396

397
    def get_rfile(self) -> asyncio.StreamReader:
6✔
398
        """
399
        Retrieves the input as a file stream
400
        """
401
        # aiohttp StreamReader is compatible with the asyncio one
402
        return cast(asyncio.StreamReader, self._request.content)
×
403

404

405
class _AioHttpWriter(http.AbstractAsyncWriter):
6✔
406
    """
407
    Wrapper for aiohttp StreamResponse
408
    """
409

410
    def __init__(self, response: aiohttp.web.StreamResponse) -> None:
6✔
411
        self._response = response
×
412

413
    async def write(self, raw: bytes) -> int:
6✔
414
        await self._response.write(raw)
×
415
        return len(raw)
×
416

417
    async def flush(self) -> None:
6✔
418
        await self._response.drain()
×
419

420

421
class _AsyncHTTPServletResponse(http.AbstractAsyncHTTPServletResponse):
6✔
422
    """
423
    HTTP Servlet response helper
424
    """
425

426
    def __init__(self, request: aiohttp.web.Request) -> None:
6✔
427
        """
428
        Sets up the response helper
429

430
        :param request: The aiohttp Request object
431
        """
432
        self._request = request
6✔
433
        self._headers_set: bool = False
6✔
434
        self._sse_set: bool = False
6✔
435
        self._response = aiohttp.web.StreamResponse()
6✔
436

437
    def to_aiohttp_response(self) -> aiohttp.web.StreamResponse:
6✔
438
        """
439
        Converts the response to an aiohttp StreamResponse object.
440
        This method should be called after all headers have been set.
441

442
        :return: The aiohttp StreamResponse object
443
        """
444
        return self._response
6✔
445

446
    def set_response(self, code: int, message: str | None = None) -> None:
6✔
447
        """
448
        Sets the response line.
449
        This method should be the first called when sending an answer.
450

451
        :param code: HTTP result code
452
        :param message: Associated message
453
        """
454
        if self._headers_set:
6✔
455
            raise OSError("Headers have already been set, cannot change the response code")
×
456

457
        self._response.set_status(code, message)
6✔
458

459
    def set_header(self, name: str, value: Any) -> None:
6✔
460
        """
461
        Sets the value of a header.
462
        This method should not be called after ``end_headers()``.
463

464
        :param name: Header name
465
        :param value: Header value
466
        """
467
        if self._headers_set:
6✔
468
            raise OSError("Headers have already been set, cannot change them")
×
469

470
        if value is None:
6✔
471
            self._response.headers.popall(name.lower(), None)
×
472
        else:
473
            self._response.headers.add(name.lower(), str(value))
6✔
474

475
    def is_header_set(self, name: str) -> bool:
6✔
476
        """
477
        Checks if the given header has already been set
478

479
        :param name: Header name
480
        :return: True if it has already been set
481
        """
482
        return self._response.headers.get(name.lower(), None) is not None
6✔
483

484
    def setup_sse(self, strict: bool = True) -> None:
6✔
485
        """
486
        Sets up the response for Server-Sent Events (SSE)
487

488
        :param strict: If True, raises an error if the request is not for SSE
489
        """
490
        if self._sse_set:
6✔
491
            # Already set up for SSE
492
            return
×
493

494
        if strict and not any(
6✔
495
            "text/event-stream" in accepted for accepted in self._request.headers.getall("accept", "")
496
        ):
497
            raise ValueError("Cannot set up SSE for a non-SSE request")
×
498

499
        if self._headers_set:
6✔
500
            raise OSError("Headers have already been set, cannot change them")
×
501

502
        self._response.headers["Content-Type"] = "text/event-stream"
6✔
503
        self._response.headers["Cache-Control"] = "no-cache"
6✔
504
        self._response.headers["Connection"] = "keep-alive"
6✔
505
        self._sse_set = True
6✔
506

507
    async def end_headers(self) -> None:
6✔
508
        """
509
        Ends the headers part
510
        """
511
        self._headers_set = True
6✔
512
        await self._response.prepare(self._request)
6✔
513

514
    def get_wfile(self) -> http.AbstractAsyncWriter:
6✔
515
        """
516
        Retrieves the output as a writer.
517
        ``end_headers()`` should have been called before.
518

519
        :return: A writer for the output stream
520
        """
521
        return _AioHttpWriter(self._response)
×
522

523
    async def write(self, data: bytes) -> None:
6✔
524
        """
525
        Writes the given data.
526
        ``end_headers()`` should have been called before, except if you want
527
        to write your own headers.
528

529
        :param data: Data to be written
530
        """
531
        await self._response.write(data)
6✔
532

533
    async def send_sse(
6✔
534
        self, event: str | None = None, data: str | None = None, id: str | None = None
535
    ) -> None:
536
        """
537
        Sends a Server-Sent Event (SSE) message.
538

539
        :param event: Optional event type (e.g., "message", "update")
540
        :param data: The event data (without newline characters)
541
        :param id: Optional event ID (set to "" to reset the ID)
542
        """
543
        if not self._sse_set:
6✔
544
            raise OSError("SSE not set up, call setup_sse() first")
×
545

546
        # Prepare the SSE message
547
        parts: list[str] = []
6✔
548
        if id:
6✔
549
            parts.append(f"id: {id}")
×
550
        elif id is not None:
6✔
551
            # Reset ID
552
            parts.append("id")
×
553

554
        if event:
6✔
555
            parts.append(f"event: {event}")
×
556
        elif event is not None:
6✔
557
            parts.append("event")
×
558

559
        if data:
6✔
560
            # Split the data into lines and prefix each line with "data: "
561
            for line in data.splitlines() or [""]:
6✔
562
                parts.append(f"data: {line}")
6✔
563
        else:
564
            # Empty data line
565
            parts.append("data")
×
566

567
        # End of the event
568
        parts.append("")
6✔
569
        parts.append("")
6✔
570

571
        try:
6✔
572
            await self.write("\n".join(parts).encode("utf-8"))
6✔
573
        except aiohttp.client_exceptions.ClientConnectionResetError:
6✔
574
            raise OSError("Client connection reset during SSE send") from None
6✔
575

576

577
class WSSession(http.WebSocketSession):
6✔
578
    def __init__(
6✔
579
        self,
580
        ws_handler: http.WebSocketHandler,
581
        servlet_request: _AsyncHTTPServletRequest,
582
        ws_response: aiohttp.web.WebSocketResponse,
583
    ) -> None:
584
        """
585
        Initializes the WebSocket session
586

587
        :param ws_handler: The WebSocket handler
588
        :param servlet_request: The servlet request
589
        :param ws_response: The WebSocket response
590
        """
591
        self._handler = ws_handler
6✔
592
        self._request = servlet_request
6✔
593
        self._response = ws_response
6✔
594

595
    def get_client_address(self) -> tuple[str, int]:
6✔
596
        """
597
        Returns the address of the client
598

599
        :return: A (host, port) tuple
600
        """
601
        return self._request.get_client_address()
×
602

603
    async def send_binary(self, message: bytes) -> None:
6✔
604
        """
605
        Sends a binary message to the client
606

607
        :param message: Binary message to send
608
        """
609
        if self._response.closed:
×
610
            raise OSError("WebSocket session is closed")
×
611

612
        await self._response.send_bytes(message)
×
613

614
    async def send_text(self, message: str) -> None:
6✔
615
        """
616
        Sends a message to the client
617

618
        :param message: Message to send
619
        """
620
        if self._response.closed:
6✔
621
            raise OSError("WebSocket session is closed")
×
622

623
        await self._response.send_str(message)
6✔
624

625
    async def close(self, code: int = 1000, reason: str | None = None) -> None:
6✔
626
        """
627
        Closes the WebSocket session
628

629
        :param code: Close code (default is 1000, normal closure)
630
        :param reason: Optional reason for the closure
631
        """
632
        if not self._response.closed:
6✔
633
            await self._response.close(code=code, message=(reason or "").encode("utf-8"))
6✔
634

635

636
# ------------------------------------------------------------------------------
637

638

639
@ComponentFactory(http.FACTORY_HTTP_ASYNC)
6✔
640
@Provides(http.HTTP_SERVICE)
6✔
641
@Requires("_servlets_services", http.Servlet, True, True)
6✔
642
@Requires("_servlets_async_services", http.AsyncServlet, True, True)
6✔
643
@Requires("_websocket_handler_services", http.WebSocketHandler, True, True)
6✔
644
@Requires("_error_handler", http.ErrorHandler, optional=True)
6✔
645
class AsyncHttpServiceImpl(AbstractHttpService):
6✔
646
    """
647
    Asynchronous HTTP service component
648
    """
649

650
    def __init__(self) -> None:
6✔
651
        super().__init__()
6✔
652

653
        # This implementation always has a logger
654
        self._logger = logging.getLogger(f"{__name__}#init")
6✔
655

656
        # Fields injected by iPOPO
657
        self._servlets_services: list[http.Servlet] = []
6✔
658
        self._servlets_async_services: list[http.AsyncServlet] = []
6✔
659
        self._websocket_handler_services: list[http.WebSocketHandler] = []
6✔
660

661
        # Server control
662
        self._bound_address: tuple[str, int] | None = None
6✔
663
        self._app: aiohttp.web.Application | None = None
6✔
664
        self._thread: threading.Thread | None = None
6✔
665
        self._executor: concurrent.futures.ThreadPoolExecutor | None = None
6✔
666
        self._loop: asyncio.AbstractEventLoop | None = None
6✔
667
        self._start_done_event: utilities.EventData[tuple[str, int]] = utilities.EventData()
6✔
668
        self._stop_event: asyncio.Event = asyncio.Event()
6✔
669
        self._stop_done_event: threading.Event = threading.Event()
6✔
670

671
    @Validate
6✔
672
    def validate(self, context: "BundleContext") -> None:
6✔
673
        """
674
        Component validated
675
        """
676
        self._normalize_configuration()
6✔
677
        self._setup_logger()
6✔
678

679
        self.log(
6✔
680
            logging.INFO,
681
            "Starting HTTP%s server: [%s]:%d ...",
682
            "S" if self._uses_ssl else "",
683
            self._address,
684
            self._port,
685
        )
686

687
        # Create the server
688
        app = aiohttp.web.Application(logger=self._logger)
6✔
689
        app.add_routes([aiohttp.web.route("*", "/{tail:.*}", self.__global_handler)])
6✔
690
        self._app = app
6✔
691

692
        # Start the server in a separate thread
693
        self._stop_event.clear()
6✔
694
        self._stop_done_event.clear()
6✔
695
        self._start_done_event.clear()
6✔
696
        self._thread = threading.Thread(target=self._run_server_thread, name="Pelix Async HTTP Server Thread")
6✔
697
        self._thread.daemon = True
6✔
698
        self._thread.start()
6✔
699

700
        # Wait for the server to be ready
701
        if not self._start_done_event.wait(10):
6✔
702
            self._logger.error("HTTP server did not start in time")
×
703
            raise OSError("HTTP server did not start in time")
×
704

705
        if self._start_done_event.data is None:
6✔
706
            self._logger.error("HTTP server did not bind to an address")
×
707
            raise OSError("HTTP server did not bind to an address")
×
708

709
        host, port = self._start_done_event.data
6✔
710
        self._bound_address = (host, port)
6✔
711
        self._port = port
6✔
712

713
        # Register the servlets bound before the server was ready
714
        self._register_bound_servlets()
6✔
715

716
        self._logger.info(
6✔
717
            "HTTP%s server bound to: [%s]:%d ...",
718
            "S" if self._uses_ssl else "",
719
            self._address,
720
            self._port,
721
        )
722

723
    @Invalidate
6✔
724
    def invalidate(self, context: "BundleContext") -> None:
6✔
725
        """
726
        Component invalidated
727
        """
728
        # Clear the validation flag
729
        self._validated = False
6✔
730

731
        self.log(
6✔
732
            logging.INFO,
733
            "Stopping HTTP%s server: [%s]:%d ...",
734
            "S" if self._uses_ssl else "",
735
            self._address,
736
            self._port,
737
        )
738

739
        # Set the stop event
740
        if self._loop is not None:
6✔
741
            # Call for a stop
742
            async def async_stop_caller():
6✔
743
                self._stop_event.set()
6✔
744

745
            asyncio.run_coroutine_threadsafe(async_stop_caller(), self._loop).result()
6✔
746

747
        # Wait for the stop done event to be set
748
        self._logger.debug("Waiting for the stop done event to be set...")
6✔
749
        if not self._stop_done_event.wait(timeout=5):
6✔
750
            self.log(
×
751
                logging.WARNING,
752
                "The stop done event was not set in time, the server may not have stopped properly",
753
            )
754

755
        # Wait for the server thread to stop
756
        if self._thread is not None and self._thread.is_alive():
6✔
757
            self._thread.join(timeout=0.5)
×
758
            if self._thread.is_alive():
×
759
                self.log(logging.WARNING, "HTTP server thread did not stop in time")
×
760

761
        self._thread = None
6✔
762

763
        # Close the event loop
764
        if self._loop is not None and not self._loop.is_closed():
6✔
765
            try:
6✔
766
                # Cancel pending tasks
767
                pending = [task for task in asyncio.all_tasks(self._loop) if not task.done()]
6✔
768
                for task in pending:
6✔
NEW
769
                    task.cancel()
×
770

771
                if pending:
6✔
NEW
772
                    self._loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
×
773

774
                self._loop.run_until_complete(self._loop.shutdown_asyncgens())
6✔
775
                self._loop.run_until_complete(self._loop.shutdown_default_executor())
6✔
776
            except Exception:
6✔
777
                self._logger.exception("Error closing the event loop")
6✔
778
            finally:
779
                self._loop.close()
6✔
780

781
        # Clear references
782
        self._loop = None
6✔
783
        self._app = None
6✔
784

785
    def _run_server_thread(self) -> None:
6✔
786
        """
787
        Runs the HTTP server in a separate thread.
788
        """
789
        try:
6✔
790
            # Set the event loop for this thread
791
            if sys.platform.startswith("win"):
6✔
792
                # aiodns requires a specific event loop on Windows
NEW
793
                self._loop = asyncio.SelectorEventLoop()
×
794
            else:
795
                self._loop = asyncio.new_event_loop()
6✔
796

797
            asyncio.set_event_loop(self._loop)
6✔
798

799
            # Setup the server
800
            self._loop.run_until_complete(self._run_server())
6✔
801
        except Exception:
×
802
            self._logger.exception("Error running async HTTP server")
×
803
        finally:
804
            if self._loop is not None:
6✔
805
                self._loop.stop()
6✔
806
            self._stop_done_event.set()
6✔
807

808
    async def _run_server(self) -> None:
6✔
809
        try:
6✔
810
            assert self._app is not None, "Application must be initialized before running the server"
6✔
811

812
            # Create the server
813
            runner = aiohttp.web.AppRunner(self._app)
6✔
814
            await runner.setup()
6✔
815

816
            # Prepare SSL context if needed
817
            ssl_context: ssl.SSLContext | None = None
6✔
818
            if self._uses_ssl:
6✔
819
                assert self._cert_file is not None, "Certificate file must be set for HTTPS"
6✔
820

821
                # Create the SSL context
822
                ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
6✔
823
                ssl_context.load_cert_chain(
6✔
824
                    certfile=self._cert_file, keyfile=self._key_file, password=self._key_password
825
                )
826

827
            # Create the site
828
            site = aiohttp.web.TCPSite(runner, self._address, self._port, ssl_context=ssl_context)
6✔
829

830
            with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
6✔
831
                self._executor = executor
6✔
832

833
                # Start the site
834
                await site.start()
6✔
835

836
                # Get bound address and port
837
                sock = cast(asyncio.Server, site._server).sockets[0]
6✔
838
                host, port = sock.getsockname()[:2]
6✔
839

840
                # We're ready
841
                self._start_done_event.set((host, port))
6✔
842

843
                # Keep the thread alive until the server is stopped
844
                await self._stop_event.wait()
6✔
845

846
                # Clean up the server
847
                await site.stop()
6✔
848
                await runner.shutdown()
6✔
849
                await runner.cleanup()
6✔
850
                await self._app.shutdown()
6✔
851
                await self._app.cleanup()
6✔
852
        except Exception as ex:
×
853
            # Anything went wrong, log the error
854
            self._logger.error("Error running the HTTP server: %s", ex)
×
855
            self._start_done_event.raise_exception(ex)
×
856

857
    async def __global_handler(self, request: aiohttp.web.Request) -> aiohttp.web.StreamResponse:
6✔
858
        """
859
        Global handler for the Pelix async HTTP service.
860

861
        :param request: The incoming request
862
        :return: The response to send
863
        """
864
        assert self._loop is not None, "EventLoop must be initialized before handling requests"
6✔
865

866
        if self._executor is None:
6✔
867
            # No executor available, cannot handle the request
868
            return aiohttp.web.Response(status=503, text="Service unavailable")
×
869

870
        # Get the corresponding servlet
871
        routing = self.resolve_request(request.path)
6✔
872
        path = routing.path
6✔
873
        servlet = routing.servlet
6✔
874
        if servlet is not None:
6✔
875
            prefix = routing.prefix
6✔
876
            servlet_type = routing.servlet_type
6✔
877

878
            async_name = f"do_async_{request.method.upper()}"
6✔
879
            sync_name = f"do_{request.method.upper()}"
6✔
880

881
            try:
6✔
882
                match servlet_type:
6✔
883
                    case http.ServletType.ASYNC if hasattr(servlet, async_name):
6✔
884
                        # Prepare the helpers
885
                        servlet_request = _AsyncHTTPServletRequest(request, path, prefix)
6✔
886
                        servlet_response = _AsyncHTTPServletResponse(request)
6✔
887

888
                        # Handle the request
889
                        handler_method = getattr(servlet, async_name)
6✔
890
                        await handler_method(servlet_request, servlet_response)
6✔
891
                        return servlet_response.to_aiohttp_response()
6✔
892

893
                    case http.ServletType.SYNC if hasattr(servlet, sync_name):
6✔
894
                        # Read the request content
895
                        # FIXME: find a better way to handle the content, wrapping the request
896
                        #        in a file-like object
897
                        content = await request.read()
6✔
898

899
                        # Prepare the helpers
900
                        servlet_request = _SyncHTTPServletRequest(request, path, prefix, content)
6✔
901
                        servlet_response = _SyncHTTPServletResponse(request, self._loop)
6✔
902

903
                        # Handle the request in the executor
904
                        handler_method = getattr(servlet, sync_name)
6✔
905
                        await self._loop.run_in_executor(
6✔
906
                            self._executor, handler_method, servlet_request, servlet_response
907
                        )
908
                        return servlet_response.to_aiohttp_response()
6✔
909

910
                    case http.ServletType.WEBSOCKET if isinstance(servlet, http.WebSocketHandler):
6✔
911
                        # Prepare the WebSocket handler
912
                        ws_handler = cast(http.WebSocketHandler, servlet)
6✔
913
                        servlet_request = _AsyncHTTPServletRequest(request, path, prefix)
6✔
914

915
                        # Prepare the WebSocket response
916
                        ws_response = aiohttp.web.WebSocketResponse()
6✔
917

918
                        # Prepare a session
919
                        ws_session = WSSession(ws_handler, servlet_request, ws_response)
6✔
920

921
                        # Early check
922
                        if not await ws_handler.ws_accept(servlet_request):
6✔
923
                            # The handler does not accept the WebSocket connection
924
                            return aiohttp.web.Response(status=400, text="WebSocket connection refused")
×
925

926
                        # Prepare the WebSocket response
927
                        await ws_response.prepare(request)
6✔
928

929
                        try:
6✔
930
                            # Notify the WebSocket handler of the new connection
931
                            await ws_handler.ws_open(ws_session, servlet_request)
6✔
932

933
                            async for msg in ws_response:
6✔
934
                                # Handle incoming messages
935
                                match msg.type:
6✔
936
                                    case aiohttp.WSMsgType.ERROR:
6✔
937
                                        # Error message received
938
                                        self._logger.error("WebSocket error: %s", ws_response.exception())
×
939
                                        await ws_handler.ws_error(ws_session, msg.data)
×
940

941
                                    case aiohttp.WSMsgType.PING:
6✔
942
                                        # Ping message received
943
                                        await ws_response.pong(msg.data)
×
944

945
                                    case aiohttp.WSMsgType.BINARY:
6✔
946
                                        # Binary message received
947
                                        await ws_handler.ws_binary(ws_session, msg.data)
×
948

949
                                    case aiohttp.WSMsgType.TEXT:
6✔
950
                                        # Text message received
951
                                        await ws_handler.ws_message(ws_session, msg.data)
6✔
952

953
                            # End of loop: the WebSocket connection is closed
954
                            code = ws_response.close_code or aiohttp.WSCloseCode.GOING_AWAY
6✔
955
                            await ws_handler.ws_close(ws_session, code, "Session closed")
6✔
956
                        except Exception as ex:
×
957
                            self._logger.exception("Error handling WebSocket connection")
×
958
                            await ws_handler.ws_error(ws_session, str(ex))
×
959
                        finally:
960
                            if not ws_response.closed:
6✔
961
                                await ws_response.close()
×
962

963
                        return ws_response
6✔
964
            except Exception:
6✔
965
                # Send a 500 error page on error.
966
                # The details are logged by make_exception_page()
967
                self._logger.error("Error handling %s request to %s", request.method, path)
6✔
968
                return self.send_exception(path)
6✔
969

970
        # Return the super implementation if needed
971
        return aiohttp.web.Response(status=404, text=self.make_not_found_page(path), content_type="text/html")
6✔
972

973
    def send_exception(self, path: str) -> aiohttp.web.Response:
6✔
974
        """
975
        Sends an exception page with a 500 error code.
976
        Must be called from inside the exception handling block.
977

978
        :param path: Erroneous request path
979
        :return: The aiohttp Response to send
980
        """
981
        # Get a formatted stack trace.
982
        # The error is logged by make_exception_page(), which also decides what
983
        # can be sent to the client
984
        stack = traceback.format_exc()
6✔
985

986
        # Send the page
987
        return aiohttp.web.Response(status=500, text=self.make_exception_page(path, stack))
6✔
988

989
    def _set_extra_parameters(self, parameters: dict[str, Any], servlet_type: http.ServletType) -> None:
6✔
990
        """
991
        Tells the servlet if it is registered in asynchronous mode
992

993
        :param parameters: The parameters given to the servlet
994
        :param servlet_type: The type of the servlet being registered
995
        """
996
        parameters[http.PARAM_ASYNC] = servlet_type != http.ServletType.SYNC
6✔
997

998
    def _register_servlet_service(
6✔
999
        self,
1000
        service: http.Servlet | http.AsyncServlet | http.WebSocketHandler,
1001
        service_reference: ServiceReference[Any],
1002
    ) -> None:
1003
        """
1004
        Registers a servlet according to its service properties
1005

1006
        :param service: A servlet service
1007
        :param service_reference: The associated ServiceReference
1008
        """
1009
        spec = cast(list[str], service_reference.get_property(fw_constants.OBJECTCLASS))
6✔
1010
        if http.HTTP_SERVLET in spec:
6✔
1011
            # Servlet bound
1012
            sync_servlet = cast(http.Servlet, service)
6✔
1013
            paths = service_reference.get_property(http.HTTP_SERVLET_PATH)
6✔
1014
            if utilities.is_string(paths):
6✔
1015
                # Register the servlet to a single path
1016
                self.register_servlet(paths, sync_servlet, {}, http.ServletType.SYNC)
6✔
1017
            elif isinstance(paths, (list, tuple)):
6✔
1018
                # Register the servlet to multiple paths
1019
                for path in paths:
6✔
1020
                    self.register_servlet(path, sync_servlet, {}, http.ServletType.SYNC)
6✔
1021

1022
        # No else here: a service could implement both specifications
1023
        if http.HTTP_SERVLET_ASYNC in spec:
6✔
1024
            # Asynchronous servlet bound
1025
            async_servlet = cast(http.AsyncServlet, service)
6✔
1026
            paths = service_reference.get_property(http.HTTP_SERVLET_ASYNC_PATH)
6✔
1027
            if utilities.is_string(paths):
6✔
1028
                # Register the servlet to a single path
1029
                self.register_servlet(paths, async_servlet, {}, http.ServletType.ASYNC)
6✔
1030
            elif isinstance(paths, (list, tuple)):
6✔
1031
                # Register the servlet to multiple paths
1032
                for path in paths:
6✔
1033
                    self.register_servlet(path, async_servlet, {}, http.ServletType.ASYNC)
6✔
1034

1035
        # No else here: a service could implement both specifications
1036
        if http.HTTP_WEBSOCKET_HANDLER in spec:
6✔
1037
            # WebSocket handler bound
1038
            websocket_handler = cast(http.WebSocketHandler, service)
×
1039
            paths = service_reference.get_property(http.HTTP_WEBSOCKET_PATH)
×
1040
            if utilities.is_string(paths):
×
1041
                # Register the WebSocket handler to a single path
1042
                self.register_servlet(paths, websocket_handler, {}, http.ServletType.WEBSOCKET)
×
1043
            elif isinstance(paths, (list, tuple)):
×
1044
                # Register the WebSocket handler to multiple paths
1045
                for path in paths:
×
1046
                    self.register_servlet(path, websocket_handler, {}, http.ServletType.WEBSOCKET)
×
1047

1048
    @BindField("_servlets_services")
6✔
1049
    @BindField("_servlets_async_services")
6✔
1050
    @BindField("_websocket_handler_services")
6✔
1051
    def _bind_servlet(
6✔
1052
        self,
1053
        _: str,
1054
        service: http.Servlet | http.AsyncServlet | http.WebSocketHandler,
1055
        service_reference: ServiceReference[http.Servlet | http.AsyncServlet | http.WebSocketHandler],
1056
    ) -> None:
1057
        """
1058
        Called by iPOPO when a service is bound
1059
        """
1060
        self._on_bind(service, service_reference)
6✔
1061

1062
    @UpdateField("_servlets_services")
6✔
1063
    @UpdateField("_servlets_async_services")
6✔
1064
    @UpdateField("_websocket_handler_services")
6✔
1065
    def _update_servlet(
6✔
1066
        self,
1067
        _: str,
1068
        service: http.Servlet | http.AsyncServlet | http.WebSocketHandler,
1069
        service_reference: ServiceReference[http.Servlet | http.AsyncServlet | http.WebSocketHandler],
1070
        old_properties: dict[str, Any],
1071
    ) -> None:
1072
        """
1073
        Called by iPOPO when the properties of a service have been updated
1074
        """
1075
        self._on_update(
6✔
1076
            service,
1077
            service_reference,
1078
            old_properties,
1079
            (http.HTTP_SERVLET_PATH, http.HTTP_SERVLET_ASYNC_PATH, http.HTTP_WEBSOCKET_PATH),
1080
        )
1081

1082
    @UnbindField("_servlets_services")
6✔
1083
    @UnbindField("_servlets_async_services")
6✔
1084
    @UnbindField("_websocket_handler_services")
6✔
1085
    def _unbind_servlet(
6✔
1086
        self,
1087
        _: str,
1088
        service: http.Servlet | http.AsyncServlet | http.WebSocketHandler,
1089
        service_reference: ServiceReference[http.Servlet | http.AsyncServlet | http.WebSocketHandler],
1090
    ) -> None:
1091
        """
1092
        Called by iPOPO when a service is gone
1093
        """
1094
        self._on_unbind(service, service_reference)
6✔
1095

1096
    def get_access(self) -> tuple[str, int]:
6✔
1097
        """
1098
        Retrieves the (address, port) tuple to access the server
1099
        """
1100
        assert self._bound_address is not None, "Server must be started before accessing its address"
6✔
1101
        return self._bound_address
6✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc