configurable.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600
  1. """A base class for objects that are configurable."""
  2. # Copyright (c) IPython Development Team.
  3. # Distributed under the terms of the Modified BSD License.
  4. from __future__ import annotations
  5. import logging
  6. import typing as t
  7. from copy import deepcopy
  8. from textwrap import dedent
  9. from traitlets.traitlets import (
  10. Any,
  11. Container,
  12. Dict,
  13. HasTraits,
  14. Instance,
  15. TraitType,
  16. default,
  17. observe,
  18. observe_compat,
  19. validate,
  20. )
  21. from traitlets.utils import warnings
  22. from traitlets.utils.bunch import Bunch
  23. from traitlets.utils.text import indent, wrap_paragraphs
  24. from .loader import Config, DeferredConfig, LazyConfigValue, _is_section_key
  25. # -----------------------------------------------------------------------------
  26. # Helper classes for Configurables
  27. # -----------------------------------------------------------------------------
  28. if t.TYPE_CHECKING:
  29. LoggerType = t.Union[logging.Logger, logging.LoggerAdapter[t.Any]]
  30. else:
  31. LoggerType = t.Any
  32. class ConfigurableError(Exception):
  33. pass
  34. class MultipleInstanceError(ConfigurableError):
  35. pass
  36. # -----------------------------------------------------------------------------
  37. # Configurable implementation
  38. # -----------------------------------------------------------------------------
  39. class Configurable(HasTraits):
  40. config = Instance(Config, (), {})
  41. parent = Instance("traitlets.config.configurable.Configurable", allow_none=True)
  42. def __init__(self, **kwargs: t.Any) -> None:
  43. """Create a configurable given a config config.
  44. Parameters
  45. ----------
  46. config : Config
  47. If this is empty, default values are used. If config is a
  48. :class:`Config` instance, it will be used to configure the
  49. instance.
  50. parent : Configurable instance, optional
  51. The parent Configurable instance of this object.
  52. Notes
  53. -----
  54. Subclasses of Configurable must call the :meth:`__init__` method of
  55. :class:`Configurable` *before* doing anything else and using
  56. :func:`super`::
  57. class MyConfigurable(Configurable):
  58. def __init__(self, config=None):
  59. super(MyConfigurable, self).__init__(config=config)
  60. # Then any other code you need to finish initialization.
  61. This ensures that instances will be configured properly.
  62. """
  63. parent = kwargs.pop("parent", None)
  64. if parent is not None:
  65. # config is implied from parent
  66. if kwargs.get("config", None) is None:
  67. kwargs["config"] = parent.config
  68. self.parent = parent
  69. config = kwargs.pop("config", None)
  70. # load kwarg traits, other than config
  71. super().__init__(**kwargs)
  72. # record traits set by config
  73. config_override_names = set()
  74. def notice_config_override(change: Bunch) -> None:
  75. """Record traits set by both config and kwargs.
  76. They will need to be overridden again after loading config.
  77. """
  78. if change.name in kwargs:
  79. config_override_names.add(change.name)
  80. self.observe(notice_config_override)
  81. # load config
  82. if config is not None:
  83. # We used to deepcopy, but for now we are trying to just save
  84. # by reference. This *could* have side effects as all components
  85. # will share config. In fact, I did find such a side effect in
  86. # _config_changed below. If a config attribute value was a mutable type
  87. # all instances of a component were getting the same copy, effectively
  88. # making that a class attribute.
  89. # self.config = deepcopy(config)
  90. self.config = config
  91. else:
  92. # allow _config_default to return something
  93. self._load_config(self.config)
  94. self.unobserve(notice_config_override)
  95. for name in config_override_names:
  96. setattr(self, name, kwargs[name])
  97. # -------------------------------------------------------------------------
  98. # Static trait notifications
  99. # -------------------------------------------------------------------------
  100. @classmethod
  101. def section_names(cls) -> list[str]:
  102. """return section names as a list"""
  103. return [
  104. c.__name__
  105. for c in reversed(cls.__mro__)
  106. if issubclass(c, Configurable) and issubclass(cls, c)
  107. ]
  108. def _find_my_config(self, cfg: Config) -> t.Any:
  109. """extract my config from a global Config object
  110. will construct a Config object of only the config values that apply to me
  111. based on my mro(), as well as those of my parent(s) if they exist.
  112. If I am Bar and my parent is Foo, and their parent is Tim,
  113. this will return merge following config sections, in this order::
  114. [Bar, Foo.Bar, Tim.Foo.Bar]
  115. With the last item being the highest priority.
  116. """
  117. cfgs = [cfg]
  118. if self.parent:
  119. cfgs.append(self.parent._find_my_config(cfg))
  120. my_config = Config()
  121. for c in cfgs:
  122. for sname in self.section_names():
  123. # Don't do a blind getattr as that would cause the config to
  124. # dynamically create the section with name Class.__name__.
  125. if c._has_section(sname):
  126. my_config.merge(c[sname])
  127. return my_config
  128. def _load_config(
  129. self,
  130. cfg: Config,
  131. section_names: list[str] | None = None,
  132. traits: dict[str, TraitType[t.Any, t.Any]] | None = None,
  133. ) -> None:
  134. """load traits from a Config object"""
  135. if traits is None:
  136. traits = self.traits(config=True)
  137. if section_names is None:
  138. section_names = self.section_names()
  139. my_config = self._find_my_config(cfg)
  140. # hold trait notifications until after all config has been loaded
  141. with self.hold_trait_notifications():
  142. for name, config_value in my_config.items():
  143. if name in traits:
  144. if isinstance(config_value, LazyConfigValue):
  145. # ConfigValue is a wrapper for using append / update on containers
  146. # without having to copy the initial value
  147. initial = getattr(self, name)
  148. config_value = config_value.get_value(initial)
  149. elif isinstance(config_value, DeferredConfig):
  150. # DeferredConfig tends to come from CLI/environment variables
  151. config_value = config_value.get_value(traits[name])
  152. # We have to do a deepcopy here if we don't deepcopy the entire
  153. # config object. If we don't, a mutable config_value will be
  154. # shared by all instances, effectively making it a class attribute.
  155. setattr(self, name, deepcopy(config_value))
  156. elif not _is_section_key(name) and not isinstance(config_value, Config):
  157. from difflib import get_close_matches
  158. if isinstance(self, LoggingConfigurable):
  159. assert self.log is not None
  160. warn = self.log.warning
  161. else:
  162. def warn(msg: t.Any) -> None:
  163. return warnings.warn(msg, UserWarning, stacklevel=9)
  164. matches = get_close_matches(name, traits)
  165. msg = f"Config option `{name}` not recognized by `{self.__class__.__name__}`."
  166. if len(matches) == 1:
  167. msg += f" Did you mean `{matches[0]}`?"
  168. elif len(matches) >= 1:
  169. msg += " Did you mean one of: `{matches}`?".format(
  170. matches=", ".join(sorted(matches))
  171. )
  172. warn(msg)
  173. @observe("config")
  174. @observe_compat
  175. def _config_changed(self, change: Bunch) -> None:
  176. """Update all the class traits having ``config=True`` in metadata.
  177. For any class trait with a ``config`` metadata attribute that is
  178. ``True``, we update the trait with the value of the corresponding
  179. config entry.
  180. """
  181. # Get all traits with a config metadata entry that is True
  182. traits = self.traits(config=True)
  183. # We auto-load config section for this class as well as any parent
  184. # classes that are Configurable subclasses. This starts with Configurable
  185. # and works down the mro loading the config for each section.
  186. section_names = self.section_names()
  187. self._load_config(change.new, traits=traits, section_names=section_names)
  188. def update_config(self, config: Config) -> None:
  189. """Update config and load the new values"""
  190. # traitlets prior to 4.2 created a copy of self.config in order to trigger change events.
  191. # Some projects (IPython < 5) relied upon one side effect of this,
  192. # that self.config prior to update_config was not modified in-place.
  193. # For backward-compatibility, we must ensure that self.config
  194. # is a new object and not modified in-place,
  195. # but config consumers should not rely on this behavior.
  196. self.config = deepcopy(self.config)
  197. # load config
  198. self._load_config(config)
  199. # merge it into self.config
  200. self.config.merge(config)
  201. # TODO: trigger change event if/when dict-update change events take place
  202. # DO NOT trigger full trait-change
  203. @classmethod
  204. def class_get_help(cls, inst: HasTraits | None = None) -> str:
  205. """Get the help string for this class in ReST format.
  206. If `inst` is given, its current trait values will be used in place of
  207. class defaults.
  208. """
  209. assert inst is None or isinstance(inst, cls)
  210. final_help = []
  211. base_classes = ", ".join(p.__name__ for p in cls.__bases__)
  212. final_help.append(f"{cls.__name__}({base_classes}) options")
  213. final_help.append(len(final_help[0]) * "-")
  214. for _, v in sorted(cls.class_traits(config=True).items()):
  215. help = cls.class_get_trait_help(v, inst)
  216. final_help.append(help)
  217. return "\n".join(final_help)
  218. @classmethod
  219. def class_get_trait_help(
  220. cls,
  221. trait: TraitType[t.Any, t.Any],
  222. inst: HasTraits | None = None,
  223. helptext: str | None = None,
  224. ) -> str:
  225. """Get the helptext string for a single trait.
  226. :param inst:
  227. If given, its current trait values will be used in place of
  228. the class default.
  229. :param helptext:
  230. If not given, uses the `help` attribute of the current trait.
  231. """
  232. assert inst is None or isinstance(inst, cls)
  233. lines = []
  234. header = f"--{cls.__name__}.{trait.name}"
  235. if isinstance(trait, (Container, Dict)):
  236. multiplicity = trait.metadata.get("multiplicity", "append")
  237. if isinstance(trait, Dict):
  238. sample_value = "<key-1>=<value-1>"
  239. else:
  240. sample_value = "<%s-item-1>" % trait.__class__.__name__.lower()
  241. if multiplicity == "append":
  242. header = f"{header}={sample_value}..."
  243. else:
  244. header = f"{header} {sample_value}..."
  245. else:
  246. header = f"{header}=<{trait.__class__.__name__}>"
  247. # header = "--%s.%s=<%s>" % (cls.__name__, trait.name, trait.__class__.__name__)
  248. lines.append(header)
  249. if helptext is None:
  250. helptext = trait.help
  251. if helptext != "":
  252. helptext = "\n".join(wrap_paragraphs(helptext, 76))
  253. lines.append(indent(helptext))
  254. if "Enum" in trait.__class__.__name__:
  255. # include Enum choices
  256. lines.append(indent("Choices: %s" % trait.info()))
  257. if inst is not None:
  258. lines.append(indent(f"Current: {getattr(inst, trait.name or '')!r}"))
  259. else:
  260. try:
  261. dvr = trait.default_value_repr()
  262. except Exception:
  263. dvr = None # ignore defaults we can't construct
  264. if dvr is not None:
  265. if len(dvr) > 64:
  266. dvr = dvr[:61] + "..."
  267. lines.append(indent("Default: %s" % dvr))
  268. return "\n".join(lines)
  269. @classmethod
  270. def class_print_help(cls, inst: HasTraits | None = None) -> None:
  271. """Get the help string for a single trait and print it."""
  272. print(cls.class_get_help(inst)) # noqa: T201
  273. @classmethod
  274. def _defining_class(
  275. cls, trait: TraitType[t.Any, t.Any], classes: t.Sequence[type[HasTraits]]
  276. ) -> type[Configurable]:
  277. """Get the class that defines a trait
  278. For reducing redundant help output in config files.
  279. Returns the current class if:
  280. - the trait is defined on this class, or
  281. - the class where it is defined would not be in the config file
  282. Parameters
  283. ----------
  284. trait : Trait
  285. The trait to look for
  286. classes : list
  287. The list of other classes to consider for redundancy.
  288. Will return `cls` even if it is not defined on `cls`
  289. if the defining class is not in `classes`.
  290. """
  291. defining_cls = cls
  292. assert trait.name is not None
  293. for parent in cls.mro():
  294. if (
  295. issubclass(parent, Configurable)
  296. and parent in classes
  297. and parent.class_own_traits(config=True).get(trait.name, None) is trait
  298. ):
  299. defining_cls = parent
  300. return defining_cls
  301. @classmethod
  302. def class_config_section(cls, classes: t.Sequence[type[HasTraits]] | None = None) -> str:
  303. """Get the config section for this class.
  304. Parameters
  305. ----------
  306. classes : list, optional
  307. The list of other classes in the config file.
  308. Used to reduce redundant information.
  309. """
  310. def c(s: str) -> str:
  311. """return a commented, wrapped block."""
  312. s = "\n\n".join(wrap_paragraphs(s, 78))
  313. return "## " + s.replace("\n", "\n# ")
  314. # section header
  315. breaker = "#" + "-" * 78
  316. parent_classes = ", ".join(p.__name__ for p in cls.__bases__ if issubclass(p, Configurable))
  317. s = f"# {cls.__name__}({parent_classes}) configuration"
  318. lines = [breaker, s, breaker]
  319. # get the description trait
  320. desc = cls.class_traits().get("description")
  321. if desc:
  322. desc = desc.default_value
  323. if not desc:
  324. # no description from trait, use __doc__
  325. desc = getattr(cls, "__doc__", "") # type:ignore[arg-type]
  326. if desc:
  327. lines.append(c(desc)) # type:ignore[arg-type]
  328. lines.append("")
  329. for name, trait in sorted(cls.class_traits(config=True).items()):
  330. default_repr = trait.default_value_repr()
  331. if classes:
  332. defining_class = cls._defining_class(trait, classes)
  333. else:
  334. defining_class = cls
  335. if defining_class is cls:
  336. # cls owns the trait, show full help
  337. if trait.help:
  338. lines.append(c(trait.help))
  339. if "Enum" in type(trait).__name__:
  340. # include Enum choices
  341. lines.append("# Choices: %s" % trait.info())
  342. lines.append("# Default: %s" % default_repr)
  343. else:
  344. # Trait appears multiple times and isn't defined here.
  345. # Truncate help to first line + "See also Original.trait"
  346. if trait.help:
  347. lines.append(c(trait.help.split("\n", 1)[0]))
  348. lines.append(f"# See also: {defining_class.__name__}.{name}")
  349. lines.append(f"# c.{cls.__name__}.{name} = {default_repr}")
  350. lines.append("")
  351. return "\n".join(lines)
  352. @classmethod
  353. def class_config_rst_doc(cls) -> str:
  354. """Generate rST documentation for this class' config options.
  355. Excludes traits defined on parent classes.
  356. """
  357. lines = []
  358. classname = cls.__name__
  359. for _, trait in sorted(cls.class_traits(config=True).items()):
  360. ttype = trait.__class__.__name__
  361. if not trait.name:
  362. continue
  363. termline = classname + "." + trait.name
  364. # Choices or type
  365. if "Enum" in ttype:
  366. # include Enum choices
  367. termline += " : " + trait.info_rst() # type:ignore[attr-defined]
  368. else:
  369. termline += " : " + ttype
  370. lines.append(termline)
  371. # Default value
  372. try:
  373. dvr = trait.default_value_repr()
  374. except Exception:
  375. dvr = None # ignore defaults we can't construct
  376. if dvr is not None:
  377. if len(dvr) > 64:
  378. dvr = dvr[:61] + "..."
  379. # Double up backslashes, so they get to the rendered docs
  380. dvr = dvr.replace("\\n", "\\\\n")
  381. lines.append(indent("Default: ``%s``" % dvr))
  382. lines.append("")
  383. help = trait.help or "No description"
  384. lines.append(indent(dedent(help)))
  385. # Blank line
  386. lines.append("")
  387. return "\n".join(lines)
  388. class LoggingConfigurable(Configurable):
  389. """A parent class for Configurables that log.
  390. Subclasses have a log trait, and the default behavior
  391. is to get the logger from the currently running Application.
  392. """
  393. log = Any(help="Logger or LoggerAdapter instance", allow_none=False)
  394. @validate("log")
  395. def _validate_log(self, proposal: Bunch) -> LoggerType:
  396. if not isinstance(proposal.value, (logging.Logger, logging.LoggerAdapter)):
  397. # warn about unsupported type, but be lenient to allow for duck typing
  398. warnings.warn(
  399. f"{self.__class__.__name__}.log should be a Logger or LoggerAdapter,"
  400. f" got {proposal.value}.",
  401. UserWarning,
  402. stacklevel=2,
  403. )
  404. return t.cast(LoggerType, proposal.value)
  405. @default("log")
  406. def _log_default(self) -> LoggerType:
  407. if isinstance(self.parent, LoggingConfigurable):
  408. assert self.parent is not None
  409. return t.cast(logging.Logger, self.parent.log)
  410. from traitlets import log
  411. return log.get_logger()
  412. def _get_log_handler(self) -> logging.Handler | None:
  413. """Return the default Handler
  414. Returns None if none can be found
  415. Deprecated, this now returns the first log handler which may or may
  416. not be the default one.
  417. """
  418. if not self.log:
  419. return None
  420. logger: logging.Logger = (
  421. self.log if isinstance(self.log, logging.Logger) else self.log.logger
  422. )
  423. if not getattr(logger, "handlers", None):
  424. # no handlers attribute or empty handlers list
  425. return None
  426. return logger.handlers[0]
  427. CT = t.TypeVar("CT", bound="SingletonConfigurable")
  428. class SingletonConfigurable(LoggingConfigurable):
  429. """A configurable that only allows one instance.
  430. This class is for classes that should only have one instance of itself
  431. or *any* subclass. To create and retrieve such a class use the
  432. :meth:`SingletonConfigurable.instance` method.
  433. """
  434. _instance = None
  435. @classmethod
  436. def _walk_mro(cls) -> t.Generator[type[SingletonConfigurable], None, None]:
  437. """Walk the cls.mro() for parent classes that are also singletons
  438. For use in instance()
  439. """
  440. for subclass in cls.mro():
  441. if (
  442. issubclass(cls, subclass)
  443. and issubclass(subclass, SingletonConfigurable)
  444. and subclass != SingletonConfigurable
  445. ):
  446. yield subclass
  447. @classmethod
  448. def clear_instance(cls) -> None:
  449. """unset _instance for this class and singleton parents."""
  450. if not cls.initialized():
  451. return
  452. for subclass in cls._walk_mro():
  453. if isinstance(subclass._instance, cls):
  454. # only clear instances that are instances
  455. # of the calling class
  456. subclass._instance = None # type:ignore[unreachable]
  457. @classmethod
  458. def instance(cls: type[CT], *args: t.Any, **kwargs: t.Any) -> CT:
  459. """Returns a global instance of this class.
  460. This method create a new instance if none have previously been created
  461. and returns a previously created instance is one already exists.
  462. The arguments and keyword arguments passed to this method are passed
  463. on to the :meth:`__init__` method of the class upon instantiation.
  464. Examples
  465. --------
  466. Create a singleton class using instance, and retrieve it::
  467. >>> from traitlets.config.configurable import SingletonConfigurable
  468. >>> class Foo(SingletonConfigurable): pass
  469. >>> foo = Foo.instance()
  470. >>> foo == Foo.instance()
  471. True
  472. Create a subclass that is retrieved using the base class instance::
  473. >>> class Bar(SingletonConfigurable): pass
  474. >>> class Bam(Bar): pass
  475. >>> bam = Bam.instance()
  476. >>> bam == Bar.instance()
  477. True
  478. """
  479. # Create and save the instance
  480. if cls._instance is None:
  481. inst = cls(*args, **kwargs)
  482. # Now make sure that the instance will also be returned by
  483. # parent classes' _instance attribute.
  484. for subclass in cls._walk_mro():
  485. subclass._instance = inst
  486. if isinstance(cls._instance, cls):
  487. return cls._instance
  488. else:
  489. raise MultipleInstanceError(
  490. f"An incompatible sibling of '{cls.__name__}' is already instantiated"
  491. f" as singleton: {type(cls._instance).__name__}"
  492. )
  493. @classmethod
  494. def initialized(cls) -> bool:
  495. """Has an instance been created?"""
  496. return hasattr(cls, "_instance") and cls._instance is not None