Source code for stratified_packager.toolbelt.debugging

"""
Optional debugpy bootstrap for attaching an IDE to the (possibly embedded) Python.

:func:`start_debug_server` opens a `debugpy <https://github.com/microsoft/debugpy>`_ listen
socket when the :envvar:`QGIS_DEBUGPY` environment variable is truthy, so an IDE can attach to
the running interpreter; it is a no-op otherwise, which keeps it dormant in normal use while
costing nothing to leave wired in.

When Python is embedded in a host application — QGIS runs the interpreter inside its ``qgis``
/ ``qgis_process`` executable — :data:`sys.executable` points at that host binary rather than
at Python. Since debugpy needs the real interpreter to spawn its adapter subprocess, the
bootstrap resolves it with :func:`~.utils.python_executable` and
passes it to ``debugpy.configure`` before listening.

Recognised environment variables (explicit ``host`` / ``port`` arguments take precedence),
read through the typed :class:`~.mapping_proxy.EnvironmentVariables`
subclass ``_DebugEnv``:

.. envvar:: QGIS_DEBUGPY

    Truthy (``1`` / ``true`` / ``yes`` / ``on``) to enable the listen server.

.. envvar:: QGIS_DEBUGPY_HOST

    Host/address to listen on (default ``"localhost"``).

.. envvar:: QGIS_DEBUGPY_PORT

    TCP port to listen on (default ``5678``).

.. envvar:: QGIS_DEBUGPY_WAIT

    Truthy to block startup until an IDE attaches — needed to break on early load (e.g.
    ``classFactory`` / ``initGui``) or on a short-lived ``qgis_process`` run.

``debugpy`` is an optional dependency imported lazily inside :func:`start_debug_server`;
when it is not installed the resulting :exc:`ImportError` is contained like any other failure,
so the bootstrap never breaks loading.
"""

from __future__ import annotations

from typing import Final

from qgis.PyQt.QtCore import QCoreApplication

from .logging import QgisLoggerWrapper
from .mapping_proxy import BoolEnvVar, EnvironmentVariables, IntEnvVar, StrEnvVar
from .utils import python_executable

log = QgisLoggerWrapper.get_logger(__name__)


_LISTEN_LATCH: Final[dict[str, bool]] = {"started": False}
"""Process-global latch (mutated in place, never rebound): ``debugpy.listen`` may be
called only once per process, so re-entry (e.g. on plugin reload) stays a quiet no-op."""


[docs] class _DebugEnv(EnvironmentVariables): """Typed view of the ``QGIS_DEBUGPY*`` environment toggles (see the module docstring).""" QGIS_DEBUGPY = BoolEnvVar(default=False) QGIS_DEBUGPY_HOST = StrEnvVar(default="localhost") QGIS_DEBUGPY_PORT = IntEnvVar(default=5678) QGIS_DEBUGPY_WAIT = BoolEnvVar(default=False)
[docs] def start_debug_server(host: str | None = None, port: int | None = None) -> bool: """ Start a debugpy listen server when :envvar:`QGIS_DEBUGPY` is truthy, else do nothing. Resolve the listen address from the arguments, then the ``QGIS_DEBUGPY*`` environment variables, then the module defaults. Safe to call on every plugin load: a repeat call, a missing ``debugpy`` or a busy port is logged and swallowed so that loading never fails because of the debugger. :param host: Host/address to listen on; overrides :envvar:`QGIS_DEBUGPY_HOST`. :param port: TCP port to listen on; overrides :envvar:`QGIS_DEBUGPY_PORT`. :return: :data:`True` when the server is (already) listening, :data:`False` otherwise. """ if _LISTEN_LATCH["started"]: return True env = _DebugEnv() if not env.QGIS_DEBUGPY: return False try: # debugpy is an optional dependency, imported lazily so a missing install is a contained # ImportError; it is absent from the type-checking environment. import debugpy # noqa: PLC0415 # ty: ignore[unresolved-import] # sys.executable is the QGIS host binary, not the interpreter; tell debugpy which # Python to use for its adapter subprocess. if python := python_executable(): debugpy.configure(python=str(python)) # debugpy.listen() returns the bound (host, port); pylint can't infer the return of the # dynamically-resolved optional import, so it wrongly flags the tuple unpacking. # pylint: disable-next=unpacking-non-sequence bound_host, bound_port = debugpy.listen( (host or env.QGIS_DEBUGPY_HOST, port or env.QGIS_DEBUGPY_PORT) ) _LISTEN_LATCH["started"] = True log.info( QCoreApplication.translate("Debugging", "debugpy is listening on %s:%s."), bound_host, bound_port, ) if env.QGIS_DEBUGPY_WAIT: log.warning( QCoreApplication.translate("Debugging", "Waiting for a debugger to attach...") ) debugpy.wait_for_client() except Exception: # noqa: BLE001 # the debugger must never break plugin loading log.warning( QCoreApplication.translate("Debugging", "Could not start the debugpy server."), exc_info=True, ) return False return True