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

tcalmant / ipopo / 30222584318

26 Jul 2026 10:10PM UTC coverage: 86.341% (-0.02%) from 86.357%
30222584318

push

github

web-flow
Merge pull request #199 from tcalmant/http-rewrite

HTTP refactoring

379 of 417 new or added lines in 5 files covered. (90.89%)

4 existing lines in 3 files now uncovered.

15222 of 17630 relevant lines covered (86.34%)

0.86 hits per line

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

82.45
/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
1✔
31
import concurrent.futures
1✔
32
import io
1✔
33
import logging
1✔
34
import ssl
1✔
35
import sys
1✔
36
import threading
1✔
37
import traceback
1✔
38
from typing import IO, TYPE_CHECKING, Any, cast
1✔
39

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

43
import pelix.constants as fw_constants
1✔
44
from pelix import http, utilities
1✔
45
from pelix.http._base import (
1✔
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
1✔
53
from pelix.ipopo.decorators import (
1✔
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)
1✔
72
__version__ = ".".join(str(x) for x in __version_info__)
1✔
73

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

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

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

88

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

94
    def __init__(self, request: aiohttp.web.Request, full_path: str, prefix: str, content: bytes) -> None:
1✔
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
1✔
104
        self._prefix = prefix
1✔
105
        self._content = content
1✔
106

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

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

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

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

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

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

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

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

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

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

157
    def get_sub_path(self) -> str:
1✔
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
1✔
164

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

171

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

177
    def get(self) -> bytes:
1✔
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()
1✔
185

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

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

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

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

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

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

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

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

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

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

217

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

223
    def __init__(self, request: aiohttp.web.Request, loop: asyncio.AbstractEventLoop) -> None:
1✔
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
1✔
231
        self._loop = loop
1✔
232
        self._headers: dict[str, str] = {}
1✔
233
        self._headers_set: bool = False
1✔
234
        self._code: int = 200
1✔
235
        self._message: str | None = None
1✔
236
        self._writer = _WriteWrapper()
1✔
237

238
    def to_aiohttp_response(self) -> aiohttp.web.StreamResponse:
1✔
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(
1✔
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:
1✔
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:
1✔
NEW
258
            raise OSError("Headers have already been set, cannot change the response code")
×
259

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

263
    def set_header(self, name: str, value: Any) -> None:
1✔
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:
1✔
NEW
272
            raise OSError("Headers have already been set, cannot change them")
×
273

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

279
    def is_header_set(self, name: str) -> bool:
1✔
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
1✔
287

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

294
    def get_wfile(self) -> IO[bytes]:
1✔
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:
1✔
303
            self.end_headers()
×
304

305
        return self._writer
1✔
306

307
    def write(self, data: bytes) -> None:
1✔
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()
1✔
316
        writer.write(data)
1✔
317
        writer.close()
1✔
318

319

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

322

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

328
    def __init__(self, request: aiohttp.web.Request, full_path: str, prefix: str) -> None:
1✔
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
1✔
337
        self._prefix = prefix
1✔
338

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

342
    def get_command(self) -> str:
1✔
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]:
1✔
349
        """
350
        Retrieves the address of the client
351

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

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

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

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

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

381
    def get_prefix_path(self) -> str:
1✔
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:
1✔
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:
1✔
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):
1✔
406
    """
407
    Wrapper for aiohttp StreamResponse
408
    """
409

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

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

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

420

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

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

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

437
    def to_aiohttp_response(self) -> aiohttp.web.StreamResponse:
1✔
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
1✔
445

446
    def set_response(self, code: int, message: str | None = None) -> None:
1✔
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:
1✔
NEW
455
            raise OSError("Headers have already been set, cannot change the response code")
×
456

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

459
    def set_header(self, name: str, value: Any) -> None:
1✔
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:
1✔
NEW
468
            raise OSError("Headers have already been set, cannot change them")
×
469

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

475
    def is_header_set(self, name: str) -> bool:
1✔
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
1✔
483

484
    def setup_sse(self, strict: bool = True) -> None:
1✔
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:
1✔
491
            # Already set up for SSE
492
            return
×
493

494
        if strict and not any(
1✔
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:
1✔
NEW
500
            raise OSError("Headers have already been set, cannot change them")
×
501

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

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

514
    def get_wfile(self) -> http.AbstractAsyncWriter:
1✔
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:
1✔
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)
1✔
532

533
    async def send_sse(
1✔
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:
1✔
NEW
544
            raise OSError("SSE not set up, call setup_sse() first")
×
545

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

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

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

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

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

576

577
class WSSession(http.WebSocketSession):
1✔
578
    def __init__(
1✔
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
1✔
592
        self._request = servlet_request
1✔
593
        self._response = ws_response
1✔
594

595
    def get_client_address(self) -> tuple[str, int]:
1✔
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:
1✔
604
        """
605
        Sends a binary message to the client
606

607
        :param message: Binary message to send
608
        """
609
        if self._response.closed:
×
NEW
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:
1✔
615
        """
616
        Sends a message to the client
617

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

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

625
    async def close(self, code: int = 1000, reason: str | None = None) -> None:
1✔
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:
1✔
633
            await self._response.close(code=code, message=(reason or "").encode("utf-8"))
1✔
634

635

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

638

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

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

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

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

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

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

679
        self.log(
1✔
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)
1✔
689
        app.add_routes([aiohttp.web.route("*", "/{tail:.*}", self.__global_handler)])
1✔
690
        self._app = app
1✔
691

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

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

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

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

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

716
        self._logger.info(
1✔
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
1✔
724
    def invalidate(self, context: "BundleContext") -> None:
1✔
725
        """
726
        Component invalidated
727
        """
728
        # Clear the validation flag
729
        self._validated = False
1✔
730

731
        self.log(
1✔
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:
1✔
741
            # Call for a stop
742
            async def async_stop_caller():
1✔
743
                self._stop_event.set()
1✔
744

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

747
        # Wait for the stop done event to be set
748
        self._logger.debug("Waiting for the stop done event to be set...")
1✔
749
        if not self._stop_done_event.wait(timeout=5):
1✔
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():
1✔
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
1✔
762

763
        # Close the event loop
764
        if self._loop is not None and not self._loop.is_closed():
1✔
765
            self._loop.close()
1✔
766

767
        # Clear references
768
        self._loop = None
1✔
769
        self._app = None
1✔
770

771
    def _run_server_thread(self) -> None:
1✔
772
        """
773
        Runs the HTTP server in a separate thread.
774
        """
775
        try:
1✔
776
            # Set the event loop for this thread
777
            if sys.platform.startswith("win"):
1✔
778
                # aiodns requires a specific event loop on Windows
779
                # FIXME: this will be removed in Python 3.16
UNCOV
780
                asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
×
781

782
            self._loop = asyncio.new_event_loop()
1✔
783
            asyncio.set_event_loop(self._loop)
1✔
784

785
            # Setup the server
786
            self._loop.run_until_complete(self._run_server())
1✔
787
        except Exception:
×
788
            self._logger.exception("Error running async HTTP server")
×
789
        finally:
790
            if self._loop is not None:
1✔
791
                self._loop.stop()
1✔
792
            self._stop_done_event.set()
1✔
793

794
    async def _run_server(self) -> None:
1✔
795
        try:
1✔
796
            assert self._app is not None, "Application must be initialized before running the server"
1✔
797

798
            # Create the server
799
            runner = aiohttp.web.AppRunner(self._app)
1✔
800
            await runner.setup()
1✔
801

802
            # Prepare SSL context if needed
803
            ssl_context: ssl.SSLContext | None = None
1✔
804
            if self._uses_ssl:
1✔
805
                assert self._cert_file is not None, "Certificate file must be set for HTTPS"
1✔
806

807
                # Create the SSL context
808
                ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
1✔
809
                ssl_context.load_cert_chain(
1✔
810
                    certfile=self._cert_file, keyfile=self._key_file, password=self._key_password
811
                )
812

813
            # Create the site
814
            site = aiohttp.web.TCPSite(runner, self._address, self._port, ssl_context=ssl_context)
1✔
815

816
            with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
1✔
817
                self._executor = executor
1✔
818

819
                # Start the site
820
                await site.start()
1✔
821

822
                # Get bound address and port
823
                sock = cast(asyncio.Server, site._server).sockets[0]
1✔
824
                host, port = sock.getsockname()[:2]
1✔
825

826
                # We're ready
827
                self._start_done_event.set((host, port))
1✔
828

829
                # Keep the thread alive until the server is stopped
830
                await self._stop_event.wait()
1✔
831

832
                # Clean up the server
833
                await site.stop()
1✔
834
                await runner.shutdown()
1✔
835
                await runner.cleanup()
1✔
836
                await self._app.shutdown()
1✔
837
                await self._app.cleanup()
1✔
838
        except Exception as ex:
×
839
            # Anything went wrong, log the error
840
            self._logger.error("Error running the HTTP server: %s", ex)
×
841
            self._start_done_event.raise_exception(ex)
×
842

843
    async def __global_handler(self, request: aiohttp.web.Request) -> aiohttp.web.StreamResponse:
1✔
844
        """
845
        Global handler for the Pelix async HTTP service.
846

847
        :param request: The incoming request
848
        :return: The response to send
849
        """
850
        assert self._loop is not None, "EventLoop must be initialized before handling requests"
1✔
851

852
        if self._executor is None:
1✔
853
            # No executor available, cannot handle the request
854
            return aiohttp.web.Response(status=503, text="Service unavailable")
×
855

856
        # Get the corresponding servlet
857
        routing = self.resolve_request(request.path)
1✔
858
        path = routing.path
1✔
859
        servlet = routing.servlet
1✔
860
        if servlet is not None:
1✔
861
            prefix = routing.prefix
1✔
862
            servlet_type = routing.servlet_type
1✔
863

864
            async_name = f"do_async_{request.method.upper()}"
1✔
865
            sync_name = f"do_{request.method.upper()}"
1✔
866

867
            try:
1✔
868
                match servlet_type:
1✔
869
                    case http.ServletType.ASYNC if hasattr(servlet, async_name):
1✔
870
                        # Prepare the helpers
871
                        servlet_request = _AsyncHTTPServletRequest(request, path, prefix)
1✔
872
                        servlet_response = _AsyncHTTPServletResponse(request)
1✔
873

874
                        # Handle the request
875
                        handler_method = getattr(servlet, async_name)
1✔
876
                        await handler_method(servlet_request, servlet_response)
1✔
877
                        return servlet_response.to_aiohttp_response()
1✔
878

879
                    case http.ServletType.SYNC if hasattr(servlet, sync_name):
1✔
880
                        # Read the request content
881
                        # FIXME: find a better way to handle the content, wrapping the request
882
                        #        in a file-like object
883
                        content = await request.read()
1✔
884

885
                        # Prepare the helpers
886
                        servlet_request = _SyncHTTPServletRequest(request, path, prefix, content)
1✔
887
                        servlet_response = _SyncHTTPServletResponse(request, self._loop)
1✔
888

889
                        # Handle the request in the executor
890
                        handler_method = getattr(servlet, sync_name)
1✔
891
                        await self._loop.run_in_executor(
1✔
892
                            self._executor, handler_method, servlet_request, servlet_response
893
                        )
894
                        return servlet_response.to_aiohttp_response()
1✔
895

896
                    case http.ServletType.WEBSOCKET if isinstance(servlet, http.WebSocketHandler):
1✔
897
                        # Prepare the WebSocket handler
898
                        ws_handler = cast(http.WebSocketHandler, servlet)
1✔
899
                        servlet_request = _AsyncHTTPServletRequest(request, path, prefix)
1✔
900

901
                        # Prepare the WebSocket response
902
                        ws_response = aiohttp.web.WebSocketResponse()
1✔
903

904
                        # Prepare a session
905
                        ws_session = WSSession(ws_handler, servlet_request, ws_response)
1✔
906

907
                        # Early check
908
                        if not await ws_handler.ws_accept(servlet_request):
1✔
909
                            # The handler does not accept the WebSocket connection
910
                            return aiohttp.web.Response(status=400, text="WebSocket connection refused")
×
911

912
                        # Prepare the WebSocket response
913
                        await ws_response.prepare(request)
1✔
914

915
                        try:
1✔
916
                            # Notify the WebSocket handler of the new connection
917
                            await ws_handler.ws_open(ws_session, servlet_request)
1✔
918

919
                            async for msg in ws_response:
1✔
920
                                # Handle incoming messages
921
                                match msg.type:
1✔
922
                                    case aiohttp.WSMsgType.ERROR:
1✔
923
                                        # Error message received
924
                                        self._logger.error("WebSocket error: %s", ws_response.exception())
×
925
                                        await ws_handler.ws_error(ws_session, msg.data)
×
926

927
                                    case aiohttp.WSMsgType.PING:
1✔
928
                                        # Ping message received
929
                                        await ws_response.pong(msg.data)
×
930

931
                                    case aiohttp.WSMsgType.BINARY:
1✔
932
                                        # Binary message received
933
                                        await ws_handler.ws_binary(ws_session, msg.data)
×
934

935
                                    case aiohttp.WSMsgType.TEXT:
1✔
936
                                        # Text message received
937
                                        await ws_handler.ws_message(ws_session, msg.data)
1✔
938

939
                            # End of loop: the WebSocket connection is closed
940
                            code = ws_response.close_code or aiohttp.WSCloseCode.GOING_AWAY
1✔
941
                            await ws_handler.ws_close(ws_session, code, "Session closed")
1✔
942
                        except Exception as ex:
×
NEW
943
                            self._logger.exception("Error handling WebSocket connection")
×
944
                            await ws_handler.ws_error(ws_session, str(ex))
×
945
                        finally:
946
                            if not ws_response.closed:
1✔
947
                                await ws_response.close()
×
948

949
                        return ws_response
1✔
950
            except Exception:
1✔
951
                # Send a 500 error page on error.
952
                # The details are logged by make_exception_page()
953
                self._logger.error("Error handling %s request to %s", request.method, path)
1✔
954
                return self.send_exception(path)
1✔
955

956
        # Return the super implementation if needed
957
        return aiohttp.web.Response(status=404, text=self.make_not_found_page(path), content_type="text/html")
1✔
958

959
    def send_exception(self, path: str) -> aiohttp.web.Response:
1✔
960
        """
961
        Sends an exception page with a 500 error code.
962
        Must be called from inside the exception handling block.
963

964
        :param path: Erroneous request path
965
        :return: The aiohttp Response to send
966
        """
967
        # Get a formatted stack trace.
968
        # The error is logged by make_exception_page(), which also decides what
969
        # can be sent to the client
970
        stack = traceback.format_exc()
1✔
971

972
        # Send the page
973
        return aiohttp.web.Response(status=500, text=self.make_exception_page(path, stack))
1✔
974

975
    def _set_extra_parameters(self, parameters: dict[str, Any], servlet_type: http.ServletType) -> None:
1✔
976
        """
977
        Tells the servlet if it is registered in asynchronous mode
978

979
        :param parameters: The parameters given to the servlet
980
        :param servlet_type: The type of the servlet being registered
981
        """
982
        parameters[http.PARAM_ASYNC] = servlet_type != http.ServletType.SYNC
1✔
983

984
    def _register_servlet_service(
1✔
985
        self,
986
        service: http.Servlet | http.AsyncServlet | http.WebSocketHandler,
987
        service_reference: ServiceReference[Any],
988
    ) -> None:
989
        """
990
        Registers a servlet according to its service properties
991

992
        :param service: A servlet service
993
        :param service_reference: The associated ServiceReference
994
        """
995
        spec = cast(list[str], service_reference.get_property(fw_constants.OBJECTCLASS))
1✔
996
        if http.HTTP_SERVLET in spec:
1✔
997
            # Servlet bound
998
            sync_servlet = cast(http.Servlet, service)
1✔
999
            paths = service_reference.get_property(http.HTTP_SERVLET_PATH)
1✔
1000
            if utilities.is_string(paths):
1✔
1001
                # Register the servlet to a single path
1002
                self.register_servlet(paths, sync_servlet, {}, http.ServletType.SYNC)
1✔
1003
            elif isinstance(paths, (list, tuple)):
1✔
1004
                # Register the servlet to multiple paths
1005
                for path in paths:
1✔
1006
                    self.register_servlet(path, sync_servlet, {}, http.ServletType.SYNC)
1✔
1007

1008
        # No else here: a service could implement both specifications
1009
        if http.HTTP_SERVLET_ASYNC in spec:
1✔
1010
            # Asynchronous servlet bound
1011
            async_servlet = cast(http.AsyncServlet, service)
1✔
1012
            paths = service_reference.get_property(http.HTTP_SERVLET_ASYNC_PATH)
1✔
1013
            if utilities.is_string(paths):
1✔
1014
                # Register the servlet to a single path
1015
                self.register_servlet(paths, async_servlet, {}, http.ServletType.ASYNC)
1✔
1016
            elif isinstance(paths, (list, tuple)):
1✔
1017
                # Register the servlet to multiple paths
1018
                for path in paths:
1✔
1019
                    self.register_servlet(path, async_servlet, {}, http.ServletType.ASYNC)
1✔
1020

1021
        # No else here: a service could implement both specifications
1022
        if http.HTTP_WEBSOCKET_HANDLER in spec:
1✔
1023
            # WebSocket handler bound
1024
            websocket_handler = cast(http.WebSocketHandler, service)
×
1025
            paths = service_reference.get_property(http.HTTP_WEBSOCKET_PATH)
×
1026
            if utilities.is_string(paths):
×
1027
                # Register the WebSocket handler to a single path
1028
                self.register_servlet(paths, websocket_handler, {}, http.ServletType.WEBSOCKET)
×
1029
            elif isinstance(paths, (list, tuple)):
×
1030
                # Register the WebSocket handler to multiple paths
1031
                for path in paths:
×
1032
                    self.register_servlet(path, websocket_handler, {}, http.ServletType.WEBSOCKET)
×
1033

1034
    @BindField("_servlets_services")
1✔
1035
    @BindField("_servlets_async_services")
1✔
1036
    @BindField("_websocket_handler_services")
1✔
1037
    def _bind_servlet(
1✔
1038
        self,
1039
        _: str,
1040
        service: http.Servlet | http.AsyncServlet | http.WebSocketHandler,
1041
        service_reference: ServiceReference[http.Servlet | http.AsyncServlet | http.WebSocketHandler],
1042
    ) -> None:
1043
        """
1044
        Called by iPOPO when a service is bound
1045
        """
1046
        self._on_bind(service, service_reference)
1✔
1047

1048
    @UpdateField("_servlets_services")
1✔
1049
    @UpdateField("_servlets_async_services")
1✔
1050
    @UpdateField("_websocket_handler_services")
1✔
1051
    def _update_servlet(
1✔
1052
        self,
1053
        _: str,
1054
        service: http.Servlet | http.AsyncServlet | http.WebSocketHandler,
1055
        service_reference: ServiceReference[http.Servlet | http.AsyncServlet | http.WebSocketHandler],
1056
        old_properties: dict[str, Any],
1057
    ) -> None:
1058
        """
1059
        Called by iPOPO when the properties of a service have been updated
1060
        """
1061
        self._on_update(
1✔
1062
            service,
1063
            service_reference,
1064
            old_properties,
1065
            (http.HTTP_SERVLET_PATH, http.HTTP_SERVLET_ASYNC_PATH, http.HTTP_WEBSOCKET_PATH),
1066
        )
1067

1068
    @UnbindField("_servlets_services")
1✔
1069
    @UnbindField("_servlets_async_services")
1✔
1070
    @UnbindField("_websocket_handler_services")
1✔
1071
    def _unbind_servlet(
1✔
1072
        self,
1073
        _: str,
1074
        service: http.Servlet | http.AsyncServlet | http.WebSocketHandler,
1075
        service_reference: ServiceReference[http.Servlet | http.AsyncServlet | http.WebSocketHandler],
1076
    ) -> None:
1077
        """
1078
        Called by iPOPO when a service is gone
1079
        """
1080
        self._on_unbind(service, service_reference)
1✔
1081

1082
    def get_access(self) -> tuple[str, int]:
1✔
1083
        """
1084
        Retrieves the (address, port) tuple to access the server
1085
        """
1086
        assert self._bound_address is not None, "Server must be started before accessing its address"
1✔
1087
        return self._bound_address
1✔
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