You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
569 lines
20 KiB
Python
569 lines
20 KiB
Python
2 years ago
|
"""A base class for objects that are configurable."""
|
||
|
|
||
|
# Copyright (c) IPython Development Team.
|
||
|
# Distributed under the terms of the Modified BSD License.
|
||
|
|
||
|
|
||
|
import logging
|
||
|
import warnings
|
||
|
from copy import deepcopy
|
||
|
from textwrap import dedent
|
||
|
|
||
|
from traitlets.traitlets import (
|
||
|
Any,
|
||
|
Container,
|
||
|
Dict,
|
||
|
HasTraits,
|
||
|
Instance,
|
||
|
default,
|
||
|
observe,
|
||
|
observe_compat,
|
||
|
validate,
|
||
|
)
|
||
|
from traitlets.utils.text import indent, wrap_paragraphs
|
||
|
|
||
|
from .loader import Config, DeferredConfig, LazyConfigValue, _is_section_key
|
||
|
|
||
|
# -----------------------------------------------------------------------------
|
||
|
# Helper classes for Configurables
|
||
|
# -----------------------------------------------------------------------------
|
||
|
|
||
|
|
||
|
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):
|
||
|
"""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):
|
||
|
"""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 notifiations
|
||
|
# -------------------------------------------------------------------------
|
||
|
|
||
|
@classmethod
|
||
|
def section_names(cls):
|
||
|
"""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):
|
||
|
"""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, section_names=None, traits=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):
|
||
|
warn = self.log.warning
|
||
|
else:
|
||
|
warn = lambda msg: warnings.warn(msg, stacklevel=9) # noqa[E371]
|
||
|
matches = get_close_matches(name, traits)
|
||
|
msg = "Config option `{option}` not recognized by `{klass}`.".format(
|
||
|
option=name, klass=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):
|
||
|
"""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):
|
||
|
"""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=None):
|
||
|
"""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, inst=None, helptext=None):
|
||
|
"""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)!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=None):
|
||
|
"""Get the help string for a single trait and print it."""
|
||
|
print(cls.class_get_help(inst))
|
||
|
|
||
|
@classmethod
|
||
|
def _defining_class(cls, trait, classes):
|
||
|
"""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
|
||
|
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=None):
|
||
|
"""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):
|
||
|
"""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__", "")
|
||
|
if desc:
|
||
|
lines.append(c(desc))
|
||
|
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):
|
||
|
"""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__
|
||
|
|
||
|
termline = classname + "." + trait.name
|
||
|
|
||
|
# Choices or type
|
||
|
if "Enum" in ttype:
|
||
|
# include Enum choices
|
||
|
termline += " : " + trait.info_rst()
|
||
|
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")
|
||
|
|
||
|
@validate("log")
|
||
|
def _validate_log(self, proposal):
|
||
|
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}."
|
||
|
)
|
||
|
return proposal.value
|
||
|
|
||
|
@default("log")
|
||
|
def _log_default(self):
|
||
|
if isinstance(self.parent, LoggingConfigurable):
|
||
|
return self.parent.log
|
||
|
from traitlets import log
|
||
|
|
||
|
return log.get_logger()
|
||
|
|
||
|
def _get_log_handler(self):
|
||
|
"""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.
|
||
|
"""
|
||
|
logger = self.log
|
||
|
if isinstance(logger, logging.LoggerAdapter):
|
||
|
logger = logger.logger
|
||
|
if not getattr(logger, "handlers", None):
|
||
|
# no handlers attribute or empty handlers list
|
||
|
return None
|
||
|
return logger.handlers[0]
|
||
|
|
||
|
|
||
|
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):
|
||
|
"""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):
|
||
|
"""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
|
||
|
|
||
|
@classmethod
|
||
|
def instance(cls, *args, **kwargs):
|
||
|
"""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 retrived 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(
|
||
|
"An incompatible sibling of '%s' is already instantiated"
|
||
|
" as singleton: %s" % (cls.__name__, type(cls._instance).__name__)
|
||
|
)
|
||
|
|
||
|
@classmethod
|
||
|
def initialized(cls):
|
||
|
"""Has an instance been created?"""
|
||
|
return hasattr(cls, "_instance") and cls._instance is not None
|