# -----------------------------------------------------------------------------
# Copyright (c) 2021- Spyder Project Contributors
#
# Released under the terms of the MIT License
# (see LICENSE.txt in the project root directory for details)
# -----------------------------------------------------------------------------
"""
Spyder API configuration helper mixins.
"""
from __future__ import annotations
# Standard library imports
import logging
import sys
import warnings
from collections.abc import Callable
from typing import Union
if sys.version_info < (3, 10):
from typing_extensions import TypeAlias
else:
from typing import TypeAlias # noqa: ICN003
# Third-party imports
from qtpy import PYSIDE6
# Local imports
from spyder.config.manager import CONF, CONF_VERSION
from spyder.config.types import ConfigurationKey
from spyder.config.user import NoDefault
logger = logging.getLogger(__name__)
BasicTypes: TypeAlias = Union[bool, int, str, tuple, list, dict]
"""Type alias for the set of basic Python types supported as config values."""
[docs]
class SpyderConfigurationAccessor:
"""Mixin to access options stored in the Spyder configuration system."""
CONF_SECTION: str | None = None
"""Name of the default configuration section to use for this object.
Will be used to record its permanent data in Spyder's config system.
"""
[docs]
def get_conf(
self,
option: ConfigurationKey,
default: NoDefault | BasicTypes = NoDefault,
section: str | None = None,
secure: bool = False,
) -> BasicTypes:
"""
Retrieve an option's value from the Spyder configuration system.
Parameters
----------
option: spyder.config.types.ConfigurationKey
Name/tuple path of the configuration option value to get.
default: spyder.config.user.NoDefault | BasicTypes, optional
Fallback value to return if the option is not found on the
configuration system. No default value if not passed.
section: str | None, optional
Name of the configuration section to use, e.g. ``"shortcuts"``.
If ``None``, then the value of :attr:`CONF_SECTION` is used.
secure: bool, optional
If ``True``, the option will be retrieved from secure storage
using the :mod:`!keyring` Python package. Otherwise, will be
retrieved from Spyder's normal configuration (the default).
Returns
-------
value: BasicTypes
Value of ``option`` in the configuration ``section``.
Raises
------
configparser.NoOptionError
If the ``section`` does not exist in Spyder's configuration.
"""
section = self.CONF_SECTION if section is None else section
if section is None:
raise AttributeError(
"A SpyderConfigurationAccessor must define a `CONF_SECTION` "
"class attribute!"
)
return CONF.get(section, option, default, secure)
[docs]
def get_conf_options(self, section: str | None = None) -> list[str]:
"""
Get all option names from the given section.
Parameters
----------
section: str | None, optional
Name of the configuration section to use, e.g. ``"shortcuts"``.
If ``None``, then the value of :attr:`CONF_SECTION` is used.
Returns
-------
values: list[str]
List of option names (keys) in the configuration ``section``.
Raises
------
configparser.NoOptionError
If ``section`` does not exist in the configuration.
"""
section = self.CONF_SECTION if section is None else section
if section is None:
raise AttributeError(
"A SpyderConfigurationAccessor must define a `CONF_SECTION` "
"class attribute!"
)
return CONF.options(section)
[docs]
def set_conf(
self,
option: ConfigurationKey,
value: BasicTypes,
section: str | None = None,
recursive_notification: bool = True,
secure: bool = False,
) -> None:
"""
Set an option's value in the Spyder configuration system.
Parameters
----------
option: spyder.config.types.ConfigurationKey
Name/tuple path of the configuration option to set.
value: BasicTypes
Value to set for the given configuration option.
section: str | None, optional
Name of the configuration section to use, e.g. ``"shortcuts"``.
If ``None``, then the value of :attr:`CONF_SECTION` is used.
recursive_notification: bool, optional
If ``True``, all objects that observe all changes on the
configuration ``section`` as well as objects that observe
partial tuple paths are notified. For example, if the
``option`` ``"opt"`` of ``section`` ``"sec"`` changes, then
all observers for section ``sec`` are notified. Likewise,
if the option ``("a", "b", "c")`` changes, then observers for
``("a", "b", "c")``, ``("a", "b")`` and ``"a"`` are all notified.
secure: bool, optional
If ``True``, the option will be saved in secure storage
using the :mod:`!keyring` Python package. Otherwise, will be
saved in Spyder's normal configuration (the default).
Returns
-------
None
"""
section = self.CONF_SECTION if section is None else section
if section is None:
raise AttributeError(
"A SpyderConfigurationAccessor must define a `CONF_SECTION` "
"class attribute!"
)
CONF.set(
section,
option,
value,
recursive_notification=recursive_notification,
secure=secure,
)
[docs]
def remove_conf(
self,
option: ConfigurationKey,
section: str | None = None,
secure: bool = False,
) -> None:
"""
Remove an option from the Spyder configuration system.
Parameters
----------
option: spyder.config.types.ConfigurationKey
Name/tuple path of the configuration option to remove.
section: str | None, optional
Name of the configuration section to use, e.g. ``"shortcuts"``.
If ``None``, then the value of :attr:`CONF_SECTION` is used.
secure: bool, optional
If ``True``, the option will be removed from secure storage
using the :mod:`!keyring` Python package. Otherwise, will be
removed from Spyder's normal configuration (the default).
Returns
-------
None
"""
section = self.CONF_SECTION if section is None else section
if section is None:
raise AttributeError(
"A SpyderConfigurationAccessor must define a `CONF_SECTION` "
"class attribute!"
)
CONF.remove_option(section, option, secure)
[docs]
def get_conf_default(
self,
option: ConfigurationKey,
section: str | None = None,
) -> Union[NoDefault, BasicTypes]:
"""
Get an option's default value from the Spyder configuration system.
Parameters
----------
option: spyder.config.types.ConfigurationKey
Name/tuple path of the config option to get the default value of.
section: str | None, optional
Name of the configuration section to use, e.g. ``"shortcuts"``.
If ``None``, then the value of :attr:`CONF_SECTION` is used.
Returns
-------
spyder.config.user.NoDefault | BasicTypes
The ``option``'s default value, or
:class:`spyder.config.user.NoDefault` if one is not set.
"""
section = self.CONF_SECTION if section is None else section
if section is None:
raise AttributeError(
"A SpyderConfigurationAccessor must define a `CONF_SECTION` "
"class attribute!"
)
return CONF.get_default(section, option)
@property
def spyder_conf_version(self) -> str:
"""Get current version of the Spyder configuration system.
Returns
-------
str
The current Spyder :const:`spyder.config.manager.CONF_VERSION`,
as a string of ``MAJOR.MINOR.MICRO``.
"""
return CONF_VERSION
@property
def old_spyder_conf_version(self) -> str:
"""Get previous version of the Spyder configuration system.
Returns
-------
str
The previous Spyder :const:`spyder.config.manager.CONF_VERSION`
prior to the most recent Spyder update, as a string of
``MAJOR.MINOR.MICRO``.
"""
return CONF.old_spyder_version
[docs]
class SpyderConfigurationObserver(SpyderConfigurationAccessor):
"""
Methods to receive and respond to changes in Spyder's configuration.
This mixin enables a class to receive configuration updates seamlessly,
by registering methods using the
:func:`~spyder.api.config.decorators.on_conf_change` decorator,
which takes a configuration section and option to observe.
When a change occurs on any of the registered configuration options,
the corresponding registered method is called with the new config value.
"""
[docs]
def __init__(self) -> None:
"""Create a new :class:`!SpyderConfigurationObserver`.
.. important::
Classes or their parents implementing this mixin must define
a :attr:`~SpyderConfigurationAccessor.CONF_SECTION` class attribute
with the name of their default configuration section.
Returns
-------
None
"""
super().__init__()
if self.CONF_SECTION is None:
warnings.warn(
"A SpyderConfigurationObserver must define a `CONF_SECTION` "
f"class attribute! Hint: {self} or its parent should define "
"the section."
)
self._configuration_listeners = {}
self._multi_option_listeners = set({})
self._gather_observers()
self._merge_none_observers()
# Register class to listen for changes in all registered options
for section in self._configuration_listeners:
section = self.CONF_SECTION if section is None else section
observed_options = self._configuration_listeners[section]
for option in observed_options:
# Avoid a crash at startup due to MRO
if not PYSIDE6:
logger.debug(
f'{self} is observing option "{option}" in section '
f'"{section}"'
)
CONF.observe_configuration(self, section, option)
[docs]
def __del__(self) -> None:
"""Remove an object from the configuration observer."""
CONF.unobserve_configuration(self)
def _gather_observers(self):
"""Gather all the methods decorated with :func:`on_conf_change`."""
for method_name in dir(self):
# Avoid crash at startup due to MRO
if PYSIDE6 and method_name in {
# PySide seems to require that the class is instantiated to
# access this method
"painters",
# Method is debounced
"restart_kernel",
}:
continue
method = getattr(self, method_name, None)
if hasattr(method, "_conf_listen"):
info = method._conf_listen
if len(info) > 1:
self._multi_option_listeners |= {method_name}
for section, option in info:
self._add_listener(method_name, option, section)
def _merge_none_observers(self):
"""Replace section ``None`` with ``CONF_SECTION`` in observers."""
default_selectors = self._configuration_listeners.get(None, {})
section_selectors = self._configuration_listeners.get(
self.CONF_SECTION, {}
)
for option in default_selectors:
default_option_receivers = default_selectors.get(option, [])
section_option_receivers = section_selectors.get(option, [])
merged_receivers = (
default_option_receivers + section_option_receivers
)
section_selectors[option] = merged_receivers
self._configuration_listeners[self.CONF_SECTION] = section_selectors
self._configuration_listeners.pop(None, None)
def _add_listener(
self, func: Callable, option: ConfigurationKey, section: str
):
"""
Add a callable as a listener of a specific configuration option.
Parameters
----------
func: Callable
Function/method that will be called when ``option`` changes.
option: spyder.config.types.ConfigurationKey
Name/tuple path of the configuration option to observe.
section: str
Name of the section containing ``option``, e.g. ``"shortcuts"``.
Returns
-------
None
"""
section_listeners = self._configuration_listeners.get(section, {})
option_listeners = section_listeners.get(option, [])
option_listeners.append(func)
section_listeners[option] = option_listeners
self._configuration_listeners[section] = section_listeners
[docs]
def on_configuration_change(
self, option: ConfigurationKey, section: str, value: BasicTypes
) -> None:
"""
Handle configuration value updates for a config option.
Parameters
----------
option: spyder.config.types.ConfigurationKey
Name/tuple path of the configuration option that changed.
section: str
Name of the section containing ``option``, e.g. ``"shortcuts"``.
value: BasicTypes
New value of the configuration option that produced the event.
Returns
-------
None
"""
section_receivers = self._configuration_listeners.get(section, {})
option_receivers = section_receivers.get(option, [])
for receiver in option_receivers:
method = (
receiver if callable(receiver) else getattr(self, receiver)
)
if receiver in self._multi_option_listeners:
method(option, value)
else:
method(value)
[docs]
def add_configuration_observer(
self,
func: Callable,
option: ConfigurationKey,
section: str | None = None,
) -> None:
"""
Add a callable to observe changes to a specific configuration option.
Parameters
----------
func: Callable
Function/method that will be called when ``option`` changes.
option: spyder.config.types.ConfigurationKey
Name/tuple path of the configuration option to observe.
section: str | None, optional
Name of the section containing ``option``, e.g. ``"shortcuts"``.
If ``None``, then the value of
:attr:`~SpyderConfigurationAccessor.CONF_SECTION` is used.
Returns
-------
None
Notes
-----
- This is only necessary if you need to add a callable that is not a
class method to observe an option. Otherwise, you only need to
decorate your method with
:func:`~spyder.api.config.decorators.on_conf_change`.
"""
if section is None:
section = self.CONF_SECTION
logger.debug(
f'{self} is observing "{option}" option on section "{section}"'
)
self._add_listener(func, option, section)
CONF.observe_configuration(self, section, option)