| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600 |
- """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 = "<key-1>=<value-1>"
- 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
|