Source code for stratified_packager.toolbelt.logging_records

"""
Record-level machinery for the QGIS logging suite: targets, filters, configs, level mapping.

The dependency-free lower layer of :mod:`~.logging`: the :class:`Target` routing flag, the
:class:`QgisContextFilter` / :class:`TargetFilter` filters, the :class:`MessageBarConfig`
/ :class:`MessageBoxConfig` per-target configs, the :class:`EmitPayload` cross-thread
snapshot, the :data:`SUCCESS` level and the :func:`level_to_qgis` mapping.
:class:`~.logging.QgisHandler` and :class:`~.logging.QgisLoggerWrapper` build on these;
nothing here depends on the handler or wrapper, so the import edge runs one way only.
"""
# pylint: disable=duplicate-code  # __all__ overlaps the logging facade's re-export list by design

from __future__ import annotations

import logging
from dataclasses import dataclass, field
from enum import Flag, auto
from typing import TYPE_CHECKING, Final, override

from qgis.core import Qgis, QgsApplication, QgsProject
from qgis.PyQt.QtWidgets import QMessageBox, QPushButton

if TYPE_CHECKING:
    from qgis.gui import QgisInterface

__all__: list[str] = [
    "SUCCESS",
    "EmitPayload",
    "MessageBarConfig",
    "MessageBoxConfig",
    "QgisContextFilter",
    "Target",
    "TargetFilter",
    "level_to_qgis",
]

SUCCESS: Final = (logging.INFO + logging.WARNING) // 2
"""
Custom logging level between :data:`logging.INFO` and :data:`logging.WARNING`.

Intended for successful operations that deserve user attention.
"""

# Registered at import so text formatters render "SUCCESS" instead of "Level 25".
logging.addLevelName(SUCCESS, "SUCCESS")

# ---------------------------------------------------------------------------
# Private sentinel keys: per-call overrides smuggled through LogRecord
# ---------------------------------------------------------------------------

_K_TARGETS: Final = "_qgis_targets"
"""Key under which a :class:`Target` override is stored in the record's ``__dict__``."""

_K_BAR_CFG: Final = "_qgis_bar_config"
"""Key under which a per-call :class:`MessageBarConfig` override is stored."""

_K_BOX_CFG: Final = "_qgis_box_config"
"""Key under which a per-call :class:`MessageBoxConfig` override is stored."""

# ---------------------------------------------------------------------------
# Level mapping
# ---------------------------------------------------------------------------

_QGIS_TO_QMSGBOX_ICON: Final[dict[Qgis.MessageLevel, QMessageBox.Icon]] = {
    Qgis.MessageLevel.Info: QMessageBox.Icon.Information,
    Qgis.MessageLevel.Warning: QMessageBox.Icon.Warning,
    Qgis.MessageLevel.Critical: QMessageBox.Icon.Critical,
    Qgis.MessageLevel.Success: QMessageBox.Icon.Information,
    Qgis.MessageLevel.NoLevel: QMessageBox.Icon.NoIcon,
}
"""
Per-level icon used for modal dialogs.

Maps each :class:`qgis.core.Qgis.MessageLevel` to a
:class:`~qgis.PyQt.QtWidgets.QMessageBox.Icon`.
"""


