"""A base class for objects that are configurable.""" # Copyright (c) IPython Development Team. # Distributed under the terms of the Modified BSD License. from __future__ import annotations import logging import typing as t from copy import deepcopy from textwrap import dedent from traitlets.traitlets import ( Any, Container, Dict, HasTraits, Instance, TraitType, default, observe, observe_compat, validate, ) from traitlets.utils import warnings from traitlets.utils.bunch import Bunch from traitlets.utils.text import indent, wrap_paragraphs from .loader import Config, DeferredConfig, LazyConfigValue, _is_section_key # ----------------------------------------------------------------------------- # Helper classes for Configurables # ----------------------------------------------------------------------------- if t.TYPE_CHECKING: LoggerType = t.Union[logging.Logger, logging.LoggerAdapter[t.Any]] else: LoggerType = t.Any class ConfigurableError(Exception): pass class MultipleInstanceError(ConfigurableError): pass # ----------------------------------------------------------------------------- # Configurable implementation # ----------------------------------------------------------------------------- class Configurable(HasTraits): config = Instance(Config, (), {}) parent = Instance("traitlets.config.configurable.Configurable", allow_none=True) def __init__(self, **kwargs: t.Any) -> None: """Create a configurable given a config config. Parameters ---------- config : Config If this is empty, default values are used. If config is a :class:`Config` instance, it will be used to configure the instance. parent : Configurable instance, optional The parent Configurable instance of this object. Notes ----- Subclasses of Configurable must call the :meth:`__init__` method of :class:`Configurable` *before* doing anything else and using :func:`super`:: class MyConfigurable(Configurable): def __init__(self, config=None): super(MyConfigurable, self).__init__(config=config) # Then any other code you need to finish initialization. This ensures that instances will be configured properly. """ parent = kwargs.pop("parent", None) if parent is not None: # config is implied from parent if kwargs.get("config", None) is None: kwargs["config"] = parent.config self.parent = parent config = kwargs.pop("config", None) # load kwarg traits, other than config super().__init__(**kwargs) # record traits set by config config_override_names = set() def notice_config_override(change: Bunch) -> None: """Record traits set by both config and kwargs. They will need to be overridden again after loading config. """ if change.name in kwargs: config_override_names.add(change.name) self.observe(notice_config_override) # load config if config is not None: # We used to deepcopy, but for now we are trying to just save # by reference. This *could* have side effects as all components # will share config. In fact, I did find such a side effect in # _config_changed below. If a config attribute value was a mutable type # all instances of a component were getting the same copy, effectively # making that a class attribute. # self.config = deepcopy(config) self.config = config else: # allow _config_default to return something self._load_config(self.config) self.unobserve(notice_config_override) for name in config_override_names: setattr(self, name, kwargs[name]) # ------------------------------------------------------------------------- # Static trait notifications # ------------------------------------------------------------------------- @classmethod def section_names(cls) -> list[str]: """return section names as a list""" return [ c.__name__ for c in reversed(cls.__mro__) if issubclass(c, Configurable) and issubclass(cls, c) ] def _find_my_config(self, cfg: Config) -> t.Any: """extract my config from a global Config object will construct a Config object of only the config values that apply to me based on my mro(), as well as those of my parent(s) if they exist. If I am Bar and my parent is Foo, and their parent is Tim, this will return merge following config sections, in this order:: [Bar, Foo.Bar, Tim.Foo.Bar] With the last item being the highest priority. """ cfgs = [cfg] if self.parent: cfgs.append(self.parent._find_my_config(cfg)) my_config = Config() for c in cfgs: for sname in self.section_names(): # Don't do a blind getattr as that would cause the config to # dynamically create the section with name Class.__name__. if c._has_section(sname): my_config.merge(c[sname]) return my_config def _load_config( self, cfg: Config, section_names: list[str] | None = None, traits: dict[str, TraitType[t.Any, t.Any]] | None = None, ) -> None: """load traits from a Config object""" if traits is None: traits = self.traits(config=True) if section_names is None: section_names = self.section_names() my_config = self._find_my_config(cfg) # hold trait notifications until after all config has been loaded with self.hold_trait_notifications(): for name, config_value in my_config.items(): if name in traits: if isinstance(config_value, LazyConfigValue): # ConfigValue is a wrapper for using append / update on containers # without having to copy the initial value initial = getattr(self, name) config_value = config_value.get_value(initial) elif isinstance(config_value, DeferredConfig): # DeferredConfig tends to come from CLI/environment variables config_value = config_value.get_value(traits[name]) # We have to do a deepcopy here if we don't deepcopy the entire # config object. If we don't, a mutable config_value will be # shared by all instances, effectively making it a class attribute. setattr(self, name, deepcopy(config_value)) elif not _is_section_key(name) and not isinstance(config_value, Config): from difflib import get_close_matches if isinstance(self, LoggingConfigurable): assert self.log is not None warn = self.log.warning else: def warn(msg: t.Any) -> None: return warnings.warn(msg, UserWarning, stacklevel=9) matches = get_close_matches(name, traits) msg = f"Config option `{name}` not recognized by `{self.__class__.__name__}`." if len(matches) == 1: msg += f" Did you mean `{matches[0]}`?" elif len(matches) >= 1: msg += " Did you mean one of: `{matches}`?".format( matches=", ".join(sorted(matches)) ) warn(msg) @observe("config") @observe_compat def _config_changed(self, change: Bunch) -> None: """Update all the class traits having ``config=True`` in metadata. For any class trait with a ``config`` metadata attribute that is ``True``, we update the trait with the value of the corresponding config entry. """ # Get all traits with a config metadata entry that is True traits = self.traits(config=True) # We auto-load config section for this class as well as any parent # classes that are Configurable subclasses. This starts with Configurable # and works down the mro loading the config for each section. section_names = self.section_names() self._load_config(change.new, traits=traits, section_names=section_names) def update_config(self, config: Config) -> None: """Update config and load the new values""" # traitlets prior to 4.2 created a copy of self.config in order to trigger change events. # Some projects (IPython < 5) relied upon one side effect of this, # that self.config prior to update_config was not modified in-place. # For backward-compatibility, we must ensure that self.config # is a new object and not modified in-place, # but config consumers should not rely on this behavior. self.config = deepcopy(self.config) # load config self._load_config(config) # merge it into self.config self.config.merge(config) # TODO: trigger change event if/when dict-update change events take place # DO NOT trigger full trait-change @classmethod def class_get_help(cls, inst: HasTraits | None = None) -> str: """Get the help string for this class in ReST format. If `inst` is given, its current trait values will be used in place of class defaults. """ assert inst is None or isinstance(inst, cls) final_help = [] base_classes = ", ".join(p.__name__ for p in cls.__bases__) final_help.append(f"{cls.__name__}({base_classes}) options") final_help.append(len(final_help[0]) * "-") for _, v in sorted(cls.class_traits(config=True).items()): help = cls.class_get_trait_help(v, inst) final_help.append(help) return "\n".join(final_help) @classmethod def class_get_trait_help( cls, trait: TraitType[t.Any, t.Any], inst: HasTraits | None = None, helptext: str | None = None, ) -> str: """Get the helptext string for a single trait. :param inst: If given, its current trait values will be used in place of the class default. :param helptext: If not given, uses the `help` attribute of the current trait. """ assert inst is None or isinstance(inst, cls) lines = [] header = f"--{cls.__name__}.{trait.name}" if isinstance(trait, (Container, Dict)): multiplicity = trait.metadata.get("multiplicity", "append") if isinstance(trait, Dict): sample_value = "=" else: sample_value = "<%s-item-1>" % trait.__class__.__name__.lower() if multiplicity == "append": header = f"{header}={sample_value}..." else: header = f"{header} {sample_value}..." else: header = f"{header}=<{trait.__class__.__name__}>" # header = "--%s.%s=<%s>" % (cls.__name__, trait.name, trait.__class__.__name__) lines.append(header) if helptext is None: helptext = trait.help if helptext != "": helptext = "\n".join(wrap_paragraphs(helptext, 76)) lines.append(indent(helptext)) if "Enum" in trait.__class__.__name__: # include Enum choices lines.append(indent("Choices: %s" % trait.info())) if inst is not None: lines.append(indent(f"Current: {getattr(inst, trait.name or '')!r}")) else: try: dvr = trait.default_value_repr() except Exception: dvr = None # ignore defaults we can't construct if dvr is not None: if len(dvr) > 64: dvr = dvr[:61] + "..." lines.append(indent("Default: %s" % dvr)) return "\n".join(lines) @classmethod def class_print_help(cls, inst: HasTraits | None = None) -> None: """Get the help string for a single trait and print it.""" print(cls.class_get_help(inst)) # noqa: T201 @classmethod def _defining_class( cls, trait: TraitType[t.Any, t.Any], classes: t.Sequence[type[HasTraits]] ) -> type[Configurable]: """Get the class that defines a trait For reducing redundant help output in config files. Returns the current class if: - the trait is defined on this class, or - the class where it is defined would not be in the config file Parameters ---------- trait : Trait The trait to look for classes : list The list of other classes to consider for redundancy. Will return `cls` even if it is not defined on `cls` if the defining class is not in `classes`. """ defining_cls = cls assert trait.name is not None for parent in cls.mro(): if ( issubclass(parent, Configurable) and parent in classes and parent.class_own_traits(config=True).get(trait.name, None) is trait ): defining_cls = parent return defining_cls @classmethod def class_config_section(cls, classes: t.Sequence[type[HasTraits]] | None = None) -> str: """Get the config section for this class. Parameters ---------- classes : list, optional The list of other classes in the config file. Used to reduce redundant information. """ def c(s: str) -> str: """return a commented, wrapped block.""" s = "\n\n".join(wrap_paragraphs(s, 78)) return "## " + s.replace("\n", "\n# ") # section header breaker = "#" + "-" * 78 parent_classes = ", ".join(p.__name__ for p in cls.__bases__ if issubclass(p, Configurable)) s = f"# {cls.__name__}({parent_classes}) configuration" lines = [breaker, s, breaker] # get the description trait desc = cls.class_traits().get("description") if desc: desc = desc.default_value if not desc: # no description from trait, use __doc__ desc = getattr(cls, "__doc__", "") # type:ignore[arg-type] if desc: lines.append(c(desc)) # type:ignore[arg-type] lines.append("") for name, trait in sorted(cls.class_traits(config=True).items()): default_repr = trait.default_value_repr() if classes: defining_class = cls._defining_class(trait, classes) else: defining_class = cls if defining_class is cls: # cls owns the trait, show full help if trait.help: lines.append(c(trait.help)) if "Enum" in type(trait).__name__: # include Enum choices lines.append("# Choices: %s" % trait.info()) lines.append("# Default: %s" % default_repr) else: # Trait appears multiple times and isn't defined here. # Truncate help to first line + "See also Original.trait" if trait.help: lines.append(c(trait.help.split("\n", 1)[0])) lines.append(f"# See also: {defining_class.__name__}.{name}") lines.append(f"# c.{cls.__name__}.{name} = {default_repr}") lines.append("") return "\n".join(lines) @classmethod def class_config_rst_doc(cls) -> str: """Generate rST documentation for this class' config options. Excludes traits defined on parent classes. """ lines = [] classname = cls.__name__ for _, trait in sorted(cls.class_traits(config=True).items()): ttype = trait.__class__.__name__ if not trait.name: continue termline = classname + "." + trait.name # Choices or type if "Enum" in ttype: # include Enum choices termline += " : " + trait.info_rst() # type:ignore[attr-defined] else: termline += " : " + ttype lines.append(termline) # Default value try: dvr = trait.default_value_repr() except Exception: dvr = None # ignore defaults we can't construct if dvr is not None: if len(dvr) > 64: dvr = dvr[:61] + "..." # Double up backslashes, so they get to the rendered docs dvr = dvr.replace("\\n", "\\\\n") lines.append(indent("Default: ``%s``" % dvr)) lines.append("") help = trait.help or "No description" lines.append(indent(dedent(help))) # Blank line lines.append("") return "\n".join(lines) class LoggingConfigurable(Configurable): """A parent class for Configurables that log. Subclasses have a log trait, and the default behavior is to get the logger from the currently running Application. """ log = Any(help="Logger or LoggerAdapter instance", allow_none=False) @validate("log") def _validate_log(self, proposal: Bunch) -> LoggerType: if not isinstance(proposal.value, (logging.Logger, logging.LoggerAdapter)): # warn about unsupported type, but be lenient to allow for duck typing warnings.warn( f"{self.__class__.__name__}.log should be a Logger or LoggerAdapter," f" got {proposal.value}.", UserWarning, stacklevel=2, ) return t.cast(LoggerType, proposal.value) @default("log") def _log_default(self) -> LoggerType: if isinstance(self.parent, LoggingConfigurable): assert self.parent is not None return t.cast(logging.Logger, self.parent.log) from traitlets import log return log.get_logger() def _get_log_handler(self) -> logging.Handler | None: """Return the default Handler Returns None if none can be found Deprecated, this now returns the first log handler which may or may not be the default one. """ if not self.log: return None logger: logging.Logger = ( self.log if isinstance(self.log, logging.Logger) else self.log.logger ) if not getattr(logger, "handlers", None): # no handlers attribute or empty handlers list return None return logger.handlers[0] CT = t.TypeVar("CT", bound="SingletonConfigurable") class SingletonConfigurable(LoggingConfigurable): """A configurable that only allows one instance. This class is for classes that should only have one instance of itself or *any* subclass. To create and retrieve such a class use the :meth:`SingletonConfigurable.instance` method. """ _instance = None @classmethod def _walk_mro(cls) -> t.Generator[type[SingletonConfigurable], None, None]: """Walk the cls.mro() for parent classes that are also singletons For use in instance() """ for subclass in cls.mro(): if ( issubclass(cls, subclass) and issubclass(subclass, SingletonConfigurable) and subclass != SingletonConfigurable ): yield subclass @classmethod def clear_instance(cls) -> None: """unset _instance for this class and singleton parents.""" if not cls.initialized(): return for subclass in cls._walk_mro(): if isinstance(subclass._instance, cls): # only clear instances that are instances # of the calling class subclass._instance = None # type:ignore[unreachable] @classmethod def instance(cls: type[CT], *args: t.Any, **kwargs: t.Any) -> CT: """Returns a global instance of this class. This method create a new instance if none have previously been created and returns a previously created instance is one already exists. The arguments and keyword arguments passed to this method are passed on to the :meth:`__init__` method of the class upon instantiation. Examples -------- Create a singleton class using instance, and retrieve it:: >>> from traitlets.config.configurable import SingletonConfigurable >>> class Foo(SingletonConfigurable): pass >>> foo = Foo.instance() >>> foo == Foo.instance() True Create a subclass that is retrieved using the base class instance:: >>> class Bar(SingletonConfigurable): pass >>> class Bam(Bar): pass >>> bam = Bam.instance() >>> bam == Bar.instance() True """ # Create and save the instance if cls._instance is None: inst = cls(*args, **kwargs) # Now make sure that the instance will also be returned by # parent classes' _instance attribute. for subclass in cls._walk_mro(): subclass._instance = inst if isinstance(cls._instance, cls): return cls._instance else: raise MultipleInstanceError( f"An incompatible sibling of '{cls.__name__}' is already instantiated" f" as singleton: {type(cls._instance).__name__}" ) @classmethod def initialized(cls) -> bool: """Has an instance been created?""" return hasattr(cls, "_instance") and cls._instance is not None