From 4f5c5d9f61ba007e0e10be141ab5cd9fe8e4e2fe Mon Sep 17 00:00:00 2001 From: Roman Miroshnychenko Date: Sun, 17 May 2026 12:02:15 +0300 Subject: [PATCH 01/13] Abstract server interactions from WebConsole --- uv.lock | 2 +- web_pdb/__init__.py | 2 +- web_pdb/server_adapter.py | 61 ++++++++++++++++++++++ web_pdb/{adapter.py => system_adapter.py} | 10 ++-- web_pdb/web_console.py | 62 ++++------------------- 5 files changed, 79 insertions(+), 58 deletions(-) create mode 100644 web_pdb/server_adapter.py rename web_pdb/{adapter.py => system_adapter.py} (93%) diff --git a/uv.lock b/uv.lock index a5bc514..8ec2f72 100644 --- a/uv.lock +++ b/uv.lock @@ -1,3 +1,3 @@ version = 1 revision = 3 -requires-python = ">=3.14" +requires-python = ">=3.8" diff --git a/web_pdb/__init__.py b/web_pdb/__init__.py index 3504fc0..43f032c 100644 --- a/web_pdb/__init__.py +++ b/web_pdb/__init__.py @@ -33,7 +33,7 @@ from pdb import Pdb from pprint import pformat -from .adapter import SystemAdapter +from .system_adapter import SystemAdapter from .web_console import WebConsole __all__ = ['WebPdb', 'set_trace', 'post_mortem', 'catch_post_mortem'] diff --git a/web_pdb/server_adapter.py b/web_pdb/server_adapter.py new file mode 100644 index 0000000..fa3cdf4 --- /dev/null +++ b/web_pdb/server_adapter.py @@ -0,0 +1,61 @@ +import queue + +from asyncore_wsgi import AsyncWebSocketHandler, make_server + +from .system_adapter import SystemAdapter +from .wsgi_app import app + + +class WebConsoleSocket(AsyncWebSocketHandler): + """ + WebConsoleSocket receives PDB commands from the front-end and + sends pings to client(s) about console updates + """ + + clients = [] + input_queue = queue.Queue() + + @classmethod + def broadcast(cls, msg): + for cl in cls.clients: + if cl.handshaked: + cl.sendMessage(msg) # sendMessage uses deque so it is thread-safe + + def handleConnected(self): + self.clients.append(self) + + def handleMessage(self): + self.input_queue.put(self.data) + + def handleClose(self): + self.clients.remove(self) + + +class ServerAdapter: + def __init__(self, host, port): + self._system_adapter = SystemAdapter() + self._httpd = make_server(host, port, app, ws_handler_class=WebConsoleSocket) + + @property + def web_socket_input_queue(self): + return WebConsoleSocket.input_queue + + @staticmethod + def web_socket_broadcast(message): + WebConsoleSocket.broadcast(message) + + def serve_forever(self) -> None: + is_started = False + while not self._system_adapter.is_abort_requested(): + if not is_started: + self._system_adapter.on_server_started(self._httpd.server_name, self._httpd.server_port) + is_started = True + try: + self._httpd.handle_request() + except (KeyboardInterrupt, SystemExit): + break + self._httpd.handle_close() + self._system_adapter.on_server_stopped() + + def close(self): + self._system_adapter.abort() diff --git a/web_pdb/adapter.py b/web_pdb/system_adapter.py similarity index 93% rename from web_pdb/adapter.py rename to web_pdb/system_adapter.py index 5e4217f..e46c679 100644 --- a/web_pdb/adapter.py +++ b/web_pdb/system_adapter.py @@ -43,17 +43,17 @@ class _BaseAdapter(ABC): def __init__(self): - self._abort = Event() + self.abort_event = Event() @abstractmethod def is_abort_requested(self): raise NotImplementedError def abort(self): - self._abort.set() + self.abort_event.set() def is_aborted(self): - return self._abort.is_set() + return self.abort_event.is_set() @abstractmethod def on_server_started(self, server_name, port): @@ -68,7 +68,7 @@ def on_exception(self): class _ServerAdapter(_BaseAdapter): def is_abort_requested(self): - return self._abort.is_set() + return self.abort_event.is_set() def on_server_started(self, server_name, port): logging.critical('Web-PDB: starting web-server on http://%s:%s', server_name, port) @@ -86,7 +86,7 @@ def __init__(self): self._dialog_progress = DialogProgress() def is_abort_requested(self): - return self._abort.is_set() or self._monitor.abortRequested() + return self.abort_event.is_set() or self._monitor.abortRequested() def on_server_started(self, server_name, port): xbmc.log('Web-PDB: web-server started.', level=xbmc.LOGINFO) diff --git a/web_pdb/web_console.py b/web_pdb/web_console.py index e5d7c3e..3e43260 100644 --- a/web_pdb/web_console.py +++ b/web_pdb/web_console.py @@ -30,51 +30,26 @@ import weakref from threading import Thread -from asyncore_wsgi import AsyncWebSocketHandler, make_server - -from .adapter import SystemAdapter from .buffer import ThreadSafeBuffer +from .server_adapter import ServerAdapter +from .system_adapter import SystemAdapter from .wsgi_app import app __all__ = ['WebConsole'] -class WebConsoleSocket(AsyncWebSocketHandler): - """ - WebConsoleSocket receives PDB commands from the front-end and - sends pings to client(s) about console updates - """ - - clients = [] - input_queue = queue.Queue() - - @classmethod - def broadcast(cls, msg): - for cl in cls.clients: - if cl.handshaked: - cl.sendMessage(msg) # sendMessage uses deque so it is thread-safe - - def handleConnected(self): - self.clients.append(self) - - def handleMessage(self): - self.input_queue.put(self.data) - - def handleClose(self): - self.clients.remove(self) - - class WebConsole: """ A file-like class for exchanging data between PDB and the web-UI """ def __init__(self, host, port, debugger): - self._adapter = SystemAdapter() + self._system_adapter = SystemAdapter() + self._server_adapter = ServerAdapter(host, port) self._debugger = weakref.proxy(debugger) self._console_history = ThreadSafeBuffer('') - self._frame_data = None - self._server_thread = Thread(target=self._run_server, args=(host, port)) + self._frame_data = app.frame_data + self._server_thread = Thread(target=self._server_adapter.serve_forever) self._server_thread.daemon = True self._server_thread.start() @@ -92,27 +67,12 @@ def encoding(self): @property def closed(self): - return self._adapter.is_aborted() - - def _run_server(self, host, port): - self._frame_data = app.frame_data - httpd = make_server(host, port, app, ws_handler_class=WebConsoleSocket) - is_started = False - while not self._adapter.is_abort_requested(): - if not is_started: - self._adapter.on_server_started(httpd.server_name, httpd.server_port) - is_started = True - try: - httpd.handle_request() - except (KeyboardInterrupt, SystemExit): - break - httpd.handle_close() - self._adapter.on_server_stopped() + return self._system_adapter.is_aborted() def readline(self): - while not self._adapter.is_abort_requested(): + while not self._system_adapter.is_abort_requested(): try: - data = WebConsoleSocket.input_queue.get(timeout=0.1) + data = self._server_adapter.web_socket_input_queue.get(timeout=0.1) break except queue.Empty: continue @@ -139,7 +99,7 @@ def writeline(self, data): } frame_data['console_history'] = self._console_history.contents self._frame_data.contents = frame_data - WebConsoleSocket.broadcast('ping') # Ping all clients about data update + self._server_adapter.web_socket_broadcast('ping') # Ping all clients about data update write = writeline @@ -155,6 +115,6 @@ def flush(self): def close(self): logging.critical('Web-PDB: stopping web-server...') - self._adapter.abort() + self._server_adapter.close() self._server_thread.join() logging.critical('Web-PDB: web-server stopped.') From ed4c029bb8a99e2decafc11004448f9ed78b959e Mon Sep 17 00:00:00 2001 From: Roman Miroshnychenko Date: Sun, 17 May 2026 15:49:24 +0300 Subject: [PATCH 02/13] Replace asyncore-wsgi/Bottle with pure-stdlib asyncio HTTP+WebSocket server Drop the `asyncore-wsgi` and `bottle` dependencies entirely. The new `asyncio_server.py` module implements RFC 6455 WebSocket framing, HTTP routing, and gzip compression using only the standard library, making the package compatible with Python 3.8+ including 3.12+ where `asyncore` is removed. `server_adapter.py` is rewritten as a thin facade; `wsgi_app.py` is deleted. Co-Authored-By: Claude Sonnet 4.6 --- .claude/CLAUDE.md | 19 +- requirements.txt | 2 - setup.cfg | 3 - web_pdb/asyncio_server.py | 380 ++++++++++++++++++++++++++++++++++++++ web_pdb/server_adapter.py | 85 ++++----- web_pdb/system_adapter.py | 7 +- web_pdb/web_console.py | 3 +- web_pdb/wsgi_app.py | 97 ---------- 8 files changed, 440 insertions(+), 156 deletions(-) create mode 100644 web_pdb/asyncio_server.py delete mode 100644 web_pdb/wsgi_app.py diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 6ab1625..e6b9110 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -25,7 +25,18 @@ The debugger backend is a two-threaded system: (`set_trace()`, `post_mortem()`, `catch_post_mortem()`). Handles command dispatch, variable formatting, and the custom `inspect`/`i` command. - `web_pdb/web_console.py`: File-like class that serves as stdin/stdout for the debugger thread. - Manages the WebSocket server, handles bidirectional communication between debugger and web UI. + Delegates server management and WebSocket broadcasting to `ServerAdapter`. Maintains + `console_history` buffer and frame data, pinging clients on each write. +- `web_pdb/server_adapter.py`: Manages the HTTP/WebSocket server lifecycle. `WebConsoleSocket` + (extends `AsyncWebSocketHandler`) handles WebSocket connections: receives PDB commands from the + frontend via a class-level `input_queue` and broadcasts pings to all connected clients. + `ServerAdapter` wraps `make_server()`, drives the event loop in `serve_forever()`, and delegates + lifecycle events to `SystemAdapter`. +- `web_pdb/system_adapter.py`: Abstraction layer for running in a standard Python environment vs. + a Kodi addon. Exposes `SystemAdapter` (alias to `_ServerAdapter` or `_KodiAdapter` depending on + whether the Kodi runtime is detected). Both implement `is_abort_requested()`, + `on_server_started()`, `on_server_stopped()`, and `on_exception()`. The Kodi variant uses + `xbmc.Monitor` for abort detection and shows progress dialogs/notifications. - `web_pdb/wsgi_app.py`: Bottle application serving the web UI and API endpoints for debugger control and frame data retrieval. - `web_pdb/buffer.py`: Thread-safe buffer (`ThreadSafeBuffer`) used for passing data between @@ -106,11 +117,13 @@ pip install . ## Threading Model The debugger maintains one active instance (`WebPdb.active_instance`) that traces one thread at a -time. The WebConsole spawns a daemon thread to run the web server. Thread safety is achieved via: +time. `WebConsole` spawns a daemon thread that runs `ServerAdapter.serve_forever()`. Thread safety +is achieved via: - `ThreadSafeBuffer` with RLock for console history and frame data. -- `queue.Queue` for PDB commands from the web UI (thread-safe by design). +- `queue.Queue` (class-level on `WebConsoleSocket`) for PDB commands from the web UI. - WebSocket deque for sending messages to clients (thread-safe for appending). +- `threading.Event` in `SystemAdapter` for coordinating server shutdown. ## Testing Notes diff --git a/requirements.txt b/requirements.txt index 0b934b5..574a349 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,5 @@ uv wheel build>=1.0.0 -bottle>=0.12.25 -asyncore-wsgi>=0.0.11 selenium==4.10.0 ruff==0.15.12 diff --git a/setup.cfg b/setup.cfg index df246b2..37efe68 100644 --- a/setup.cfg +++ b/setup.cfg @@ -12,7 +12,6 @@ license = MIT License classifiers = Development Status :: 5 - Production/Stable Environment :: Web Environment - Framework :: Bottle Intended Audience :: Developers License :: OSI Approved :: MIT License Operating System :: OS Independent @@ -28,8 +27,6 @@ zip_safe = False include_package_data = True python_requires = >=3.8 install_requires = - bottle>=0.12.25 - asyncore-wsgi>=0.0.11 test_suite = tests.tests tests_require = selenium==4.10.0 diff --git a/web_pdb/asyncio_server.py b/web_pdb/asyncio_server.py new file mode 100644 index 0000000..adb0148 --- /dev/null +++ b/web_pdb/asyncio_server.py @@ -0,0 +1,380 @@ +""" +Asyncio-based HTTP server with WebSocket support for Web-PDB +""" + +from __future__ import annotations + +import asyncio +import base64 +import gzip +import hashlib +import json +import logging +import mimetypes +import queue +import socket +import struct +from pathlib import Path +from urllib.parse import unquote + +__all__ = ['AsyncioServer'] + +logger = logging.getLogger(__name__) + +_WS_MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11' +_GZIP_TYPES = ('text/', 'application/json', 'application/javascript', 'image/svg+xml') +_GZIP_MIN_SIZE = 1024 +_MAX_HEADER_SIZE = 8192 +_WS_OUTBOUND_QUEUE_SIZE = 32 + +_this_dir = Path(__file__).parent +_static_dir = _this_dir / 'static' +_index_file = _this_dir / 'templates' / 'index.html' + +# Opcode constants +_OP_CONTINUATION = 0x0 +_OP_TEXT = 0x1 +_OP_BINARY = 0x2 +_OP_CLOSE = 0x8 +_OP_PING = 0x9 +_OP_PONG = 0xA + + +def _ws_accept_key(key: str) -> str: + digest = hashlib.sha1((key + _WS_MAGIC).encode()).digest() + return base64.b64encode(digest).decode() + + +def _ws_encode_frame(payload: bytes, opcode: int = _OP_TEXT) -> bytes: + length = len(payload) + if length <= 125: + header = bytes([0x80 | opcode, length]) + elif length <= 65535: + header = bytes([0x80 | opcode, 126]) + struct.pack('>H', length) + else: + header = bytes([0x80 | opcode, 127]) + struct.pack('>Q', length) + return header + payload + + +async def _ws_read_frame(reader: asyncio.StreamReader): + """Read one WebSocket frame; return (opcode, payload_bytes) or raise.""" + header = await reader.readexactly(2) + fin = (header[0] & 0x80) != 0 + opcode = header[0] & 0x0F + masked = (header[1] & 0x80) != 0 + length = header[1] & 0x7F + + if length == 126: + length = struct.unpack('>H', await reader.readexactly(2))[0] + elif length == 127: + length = struct.unpack('>Q', await reader.readexactly(8))[0] + + mask_key = await reader.readexactly(4) if masked else b'' + payload = bytearray(await reader.readexactly(length)) + + if masked: + for i in range(length): + payload[i] ^= mask_key[i % 4] + + if not fin: + # We don't send large messages from the client, so just discard + # continuation frames — they won't appear in practice. + pass + + return opcode, bytes(payload) + + +def _maybe_gzip(body: bytes, content_type: str, accept_encoding: str) -> tuple: + """Return (body, extra_headers) with gzip applied when appropriate.""" + if ( + 'gzip' in accept_encoding + and len(body) > _GZIP_MIN_SIZE + and any(content_type.startswith(t) for t in _GZIP_TYPES) + ): + body = gzip.compress(body) + return body, {'Content-Encoding': 'gzip'} + return body, {} + + +def _build_response( + status: str, + body: bytes, + content_type: str, + extra_headers: dict | None = None, +) -> bytes: + headers = { + 'Content-Type': content_type, + 'Content-Length': str(len(body)), + 'Connection': 'close', + } + if extra_headers: + headers.update(extra_headers) + header_lines = ''.join(f'{k}: {v}\r\n' for k, v in headers.items()) + return f'HTTP/1.1 {status}\r\n{header_lines}\r\n'.encode() + body + + +class _WebSocketConnection: + def __init__(self, reader, writer, input_queue: queue.Queue): + self._reader = reader + self._writer = writer + self._input_queue = input_queue + self._send_queue: asyncio.Queue = asyncio.Queue(maxsize=_WS_OUTBOUND_QUEUE_SIZE) + self._closed = False + + def send(self, message: str) -> None: + if self._closed: + return + payload = message.encode('utf-8') + try: + self._send_queue.put_nowait(payload) + except asyncio.QueueFull: + # Drop the oldest ping and enqueue the new one + try: + self._send_queue.get_nowait() + except asyncio.QueueEmpty: + pass + try: + self._send_queue.put_nowait(payload) + except asyncio.QueueFull: + pass + + async def run(self) -> None: + writer_task = asyncio.create_task(self._writer_loop()) + try: + await self._reader_loop() + finally: + self._closed = True + writer_task.cancel() + try: + await writer_task + except (asyncio.CancelledError, Exception): + pass + try: + self._writer.write(_ws_encode_frame(b'', _OP_CLOSE)) + await self._writer.drain() + except Exception: + pass + try: + self._writer.close() + except Exception: + pass + + async def _reader_loop(self) -> None: + while True: + try: + opcode, payload = await _ws_read_frame(self._reader) + except (asyncio.IncompleteReadError, ConnectionError, EOFError): + break + if opcode in (_OP_TEXT, _OP_BINARY, _OP_CONTINUATION): + text = payload.decode('utf-8', errors='replace') + self._input_queue.put(text) + elif opcode == _OP_PING: + self._writer.write(_ws_encode_frame(payload, _OP_PONG)) + try: + await self._writer.drain() + except Exception: + break + elif opcode == _OP_CLOSE: + break + + async def _writer_loop(self) -> None: + while True: + payload = await self._send_queue.get() + try: + self._writer.write(_ws_encode_frame(payload, _OP_TEXT)) + await self._writer.drain() + except Exception: + break + + +class AsyncioServer: + def __init__(self, host: str, port: int, frame_data, input_queue: queue.Queue): + self._host = host + self._port = port + self._frame_data = frame_data + self._input_queue = input_queue + self._connections: set[_WebSocketConnection] = set() + self._loop: asyncio.AbstractEventLoop | None = None + self._stop_event: asyncio.Event | None = None + self.server_name: str = '' + self.server_port: int = 0 + + def _broadcast(self, message: str) -> None: + for conn in list(self._connections): + conn.send(message) + + def broadcast(self, message: str) -> None: + if self._loop is not None: + try: + self._loop.call_soon_threadsafe(self._broadcast, message) + except RuntimeError: + pass + + def stop(self) -> None: + if self._loop is not None and self._stop_event is not None: + try: + self._loop.call_soon_threadsafe(self._stop_event.set) + except RuntimeError: + pass + + async def _main(self, is_abort_requested, on_started, on_stopped) -> None: + self._loop = asyncio.get_running_loop() + self._stop_event = asyncio.Event() + + server = await asyncio.start_server(self._handle_connection, self._host, self._port) + async with server: + sock = server.sockets[0] + addr = sock.getsockname() + self.server_port = addr[1] + self.server_name = socket.getfqdn(self._host) if self._host else socket.getfqdn() + on_started(self.server_name, self.server_port) + + async def _abort_watcher(): + while not is_abort_requested(): + await asyncio.sleep(0.1) + self._stop_event.set() + + watcher = asyncio.create_task(_abort_watcher()) + await self._stop_event.wait() + watcher.cancel() + + pending = {t for t in asyncio.all_tasks() if t is not asyncio.current_task()} + for task in pending: + task.cancel() + if pending: + await asyncio.gather(*pending, return_exceptions=True) + + on_stopped() + + async def _handle_connection( + self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter + ) -> None: + try: + await self._process_request(reader, writer) + except Exception: + logger.exception('Unhandled error in HTTP connection handler') + finally: + try: + writer.close() + except Exception: + pass + + async def _process_request( + self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter + ) -> None: + try: + raw = await asyncio.wait_for(reader.readuntil(b'\r\n\r\n'), timeout=10.0) + except asyncio.LimitOverrunError: + writer.write(b'HTTP/1.1 414 Request-URI Too Long\r\nConnection: close\r\n\r\n') + await writer.drain() + return + except (asyncio.TimeoutError, asyncio.IncompleteReadError, ConnectionError): + return + + if len(raw) > _MAX_HEADER_SIZE: + writer.write(b'HTTP/1.1 414 Request-URI Too Long\r\nConnection: close\r\n\r\n') + await writer.drain() + return + + lines = raw.split(b'\r\n') + try: + request_line = lines[0].decode('latin-1') + method, raw_path, _ = request_line.split() + except (ValueError, UnicodeDecodeError): + writer.write(b'HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n') + await writer.drain() + return + + headers: dict[str, str] = {} + for line in lines[1:]: + if b':' in line: + k, _, v = line.partition(b':') + headers[k.strip().lower().decode('latin-1')] = v.strip().decode('latin-1') + + path = unquote(raw_path.split('?')[0]) + accept_encoding = headers.get('accept-encoding', '') + + if method != 'GET': + writer.write(b'HTTP/1.1 405 Method Not Allowed\r\nConnection: close\r\n\r\n') + await writer.drain() + return + + if path == '/': + await self._serve_index(writer, accept_encoding) + elif path == '/frame-data': + await self._serve_frame_data(writer, accept_encoding) + elif path.startswith('/static/'): + await self._serve_static(writer, path[len('/static/'):], accept_encoding) + elif path == '/ws': + await self._serve_websocket(reader, writer, headers) + else: + writer.write(b'HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n') + await writer.drain() + + async def _serve_index(self, writer, accept_encoding: str) -> None: + body = _index_file.read_bytes() + body, gz_headers = _maybe_gzip(body, 'text/html; charset=utf-8', accept_encoding) + response = _build_response('200 OK', body, 'text/html; charset=utf-8', gz_headers) + writer.write(response) + await writer.drain() + + async def _serve_frame_data(self, writer, accept_encoding: str) -> None: + body = json.dumps(self._frame_data.contents).encode('utf-8') + body, gz_headers = _maybe_gzip(body, 'application/json', accept_encoding) + extra = {'Cache-Control': 'no-store'} + extra.update(gz_headers) + response = _build_response('200 OK', body, 'application/json', extra) + writer.write(response) + await writer.drain() + + async def _serve_static(self, writer, rel_path: str, accept_encoding: str) -> None: + try: + requested = (_static_dir / rel_path).resolve() + except Exception: + writer.write(b'HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n') + await writer.drain() + return + + if not str(requested).startswith(str(_static_dir.resolve())): + writer.write(b'HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n') + await writer.drain() + return + + if not requested.is_file(): + writer.write(b'HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n') + await writer.drain() + return + + body = requested.read_bytes() + content_type = mimetypes.guess_type(str(requested))[0] or 'application/octet-stream' + body, gz_headers = _maybe_gzip(body, content_type, accept_encoding) + response = _build_response('200 OK', body, content_type, gz_headers) + writer.write(response) + await writer.drain() + + async def _serve_websocket(self, reader, writer, headers: dict) -> None: + ws_key = headers.get('sec-websocket-key', '').strip() + ws_version = headers.get('sec-websocket-version', '').strip() + + if not ws_key or ws_version != '13': + writer.write(b'HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n') + await writer.drain() + return + + accept = _ws_accept_key(ws_key) + handshake = ( + 'HTTP/1.1 101 Switching Protocols\r\n' + 'Upgrade: websocket\r\n' + 'Connection: Upgrade\r\n' + f'Sec-WebSocket-Accept: {accept}\r\n' + '\r\n' + ) + writer.write(handshake.encode()) + await writer.drain() + + conn = _WebSocketConnection(reader, writer, self._input_queue) + self._connections.add(conn) + try: + await conn.run() + finally: + self._connections.discard(conn) diff --git a/web_pdb/server_adapter.py b/web_pdb/server_adapter.py index fa3cdf4..525f1f6 100644 --- a/web_pdb/server_adapter.py +++ b/web_pdb/server_adapter.py @@ -1,61 +1,52 @@ -import queue - -from asyncore_wsgi import AsyncWebSocketHandler, make_server - -from .system_adapter import SystemAdapter -from .wsgi_app import app - +""" +Facade that owns the asyncio HTTP/WebSocket server and exposes the interface +that WebConsole depends on. +""" -class WebConsoleSocket(AsyncWebSocketHandler): - """ - WebConsoleSocket receives PDB commands from the front-end and - sends pings to client(s) about console updates - """ +from __future__ import annotations - clients = [] - input_queue = queue.Queue() - - @classmethod - def broadcast(cls, msg): - for cl in cls.clients: - if cl.handshaked: - cl.sendMessage(msg) # sendMessage uses deque so it is thread-safe - - def handleConnected(self): - self.clients.append(self) +import asyncio +import queue - def handleMessage(self): - self.input_queue.put(self.data) +from .asyncio_server import AsyncioServer +from .buffer import ThreadSafeBuffer +from .system_adapter import SystemAdapter - def handleClose(self): - self.clients.remove(self) +__all__ = ['ServerAdapter'] class ServerAdapter: - def __init__(self, host, port): + def __init__(self, host: str, port: int): self._system_adapter = SystemAdapter() - self._httpd = make_server(host, port, app, ws_handler_class=WebConsoleSocket) + self._input_queue: queue.Queue = queue.Queue() + self.frame_data: ThreadSafeBuffer = ThreadSafeBuffer() + self._server = AsyncioServer(host, port, self.frame_data, self._input_queue) + self._loop: asyncio.AbstractEventLoop | None = None @property - def web_socket_input_queue(self): - return WebConsoleSocket.input_queue + def web_socket_input_queue(self) -> queue.Queue: + return self._input_queue - @staticmethod - def web_socket_broadcast(message): - WebConsoleSocket.broadcast(message) + def web_socket_broadcast(self, message: str) -> None: + self._server.broadcast(message) def serve_forever(self) -> None: - is_started = False - while not self._system_adapter.is_abort_requested(): - if not is_started: - self._system_adapter.on_server_started(self._httpd.server_name, self._httpd.server_port) - is_started = True - try: - self._httpd.handle_request() - except (KeyboardInterrupt, SystemExit): - break - self._httpd.handle_close() - self._system_adapter.on_server_stopped() - - def close(self): + self._loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._loop) + try: + self._loop.run_until_complete( + self._server._main( + self._system_adapter.is_abort_requested, + self._system_adapter.on_server_started, + self._system_adapter.on_server_stopped, + ) + ) + except (KeyboardInterrupt, SystemExit): + pass + finally: + self._loop.close() + self._loop = None + + def close(self) -> None: self._system_adapter.abort() + self._server.stop() diff --git a/web_pdb/system_adapter.py b/web_pdb/system_adapter.py index e46c679..fe10805 100644 --- a/web_pdb/system_adapter.py +++ b/web_pdb/system_adapter.py @@ -24,6 +24,7 @@ Abstraction layer for using Web-PDB either in a regular PC/Server or in a Kodi addon. """ +import sys import logging import traceback from abc import ABC, abstractmethod @@ -66,7 +67,7 @@ def on_exception(self): pass -class _ServerAdapter(_BaseAdapter): +class _GeneralAdapter(_BaseAdapter): def is_abort_requested(self): return self.abort_event.is_set() @@ -74,9 +75,11 @@ def on_server_started(self, server_name, port): logging.critical('Web-PDB: starting web-server on http://%s:%s', server_name, port) -SystemAdapter = _ServerAdapter +SystemAdapter = _GeneralAdapter if is_kodi: + sys.modules['_asyncio'] = None # See: https://kodi.wiki/view/Python_Problems#asyncio + class _KodiAdapter(_BaseAdapter): def __init__(self): diff --git a/web_pdb/web_console.py b/web_pdb/web_console.py index 3e43260..ef02ba7 100644 --- a/web_pdb/web_console.py +++ b/web_pdb/web_console.py @@ -33,7 +33,6 @@ from .buffer import ThreadSafeBuffer from .server_adapter import ServerAdapter from .system_adapter import SystemAdapter -from .wsgi_app import app __all__ = ['WebConsole'] @@ -48,7 +47,7 @@ def __init__(self, host, port, debugger): self._server_adapter = ServerAdapter(host, port) self._debugger = weakref.proxy(debugger) self._console_history = ThreadSafeBuffer('') - self._frame_data = app.frame_data + self._frame_data = self._server_adapter.frame_data self._server_thread = Thread(target=self._server_adapter.serve_forever) self._server_thread.daemon = True self._server_thread.start() diff --git a/web_pdb/wsgi_app.py b/web_pdb/wsgi_app.py deleted file mode 100644 index d26518a..0000000 --- a/web_pdb/wsgi_app.py +++ /dev/null @@ -1,97 +0,0 @@ -# Author: Roman Miroshnychenko aka Roman V.M. -# E-mail: roman1972@gmail.com -# -# Copyright (c) 2016 Roman Miroshnychenko -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -""" -Web-UI WSGI application -""" - -import gzip -import json -import os -from functools import wraps -from io import BytesIO - -import bottle - -from .buffer import ThreadSafeBuffer - -__all__ = ['app'] - -# bottle.debug(True) - -this_dir = os.path.dirname(os.path.abspath(__file__)) -bottle.TEMPLATE_PATH.append(os.path.join(this_dir, 'templates')) -static_path = os.path.join(this_dir, 'static') - - -def compress(func): - """ - Compress route return data with gzip compression - """ - - @wraps(func) - def wrapper(*args, **kwargs): - result = func(*args, **kwargs) - # pylint: disable=no-member - if ( - 'gzip' in bottle.request.headers.get('Accept-Encoding', '') - and isinstance(result, str) - and len(result) > 1024 - ): - if isinstance(result, str): - result = result.encode('utf-8') - tmp_fo = BytesIO() - with gzip.GzipFile(mode='wb', fileobj=tmp_fo) as gzip_fo: - gzip_fo.write(result) - result = tmp_fo.getvalue() - bottle.response.add_header('Content-Encoding', 'gzip') - return result - - return wrapper - - -class WebConsoleApp(bottle.Bottle): - def __init__(self): - super().__init__() - self.frame_data = ThreadSafeBuffer() - - -app = WebConsoleApp() - - -@app.route('/') -@compress -def root(): - return bottle.template('index') - - -@app.route('/frame-data') -@compress -def get_frame_data(): - bottle.response.cache_control = 'no-store' - bottle.response.content_type = 'application/json' - return json.dumps(app.frame_data.contents) - - -@app.route('/static/') -def get_static(path): - return bottle.static_file(path, root=static_path) From 09fe02bd24384b879ae50978648a1ab1814cda83 Mon Sep 17 00:00:00 2001 From: Roman Miroshnychenko Date: Sun, 17 May 2026 15:59:13 +0300 Subject: [PATCH 03/13] Fix Kodi compatibility shim --- web_pdb/asyncio_server.py | 8 ++++++++ web_pdb/system_adapter.py | 2 -- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/web_pdb/asyncio_server.py b/web_pdb/asyncio_server.py index adb0148..da3ef0a 100644 --- a/web_pdb/asyncio_server.py +++ b/web_pdb/asyncio_server.py @@ -4,6 +4,14 @@ from __future__ import annotations +try: # Kodi compatibility fix + import xbmc + if not getattr(xbmc, '__kodistubs__', False): + import sys + sys.modules['_asyncio'] = None # See: https://kodi.wiki/view/Python_Problems#asyncio +except ImportError: + pass + import asyncio import base64 import gzip diff --git a/web_pdb/system_adapter.py b/web_pdb/system_adapter.py index fe10805..1cfebd9 100644 --- a/web_pdb/system_adapter.py +++ b/web_pdb/system_adapter.py @@ -24,7 +24,6 @@ Abstraction layer for using Web-PDB either in a regular PC/Server or in a Kodi addon. """ -import sys import logging import traceback from abc import ABC, abstractmethod @@ -78,7 +77,6 @@ def on_server_started(self, server_name, port): SystemAdapter = _GeneralAdapter if is_kodi: - sys.modules['_asyncio'] = None # See: https://kodi.wiki/view/Python_Problems#asyncio class _KodiAdapter(_BaseAdapter): From c3d2e7376b2a9aa3da1407fcc02e77df7de0d9d9 Mon Sep 17 00:00:00 2001 From: Roman Miroshnychenko Date: Sun, 17 May 2026 16:06:58 +0300 Subject: [PATCH 04/13] Use tuple() instead of list() --- web_pdb/asyncio_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web_pdb/asyncio_server.py b/web_pdb/asyncio_server.py index da3ef0a..36d353d 100644 --- a/web_pdb/asyncio_server.py +++ b/web_pdb/asyncio_server.py @@ -208,7 +208,7 @@ def __init__(self, host: str, port: int, frame_data, input_queue: queue.Queue): self.server_port: int = 0 def _broadcast(self, message: str) -> None: - for conn in list(self._connections): + for conn in tuple(self._connections): conn.send(message) def broadcast(self, message: str) -> None: From 0bbc1881736c992328155d98d4e16f197602d13b Mon Sep 17 00:00:00 2001 From: Roman Miroshnychenko Date: Mon, 18 May 2026 08:55:02 +0300 Subject: [PATCH 05/13] Implement AI review feedback --- web_pdb/asyncio_server.py | 25 ++++++++++++------------- web_pdb/server_adapter.py | 12 ++++++++---- web_pdb/system_adapter.py | 11 +++++------ web_pdb/web_console.py | 2 +- 4 files changed, 26 insertions(+), 24 deletions(-) diff --git a/web_pdb/asyncio_server.py b/web_pdb/asyncio_server.py index 36d353d..35565de 100644 --- a/web_pdb/asyncio_server.py +++ b/web_pdb/asyncio_server.py @@ -37,6 +37,7 @@ _this_dir = Path(__file__).parent _static_dir = _this_dir / 'static' +_static_dir_resolved = _static_dir.resolve() _index_file = _this_dir / 'templates' / 'index.html' # Opcode constants @@ -65,7 +66,7 @@ def _ws_encode_frame(payload: bytes, opcode: int = _OP_TEXT) -> bytes: async def _ws_read_frame(reader: asyncio.StreamReader): - """Read one WebSocket frame; return (opcode, payload_bytes) or raise.""" + """Read one WebSocket frame; return (fin, opcode, payload_bytes) or raise.""" header = await reader.readexactly(2) fin = (header[0] & 0x80) != 0 opcode = header[0] & 0x0F @@ -84,12 +85,7 @@ async def _ws_read_frame(reader: asyncio.StreamReader): for i in range(length): payload[i] ^= mask_key[i % 4] - if not fin: - # We don't send large messages from the client, so just discard - # continuation frames — they won't appear in practice. - pass - - return opcode, bytes(payload) + return fin, opcode, bytes(payload) def _maybe_gzip(body: bytes, content_type: str, accept_encoding: str) -> tuple: @@ -156,24 +152,27 @@ async def run(self) -> None: try: await writer_task except (asyncio.CancelledError, Exception): + # CancelledError is not a subclass of Exception in 3.8+; both are expected here pass try: self._writer.write(_ws_encode_frame(b'', _OP_CLOSE)) await self._writer.drain() except Exception: - pass + logger.debug('WebSocket close frame could not be sent', exc_info=True) try: self._writer.close() except Exception: - pass + logger.debug('WebSocket writer close failed', exc_info=True) async def _reader_loop(self) -> None: while True: try: - opcode, payload = await _ws_read_frame(self._reader) + fin, opcode, payload = await _ws_read_frame(self._reader) except (asyncio.IncompleteReadError, ConnectionError, EOFError): break if opcode in (_OP_TEXT, _OP_BINARY, _OP_CONTINUATION): + if not fin or opcode == _OP_CONTINUATION: + continue # We don't reassemble fragmented messages text = payload.decode('utf-8', errors='replace') self._input_queue.put(text) elif opcode == _OP_PING: @@ -225,7 +224,7 @@ def stop(self) -> None: except RuntimeError: pass - async def _main(self, is_abort_requested, on_started, on_stopped) -> None: + async def run(self, is_abort_requested, on_started, on_stopped) -> None: self._loop = asyncio.get_running_loop() self._stop_event = asyncio.Event() @@ -234,7 +233,7 @@ async def _main(self, is_abort_requested, on_started, on_stopped) -> None: sock = server.sockets[0] addr = sock.getsockname() self.server_port = addr[1] - self.server_name = socket.getfqdn(self._host) if self._host else socket.getfqdn() + self.server_name = socket.getfqdn(self._host or '') on_started(self.server_name, self.server_port) async def _abort_watcher(): @@ -343,7 +342,7 @@ async def _serve_static(self, writer, rel_path: str, accept_encoding: str) -> No await writer.drain() return - if not str(requested).startswith(str(_static_dir.resolve())): + if _static_dir_resolved not in requested.parents: writer.write(b'HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n') await writer.drain() return diff --git a/web_pdb/server_adapter.py b/web_pdb/server_adapter.py index 525f1f6..2c276c6 100644 --- a/web_pdb/server_adapter.py +++ b/web_pdb/server_adapter.py @@ -6,18 +6,20 @@ from __future__ import annotations import asyncio +import logging import queue from .asyncio_server import AsyncioServer from .buffer import ThreadSafeBuffer -from .system_adapter import SystemAdapter __all__ = ['ServerAdapter'] +logger = logging.getLogger(__name__) + class ServerAdapter: - def __init__(self, host: str, port: int): - self._system_adapter = SystemAdapter() + def __init__(self, host: str, port: int, system_adapter): + self._system_adapter = system_adapter self._input_queue: queue.Queue = queue.Queue() self.frame_data: ThreadSafeBuffer = ThreadSafeBuffer() self._server = AsyncioServer(host, port, self.frame_data, self._input_queue) @@ -35,7 +37,7 @@ def serve_forever(self) -> None: asyncio.set_event_loop(self._loop) try: self._loop.run_until_complete( - self._server._main( + self._server.run( self._system_adapter.is_abort_requested, self._system_adapter.on_server_started, self._system_adapter.on_server_stopped, @@ -43,6 +45,8 @@ def serve_forever(self) -> None: ) except (KeyboardInterrupt, SystemExit): pass + except Exception: + logger.exception('Web-PDB: unexpected error in server thread') finally: self._loop.close() self._loop = None diff --git a/web_pdb/system_adapter.py b/web_pdb/system_adapter.py index 1cfebd9..53ef2c1 100644 --- a/web_pdb/system_adapter.py +++ b/web_pdb/system_adapter.py @@ -43,17 +43,17 @@ class _BaseAdapter(ABC): def __init__(self): - self.abort_event = Event() + self._abort_event = Event() @abstractmethod def is_abort_requested(self): raise NotImplementedError def abort(self): - self.abort_event.set() + self._abort_event.set() def is_aborted(self): - return self.abort_event.is_set() + return self._abort_event.is_set() @abstractmethod def on_server_started(self, server_name, port): @@ -68,7 +68,7 @@ def on_exception(self): class _GeneralAdapter(_BaseAdapter): def is_abort_requested(self): - return self.abort_event.is_set() + return self._abort_event.is_set() def on_server_started(self, server_name, port): logging.critical('Web-PDB: starting web-server on http://%s:%s', server_name, port) @@ -78,7 +78,6 @@ def on_server_started(self, server_name, port): if is_kodi: - class _KodiAdapter(_BaseAdapter): def __init__(self): super().__init__() @@ -87,7 +86,7 @@ def __init__(self): self._dialog_progress = DialogProgress() def is_abort_requested(self): - return self.abort_event.is_set() or self._monitor.abortRequested() + return self._abort_event.is_set() or self._monitor.abortRequested() def on_server_started(self, server_name, port): xbmc.log('Web-PDB: web-server started.', level=xbmc.LOGINFO) diff --git a/web_pdb/web_console.py b/web_pdb/web_console.py index ef02ba7..f5adbe1 100644 --- a/web_pdb/web_console.py +++ b/web_pdb/web_console.py @@ -44,7 +44,7 @@ class WebConsole: def __init__(self, host, port, debugger): self._system_adapter = SystemAdapter() - self._server_adapter = ServerAdapter(host, port) + self._server_adapter = ServerAdapter(host, port, self._system_adapter) self._debugger = weakref.proxy(debugger) self._console_history = ThreadSafeBuffer('') self._frame_data = self._server_adapter.frame_data From 76632ef8cc51a5b388ed6632485cd58bb541ed31 Mon Sep 17 00:00:00 2001 From: Roman Miroshnychenko Date: Mon, 18 May 2026 08:55:22 +0300 Subject: [PATCH 06/13] Update Changelog.rst --- .claude/CLAUDE.md | 30 ++++++++++++++++-------------- Changelog.rst | 7 ++++--- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index e6b9110..b414663 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -16,8 +16,8 @@ The debugger backend is a two-threaded system: - **Debugger thread**: Runs the WebPdb class (extends Python's `Pdb`), which executes user code and handles debug commands. -- **Web server thread**: Runs a Bottle-based WSGI application that serves the web UI and communicates - with the debugger thread via WebSockets. +- **Web server thread**: Runs a pure-stdlib asyncio HTTP+WebSocket server that serves the web UI + and communicates with the debugger thread via a shared queue. **Key modules:** @@ -27,18 +27,20 @@ The debugger backend is a two-threaded system: - `web_pdb/web_console.py`: File-like class that serves as stdin/stdout for the debugger thread. Delegates server management and WebSocket broadcasting to `ServerAdapter`. Maintains `console_history` buffer and frame data, pinging clients on each write. -- `web_pdb/server_adapter.py`: Manages the HTTP/WebSocket server lifecycle. `WebConsoleSocket` - (extends `AsyncWebSocketHandler`) handles WebSocket connections: receives PDB commands from the - frontend via a class-level `input_queue` and broadcasts pings to all connected clients. - `ServerAdapter` wraps `make_server()`, drives the event loop in `serve_forever()`, and delegates - lifecycle events to `SystemAdapter`. +- `web_pdb/server_adapter.py`: Thin facade over `AsyncioServer`. `ServerAdapter` owns the + `input_queue` and `frame_data` buffer, starts the asyncio event loop in a daemon thread via + `serve_forever()`, and exposes `web_socket_broadcast()` and `web_socket_input_queue` to + `WebConsole`. Shutdown is coordinated via `SystemAdapter.abort()` and `AsyncioServer.stop()`. +- `web_pdb/asyncio_server.py`: Pure-stdlib asyncio HTTP/WebSocket server. `AsyncioServer` handles + all HTTP routing (index, `/frame-data`, `/static/`, `/ws`), WebSocket handshake and framing, and + gzip compression. `_WebSocketConnection` manages per-connection send/receive coroutines and feeds + incoming PDB commands into the shared `input_queue`. Active connections are tracked in + `AsyncioServer._connections` (a `set`). - `web_pdb/system_adapter.py`: Abstraction layer for running in a standard Python environment vs. - a Kodi addon. Exposes `SystemAdapter` (alias to `_ServerAdapter` or `_KodiAdapter` depending on + a Kodi addon. Exposes `SystemAdapter` (alias to `_GeneralAdapter` or `_KodiAdapter` depending on whether the Kodi runtime is detected). Both implement `is_abort_requested()`, `on_server_started()`, `on_server_stopped()`, and `on_exception()`. The Kodi variant uses `xbmc.Monitor` for abort detection and shows progress dialogs/notifications. -- `web_pdb/wsgi_app.py`: Bottle application serving the web UI and API endpoints for debugger - control and frame data retrieval. - `web_pdb/buffer.py`: Thread-safe buffer (`ThreadSafeBuffer`) used for passing data between threads with dirty-flag semantics. @@ -121,9 +123,9 @@ time. `WebConsole` spawns a daemon thread that runs `ServerAdapter.serve_forever is achieved via: - `ThreadSafeBuffer` with RLock for console history and frame data. -- `queue.Queue` (class-level on `WebConsoleSocket`) for PDB commands from the web UI. -- WebSocket deque for sending messages to clients (thread-safe for appending). -- `threading.Event` in `SystemAdapter` for coordinating server shutdown. +- `queue.Queue` (instance-level on `ServerAdapter`) for PDB commands from the web UI. +- `asyncio.Queue` per `_WebSocketConnection` for outbound WebSocket messages (asyncio-internal). +- `threading.Event` in `_BaseAdapter` (`SystemAdapter`) for coordinating server shutdown. ## Testing Notes @@ -145,7 +147,7 @@ is achieved via: ## Requirements - **Python**: 3.6+ -- **Core dependencies**: `bottle>=0.12.25`, `asyncore-wsgi>=0.0.11` +- **Core dependencies**: pure Python stdlib (no third-party runtime dependencies) - **Development dependencies**: `ruff==0.15.12`, `selenium==4.10.0` - **Package manager**: `uv` diff --git a/Changelog.rst b/Changelog.rst index d7d0fcd..4221cd4 100644 --- a/Changelog.rst +++ b/Changelog.rst @@ -4,8 +4,9 @@ Changelog v.2.0.0 ======= -* Fully reworked UI. -* Internal changes. +* Fully reworked web-UI. +* Internal changes: a new web-server, removed external runtime dependencies, + unified code with ``kodi.web-pdb`` project. v.1.6.3 ======= @@ -36,7 +37,7 @@ v.1.5.6 v.1.5.3 ======= -* Fixed the issue with closed debugger still being stored in ``active_instance`` +* Fixed the issue with a closed debugger still being stored in ``active_instance`` class property that prevented starting a new debugger session (thanks to **maiamcc**). v.1.5.2 From c14ed8c9df39493fc603285accc22145b74b2e03 Mon Sep 17 00:00:00 2001 From: Roman Miroshnychenko Date: Mon, 18 May 2026 09:16:00 +0300 Subject: [PATCH 07/13] Use _KodiLogHandler for logging in Kodi --- web_pdb/system_adapter.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/web_pdb/system_adapter.py b/web_pdb/system_adapter.py index 53ef2c1..99a8ce0 100644 --- a/web_pdb/system_adapter.py +++ b/web_pdb/system_adapter.py @@ -78,12 +78,50 @@ def on_server_started(self, server_name, port): if is_kodi: + + class _KodiLogHandler(logging.Handler): + """ + Logging handler that writes to the Kodi log with correct levels + """ + LOG_FORMAT = '[kodi.web-pdb] {message}' + LEVEL_MAP = { + logging.NOTSET: xbmc.LOGNONE, + logging.DEBUG: xbmc.LOGDEBUG, + logging.INFO: xbmc.LOGINFO, + logging.WARN: xbmc.LOGWARNING, + logging.WARNING: xbmc.LOGWARNING, + logging.ERROR: xbmc.LOGERROR, + logging.CRITICAL: xbmc.LOGFATAL, + } + + def emit(self, record): + message = self.format(record) + kodi_log_level = self.LEVEL_MAP.get(record.levelno, xbmc.LOGDEBUG) + xbmc.log(message, level=kodi_log_level) + + @classmethod + def initialize_logging(cls): + """ + Initialize the root logger that writes to the Kodi log + + After initialization, you can use Python logging facilities as usual. + """ + logging.basicConfig( + format=cls.LOG_FORMAT, + style='{', + level=logging.DEBUG, + handlers=[cls()], + force=True + ) + + class _KodiAdapter(_BaseAdapter): def __init__(self): super().__init__() self._monitor = xbmc.Monitor() self._addon = xbmcaddon.Addon() self._dialog_progress = DialogProgress() + _KodiLogHandler.initialize_logging() def is_abort_requested(self): return self._abort_event.is_set() or self._monitor.abortRequested() From 814523e44270f240f5f6848f34a44e85b30744f2 Mon Sep 17 00:00:00 2001 From: Roman Miroshnychenko Date: Mon, 18 May 2026 09:25:51 +0300 Subject: [PATCH 08/13] Minor renaming --- web_pdb/asyncio_server.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/web_pdb/asyncio_server.py b/web_pdb/asyncio_server.py index 35565de..28fa9b0 100644 --- a/web_pdb/asyncio_server.py +++ b/web_pdb/asyncio_server.py @@ -88,7 +88,7 @@ async def _ws_read_frame(reader: asyncio.StreamReader): return fin, opcode, bytes(payload) -def _maybe_gzip(body: bytes, content_type: str, accept_encoding: str) -> tuple: +def _gzip_if_accepted(body: bytes, content_type: str, accept_encoding: str) -> tuple: """Return (body, extra_headers) with gzip applied when appropriate.""" if ( 'gzip' in accept_encoding @@ -320,14 +320,14 @@ async def _process_request( async def _serve_index(self, writer, accept_encoding: str) -> None: body = _index_file.read_bytes() - body, gz_headers = _maybe_gzip(body, 'text/html; charset=utf-8', accept_encoding) + body, gz_headers = _gzip_if_accepted(body, 'text/html; charset=utf-8', accept_encoding) response = _build_response('200 OK', body, 'text/html; charset=utf-8', gz_headers) writer.write(response) await writer.drain() async def _serve_frame_data(self, writer, accept_encoding: str) -> None: body = json.dumps(self._frame_data.contents).encode('utf-8') - body, gz_headers = _maybe_gzip(body, 'application/json', accept_encoding) + body, gz_headers = _gzip_if_accepted(body, 'application/json', accept_encoding) extra = {'Cache-Control': 'no-store'} extra.update(gz_headers) response = _build_response('200 OK', body, 'application/json', extra) @@ -354,7 +354,7 @@ async def _serve_static(self, writer, rel_path: str, accept_encoding: str) -> No body = requested.read_bytes() content_type = mimetypes.guess_type(str(requested))[0] or 'application/octet-stream' - body, gz_headers = _maybe_gzip(body, content_type, accept_encoding) + body, gz_headers = _gzip_if_accepted(body, content_type, accept_encoding) response = _build_response('200 OK', body, content_type, gz_headers) writer.write(response) await writer.drain() From db3c5184807c7666018103c00e5334d8a5c08e6f Mon Sep 17 00:00:00 2001 From: Roman Miroshnychenko Date: Mon, 18 May 2026 09:27:21 +0300 Subject: [PATCH 09/13] Update Changelog.rst --- Changelog.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Changelog.rst b/Changelog.rst index 4221cd4..ccb5114 100644 --- a/Changelog.rst +++ b/Changelog.rst @@ -4,8 +4,8 @@ Changelog v.2.0.0 ======= -* Fully reworked web-UI. -* Internal changes: a new web-server, removed external runtime dependencies, +* Fully reworked web-UI: light and dark modes, drop outdated dependencies. +* Internal changes: a new async web-server, removed external runtime dependencies, unified code with ``kodi.web-pdb`` project. v.1.6.3 From 8fd596d29a1541f1138c11f0d56019e7258382a0 Mon Sep 17 00:00:00 2001 From: Roman Miroshnychenko Date: Mon, 18 May 2026 18:36:59 +0300 Subject: [PATCH 10/13] Update Changelog.rst --- web_pdb/asyncio_server.py | 4 +++- web_pdb/system_adapter.py | 9 ++------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/web_pdb/asyncio_server.py b/web_pdb/asyncio_server.py index 28fa9b0..794545c 100644 --- a/web_pdb/asyncio_server.py +++ b/web_pdb/asyncio_server.py @@ -6,8 +6,10 @@ try: # Kodi compatibility fix import xbmc + if not getattr(xbmc, '__kodistubs__', False): import sys + sys.modules['_asyncio'] = None # See: https://kodi.wiki/view/Python_Problems#asyncio except ImportError: pass @@ -311,7 +313,7 @@ async def _process_request( elif path == '/frame-data': await self._serve_frame_data(writer, accept_encoding) elif path.startswith('/static/'): - await self._serve_static(writer, path[len('/static/'):], accept_encoding) + await self._serve_static(writer, path[len('/static/') :], accept_encoding) elif path == '/ws': await self._serve_websocket(reader, writer, headers) else: diff --git a/web_pdb/system_adapter.py b/web_pdb/system_adapter.py index 99a8ce0..0b7be02 100644 --- a/web_pdb/system_adapter.py +++ b/web_pdb/system_adapter.py @@ -78,11 +78,11 @@ def on_server_started(self, server_name, port): if is_kodi: - class _KodiLogHandler(logging.Handler): """ Logging handler that writes to the Kodi log with correct levels """ + LOG_FORMAT = '[kodi.web-pdb] {message}' LEVEL_MAP = { logging.NOTSET: xbmc.LOGNONE, @@ -107,14 +107,9 @@ def initialize_logging(cls): After initialization, you can use Python logging facilities as usual. """ logging.basicConfig( - format=cls.LOG_FORMAT, - style='{', - level=logging.DEBUG, - handlers=[cls()], - force=True + format=cls.LOG_FORMAT, style='{', level=logging.DEBUG, handlers=[cls()], force=True ) - class _KodiAdapter(_BaseAdapter): def __init__(self): super().__init__() From 6af6981d3fa1bdf0c249ad00f0a2f69b7d8d2cd4 Mon Sep 17 00:00:00 2001 From: Roman Miroshnychenko Date: Mon, 18 May 2026 18:57:37 +0300 Subject: [PATCH 11/13] Fix saving screenshots on failed Selenium tests --- tests/tests.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/tests/tests.py b/tests/tests.py index 11cda70..b915f79 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -65,12 +65,9 @@ def tearDownClass(cls): cls.browser.quit() def tearDown(self): - if hasattr(self, '_outcome'): - result = self._outcome.result - if result.failures: - failed_tests = [test for test, _ in result.failures] - if self in failed_tests: - self.browser.save_screenshot(f'screenshot-{self}.png') + if hasattr(self, '_outcome') and any(e is not None for _, e in self._outcome.errors): + self.browser.save_screenshot(f'screenshot-{self}.png') + class WebPdbTestCase(SeleniumTestCase): """ From b0139d9d98b407bdf213117936cfe310eaffd19f Mon Sep 17 00:00:00 2001 From: Roman Miroshnychenko Date: Mon, 18 May 2026 19:02:08 +0300 Subject: [PATCH 12/13] Remove outdated email --- tests/__init__.py | 4 +--- tests/db.py | 2 +- tests/db_pm.py | 2 +- tests/tests.py | 2 +- 4 files changed, 4 insertions(+), 6 deletions(-) diff --git a/tests/__init__.py b/tests/__init__.py index 01b7693..6154eb5 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,5 +1,3 @@ # coding: utf-8 # Created on: 03.10.2016 -# Author: Roman Miroshnychenko aka Roman V.M. (romanvm@yandex.ua) - - +# Author: Roman Miroshnychenko aka Roman V.M. diff --git a/tests/db.py b/tests/db.py index 90de99f..35395ad 100644 --- a/tests/db.py +++ b/tests/db.py @@ -1,6 +1,6 @@ # coding: utf-8 # Created on: 13.09.2016 -# Author: Roman Miroshnychenko aka Roman V.M. (romanvm@yandex.ua) +# Author: Roman Miroshnychenko aka Roman V.M. import os import sys diff --git a/tests/db_pm.py b/tests/db_pm.py index b617971..ae78685 100644 --- a/tests/db_pm.py +++ b/tests/db_pm.py @@ -1,6 +1,6 @@ # coding: utf-8 # Created on: 16.09.2016 -# Author: Roman Miroshnychenko aka Roman V.M. (romanvm@yandex.ua) +# Author: Roman Miroshnychenko aka Roman V.M. import os diff --git a/tests/tests.py b/tests/tests.py index b915f79..21bb259 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -1,5 +1,5 @@ # Created on: 13.09.2016 -# Author: Roman Miroshnychenko aka Roman V.M. (romanvm@yandex.ua) +# Author: Roman Miroshnychenko aka Roman V.M. # # Copyright (c) 2016 Roman Miroshnychenko # From 2936d383187cea90590fa068ad19fa810fc63213 Mon Sep 17 00:00:00 2001 From: Roman Miroshnychenko Date: Tue, 19 May 2026 08:39:03 +0300 Subject: [PATCH 13/13] Fix saving screenshots on test failures --- tests/tests.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/tests.py b/tests/tests.py index 21bb259..cc7763a 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -64,9 +64,13 @@ def tearDownClass(cls): cls.db_proc.kill() cls.browser.quit() - def tearDown(self): - if hasattr(self, '_outcome') and any(e is not None for _, e in self._outcome.errors): - self.browser.save_screenshot(f'screenshot-{self}.png') + def run(self, result=None): + outcome = super().run(result) + if result is not None: + failed = [t for t, _ in result.failures + result.errors] + if self in failed: + self.browser.save_screenshot(str(CWD.parent / f'screenshot-{self}.png')) + return outcome class WebPdbTestCase(SeleniumTestCase):