1"""A base class for a configurable application."""
2
3# Copyright (c) IPython Development Team.
4# Distributed under the terms of the Modified BSD License.
5from __future__ import annotations
6
7import functools
8import json
9import logging
10import os
11import pprint
12import re
13import sys
14import typing as t
15from collections import OrderedDict, defaultdict
16from contextlib import suppress
17from copy import deepcopy
18from logging.config import dictConfig
19from textwrap import dedent
20
21from traitlets.config.configurable import Configurable, SingletonConfigurable
22from traitlets.config.loader import (
23 ArgumentError,
24 Config,
25 ConfigFileNotFound,
26 DeferredConfigString,
27 JSONFileConfigLoader,
28 KVArgParseConfigLoader,
29 PyFileConfigLoader,
30)
31from traitlets.traitlets import (
32 Bool,
33 Dict,
34 Enum,
35 Instance,
36 List,
37 TraitError,
38 Unicode,
39 default,
40 observe,
41 observe_compat,
42)
43from traitlets.utils.bunch import Bunch
44from traitlets.utils.nested_update import nested_update
45from traitlets.utils.text import indent, wrap_paragraphs
46
47from ..utils import cast_unicode
48from ..utils.importstring import import_item
49
50# -----------------------------------------------------------------------------
51# Descriptions for the various sections
52# -----------------------------------------------------------------------------
53# merge flags&aliases into options
54option_description = """
55The options below are convenience aliases to configurable class-options,
56as listed in the "Equivalent to" description-line of the aliases.
57To see all configurable class-options for some <cmd>, use:
58 <cmd> --help-all
59""".strip() # trim newlines of front and back
60
61keyvalue_description = """
62The command-line option below sets the respective configurable class-parameter:
63 --Class.parameter=value
64This line is evaluated in Python, so simple expressions are allowed.
65For instance, to set `C.a=[0,1,2]`, you may type this:
66 --C.a='range(3)'
67""".strip() # trim newlines of front and back
68
69# sys.argv can be missing, for example when python is embedded. See the docs
70# for details: https://docs.python.org/3/c-api/intro.html#embedding-python
71if not hasattr(sys, "argv"):
72 sys.argv = [""]
73
74subcommand_description = """
75Subcommands are launched as `{app} cmd [args]`. For information on using
76subcommand 'cmd', do: `{app} cmd -h`.
77"""
78# get running program name
79
80# -----------------------------------------------------------------------------
81# Application class
82# -----------------------------------------------------------------------------
83
84
85_envvar = os.environ.get("TRAITLETS_APPLICATION_RAISE_CONFIG_FILE_ERROR", "")
86if _envvar.lower() in {"1", "true"}:
87 TRAITLETS_APPLICATION_RAISE_CONFIG_FILE_ERROR = True
88elif _envvar.lower() in {"0", "false", ""}:
89 TRAITLETS_APPLICATION_RAISE_CONFIG_FILE_ERROR = False
90else:
91 raise ValueError(
92 f"Unsupported value for environment variable: 'TRAITLETS_APPLICATION_RAISE_CONFIG_FILE_ERROR' is set to '{_envvar}' which is none of {{'0', '1', 'false', 'true', ''}}."
93 )
94
95
96IS_PYTHONW = sys.executable and sys.executable.endswith("pythonw.exe")
97
98T = t.TypeVar("T", bound=t.Callable[..., t.Any])
99AnyLogger = t.Union[logging.Logger, "logging.LoggerAdapter[t.Any]"]
100StrDict = dict[str, t.Any]
101ArgvType = list[str] | None
102ClassesType = list[type[Configurable]]
103
104
105def catch_config_error(method: T) -> T:
106 """Method decorator for catching invalid config (Trait/ArgumentErrors) during init.
107
108 On a TraitError (generally caused by bad config), this will print the trait's
109 message, and exit the app.
110
111 For use on init methods, to prevent invoking excepthook on invalid input.
112 """
113
114 @functools.wraps(method)
115 def inner(app: Application, *args: t.Any, **kwargs: t.Any) -> t.Any:
116 try:
117 return method(app, *args, **kwargs)
118 except (TraitError, ArgumentError) as e:
119 app.log.fatal("Bad config encountered during initialization: %s", e)
120 app.log.debug("Config at the time: %s", app.config)
121 app.exit(1)
122
123 return t.cast(T, inner)
124
125
126class ApplicationError(Exception):
127 pass
128
129
130class LevelFormatter(logging.Formatter):
131 """Formatter with additional `highlevel` record
132
133 This field is empty if log level is less than highlevel_limit,
134 otherwise it is formatted with self.highlevel_format.
135
136 Useful for adding 'WARNING' to warning messages,
137 without adding 'INFO' to info, etc.
138 """
139
140 highlevel_limit = logging.WARN
141 highlevel_format = " %(levelname)s |"
142
143 def format(self, record: logging.LogRecord) -> str:
144 if record.levelno >= self.highlevel_limit:
145 record.highlevel = self.highlevel_format % record.__dict__
146 else:
147 record.highlevel = ""
148 return super().format(record)
149
150
151class Application(SingletonConfigurable):
152 """A singleton application with full configuration support."""
153
154 # The name of the application, will usually match the name of the command
155 # line application
156 name: str | Unicode[str, str | bytes] = Unicode("application")
157
158 # The description of the application that is printed at the beginning
159 # of the help.
160 description: str | Unicode[str, str | bytes] = Unicode("This is an application.")
161 # default section descriptions
162 option_description: str | Unicode[str, str | bytes] = Unicode(option_description)
163 keyvalue_description: str | Unicode[str, str | bytes] = Unicode(keyvalue_description)
164 subcommand_description: str | Unicode[str, str | bytes] = Unicode(subcommand_description)
165
166 python_config_loader_class = PyFileConfigLoader
167 json_config_loader_class = JSONFileConfigLoader
168
169 # The usage and example string that goes at the end of the help string.
170 examples: str | Unicode[str, str | bytes] = Unicode()
171
172 # A sequence of Configurable subclasses whose config=True attributes will
173 # be exposed at the command line.
174 classes: ClassesType = []
175
176 def _classes_inc_parents(
177 self, classes: ClassesType | None = None
178 ) -> t.Generator[type[Configurable], None, None]:
179 """Iterate through configurable classes, including configurable parents
180
181 :param classes:
182 The list of classes to iterate; if not set, uses :attr:`classes`.
183
184 Children should always be after parents, and each class should only be
185 yielded once.
186 """
187 if classes is None:
188 classes = self.classes
189
190 seen = set()
191 for c in classes:
192 # We want to sort parents before children, so we reverse the MRO
193 for parent in reversed(c.mro()):
194 if issubclass(parent, Configurable) and (parent not in seen):
195 seen.add(parent)
196 yield parent
197
198 # The version string of this application.
199 version: str | Unicode[str, str | bytes] = Unicode("0.0")
200
201 # the argv used to initialize the application
202 argv: list[str] | List[str] = List()
203
204 # Whether failing to load config files should prevent startup
205 raise_config_file_errors = Bool(TRAITLETS_APPLICATION_RAISE_CONFIG_FILE_ERROR)
206
207 # The log level for the application
208 log_level = Enum(
209 (0, 10, 20, 30, 40, 50, "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL"),
210 default_value=logging.WARN,
211 help="Set the log level by value or name.",
212 ).tag(config=True)
213
214 _log_formatter_cls = LevelFormatter
215
216 log_datefmt = Unicode(
217 "%Y-%m-%d %H:%M:%S",
218 help="The date format used by logging formatters for `logging.Formatter` ``datefmt`` parameter",
219 ).tag(config=True)
220
221 log_format = Unicode(
222 "[%(name)s]%(highlevel)s %(message)s",
223 help="The Logging format template",
224 ).tag(config=True)
225
226 def get_default_logging_config(self) -> StrDict:
227 """Return the base logging configuration.
228
229 The default is to log to stderr using a StreamHandler, if no default
230 handler already exists.
231
232 The log handler level starts at logging.WARN, but this can be adjusted
233 by setting the ``log_level`` attribute.
234
235 The ``logging_config`` trait is merged into this allowing for finer
236 control of logging.
237
238 """
239 config: StrDict = {
240 "version": 1,
241 "handlers": {
242 "console": {
243 "class": "logging.StreamHandler",
244 "formatter": "console",
245 "level": logging.getLevelName(self.log_level), # type:ignore[call-overload]
246 "stream": "ext://sys.stderr",
247 },
248 },
249 "formatters": {
250 "console": {
251 "class": (
252 f"{self._log_formatter_cls.__module__}.{self._log_formatter_cls.__name__}"
253 ),
254 "format": self.log_format,
255 "datefmt": self.log_datefmt,
256 },
257 },
258 "loggers": {
259 self.__class__.__name__: {
260 "level": "DEBUG",
261 "handlers": ["console"],
262 }
263 },
264 "disable_existing_loggers": False,
265 }
266
267 if IS_PYTHONW:
268 # disable logging
269 # (this should really go to a file, but file-logging is only
270 # hooked up in parallel applications)
271 del config["handlers"]
272 del config["loggers"]
273
274 return config
275
276 @observe("log_datefmt", "log_format", "log_level", "logging_config")
277 def _observe_logging_change(self, change: Bunch) -> None:
278 # convert log level strings to ints
279 log_level = self.log_level
280 if isinstance(log_level, str):
281 self.log_level = t.cast(int, getattr(logging, log_level))
282 self._configure_logging()
283
284 @observe("log", type="default")
285 def _observe_logging_default(self, change: Bunch) -> None:
286 self._configure_logging()
287
288 def _configure_logging(self) -> None:
289 config = self.get_default_logging_config()
290 nested_update(config, self.logging_config or {})
291 dictConfig(config)
292 # make a note that we have configured logging
293 self._logging_configured = True
294
295 @default("log")
296 def _log_default(self) -> AnyLogger:
297 """Start logging for this application."""
298 log = logging.getLogger(self.__class__.__name__)
299 log.propagate = False
300 _log = log # copied from Logger.hasHandlers() (new in Python 3.2)
301 while _log is not None:
302 if _log.handlers:
303 return log
304 if not _log.propagate:
305 break
306 _log = _log.parent # type:ignore[assignment]
307 return log
308
309 logging_config = Dict(
310 help="""
311 Configure additional log handlers.
312
313 The default stderr logs handler is configured by the
314 log_level, log_datefmt and log_format settings.
315
316 This configuration can be used to configure additional handlers
317 (e.g. to output the log to a file) or for finer control over the
318 default handlers.
319
320 If provided this should be a logging configuration dictionary, for
321 more information see:
322 https://docs.python.org/3/library/logging.config.html#logging-config-dictschema
323
324 This dictionary is merged with the base logging configuration which
325 defines the following:
326
327 * A logging formatter intended for interactive use called
328 ``console``.
329 * A logging handler that writes to stderr called
330 ``console`` which uses the formatter ``console``.
331 * A logger with the name of this application set to ``DEBUG``
332 level.
333
334 This example adds a new handler that writes to a file:
335
336 .. code-block:: python
337
338 c.Application.logging_config = {
339 "handlers": {
340 "file": {
341 "class": "logging.FileHandler",
342 "level": "DEBUG",
343 "filename": "<path/to/file>",
344 }
345 },
346 "loggers": {
347 "<application-name>": {
348 "level": "DEBUG",
349 # NOTE: if you don't list the default "console"
350 # handler here then it will be disabled
351 "handlers": ["console", "file"],
352 },
353 },
354 }
355
356 """,
357 ).tag(config=True)
358
359 #: the alias map for configurables
360 #: Keys might strings or tuples for additional options; single-letter alias accessed like `-v`.
361 #: Values might be like "Class.trait" strings of two-tuples: (Class.trait, help-text),
362 # or just the "Class.trait" string, in which case the help text is inferred from the
363 # corresponding trait
364 aliases: StrDict = {"log-level": "Application.log_level"}
365
366 # flags for loading Configurables or store_const style flags
367 # flags are loaded from this dict by '--key' flags
368 # this must be a dict of two-tuples, the first element being the Config/dict
369 # and the second being the help string for the flag
370 flags: StrDict = {
371 "debug": (
372 {
373 "Application": {
374 "log_level": logging.DEBUG,
375 },
376 },
377 "Set log-level to debug, for the most verbose logging.",
378 ),
379 "show-config": (
380 {
381 "Application": {
382 "show_config": True,
383 },
384 },
385 "Show the application's configuration (human-readable format)",
386 ),
387 "show-config-json": (
388 {
389 "Application": {
390 "show_config_json": True,
391 },
392 },
393 "Show the application's configuration (json format)",
394 ),
395 }
396
397 # subcommands for launching other applications
398 # if this is not empty, this will be a parent Application
399 # this must be a dict of two-tuples,
400 # the first element being the application class/import string
401 # and the second being the help string for the subcommand
402 subcommands: dict[str, t.Any] | Dict[str, t.Any] = Dict()
403 # parse_command_line will initialize a subapp, if requested
404 subapp = Instance("traitlets.config.application.Application", allow_none=True)
405
406 # extra command-line arguments that don't set config values
407 extra_args = List(Unicode())
408
409 cli_config = Instance(
410 Config,
411 (),
412 {},
413 help="""The subset of our configuration that came from the command-line
414
415 We re-load this configuration after loading config files,
416 to ensure that it maintains highest priority.
417 """,
418 )
419
420 _loaded_config_files: List[str] = List()
421
422 show_config = Bool(
423 help="Instead of starting the Application, dump configuration to stdout"
424 ).tag(config=True)
425
426 show_config_json = Bool(
427 help="Instead of starting the Application, dump configuration to stdout (as JSON)"
428 ).tag(config=True)
429
430 @observe("show_config_json")
431 def _show_config_json_changed(self, change: Bunch) -> None:
432 self.show_config = change.new
433
434 @observe("show_config")
435 def _show_config_changed(self, change: Bunch) -> None:
436 if change.new:
437 self._save_start = self.start
438 self.start = self.start_show_config # type:ignore[method-assign]
439
440 def __init__(self, **kwargs: t.Any) -> None:
441 SingletonConfigurable.__init__(self, **kwargs)
442 # Ensure my class is in self.classes, so my attributes appear in command line
443 # options and config files.
444 cls = self.__class__
445 if cls not in self.classes:
446 if self.classes is cls.classes:
447 # class attr, assign instead of insert
448 self.classes = [cls, *self.classes]
449 else:
450 self.classes.insert(0, self.__class__)
451
452 @observe("config")
453 @observe_compat
454 def _config_changed(self, change: Bunch) -> None:
455 super()._config_changed(change)
456 self.log.debug("Config changed: %r", change.new)
457
458 @catch_config_error
459 def initialize(self, argv: ArgvType = None) -> None:
460 """Do the basic steps to configure me.
461
462 Override in subclasses.
463 """
464 self.parse_command_line(argv)
465
466 def start(self) -> None:
467 """Start the app mainloop.
468
469 Override in subclasses.
470 """
471 if self.subapp is not None:
472 assert isinstance(self.subapp, Application)
473 return self.subapp.start()
474
475 def start_show_config(self) -> None:
476 """start function used when show_config is True"""
477 config = self.config.copy()
478 # exclude show_config flags from displayed config
479 for cls in self.__class__.mro():
480 if cls.__name__ in config:
481 cls_config = config[cls.__name__]
482 cls_config.pop("show_config", None)
483 cls_config.pop("show_config_json", None)
484
485 if self.show_config_json:
486 json.dump(config, sys.stdout, indent=1, sort_keys=True, default=repr)
487 # add trailing newline
488 sys.stdout.write("\n")
489 return
490
491 if self._loaded_config_files:
492 print("Loaded config files:")
493 for f in self._loaded_config_files:
494 print(" " + f)
495 print()
496
497 for classname in sorted(config):
498 class_config = config[classname]
499 if not class_config:
500 continue
501 print(classname)
502 pformat_kwargs: StrDict = dict(indent=4, compact=True) # noqa: C408
503
504 for traitname in sorted(class_config):
505 value = class_config[traitname]
506 print(f" .{traitname} = {pprint.pformat(value, **pformat_kwargs)}")
507
508 def print_alias_help(self) -> None:
509 """Print the alias parts of the help."""
510 print("\n".join(self.emit_alias_help()))
511
512 def emit_alias_help(self) -> t.Generator[str, None, None]:
513 """Yield the lines for alias part of the help."""
514 if not self.aliases:
515 return
516
517 classdict: dict[str, type[Configurable]] = {}
518 for cls in self.classes:
519 # include all parents (up to, but excluding Configurable) in available names
520 for c in cls.mro()[:-3]:
521 classdict[c.__name__] = t.cast(type[Configurable], c)
522
523 fhelp: str | None
524 for alias, longname in self.aliases.items():
525 try:
526 if isinstance(longname, tuple):
527 longname, fhelp = longname
528 else:
529 fhelp = None
530 classname, traitname = longname.split(".")[-2:]
531 longname = classname + "." + traitname
532 cls = classdict[classname]
533
534 trait = cls.class_traits(config=True)[traitname]
535 fhelp_lines = cls.class_get_trait_help(trait, helptext=fhelp).splitlines()
536
537 if not isinstance(alias, tuple): # type:ignore[unreachable]
538 alias = (alias,) # type:ignore[assignment]
539 alias = sorted(alias, key=len) # type:ignore[assignment]
540 alias = ", ".join(("--%s" if len(m) > 1 else "-%s") % m for m in alias)
541
542 # reformat first line
543 fhelp_lines[0] = fhelp_lines[0].replace("--" + longname, alias)
544 yield from fhelp_lines
545 yield indent(f"Equivalent to: [--{longname}]")
546 except Exception as ex:
547 self.log.error("Failed collecting help-message for alias %r, due to: %s", alias, ex)
548 raise
549
550 def print_flag_help(self) -> None:
551 """Print the flag part of the help."""
552 print("\n".join(self.emit_flag_help()))
553
554 def emit_flag_help(self) -> t.Generator[str, None, None]:
555 """Yield the lines for the flag part of the help."""
556 if not self.flags:
557 return
558
559 for flags, (cfg, fhelp) in self.flags.items():
560 try:
561 if not isinstance(flags, tuple): # type:ignore[unreachable]
562 flags = (flags,) # type:ignore[assignment]
563 flags = sorted(flags, key=len) # type:ignore[assignment]
564 flags = ", ".join(("--%s" if len(m) > 1 else "-%s") % m for m in flags)
565 yield flags
566 yield indent(dedent(fhelp.strip()))
567 cfg_list = " ".join(
568 f"--{clname}.{prop}={val}"
569 for clname, props_dict in cfg.items()
570 for prop, val in props_dict.items()
571 )
572 cfg_txt = f"Equivalent to: [{cfg_list}]"
573 yield indent(dedent(cfg_txt))
574 except Exception as ex:
575 self.log.error("Failed collecting help-message for flag %r, due to: %s", flags, ex)
576 raise
577
578 def print_options(self) -> None:
579 """Print the options part of the help."""
580 print("\n".join(self.emit_options_help()))
581
582 def emit_options_help(self) -> t.Generator[str, None, None]:
583 """Yield the lines for the options part of the help."""
584 if not self.flags and not self.aliases:
585 return
586 header = "Options"
587 yield header
588 yield "=" * len(header)
589 for p in wrap_paragraphs(self.option_description):
590 yield p
591 yield ""
592
593 yield from self.emit_flag_help()
594 yield from self.emit_alias_help()
595 yield ""
596
597 def print_subcommands(self) -> None:
598 """Print the subcommand part of the help."""
599 print("\n".join(self.emit_subcommands_help()))
600
601 def emit_subcommands_help(self) -> t.Generator[str, None, None]:
602 """Yield the lines for the subcommand part of the help."""
603 if not self.subcommands:
604 return
605
606 header = "Subcommands"
607 yield header
608 yield "=" * len(header)
609 for p in wrap_paragraphs(self.subcommand_description.format(app=self.name)):
610 yield p
611 yield ""
612 for subc, (_, help) in self.subcommands.items():
613 yield subc
614 if help:
615 yield indent(dedent(help.strip()))
616 yield ""
617
618 def emit_help_epilogue(self, classes: bool) -> t.Generator[str, None, None]:
619 """Yield the very bottom lines of the help message.
620
621 If classes=False (the default), print `--help-all` msg.
622 """
623 if not classes:
624 yield "To see all available configurables, use `--help-all`."
625 yield ""
626
627 def print_help(self, classes: bool = False) -> None:
628 """Print the help for each Configurable class in self.classes.
629
630 If classes=False (the default), only flags and aliases are printed.
631 """
632 print("\n".join(self.emit_help(classes=classes)))
633
634 def emit_help(self, classes: bool = False) -> t.Generator[str, None, None]:
635 """Yield the help-lines for each Configurable class in self.classes.
636
637 If classes=False (the default), only flags and aliases are printed.
638 """
639 yield from self.emit_description()
640 yield from self.emit_subcommands_help()
641 yield from self.emit_options_help()
642
643 if classes:
644 help_classes = self._classes_with_config_traits()
645 if help_classes is not None:
646 yield "Class options"
647 yield "============="
648 for p in wrap_paragraphs(self.keyvalue_description):
649 yield p
650 yield ""
651
652 for cls in help_classes:
653 yield cls.class_get_help()
654 yield ""
655 yield from self.emit_examples()
656
657 yield from self.emit_help_epilogue(classes)
658
659 def document_config_options(self) -> str:
660 """Generate rST format documentation for the config options this application
661
662 Returns a multiline string.
663 """
664 return "\n".join(c.class_config_rst_doc() for c in self._classes_inc_parents())
665
666 def print_description(self) -> None:
667 """Print the application description."""
668 print("\n".join(self.emit_description()))
669
670 def emit_description(self) -> t.Generator[str, None, None]:
671 """Yield lines with the application description."""
672 for p in wrap_paragraphs(self.description or self.__doc__ or ""):
673 yield p
674 yield ""
675
676 def print_examples(self) -> None:
677 """Print usage and examples (see `emit_examples()`)."""
678 print("\n".join(self.emit_examples()))
679
680 def emit_examples(self) -> t.Generator[str, None, None]:
681 """Yield lines with the usage and examples.
682
683 This usage string goes at the end of the command line help string
684 and should contain examples of the application's usage.
685 """
686 if self.examples:
687 yield "Examples"
688 yield "--------"
689 yield ""
690 yield indent(dedent(self.examples.strip()))
691 yield ""
692
693 def print_version(self) -> None:
694 """Print the version string."""
695 print(self.version)
696
697 @catch_config_error
698 def initialize_subcommand(self, subc: str, argv: ArgvType = None) -> None:
699 """Initialize a subcommand with argv."""
700 val = self.subcommands.get(subc)
701 assert val is not None
702 subapp, _ = val
703
704 if isinstance(subapp, str):
705 subapp = import_item(subapp)
706
707 # Cannot issubclass() on a non-type (SOhttp://stackoverflow.com/questions/8692430)
708 if isinstance(subapp, type) and issubclass(subapp, Application):
709 # Clear existing instances before...
710 self.__class__.clear_instance()
711 # instantiating subapp...
712 self.subapp = subapp.instance(parent=self)
713 elif callable(subapp):
714 # or ask factory to create it...
715 self.subapp = subapp(self)
716 else:
717 raise AssertionError(f"Invalid mappings for subcommand '{subc}'!")
718
719 # ... and finally initialize subapp.
720 self.subapp.initialize(argv)
721
722 def flatten_flags(self) -> tuple[dict[str, t.Any], dict[str, t.Any]]:
723 """Flatten flags and aliases for loaders, so cl-args override as expected.
724
725 This prevents issues such as an alias pointing to InteractiveShell,
726 but a config file setting the same trait in TerminalInteraciveShell
727 getting inappropriate priority over the command-line arg.
728 Also, loaders expect ``(key: longname)`` and not ``key: (longname, help)`` items.
729
730 Only aliases with exactly one descendent in the class list
731 will be promoted.
732
733 """
734 # build a tree of classes in our list that inherit from a particular
735 # it will be a dict by parent classname of classes in our list
736 # that are descendents
737 mro_tree = defaultdict(list)
738 for cls in self.classes:
739 clsname = cls.__name__
740 for parent in cls.mro()[1:-3]:
741 # exclude cls itself and Configurable,HasTraits,object
742 mro_tree[parent.__name__].append(clsname)
743 # flatten aliases, which have the form:
744 # { 'alias' : 'Class.trait' }
745 aliases: dict[str, str] = {}
746 for alias, longname in self.aliases.items():
747 if isinstance(longname, tuple):
748 longname, _ = longname
749 cls, trait = longname.split(".", 1)
750 children = mro_tree[cls]
751 if len(children) == 1:
752 # exactly one descendent, promote alias
753 cls = children[0] # type:ignore[assignment]
754 if not isinstance(alias, tuple): # type:ignore[unreachable]
755 alias = (alias,) # type:ignore[assignment]
756 for al in alias:
757 aliases[al] = ".".join([cls, trait])
758
759 # flatten flags, which are of the form:
760 # { 'key' : ({'Cls' : {'trait' : value}}, 'help')}
761 flags = {}
762 for key, (flagdict, help) in self.flags.items():
763 newflag: dict[t.Any, t.Any] = {}
764 for cls, subdict in flagdict.items():
765 children = mro_tree[cls]
766 # exactly one descendent, promote flag section
767 if len(children) == 1:
768 cls = children[0] # type:ignore[assignment]
769
770 if cls in newflag:
771 newflag[cls].update(subdict)
772 else:
773 newflag[cls] = subdict
774
775 if not isinstance(key, tuple): # type:ignore[unreachable]
776 key = (key,) # type:ignore[assignment]
777 for k in key:
778 flags[k] = (newflag, help)
779 return flags, aliases
780
781 def _create_loader(
782 self,
783 argv: list[str] | None,
784 aliases: StrDict,
785 flags: StrDict,
786 classes: ClassesType | None,
787 ) -> KVArgParseConfigLoader:
788 return KVArgParseConfigLoader(
789 argv, aliases, flags, classes=classes, log=self.log, subcommands=self.subcommands
790 )
791
792 @classmethod
793 def _get_sys_argv(cls, check_argcomplete: bool = False) -> list[str]:
794 """Get `sys.argv` or equivalent from `argcomplete`
795
796 `argcomplete`'s strategy is to call the python script with no arguments,
797 so ``len(sys.argv) == 1``, and run until the `ArgumentParser` is constructed
798 and determine what completions are available.
799
800 On the other hand, `traitlet`'s subcommand-handling strategy is to check
801 ``sys.argv[1]`` and see if it matches a subcommand, and if so then dynamically
802 load the subcommand app and initialize it with ``sys.argv[1:]``.
803
804 This helper method helps to take the current tokens for `argcomplete` and pass
805 them through as `argv`.
806 """
807 if check_argcomplete and "_ARGCOMPLETE" in os.environ:
808 try:
809 from traitlets.config.argcomplete_config import get_argcomplete_cwords
810
811 cwords = get_argcomplete_cwords()
812 assert cwords is not None
813 return cwords
814 except (ImportError, ModuleNotFoundError):
815 pass
816 return sys.argv
817
818 @classmethod
819 def _handle_argcomplete_for_subcommand(cls) -> None:
820 """Helper for `argcomplete` to recognize `traitlets` subcommands
821
822 `argcomplete` does not know that `traitlets` has already consumed subcommands,
823 as it only "sees" the final `argparse.ArgumentParser` that is constructed.
824 (Indeed `KVArgParseConfigLoader` does not get passed subcommands at all currently.)
825 We explicitly manipulate the environment variables used internally by `argcomplete`
826 to get it to skip over the subcommand tokens.
827 """
828 if "_ARGCOMPLETE" not in os.environ:
829 return
830
831 try:
832 from traitlets.config.argcomplete_config import increment_argcomplete_index
833
834 increment_argcomplete_index()
835 except (ImportError, ModuleNotFoundError):
836 pass
837
838 @catch_config_error
839 def parse_command_line(self, argv: ArgvType = None) -> None:
840 """Parse the command line arguments."""
841 assert not isinstance(argv, str)
842 if argv is None:
843 argv = self._get_sys_argv(check_argcomplete=bool(self.subcommands))[1:]
844 self.argv = [cast_unicode(arg) for arg in argv]
845
846 if argv and argv[0] == "help":
847 # turn `ipython help notebook` into `ipython notebook -h`
848 argv = [*argv[1:], "-h"]
849
850 if self.subcommands and len(argv) > 0:
851 # we have subcommands, and one may have been specified
852 subc, subargv = argv[0], argv[1:]
853 if re.match(r"^\w(\-?\w)*$", subc) and subc in self.subcommands:
854 # it's a subcommand, and *not* a flag or class parameter
855 self._handle_argcomplete_for_subcommand()
856 return self.initialize_subcommand(subc, subargv)
857
858 # Arguments after a '--' argument are for the script IPython may be
859 # about to run, not IPython iteslf. For arguments parsed here (help and
860 # version), we want to only search the arguments up to the first
861 # occurrence of '--', which we're calling interpreted_argv.
862 try:
863 interpreted_argv = argv[: argv.index("--")]
864 except ValueError:
865 interpreted_argv = argv
866
867 if any(x in interpreted_argv for x in ("-h", "--help-all", "--help")):
868 self.print_help("--help-all" in interpreted_argv)
869 self.exit(0)
870
871 if "--version" in interpreted_argv or "-V" in interpreted_argv:
872 self.print_version()
873 self.exit(0)
874
875 # flatten flags&aliases, so cl-args get appropriate priority:
876 flags, aliases = self.flatten_flags()
877 classes = list(self._classes_with_config_traits())
878 loader = self._create_loader(argv, aliases, flags, classes=classes)
879 try:
880 self.cli_config = deepcopy(loader.load_config())
881 except SystemExit:
882 # traitlets 5: no longer print help output on error
883 # help output is huge, and comes after the error
884 raise
885 self.update_config(self.cli_config)
886 # store unparsed args in extra_args
887 self.extra_args = loader.extra_args
888
889 @classmethod
890 def _load_config_files(
891 cls,
892 basefilename: str,
893 path: str | t.Sequence[str | None] | None,
894 log: AnyLogger | None = None,
895 raise_config_file_errors: bool = False,
896 ) -> t.Generator[t.Any, None, None]:
897 """Load config files (py,json) by filename and path.
898
899 yield each config object in turn.
900 """
901 if os.path.isabs(basefilename):
902 path = [None]
903 if isinstance(path, str) or path is None:
904 path = [path]
905 for current in reversed(path):
906 # path list is in descending priority order, so load files backwards:
907 pyloader = cls.python_config_loader_class(basefilename + ".py", path=current, log=log)
908 if log:
909 log.debug("Looking for %s in %s", basefilename, current or os.getcwd())
910 jsonloader = cls.json_config_loader_class(basefilename + ".json", path=current, log=log)
911 loaded: list[t.Any] = []
912 filenames: list[str] = []
913 for loader in [pyloader, jsonloader]:
914 config = None
915 try:
916 config = loader.load_config()
917 except ConfigFileNotFound:
918 pass
919 except Exception:
920 # try to get the full filename, but it will be empty in the
921 # unlikely event that the error raised before filefind finished
922 filename = loader.full_filename or basefilename
923 # problem while running the file
924 if raise_config_file_errors:
925 raise
926 if log:
927 log.error("Exception while loading config file %s", filename, exc_info=True) # noqa: G201
928 else:
929 if log:
930 log.debug("Loaded config file: %s", loader.full_filename)
931 if config:
932 for filename, earlier_config in zip(filenames, loaded, strict=True):
933 collisions = earlier_config.collisions(config)
934 if collisions and log:
935 log.warning(
936 "Collisions detected in %s and %s config files."
937 " %s has higher priority: %s",
938 filename,
939 loader.full_filename,
940 loader.full_filename,
941 json.dumps(collisions, indent=2),
942 )
943 yield (config, loader.full_filename)
944 loaded.append(config)
945 filenames.append(loader.full_filename)
946
947 @property
948 def loaded_config_files(self) -> list[str]:
949 """Currently loaded configuration files"""
950 return self._loaded_config_files[:]
951
952 @catch_config_error
953 def load_config_file(
954 self, filename: str, path: str | t.Sequence[str | None] | None = None
955 ) -> None:
956 """Load config files by filename and path."""
957 filename, _ext = os.path.splitext(filename)
958 new_config = Config()
959 for config, fname in self._load_config_files(
960 filename,
961 path=path,
962 log=self.log,
963 raise_config_file_errors=self.raise_config_file_errors,
964 ):
965 new_config.merge(config)
966 if (
967 fname not in self._loaded_config_files
968 ): # only add to list of loaded files if not previously loaded
969 self._loaded_config_files.append(fname)
970 # add self.cli_config to preserve CLI config priority
971 new_config.merge(self.cli_config)
972 self.update_config(new_config)
973
974 @catch_config_error
975 def load_config_environ(self) -> None:
976 """Load config files by environment."""
977 PREFIX = self.name.upper().replace("-", "_")
978 new_config = Config()
979
980 self.log.debug('Looping through config variables with prefix "%s"', PREFIX)
981
982 for k, v in os.environ.items():
983 if k.startswith(PREFIX):
984 self.log.debug('Seeing environ "%s"="%s"', k, v)
985 # use __ instead of . as separator in env variable.
986 # Warning, case sensitive !
987 _, *path, key = k.split("__")
988 section = new_config
989 for p in path:
990 section = section[p]
991 setattr(section, key, DeferredConfigString(v))
992
993 new_config.merge(self.cli_config)
994 self.update_config(new_config)
995
996 def _classes_with_config_traits(
997 self, classes: ClassesType | None = None
998 ) -> t.Generator[type[Configurable], None, None]:
999 """
1000 Yields only classes with configurable traits, and their subclasses.
1001
1002 :param classes:
1003 The list of classes to iterate; if not set, uses :attr:`classes`.
1004
1005 Thus, produced sample config-file will contain all classes
1006 on which a trait-value may be overridden:
1007
1008 - either on the class owning the trait,
1009 - or on its subclasses, even if those subclasses do not define
1010 any traits themselves.
1011 """
1012 if classes is None:
1013 classes = self.classes
1014
1015 cls_to_config = OrderedDict(
1016 (cls, bool(cls.class_own_traits(config=True)))
1017 for cls in self._classes_inc_parents(classes)
1018 )
1019
1020 def is_any_parent_included(cls: t.Any) -> bool:
1021 return any(b in cls_to_config and cls_to_config[b] for b in cls.__bases__)
1022
1023 # Mark "empty" classes for inclusion if their parents own-traits,
1024 # and loop until no more classes gets marked.
1025 #
1026 while True:
1027 to_incl_orig = cls_to_config.copy()
1028 cls_to_config = OrderedDict(
1029 (cls, inc_yes or is_any_parent_included(cls))
1030 for cls, inc_yes in cls_to_config.items()
1031 )
1032 if cls_to_config == to_incl_orig:
1033 break
1034 for cl, inc_yes in cls_to_config.items():
1035 if inc_yes:
1036 yield cl
1037
1038 def generate_config_file(self, classes: ClassesType | None = None) -> str:
1039 """generate default config file from Configurables"""
1040 lines = [f"# Configuration file for {self.name}."]
1041 lines.append("")
1042 lines.append("c = get_config() #" + "noqa")
1043 lines.append("")
1044 classes = self.classes if classes is None else classes
1045 config_classes = list(self._classes_with_config_traits(classes))
1046 for cls in config_classes:
1047 lines.append(cls.class_config_section(config_classes))
1048 return "\n".join(lines)
1049
1050 def close_handlers(self) -> None:
1051 if getattr(self, "_logging_configured", False):
1052 # don't attempt to close handlers unless they have been opened
1053 # (note accessing self.log.handlers will create handlers if they
1054 # have not yet been initialised)
1055 for handler in self.log.handlers:
1056 with suppress(Exception):
1057 handler.close()
1058 self._logging_configured = False
1059
1060 def exit(self, exit_status: int | str | None = 0) -> None:
1061 self.log.debug("Exiting application: %s", self.name)
1062 self.close_handlers()
1063 sys.exit(exit_status)
1064
1065 def __del__(self) -> None:
1066 # __del__ may be called during process teardown,
1067 # at which point any fraction of attributes and modules may have been cleared,
1068 # e.g. even _accessing_ self.log may fail.
1069 with suppress(Exception):
1070 self.close_handlers()
1071
1072 @classmethod
1073 def launch_instance(cls, argv: ArgvType = None, **kwargs: t.Any) -> None:
1074 """Launch a global instance of this Application
1075
1076 If a global instance already exists, this reinitializes and starts it
1077 """
1078 app = cls.instance(**kwargs)
1079 app.initialize(argv)
1080 app.start()
1081
1082
1083# -----------------------------------------------------------------------------
1084# utility functions, for convenience
1085# -----------------------------------------------------------------------------
1086
1087default_aliases = Application.aliases
1088default_flags = Application.flags
1089
1090
1091def boolean_flag(name: str, configurable: str, set_help: str = "", unset_help: str = "") -> StrDict:
1092 """Helper for building basic --trait, --no-trait flags.
1093
1094 Parameters
1095 ----------
1096 name : str
1097 The name of the flag.
1098 configurable : str
1099 The 'Class.trait' string of the trait to be set/unset with the flag
1100 set_help : unicode
1101 help string for --name flag
1102 unset_help : unicode
1103 help string for --no-name flag
1104
1105 Returns
1106 -------
1107 cfg : dict
1108 A dict with two keys: 'name', and 'no-name', for setting and unsetting
1109 the trait, respectively.
1110 """
1111 # default helpstrings
1112 set_help = set_help or f"set {configurable}=True"
1113 unset_help = unset_help or f"set {configurable}=False"
1114
1115 cls, trait = configurable.split(".")
1116
1117 setter = {cls: {trait: True}}
1118 unsetter = {cls: {trait: False}}
1119 return {name: (setter, set_help), "no-" + name: (unsetter, unset_help)}
1120
1121
1122def get_config() -> Config:
1123 """Get the config object for the global Application instance, if there is one
1124
1125 otherwise return an empty config object
1126 """
1127 if Application.initialized():
1128 return Application.instance().config
1129 else:
1130 return Config()
1131
1132
1133if __name__ == "__main__":
1134 Application.launch_instance()