Source code for stratified_packager.toolbelt.settings

"""
QGIS-specific persistence proxies: typed settings, project entries, variables, layer properties.

This module wraps the disparate QGIS persistence APIs documented in the PyQGIS Developer
Cookbook (*Reading and Storing Settings*) behind a small set of Pythonic objects that read
and write values while converting to and from the appropriate Python types:

================================  =======================================================
Proxy                             Backing QGIS API
================================  =======================================================
:class:`SettingsProxy`            :class:`qgis.core.QgsSettings` (flat key/value)
:class:`PluginSettingsBase`       the above plus typed ``QgsSettingsEntry*`` objects
:class:`ProjectEntries`           :meth:`qgis.core.QgsProject.readEntry` / ``writeEntry``
:class:`ProjectVariables`         :class:`qgis.core.QgsExpressionContextUtils` project scope
:class:`LayerCustomProperties`    :meth:`qgis.core.QgsMapLayer.customProperty`
:class:`LayerVariables`           :class:`qgis.core.QgsExpressionContextUtils` layer scope
================================  =======================================================

Two complementary access styles are offered, mirroring how
``QgsSettingsEntry*`` sits over a flat store:

* **Dict-like** — every proxy is a :class:`~collections.abc.MutableMapping`
  keyed by string, so arbitrary dynamic keys work out of the box::

      gs = SettingsProxy()
      gs["ui/theme"]  # raw stored value
      gs.get("max_threads", 4, cast=int)  # with type and default

* **Typed descriptors** — :class:`PluginSettingsBase` subclasses declare known keys as class
  attributes whose :class:`Setting` descriptors read and write typed ``QgsSettingsEntry*``
  objects registered under the plugin's node in the global settings tree::

      class MySettings(PluginSettingsBase):
          _PLUGIN_NAME = "my_plugin"
          debug_mode = BoolSetting(default=False)


      s = MySettings()
      s.debug_mode  # -> bool
      s.debug_mode = True  # persisted

The proxies build on the QGIS-free :mod:`.mapping_proxy`: they subclass its
:class:`~.mapping_proxy.MappingProxy` and read and write through its conversion registry
(:func:`~.mapping_proxy.to_storage` / :func:`~.mapping_proxy.from_storage`). That registry
covers the stdlib types; this module registers the QGIS-only :class:`~qgis.PyQt.QtGui.QColor`
converter at import so :class:`ColorSetting` and ``cast=QColor`` round-trip.

.. warning::
    Every proxy here touches main-thread QGIS objects (:class:`~qgis.core.QgsProject`,
    :class:`~qgis.core.QgsMapLayer`), so they must only be used on the GUI thread.
    :class:`qgis.core.QgsSettings` is the sole exception (it is thread-safe), but
    the proxies make no special accommodation for off-thread use.
"""

from __future__ import annotations

from enum import Enum
from typing import Any, ClassVar, overload, override
from typing import cast as _cast

from qgis.core import (
    QgsExpressionContextScope,
    QgsExpressionContextUtils,
    QgsMapLayer,
    QgsProject,
    QgsSettings,
    QgsSettingsEntryBase,
    QgsSettingsEntryBool,
    QgsSettingsEntryColor,
    QgsSettingsEntryDouble,
    QgsSettingsEntryInteger,
    QgsSettingsEntryString,
    QgsSettingsEntryStringList,
    QgsSettingsEntryVariant,
    QgsSettingsEntryVariantMap,
    QgsSettingsTree,
)
from qgis.PyQt import sip
from qgis.PyQt.QtGui import QColor

# _is_item_iterable / _to_str_list are shared coercion helpers reused by the QGIS proxies.
from .mapping_proxy import (
    MappingProxy,
    _is_item_iterable,
    _to_str_list,
    from_storage,
    register_converter,
)

__all__: list[str] = [
    "BoolSetting",
    "ColorSetting",
    "DoubleSetting",
    "EnumSetting",
    "IntSetting",
    "LayerCustomProperties",
    "LayerVariables",
    "PluginSettingsBase",
    "ProjectEntries",
    "ProjectVariables",
    "Setting",
    "SettingsProxy",
    "StringListSetting",
    "StringSetting",
    "VariantMapSetting",
    "VariantSetting",
]


