"""Main plugin module."""
from __future__ import annotations
from functools import partial
from typing import TYPE_CHECKING
from qgis.core import QgsApplication, QgsSettings
from qgis.PyQt.QtCore import QCoreApplication, QLocale, QTranslator, QUrl
from qgis.PyQt.QtGui import QDesktopServices
from qgis.PyQt.QtWidgets import QAction
from .__about__ import (
DIR_PLUGIN_ROOT,
__title__,
__uri_homepage__,
)
from .identity import PLUGIN_SLUG, plugin_icon
from .processing.provider import StratifiedPackagerProvider
from .settings import StratifiedPackagerSettings
from .toolbelt.debugging import start_debug_server
from .toolbelt.i18n import Translatable
from .toolbelt.logging import QgisLoggerWrapper, Target
if TYPE_CHECKING:
from qgis.gui import QgisInterface
from .gui import (
LayerOptionsPageWidgetFactory,
PluginOptionsPageWidgetFactory,
ProjectOptionsPageWidgetFactory,
)
log = QgisLoggerWrapper.get_logger(__name__)
[docs]
class StratifiedPackager(Translatable):
"""The plugin entry object: wires the provider, settings UI and defaults pages."""
[docs]
def __init__(self, iface: QgisInterface) -> None:
"""
Initialize the plugin and install its translator.
:param iface: The running QGIS interface handle.
"""
self.locale: str = QgsSettings().value("locale/userLocale", QLocale().name())[0:2]
locale_path = DIR_PLUGIN_ROOT / f"resources/i18n/{PLUGIN_SLUG}_{self.locale}.qm"
log.debug("Translation: %s, %s", self.locale, locale_path)
if locale_path.exists():
self.translator = QTranslator()
if not self.translator.load(str(locale_path.resolve())):
log.warning("Could not load translation file %s.", locale_path)
elif not QCoreApplication.installTranslator(self.translator):
log.warning("Could not install translator for locale %s.", self.locale)
# Optionally open a debugpy listen socket (a no-op unless QGIS_DEBUGPY is set).
# Done after setting up translation, so the logs can be translated.
start_debug_server()
self.iface: QgisInterface = iface
self.provider: StratifiedPackagerProvider | None = None
self.plugin_options_factory: PluginOptionsPageWidgetFactory | None = None
self.project_options_factory: ProjectOptionsPageWidgetFactory | None = None
self.layer_options_factory: LayerOptionsPageWidgetFactory | None = None
self.action_help: QAction | None = None
self.action_settings: QAction | None = None
self.action_project_defaults: QAction | None = None
self.action_layers: QAction | None = None
self.action_plugin_help_menu_separator: QAction | None = None
self.action_plugin_help_menu_documentation: QAction | None = None
log.debug(self.tr("Plugin initialized successfully."))
[docs]
def initGui(self) -> None: # noqa: N802
"""Set up plugin UI elements."""
# Deferred import: qgis_process never calls initGui, so headless sessions skip
# loading the four GUI modules (each compiles its .ui form at import time).
from .gui import ( # noqa: PLC0415
LayerOptionsPageWidgetFactory,
PluginOptionsPageWidgetFactory,
ProjectOptionsPageWidgetFactory,
)
# settings page within the QGIS preferences menu
self.plugin_options_factory = PluginOptionsPageWidgetFactory()
self.iface.registerOptionsWidgetFactory(self.plugin_options_factory)
# -- Defaults-editing pages (SPEC §19): project-scope and per-layer scope.
self.project_options_factory = ProjectOptionsPageWidgetFactory()
self.iface.registerProjectPropertiesWidgetFactory(self.project_options_factory)
self.layer_options_factory = LayerOptionsPageWidgetFactory()
self.iface.registerMapLayerConfigWidgetFactory(self.layer_options_factory)
# -- Actions
self.action_help = QAction(
QgsApplication.getThemeIcon("mActionHelpContents.svg"),
self.tr("Help"),
self.iface.mainWindow(),
)
self.action_help.setObjectName(f"action_{PLUGIN_SLUG}_help")
self.action_help.triggered.connect(
partial(QDesktopServices.openUrl, QUrl(__uri_homepage__))
)
self.action_settings = QAction(
QgsApplication.getThemeIcon("console/iconSettingsConsole.svg"),
self.tr("Settings"),
self.iface.mainWindow(),
)
self.action_settings.setObjectName(f"action_{PLUGIN_SLUG}_settings")
self.action_settings.triggered.connect(
lambda: self.iface.showOptionsDialog(
currentPage="wdg_stratified_packager_plugin_options_page"
)
)
# -- Project-scope defaults page
self.action_project_defaults = QAction(
QgsApplication.getThemeIcon("mActionProjectProperties.svg"),
self.tr("Project defaults…"),
self.iface.mainWindow(),
)
self.action_project_defaults.setObjectName(f"action_{PLUGIN_SLUG}_project_defaults")
self.action_project_defaults.triggered.connect(
lambda: self.iface.showProjectPropertiesDialog(currentPage=__title__)
)
# -- All-layers packaging-settings dialog
self.action_layers = QAction(
QgsApplication.getThemeIcon("mActionOpenTable.svg"),
self.tr("Configure layers for packaging…"),
self.iface.mainWindow(),
)
self.action_layers.setObjectName(f"action_{PLUGIN_SLUG}_layers")
self.action_layers.triggered.connect(self._open_layers_dialog)
# -- Menu
self.iface.addPluginToMenu(__title__, self.action_settings)
self.iface.addPluginToMenu(__title__, self.action_project_defaults)
self.iface.addPluginToMenu(__title__, self.action_layers)
self.iface.addPluginToMenu(__title__, self.action_help)
# Give the submenu that addPluginToMenu created (identified by the actions it holds,
# not by title, dodging & mnemonics) the plugin icon. QGIS owns the submenu, so
# removePluginMenu disposes it — and the icon — in unload(); nothing extra to track.
if plugin_menu := self.iface.pluginMenu():
for menu_action in plugin_menu.actions():
submenu = menu_action.menu()
if submenu is not None and self.action_settings in submenu.actions():
menu_action.setIcon(plugin_icon())
break
# -- Processing
self.initProcessing()
# -- Keep the algorithm's dynamic defaults (SPEC §5) in step with project edits.
# GUI sessions only: these signal hookups segfault qgis_process (SPEC §5).
if self.provider is not None:
self.provider.connect_project_signals()
# -- Plugin help menu
plugin_help_menu = self.iface.pluginHelpMenu()
if plugin_help_menu is None:
log.error(self.tr("Could not find QGIS plugin help menu to add documentation link."))
return
# documentation
if action_plugin_help_menu_separator := plugin_help_menu.addSeparator():
action_plugin_help_menu_separator.setObjectName(
f"action_{PLUGIN_SLUG}_plugin_help_menu_separator"
)
self.action_plugin_help_menu_separator = action_plugin_help_menu_separator
self.action_plugin_help_menu_documentation = QAction(
plugin_icon(),
self.tr("{} - Documentation").format(__title__),
self.iface.mainWindow(),
)
self.action_plugin_help_menu_documentation.setObjectName(
f"action_{PLUGIN_SLUG}_plugin_help_menu_documentation"
)
self.action_plugin_help_menu_documentation.triggered.connect(
partial(QDesktopServices.openUrl, QUrl(__uri_homepage__))
)
plugin_help_menu.addAction(self.action_plugin_help_menu_documentation)
[docs]
def _open_layers_dialog(self) -> int:
"""Open the all-layers packaging-settings dialog."""
from .gui import LayersTableDialog # noqa: PLC0415 # deferred with the rest of .gui
dialog = LayersTableDialog(self.iface, self.iface.mainWindow(), self.layer_options_factory)
return dialog.exec()
[docs]
def initProcessing(self) -> None: # noqa: N802
"""Initialize the processing provider."""
self.provider = StratifiedPackagerProvider()
if processing_registry := QgsApplication.processingRegistry():
if processing_registry.addProvider(self.provider):
log.debug(self.tr("Processing provider added successfully."))
else:
log.error(
self.tr("Failed to add processing provider."), targets=Target.LOG | Target.BAR
)
else:
log.error(self.tr("Could not access QGIS processing registry to add provider."))
[docs]
def unload(self) -> None:
"""Clean up when plugin is disabled/uninstalled."""
self._teardown_factories()
# -- Unregister the plugin's settings-tree node so a reload re-registers cleanly.
# Contained so the GUI cleanup below still runs if teardown fails.
try:
StratifiedPackagerSettings.teardown()
except Exception: # boundary: unload must finish cleaning up regardless
log.exception(self.tr("Failed to tear down the plugin settings node."))
self._teardown_provider()
self._teardown_menu_actions()
# -- Detach the logging handler last, so the steps above can still log through it.
# A handler left attached would duplicate every log record after each plugin reload.
try:
QgisLoggerWrapper.teardown(__name__.partition(".")[0])
except Exception: # boundary: unload must finish cleaning up regardless
log.exception(self.tr("Failed to tear down the plugin logging handler."))
[docs]
def _teardown_factories(self) -> None:
"""Unregister the Options, Project and Layer widget factories."""
# Guarded per factory: unload() may run without initGui ever having been called
# (headless sessions), leaving every factory None.
if self.plugin_options_factory is not None:
self.iface.unregisterOptionsWidgetFactory(self.plugin_options_factory)
self.plugin_options_factory = None
if self.project_options_factory is not None:
self.iface.unregisterProjectPropertiesWidgetFactory(self.project_options_factory)
self.project_options_factory = None
if self.layer_options_factory is not None:
self.iface.unregisterMapLayerConfigWidgetFactory(self.layer_options_factory)
self.layer_options_factory = None
[docs]
def _teardown_provider(self) -> None:
"""Disconnect the §5 refresh hookups, then unregister the Processing provider."""
if self.provider is None:
return
self.provider.disconnect_project_signals()
if processing_registry := QgsApplication.processingRegistry():
if not processing_registry.removeProvider(self.provider):
log.warning(self.tr("Failed to remove processing provider during plugin unload."))
else:
log.warning(self.tr("Could not access QGIS processing registry to remove provider."))
self.provider = None