[docs] def level_to_qgis(level: int) -> Qgis.MessageLevel: """ Convert a stdlib logging level to the nearest :class:`qgis.core.Qgis.MessageLevel`. :param level: Numeric stdlib level (e.g. :data:`logging.WARNING`) or :data:`SUCCESS`. :return: The closest matching :class:`qgis.core.Qgis.MessageLevel`. """ if level >= logging.ERROR: return Qgis.MessageLevel.Critical if level >= logging.WARNING: return Qgis.MessageLevel.Warning if level >= SUCCESS: return Qgis.MessageLevel.Success if level >= logging.DEBUG: return Qgis.MessageLevel.Info return Qgis.MessageLevel.NoLevel
[docs] def _qmsgbox_icon(level: int) -> QMessageBox.Icon: """ Return the :class:`~qgis.PyQt.QtWidgets.QMessageBox.Icon` for a stdlib logging level. Delegates to :func:`level_to_qgis` and then looks up the result in :data:`_QGIS_TO_QMSGBOX_ICON`, so the two mappings stay in sync. :param level: Numeric stdlib level (e.g. :data:`logging.WARNING`). :return: The corresponding :class:`~qgis.PyQt.QtWidgets.QMessageBox.Icon`. """ return _QGIS_TO_QMSGBOX_ICON[level_to_qgis(level)]
# --------------------------------------------------------------------------- # Targets # ---------------------------------------------------------------------------
[docs] class Target(Flag): """ Bit-flag enum selecting which QGIS outputs receive log records. Members can be combined with ``|``:: Target.LOG | Target.BAR """ LOG = auto() """Route to :class:`qgis.core.QgsMessageLog`.""" BAR = auto() """Route to :class:`qgis.gui.QgsMessageBar`.""" DIALOG = auto() """Route to a :class:`~qgis.PyQt.QtWidgets.QMessageBox` modal dialog."""
# --------------------------------------------------------------------------- # Filters # ---------------------------------------------------------------------------
[docs] class QgisContextFilter(logging.Filter): """ A :class:`logging.Filter` that stamps QGIS context and static fields onto records. Attach this filter to a :class:`logging.Logger` (or to any of its handlers) to have the fields below stamped onto every :class:`logging.LogRecord` automatically, without any changes to call sites. **Dynamic fields** (evaluated lazily at emit time): ``qgis_version`` The running QGIS version string (e.g. ``"4.2.0-Belém do Pará"``). ``qgis_project_path`` Absolute path of the currently open project file, or an empty string if no project is loaded. ``qgis_active_layer_id`` ID of the layer currently selected in the layer panel, or an empty string when no layer is active or ``iface`` is :data:`None`. ``qgis_locale`` The QGIS UI locale string (e.g. ``"pt_BR"``). **Static fields** (set once at construction, stamped on every record): Any key/value pairs passed via *static_fields* are merged onto every record verbatim. This is the intended replacement for call-site ``extra=`` dicts: plugin-level constants such as a plugin version, task name, or environment tag belong here rather than being repeated on every ``log.info(...)`` call. Static fields are stamped before dynamic fields, so dynamic fields take precedence on name collision. Example:: log = QgisLoggerWrapper.get_logger("MyPlugin") context_filter = QgisContextFilter(static_fields={"plugin_version": "1.4.2"}, iface=iface) log.logger.addFilter(context_filter) # Every record now carries qgis_version, qgis_project_path, # qgis_active_layer_id, qgis_locale, and plugin_version. log.info("Processing started.") # Per-record context that varies per call still goes in extra=. log.warning("Bad geometry.", extra={"feature_id": feat.id()}) """
[docs] def __init__( self, static_fields: dict[str, object] | None = None, name: str = "", iface: QgisInterface | None = None, ) -> None: """ Initialize the filter. :param static_fields: Plugin-level constant attributes to stamp on every record (e.g. ``{"plugin_version": "1.4.2", "task": "import"}``). Keys must not clash with standard :class:`logging.LogRecord` attributes. :param name: Passed to :class:`logging.Filter`; restricts the filter to records from loggers whose name starts with this prefix. An empty string (the default) matches all loggers. :param iface: The QGIS interface instance, used to obtain the active layer id. """ super().__init__(name) self._static_fields: dict[str, object] = static_fields or {} self._iface: QgisInterface | None = iface
[docs] @override def filter(self, record: logging.LogRecord) -> bool: """ Stamp static and dynamic QGIS context fields onto *record*. This filter never blocks records — it always returns :data:`True`. Static fields supplied at construction time are applied first; dynamic QGIS fields are applied second and take precedence on name collision. :param record: The :class:`logging.LogRecord` to enrich. :return: Always :data:`True`. """ for key, value in self._static_fields.items(): setattr(record, key, value) record.qgis_version = Qgis.version() if app := QgsApplication.instance(): record.qgis_locale = app.locale() if project := QgsProject.instance(): record.qgis_project_path = project.absoluteFilePath() if self._iface is not None: map_layer = self._iface.activeLayer() record.qgis_active_layer_id = map_layer.id() if map_layer else "" return True
[docs] class TargetFilter(logging.Filter): """ :class:`logging.Filter` that gates records based on :class:`Target` and minimum logging level. Intended to be attached to a :class:`~.logging.QgisHandler`. This filter expresses *permanent, handler-level routing rules* — complementing the per-call overrides available through :class:`~.logging.QgisLoggerWrapper` methods. A typical use is to restrict a :class:`~.logging.QgisHandler` configured with ``Target.DIALOG`` to only show modal dialogs for :data:`~logging.ERROR` and above, while the same handler (or a sibling one) routes :data:`~logging.INFO` and :data:`~logging.WARNING` records to the message bar or log panel. When the record carries a per-call :class:`Target` override (injected by :class:`~.logging.QgisLoggerWrapper`), that override is compared against *allowed_targets*: the record is allowed through only if the two share at least one flag in common. Records emitted by a plain :class:`logging.Logger` (no override) are always allowed through, since target routing is the handler's responsibility in that case. Example — restrict a DIALOG handler to errors only:: handler = QgisHandler( plugin_name="MyPlugin", targets=Target.DIALOG, box_config=MessageBoxConfig(title="Error"), ) handler.addFilter( TargetFilter( allowed_targets=Target.DIALOG, min_level=logging.ERROR, ) ) Example — a BAR handler that ignores records explicitly routed elsewhere:: bar_handler = QgisHandler( plugin_name="MyPlugin", targets=Target.BAR, bar=iface.messageBar(), ) bar_handler.addFilter(TargetFilter(allowed_targets=Target.BAR)) """
[docs] def __init__( self, allowed_targets: Target, min_level: int = logging.NOTSET, name: str = "", ) -> None: """ Initialize the filter. :param allowed_targets: The :class:`Target` flags this handler is permitted to process. Records whose per-call target override shares no flag with this value are dropped. :param min_level: Minimum :mod:`logging` level to allow through. Records below this level are dropped regardless of target. Defaults to :data:`logging.NOTSET` (no additional level gate beyond the logger's own). :param name: Passed to :class:`logging.Filter`; restricts the filter to records from loggers whose name starts with this prefix. """ super().__init__(name) self._allowed_targets: Target = allowed_targets self._min_level: int = min_level
@property def allowed_targets(self) -> Target: """ The :class:`Target` flags permitted by this filter. :return: The current allowed :class:`Target` combination. """ return self._allowed_targets @allowed_targets.setter def allowed_targets(self, value: Target) -> None: """ Replace the allowed targets at runtime. :param value: New :class:`Target` combination. """ self._allowed_targets = value @property def min_level(self) -> int: """ Minimum logging level permitted by this filter. :return: An integer logging level constant. """ return self._min_level @min_level.setter def min_level(self, value: int) -> None: """ Replace the minimum level at runtime. :param value: New minimum level (e.g. :data:`logging.WARNING`). """ self._min_level = value
[docs] @override def filter(self, record: logging.LogRecord) -> bool: """ Allow the record through only if it passes both gates. The two gates, applied in order: 1. **Level gate** — the record's level must be ``>=`` :attr:`min_level`. 2. **Target gate** — if the record carries a per-call :class:`Target` override, it must share at least one flag with :attr:`allowed_targets`. Records without an override always pass this gate. :param record: The :class:`logging.LogRecord` to evaluate. :return: :data:`True` if the record should be processed, :data:`False` if it should be silently dropped. """ if record.levelno < self._min_level: return False per_call_targets: Target | None = getattr(record, _K_TARGETS, None) if per_call_targets is not None: return bool(per_call_targets & self._allowed_targets) return True
# --------------------------------------------------------------------------- # Configuration dataclasses # ---------------------------------------------------------------------------
[docs] @dataclass class MessageBarConfig: """Configuration for the :class:`qgis.gui.QgsMessageBar` target.""" duration: int = 5 """Seconds the message stays visible (``0`` = permanent).""" buttons: list[QPushButton] = field(default_factory=list) """ Extra :class:`~qgis.PyQt.QtWidgets.QPushButton` instances to embed in the bar. They are created by the caller and passed here so that signal connections can be set up before the bar shows them. """ show_progress: bool = False """ Whether the message bar shows a progress indicator. When :data:`True`, a :class:`~qgis.PyQt.QtWidgets.QProgressBar` is added to the bar widget. """ progress_value: int = 0 """Initial value of the progress bar (0-100).""" progress_format: str = "%p%" """ Format string for the progress bar. Passed to :meth:`~qgis.PyQt.QtWidgets.QProgressBar.setFormat` (e.g. ``"%p%"``). """
[docs] @dataclass class MessageBoxConfig: """Configuration for the :class:`~qgis.PyQt.QtWidgets.QMessageBox` target.""" title: str | None = None """ Window title of the dialog. When :data:`None`, the plugin name supplied to :class:`~.logging.QgisHandler` is used. """ standard_buttons: QMessageBox.StandardButton = QMessageBox.StandardButton.Ok """Combination of :class:`~qgis.PyQt.QtWidgets.QMessageBox.StandardButton` flags to display.""" default_button: QMessageBox.StandardButton = QMessageBox.StandardButton.Ok """The button that receives focus by default.""" detailed_text: str | None = None """Optional detailed-text string shown via the *Show Details* expander."""
# --------------------------------------------------------------------------- # Internal payload for cross-thread dispatch # ---------------------------------------------------------------------------
[docs] @dataclass(frozen=True) class EmitPayload: """ Immutable snapshot of everything needed to render a log record on the main thread. Constructed inside :meth:`~.logging.QgisHandler.emit` (possibly on a worker thread) and consumed by :meth:`~.logging.QgisHandler._render`. Per-call overrides — if present on the record — take precedence over the handler's own defaults before this object is built, so the render path never needs to inspect the record again. """ targets: Target """Resolved :class:`Target` flags for this record.""" plugin_name: str """Human-readable plugin identifier.""" tag: str """Tag / topic for :class:`qgis.core.QgsMessageLog`.""" message: str """Formatted log message.""" level: int """Original :mod:`logging` level integer.""" bar_config: MessageBarConfig """Resolved :class:`MessageBarConfig` for this record.""" box_config: MessageBoxConfig """Resolved :class:`MessageBoxConfig` for this record."""