# ---------------------------------------------------------------------------
# QGIS-type converter registration
# ---------------------------------------------------------------------------

# QColor lives in qgis.PyQt, so its converter is registered here rather than in the
# QGIS-free mapping_proxy module; importing this module makes cast=QColor round-trip.
register_converter(
    QColor,
    lambda c: c.name(QColor.NameFormat.HexArgb),
    lambda r: r if isinstance(r, QColor) else QColor(str(r)),
)


# ---------------------------------------------------------------------------
# Typed descriptors over QgsSettingsEntry*
# ---------------------------------------------------------------------------


[docs] class Setting[T]: """ Descriptor mapping a class attribute to a typed ``QgsSettingsEntry*``. Declare instances as class attributes of a :class:`PluginSettingsBase` subclass. On first access, the descriptor lazily constructs its backing entry using the *plugin* constructor of the relevant ``QgsSettingsEntry*`` class — keyed by the owning proxy's :attr:`PluginSettingsBase._PLUGIN_NAME` — so the value is persisted at ``plugins/<plugin-name>/<key>`` and appears in the QGIS settings tree. That key coincides with the one a :class:`SettingsProxy` prefixed with ``plugins/<plugin-name>`` produces, so descriptor access and dict access address the same stored value. Subclasses set :attr:`_ENTRY_CLASS` to the concrete entry type; only :class:`EnumSetting` needs to override the value-marshalling hooks. """ _ENTRY_CLASS: ClassVar[type[QgsSettingsEntryBase]] """Concrete ``QgsSettingsEntry*`` class backing this descriptor."""
[docs] def __init__(self, default: T, description: str = "", *, key: str | None = None) -> None: """ Initialize the descriptor. :param default: Value returned when the setting has never been written. :param description: Human-readable description stored on the entry. :param key: Storage key; defaults to the attribute name the descriptor is assigned to. """ self._default: T = default self._description: str = description self._explicit_key: str | None = key self._name: str = "" self._entries: dict[type, QgsSettingsEntryVariant] = {}
def __set_name__(self, owner: type, name: str) -> None: """ Capture the attribute name to use as the default storage key. :param owner: The class the descriptor is assigned to. :param name: The attribute name the descriptor is bound to. """ self._name = name @property def key(self) -> str: """ Storage key of this setting (the explicit key, or the attribute name). :return: The key used under the plugin's settings node. """ return self._explicit_key if self._explicit_key is not None else self._name
[docs] def _build_entry(self, plugin_name: str) -> QgsSettingsEntryVariant: """ Construct the backing entry under *plugin_name*'s settings node. The concrete entry is built from :attr:`_ENTRY_CLASS` and viewed as a :class:`~qgis.core.QgsSettingsEntryVariant` so its ``value`` / ``setValue`` methods are visible to type checkers (the abstract base lacks them). :param plugin_name: Plugin name inserted into the entry key. :return: The backing entry, viewed as a variant entry. """ entry = self._ENTRY_CLASS(self.key, plugin_name, self._default, self._description) return _cast("QgsSettingsEntryVariant", entry)
[docs] def _entry(self, owner: type[PluginSettingsBase]) -> QgsSettingsEntryVariant: """ Return the backing entry for *owner*, building and caching it on first use. :param owner: The :class:`PluginSettingsBase` subclass owning the descriptor. :return: The cached backing entry. """ entry = self._entries.get(owner) if entry is None: # owner is the cooperating PluginSettingsBase subclass supplying the plugin name. entry = self._build_entry(owner._PLUGIN_NAME) # noqa: SLF001 # The plugin constructor registers the entry under the settings-tree node, which # owns and deletes it on teardown() → unregisterPluginTreeNode. Hand ownership to # C++ so Python's later GC of this wrapper can't double-free that already-deleted # entry — a segfault on some PyQt5/SIP builds. # PyQt6's sip.pyi mistypes transferto's owner as `wrapper` only, so None goes via cast. sip.transferto(entry, _cast("Any", None)) self._entries[owner] = entry return entry
[docs] def _decode(self, entry: QgsSettingsEntryVariant) -> T: """ Marshal the entry's stored value into ``T``. :param entry: The backing entry to read. :return: The typed value. """ value: T = entry.value() return value
[docs] def _encode(self, value: T) -> object: """ Marshal ``T`` into the form the entry persists. :param value: The value being assigned. :return: The representation passed to ``QgsSettingsEntryBase.setValue``. """ return value
@overload def __get__(self, instance: None, owner: type[PluginSettingsBase]) -> Setting[T]: ... @overload def __get__(self, instance: PluginSettingsBase, owner: type[PluginSettingsBase]) -> T: ... def __get__( self, instance: PluginSettingsBase | None, owner: type[PluginSettingsBase] ) -> Setting[T] | T: """ Return the descriptor on class access, or the typed value on instance access. :param instance: The owning instance, or :data:`None` for class access. :param owner: The owning class. :return: ``self`` for class access, otherwise the decoded value. """ if instance is None: return self return self._decode(self._entry(owner)) def __set__(self, instance: PluginSettingsBase, value: T) -> None: """ Persist *value* through the backing entry. :param instance: The owning instance. :param value: The value to store. :raise RuntimeError: If QGIS rejects the value (e.g. it fails the entry's validation). """ if not self._entry(type(instance)).setValue(self._encode(value)): msg = f"QGIS rejected the value for setting {self.key!r}." raise RuntimeError(msg)
[docs] def reset(self, owner: type[PluginSettingsBase]) -> None: """ Remove the stored value so the default applies again. :param owner: The owning :class:`PluginSettingsBase` subclass. """ self._entry(owner).remove()
[docs] def _forget(self) -> None: """Drop cached entries so they are rebuilt after a settings-tree teardown.""" self._entries.clear()
[docs] class StringSetting(Setting[str]): """A :class:`str` setting backed by :class:`~qgis.core.QgsSettingsEntryString`.""" _ENTRY_CLASS: ClassVar[type[QgsSettingsEntryBase]] = QgsSettingsEntryString
[docs] class IntSetting(Setting[int]): """An :class:`int` setting backed by :class:`~qgis.core.QgsSettingsEntryInteger`.""" _ENTRY_CLASS: ClassVar[type[QgsSettingsEntryBase]] = QgsSettingsEntryInteger
[docs] class DoubleSetting(Setting[float]): """A :class:`float` setting backed by :class:`~qgis.core.QgsSettingsEntryDouble`.""" _ENTRY_CLASS: ClassVar[type[QgsSettingsEntryBase]] = QgsSettingsEntryDouble
[docs] class BoolSetting(Setting[bool]): """A :class:`bool` setting backed by :class:`~qgis.core.QgsSettingsEntryBool`.""" _ENTRY_CLASS: ClassVar[type[QgsSettingsEntryBase]] = QgsSettingsEntryBool
[docs] class StringListSetting(Setting[list[str]]): """A ``list[str]`` setting backed by :class:`~qgis.core.QgsSettingsEntryStringList`.""" _ENTRY_CLASS: ClassVar[type[QgsSettingsEntryBase]] = QgsSettingsEntryStringList
[docs] class ColorSetting(Setting[QColor]): """ A :class:`~qgis.PyQt.QtGui.QColor` setting. Backed by :class:`~qgis.core.QgsSettingsEntryColor`. """ _ENTRY_CLASS: ClassVar[type[QgsSettingsEntryBase]] = QgsSettingsEntryColor
[docs] class VariantSetting(Setting[object]): """A free-form setting backed by :class:`~qgis.core.QgsSettingsEntryVariant`.""" _ENTRY_CLASS: ClassVar[type[QgsSettingsEntryBase]] = QgsSettingsEntryVariant
[docs] class VariantMapSetting(Setting[dict[str, Any]]): """ A ``dict[str, Any]`` setting backed by :class:`~qgis.core.QgsSettingsEntryVariantMap`. Values round-trip type-preservingly (nested scalars and string lists keep their Python types). Like every descriptor this is read-replace: mutating the returned dict in place does **not** persist; reassign the whole value, e.g. ``proxy.conf = proxy.conf | {"key": value}``. """ _ENTRY_CLASS: ClassVar[type[QgsSettingsEntryBase]] = QgsSettingsEntryVariantMap
[docs] class EnumSetting[E: Enum](Setting[E]): """ An :class:`enum.Enum` setting persisted by its member ``value``. The backing ``QgsSettingsEntry*`` is chosen from the type of the default member's ``value`` (:class:`bool`/:class:`int`/:class:`float`/:class:`str`, falling back to :class:`~qgis.core.QgsSettingsEntryVariant`). Reading reconstructs the member via ``enum_type(stored_value)``, which also handles :class:`enum.IntFlag` combinations. This sidesteps the inconsistent return typing of ``QgsSettingsEntryEnumFlag`` while still persisting through a typed entry. """
[docs] def __init__( self, enum_type: type[E], default: E, description: str = "", *, key: str | None = None ) -> None: """ Initialize the descriptor. :param enum_type: The concrete :class:`enum.Enum` subclass. :param default: Default member. :param description: Human-readable description stored on the entry. :param key: Storage key; defaults to the attribute name. """ super().__init__(default, description, key=key) self._enum_type: type[E] = enum_type
[docs] @override def _build_entry(self, plugin_name: str) -> QgsSettingsEntryVariant: """ Construct a typed entry storing the member value (see the class docstring). :param plugin_name: Plugin name inserted into the entry key. :return: A backing entry whose value type matches the enum's values. """ entry_class: type[QgsSettingsEntryBase] match stored := self._default.value: case bool(): # bool is a subclass of int, so its case must precede int(). entry_class = QgsSettingsEntryBool case int(): entry_class = QgsSettingsEntryInteger case float(): entry_class = QgsSettingsEntryDouble case str(): entry_class = QgsSettingsEntryString case _: entry_class = QgsSettingsEntryVariant return _cast( "QgsSettingsEntryVariant", entry_class(self.key, plugin_name, stored, self._description), )
[docs] @override def _decode(self, entry: QgsSettingsEntryVariant) -> E: """ Rebuild the enum member from the stored value. :param entry: The backing entry to read. :return: The reconstructed enum member. """ return self._enum_type(entry.value())
[docs] @override def _encode(self, value: E) -> object: """ Reduce the enum member to its stored value. :param value: The enum member being assigned. :return: ``value.value``. """ return value.value
# --------------------------------------------------------------------------- # Global settings # ---------------------------------------------------------------------------
[docs] class SettingsProxy(MappingProxy): """ Dict-like proxy over :class:`qgis.core.QgsSettings`, optionally scoped to a prefix. Keys are joined to *prefix* with ``/``; an empty prefix (the default) addresses the raw global namespace, so ``SettingsProxy()["ui/theme"]`` reads the same key the QGIS options dialog writes. For typed reads prefer :meth:`~.mapping_proxy.MappingProxy.get` with ``cast=`` — it routes the native types (:class:`str`, :class:`int`, :class:`float`, :class:`bool`, :class:`list`) through :meth:`qgis.core.QgsSettings.value`'s own coercion (which correctly turns the string ``"true"`` back into :data:`True`) and uses the converter registry for the rest. Plain ``[]`` access returns the raw stored value without coercion. """ _NATIVE_TYPES: ClassVar[tuple[type, ...]] = (str, int, float, bool, list) """Types :meth:`qgis.core.QgsSettings.value` can coerce directly via its ``type=`` argument."""
[docs] def __init__(self, prefix: str = "", *, settings: QgsSettings | None = None) -> None: """ Initialize the proxy. :param prefix: Key prefix; surrounding slashes are stripped. :param settings: Existing :class:`~qgis.core.QgsSettings` to use; a fresh one is created when omitted. """ self._prefix: str = prefix.strip("/") self._settings: QgsSettings = settings if settings is not None else QgsSettings()
[docs] def _full_key(self, key: str) -> str: """ Join *key* to the configured prefix. :param key: The relative key. :return: The fully qualified :class:`~qgis.core.QgsSettings` key. """ return f"{self._prefix}/{key}" if self._prefix else key
[docs] @override def _raw_get(self, key: str) -> tuple[object, bool]: """ Read *key* from :class:`~qgis.core.QgsSettings`. :param key: The relative key. :return: A ``(value, present)`` pair. """ full = self._full_key(key) if not self._settings.contains(full): return None, False return self._settings.value(full), True
[docs] @override def _raw_set(self, key: str, value: object) -> None: """ Write *value* at *key*. :param key: The relative key. :param value: The storable value. """ # QGS202 targets QgsRasterBlock/QgsRasterAttributeTable; QgsSettings.setValue returns None. self._settings.setValue(self._full_key(key), value) # noqa: QGS202
[docs] @override def _raw_del(self, key: str) -> None: """ Remove *key* and any sub-settings. :param key: The relative key. """ self._settings.remove(self._full_key(key))
[docs] @override def _raw_keys(self) -> list[str]: """ Return the keys under the configured prefix, relative to it. :return: The list of relative keys. """ if not self._prefix: return list(self._settings.allKeys()) self._settings.beginGroup(self._prefix) try: return list(self._settings.allKeys()) finally: self._settings.endGroup()
[docs] @override def _typed_get(self, key: str, cast: type[object] | None) -> tuple[object, bool]: """ Read *key*, coercing native types through :meth:`qgis.core.QgsSettings.value`. :param key: The relative key. :param cast: Target Python type, or :data:`None` for the raw value. :return: A ``(value, present)`` pair. """ full = self._full_key(key) if not self._settings.contains(full): return None, False if cast is None: return self._settings.value(full), True if cast in self._NATIVE_TYPES: return self._settings.value(full, None, cast), True return from_storage(self._settings.value(full), cast), True
[docs] class PluginSettingsBase(SettingsProxy): """ Plugin-agnostic base for a plugin's typed settings schema, scoped under ``plugins/<name>``. Concrete subclasses set a non-empty :attr:`_PLUGIN_NAME` and declare their known keys as typed :class:`Setting` descriptors while remaining a full dict-like :class:`SettingsProxy` for any additional keys. Because the scope matches the descriptors' entry keys, ``self.<key>`` and ``self["<key>"]`` address the same stored value. Everything is derived from :attr:`_PLUGIN_NAME`, so this class carries no dependency on any particular plugin. The name is enforced at class-creation time (see ``__init_subclass__``); an intermediate base that leaves it intentionally unset must opt out with ``class Mixin(PluginSettingsBase, abstract=True)``. """ _PLUGIN_NAME: ClassVar[str] """Plugin name (tree node and scope); a concrete subclass MUST set a non-empty value.""" @override def __init_subclass__(cls, *, abstract: bool = False, **kwargs: object) -> None: r""" Reject concrete subclasses that fail to set a non-empty :attr:`_PLUGIN_NAME`. :param abstract: When :data:`True`, skip the check so an intermediate base may leave :attr:`_PLUGIN_NAME` unset for its own concrete subclasses to fill. :param \*\*kwargs: Further keyword arguments forwarded to :meth:`object.__init_subclass__`. :raise TypeError: If a non-abstract subclass leaves :attr:`_PLUGIN_NAME` empty. """ super().__init_subclass__(**kwargs) # getattr default covers the bare annotation: the base declares but never assigns it. if not abstract and not getattr(cls, "_PLUGIN_NAME", ""): msg = ( f"{cls.__name__} must set a non-empty '_PLUGIN_NAME' " f"(or be declared with 'class {cls.__name__}(..., abstract=True)')." ) raise TypeError(msg)
[docs] def __init__(self, *, settings: QgsSettings | None = None) -> None: """ Initialize the proxy scoped to the plugin's settings node. :param settings: Existing :class:`~qgis.core.QgsSettings` to use; a fresh one is created when omitted. """ super().__init__(prefix=f"plugins/{self._PLUGIN_NAME}", settings=settings)
[docs] @classmethod def teardown(cls) -> None: """ Unregister the plugin's settings-tree node and drop cached descriptor entries. Call this from the plugin's ``unload()`` so a subsequent reload can re-register the same :class:`Setting` entries cleanly. Safe to call when nothing has been registered. """ QgsSettingsTree.unregisterPluginTreeNode(cls._PLUGIN_NAME) for klass in cls.__mro__: for attr in vars(klass).values(): if isinstance(attr, Setting): # Setting cooperates with this proxy; dropping its cache is intentional. attr._forget() # noqa: SLF001
[docs] def reset_defaults(self) -> None: """Remove every declared :class:`Setting`'s stored value, restoring its default.""" owner = type(self) for klass in owner.__mro__: for attr in vars(klass).values(): if isinstance(attr, Setting): attr.reset(owner)
# --------------------------------------------------------------------------- # Project entries # ---------------------------------------------------------------------------
[docs] class ProjectEntries(MappingProxy): """ Dict-like proxy over a project's custom entries (:meth:`qgis.core.QgsProject.writeEntry`). Entries are persisted in the ``.qgs``/``.qgz`` file under a caller-supplied *scope* (group). They are **not** exposed as expression variables — use :class:`ProjectVariables` for those. QGIS reads project entries through type-specific methods, so for typed reads use :meth:`~.mapping_proxy.MappingProxy.get` with ``cast=``; plain ``[]`` access returns the value as a string. Writes dispatch on the Python type to the matching ``writeEntry*`` method — :meth:`~qgis.core.QgsProject.writeEntryBool` / :meth:`~qgis.core.QgsProject.writeEntryDouble` for :class:`bool` / :class:`float`, and :meth:`~qgis.core.QgsProject.writeEntry` for everything else — so each value round-trips through its corresponding ``read*Entry`` (e.g. read a float back with ``cast=float``). Iteration yields the immediate child entry keys of the scope; nested keys are reached with a proxy scoped one level deeper. """
[docs] def __init__(self, scope: str, *, project: QgsProject | None = None) -> None: """ Initialize the proxy. :param scope: Entry scope (group) name (e.g. the plugin slug). :param project: Project to read/write; defaults to :meth:`qgis.core.QgsProject.instance`. """ self._scope: str = scope self._project: QgsProject = _resolve_project(project)
[docs] @override def _raw_get(self, key: str) -> tuple[object, bool]: """ Read *key* as a string from the project scope. :param key: The entry key. :return: A ``(value, present)`` pair. """ return self._project.readEntry(self._scope, key)
[docs] @override def _raw_set(self, key: str, value: object) -> None: """ Write *value* at *key*, dispatching on the storable type. :param key: The entry key. :param value: The storable value already passed through :func:`~.mapping_proxy.to_storage`. :raise RuntimeError: If :class:`~qgis.core.QgsProject` reports the write failed. """ if isinstance(value, bool): ok = self._project.writeEntryBool(self._scope, key, value) elif isinstance(value, (int, str)): ok = self._project.writeEntry(self._scope, key, value) elif isinstance(value, float): ok = self._project.writeEntryDouble(self._scope, key, value) elif _is_item_iterable(value): ok = self._project.writeEntry(self._scope, key, [str(item) for item in value]) else: ok = self._project.writeEntry(self._scope, key, str(value)) if not ok: msg = f"QgsProject failed to write entry {key!r} in scope {self._scope!r}." raise RuntimeError(msg)
[docs] @override def _raw_del(self, key: str) -> None: """ Remove *key* from the project scope. :param key: The entry key. :raise RuntimeError: If :class:`~qgis.core.QgsProject` reports the removal failed. """ if not self._project.removeEntry(self._scope, key): msg = f"QgsProject failed to remove entry {key!r} in scope {self._scope!r}." raise RuntimeError(msg)
[docs] @override def _raw_keys(self) -> list[str]: """ Return the immediate child entry keys of the scope. :return: The list of entry keys holding values. """ return list(self._project.entryList(self._scope, ""))
@override def __getitem__(self, key: str) -> object: """ Return the string value of entry *key*. :param key: The entry key. :return: The entry value read as a string. :raise KeyError: If *key* is not an entry of the scope. """ if key not in self: raise KeyError(key) return self._project.readEntry(self._scope, key)[0] @override def __contains__(self, key: object) -> bool: """ Report whether *key* is an entry of the scope. :param key: The candidate key. :return: :data:`True` if *key* is a present string entry key. """ return isinstance(key, str) and key in self._raw_keys() @override def __delitem__(self, key: str) -> None: """ Delete entry *key* from the scope. :param key: The entry key. :raise KeyError: If *key* is not present. """ if key not in self: raise KeyError(key) self._raw_del(key)
[docs] @override def _typed_get(self, key: str, cast: type[object] | None) -> tuple[object, bool]: """ Read *key* with the :class:`~qgis.core.QgsProject` method matching *cast*. :class:`bool`, :class:`int`, :class:`float` and :class:`list` route to :meth:`~qgis.core.QgsProject.readBoolEntry`, :meth:`~qgis.core.QgsProject.readNumEntry`, :meth:`~qgis.core.QgsProject.readDoubleEntry` and :meth:`~qgis.core.QgsProject.readListEntry` respectively; other casts read the string entry and convert via :func:`~.mapping_proxy.from_storage`. :param key: The entry key. :param cast: Target Python type, or :data:`None` for the string value. :return: A ``(value, present)`` pair. """ value: object ok: bool if cast is bool: value, ok = self._project.readBoolEntry(self._scope, key) elif cast is int: value, ok = self._project.readNumEntry(self._scope, key) elif cast is float: value, ok = self._project.readDoubleEntry(self._scope, key) elif cast is list: value, ok = self._project.readListEntry(self._scope, key) elif cast is None or cast is str: value, ok = self._project.readEntry(self._scope, key) else: raw, ok = self._project.readEntry(self._scope, key) value = from_storage(raw, cast) if ok else None return value, ok
# --------------------------------------------------------------------------- # Expression-variable proxies # ---------------------------------------------------------------------------
[docs] class ProjectVariables(MappingProxy): """ Dict-like proxy over a project's expression variables. Wraps :class:`qgis.core.QgsExpressionContextUtils`' project scope — the ``@``-variables shown under *Project Properties → Variables*. Assignment creates or replaces a user-defined variable; deletion removes one. Iteration and membership reflect the **full** project scope, which also includes QGIS' built-in project variables (``project_path``, ``project_title``, …). Those built-ins are read-only: deleting a name that exists only as a built-in has no effect. """
[docs] def __init__(self, *, project: QgsProject | None = None) -> None: """ Initialize the proxy. :param project: Project to read/write; defaults to :meth:`qgis.core.QgsProject.instance`. """ self._project: QgsProject = _resolve_project(project)
[docs] def _scope(self) -> QgsExpressionContextScope: """ Return a fresh project expression scope. :return: The project :class:`~qgis.core.QgsExpressionContextScope`. :raise RuntimeError: If QGIS returns no project scope. """ scope = QgsExpressionContextUtils.projectScope(self._project) if scope is None: msg = "QGIS returned no project expression scope." raise RuntimeError(msg) return scope
[docs] @override def _raw_get(self, key: str) -> tuple[object, bool]: """ Read variable *key* from the project scope. :param key: The variable name. :return: A ``(value, present)`` pair. """ scope = self._scope() if not scope.hasVariable(key): return None, False return scope.variable(key), True
[docs] @override def _raw_set(self, key: str, value: object) -> None: """ Set the user-defined project variable *key*. :param key: The variable name. :param value: The storable value. """ QgsExpressionContextUtils.setProjectVariable(self._project, key, value)
[docs] @override def _raw_del(self, key: str) -> None: """ Remove the user-defined project variable *key*. :param key: The variable name. """ QgsExpressionContextUtils.removeProjectVariable(self._project, key)
[docs] @override def _raw_keys(self) -> list[str]: """ Return all variable names in the project scope (custom and built-in). :return: The list of variable names. """ return list(self._scope().variableNames())
[docs] class LayerVariables(MappingProxy): """ Dict-like proxy over a layer's expression variables. Wraps :class:`qgis.core.QgsExpressionContextUtils`' layer scope — the ``@``-variables shown under *Layer Properties → Variables*. As with :class:`ProjectVariables`, iteration and membership reflect the full layer scope (including built-ins such as ``layer_name``/``layer_id``). QGIS exposes no ``removeLayerVariable``, so deletion rewrites the layer's user-defined variables without the removed key via :meth:`qgis.core.QgsExpressionContextUtils.setLayerVariables`; deleting a name that exists only as a built-in therefore has no effect. """ _VARIABLE_NAMES_PROPERTY: ClassVar[str] = "variableNames" """Custom-property key under which QGIS stores user-defined variable names.""" _VARIABLE_VALUES_PROPERTY: ClassVar[str] = "variableValues" """Custom-property key under which QGIS stores user-defined variable values."""
[docs] def __init__(self, layer: QgsMapLayer) -> None: """ Initialize the proxy. :param layer: The layer whose variables are proxied. """ self._layer: QgsMapLayer = layer
[docs] def _scope(self) -> QgsExpressionContextScope: """ Return a fresh layer expression scope. :return: The layer :class:`~qgis.core.QgsExpressionContextScope`. :raise RuntimeError: If QGIS returns no layer scope. """ scope = QgsExpressionContextUtils.layerScope(self._layer) if scope is None: msg = "QGIS returned no layer expression scope." raise RuntimeError(msg) return scope
[docs] def _custom_pairs(self) -> dict[str, str]: """ Return the layer's user-defined variables from its custom properties. :return: A mapping of user-defined variable names to their stored values. """ names = _to_str_list(self._layer.customProperty(self._VARIABLE_NAMES_PROPERTY)) values = _to_str_list(self._layer.customProperty(self._VARIABLE_VALUES_PROPERTY)) return dict(zip(names, values, strict=False))
[docs] @override def _raw_get(self, key: str) -> tuple[object, bool]: """ Read variable *key* from the layer scope. :param key: The variable name. :return: A ``(value, present)`` pair. """ scope = self._scope() if not scope.hasVariable(key): return None, False return scope.variable(key), True
[docs] @override def _raw_set(self, key: str, value: object) -> None: """ Set the user-defined layer variable *key*. :param key: The variable name. :param value: The storable value. """ QgsExpressionContextUtils.setLayerVariable(self._layer, key, value)
[docs] @override def _raw_del(self, key: str) -> None: """ Remove the user-defined layer variable *key* by rewriting the rest. :param key: The variable name. """ remaining: dict[str | None, Any] = { name: value for name, value in self._custom_pairs().items() if name != key } QgsExpressionContextUtils.setLayerVariables(self._layer, remaining)
[docs] @override def _raw_keys(self) -> list[str]: """ Return all variable names in the layer scope (custom and built-in). :return: The list of variable names. """ return list(self._scope().variableNames())
# --------------------------------------------------------------------------- # Layer custom properties # ---------------------------------------------------------------------------
[docs] class LayerCustomProperties(MappingProxy): """ Dict-like proxy over a layer's custom properties. Wraps :meth:`qgis.core.QgsMapLayer.customProperty` / :meth:`~qgis.core.QgsMapLayer.setCustomProperty` — arbitrary key/value pairs persisted with the layer in the project file. """
[docs] def __init__(self, layer: QgsMapLayer) -> None: """ Initialize the proxy. :param layer: The layer whose custom properties are proxied. """ self._layer: QgsMapLayer = layer
[docs] @override def _raw_get(self, key: str) -> tuple[object, bool]: """ Read custom property *key*. :param key: The property key. :return: A ``(value, present)`` pair. """ if key not in self._layer.customPropertyKeys(): return None, False return self._layer.customProperty(key), True
[docs] @override def _raw_set(self, key: str, value: object) -> None: """ Set custom property *key*. :param key: The property key. :param value: The storable value. """ self._layer.setCustomProperty(key, value)
[docs] @override def _raw_del(self, key: str) -> None: """ Remove custom property *key*. :param key: The property key. """ self._layer.removeCustomProperty(key)
[docs] @override def _raw_keys(self) -> list[str]: """ Return the layer's custom property keys. :return: The list of property keys. """ return list(self._layer.customPropertyKeys())
# --------------------------------------------------------------------------- # Helpers # ---------------------------------------------------------------------------
[docs] def _resolve_project(project: QgsProject | None) -> QgsProject: """ Return *project*, falling back to the singleton :meth:`qgis.core.QgsProject.instance`. :param project: An explicit project, or :data:`None` to use the singleton. :return: A non-:data:`None` :class:`~qgis.core.QgsProject`. :raise RuntimeError: If no project is available. """ resolved = project if project is not None else QgsProject.instance() if resolved is None: msg = "No QgsProject available; pass project= or ensure QGIS is initialized." raise RuntimeError(msg) return resolved