|
"""A base class for a configurable application.""" |
|
|
|
|
|
|
|
from __future__ import annotations |
|
|
|
import functools |
|
import json |
|
import logging |
|
import os |
|
import pprint |
|
import re |
|
import sys |
|
import typing as t |
|
from collections import OrderedDict, defaultdict |
|
from contextlib import suppress |
|
from copy import deepcopy |
|
from logging.config import dictConfig |
|
from textwrap import dedent |
|
|
|
from traitlets.config.configurable import Configurable, SingletonConfigurable |
|
from traitlets.config.loader import ( |
|
ArgumentError, |
|
Config, |
|
ConfigFileNotFound, |
|
DeferredConfigString, |
|
JSONFileConfigLoader, |
|
KVArgParseConfigLoader, |
|
PyFileConfigLoader, |
|
) |
|
from traitlets.traitlets import ( |
|
Bool, |
|
Dict, |
|
Enum, |
|
Instance, |
|
List, |
|
TraitError, |
|
Unicode, |
|
default, |
|
observe, |
|
observe_compat, |
|
) |
|
from traitlets.utils.bunch import Bunch |
|
from traitlets.utils.nested_update import nested_update |
|
from traitlets.utils.text import indent, wrap_paragraphs |
|
|
|
from ..utils import cast_unicode |
|
from ..utils.importstring import import_item |
|
|
|
|
|
|
|
|
|
|
|
option_description = """ |
|
The options below are convenience aliases to configurable class-options, |
|
as listed in the "Equivalent to" description-line of the aliases. |
|
To see all configurable class-options for some <cmd>, use: |
|
<cmd> --help-all |
|
""".strip() |
|
|
|
keyvalue_description = """ |
|
The command-line option below sets the respective configurable class-parameter: |
|
--Class.parameter=value |
|
This line is evaluated in Python, so simple expressions are allowed. |
|
For instance, to set `C.a=[0,1,2]`, you may type this: |
|
--C.a='range(3)' |
|
""".strip() |
|
|
|
|
|
|
|
if not hasattr(sys, "argv"): |
|
sys.argv = [""] |
|
|
|
subcommand_description = """ |
|
Subcommands are launched as `{app} cmd [args]`. For information on using |
|
subcommand 'cmd', do: `{app} cmd -h`. |
|
""" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_envvar = os.environ.get("TRAITLETS_APPLICATION_RAISE_CONFIG_FILE_ERROR", "") |
|
if _envvar.lower() in {"1", "true"}: |
|
TRAITLETS_APPLICATION_RAISE_CONFIG_FILE_ERROR = True |
|
elif _envvar.lower() in {"0", "false", ""}: |
|
TRAITLETS_APPLICATION_RAISE_CONFIG_FILE_ERROR = False |
|
else: |
|
raise ValueError( |
|
"Unsupported value for environment variable: 'TRAITLETS_APPLICATION_RAISE_CONFIG_FILE_ERROR' is set to '%s' which is none of {'0', '1', 'false', 'true', ''}." |
|
% _envvar |
|
) |
|
|
|
|
|
IS_PYTHONW = sys.executable and sys.executable.endswith("pythonw.exe") |
|
|
|
T = t.TypeVar("T", bound=t.Callable[..., t.Any]) |
|
AnyLogger = t.Union[logging.Logger, "logging.LoggerAdapter[t.Any]"] |
|
StrDict = t.Dict[str, t.Any] |
|
ArgvType = t.Optional[t.List[str]] |
|
ClassesType = t.List[t.Type[Configurable]] |
|
|
|
|
|
def catch_config_error(method: T) -> T: |
|
"""Method decorator for catching invalid config (Trait/ArgumentErrors) during init. |
|
|
|
On a TraitError (generally caused by bad config), this will print the trait's |
|
message, and exit the app. |
|
|
|
For use on init methods, to prevent invoking excepthook on invalid input. |
|
""" |
|
|
|
@functools.wraps(method) |
|
def inner(app: Application, *args: t.Any, **kwargs: t.Any) -> t.Any: |
|
try: |
|
return method(app, *args, **kwargs) |
|
except (TraitError, ArgumentError) as e: |
|
app.log.fatal("Bad config encountered during initialization: %s", e) |
|
app.log.debug("Config at the time: %s", app.config) |
|
app.exit(1) |
|
|
|
return t.cast(T, inner) |
|
|
|
|
|
class ApplicationError(Exception): |
|
pass |
|
|
|
|
|
class LevelFormatter(logging.Formatter): |
|
"""Formatter with additional `highlevel` record |
|
|
|
This field is empty if log level is less than highlevel_limit, |
|
otherwise it is formatted with self.highlevel_format. |
|
|
|
Useful for adding 'WARNING' to warning messages, |
|
without adding 'INFO' to info, etc. |
|
""" |
|
|
|
highlevel_limit = logging.WARN |
|
highlevel_format = " %(levelname)s |" |
|
|
|
def format(self, record: logging.LogRecord) -> str: |
|
if record.levelno >= self.highlevel_limit: |
|
record.highlevel = self.highlevel_format % record.__dict__ |
|
else: |
|
record.highlevel = "" |
|
return super().format(record) |
|
|
|
|
|
class Application(SingletonConfigurable): |
|
"""A singleton application with full configuration support.""" |
|
|
|
|
|
|
|
name: str | Unicode[str, str | bytes] = Unicode("application") |
|
|
|
|
|
|
|
description: str | Unicode[str, str | bytes] = Unicode("This is an application.") |
|
|
|
option_description: str | Unicode[str, str | bytes] = Unicode(option_description) |
|
keyvalue_description: str | Unicode[str, str | bytes] = Unicode(keyvalue_description) |
|
subcommand_description: str | Unicode[str, str | bytes] = Unicode(subcommand_description) |
|
|
|
python_config_loader_class = PyFileConfigLoader |
|
json_config_loader_class = JSONFileConfigLoader |
|
|
|
|
|
examples: str | Unicode[str, str | bytes] = Unicode() |
|
|
|
|
|
|
|
classes: ClassesType = [] |
|
|
|
def _classes_inc_parents( |
|
self, classes: ClassesType | None = None |
|
) -> t.Generator[type[Configurable], None, None]: |
|
"""Iterate through configurable classes, including configurable parents |
|
|
|
:param classes: |
|
The list of classes to iterate; if not set, uses :attr:`classes`. |
|
|
|
Children should always be after parents, and each class should only be |
|
yielded once. |
|
""" |
|
if classes is None: |
|
classes = self.classes |
|
|
|
seen = set() |
|
for c in classes: |
|
|
|
for parent in reversed(c.mro()): |
|
if issubclass(parent, Configurable) and (parent not in seen): |
|
seen.add(parent) |
|
yield parent |
|
|
|
|
|
version: str | Unicode[str, str | bytes] = Unicode("0.0") |
|
|
|
|
|
argv: list[str] | List[str] = List() |
|
|
|
|
|
raise_config_file_errors = Bool(TRAITLETS_APPLICATION_RAISE_CONFIG_FILE_ERROR) |
|
|
|
|
|
log_level = Enum( |
|
(0, 10, 20, 30, 40, 50, "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL"), |
|
default_value=logging.WARN, |
|
help="Set the log level by value or name.", |
|
).tag(config=True) |
|
|
|
_log_formatter_cls = LevelFormatter |
|
|
|
log_datefmt = Unicode( |
|
"%Y-%m-%d %H:%M:%S", help="The date format used by logging formatters for %(asctime)s" |
|
).tag(config=True) |
|
|
|
log_format = Unicode( |
|
"[%(name)s]%(highlevel)s %(message)s", |
|
help="The Logging format template", |
|
).tag(config=True) |
|
|
|
def get_default_logging_config(self) -> StrDict: |
|
"""Return the base logging configuration. |
|
|
|
The default is to log to stderr using a StreamHandler, if no default |
|
handler already exists. |
|
|
|
The log handler level starts at logging.WARN, but this can be adjusted |
|
by setting the ``log_level`` attribute. |
|
|
|
The ``logging_config`` trait is merged into this allowing for finer |
|
control of logging. |
|
|
|
""" |
|
config: StrDict = { |
|
"version": 1, |
|
"handlers": { |
|
"console": { |
|
"class": "logging.StreamHandler", |
|
"formatter": "console", |
|
"level": logging.getLevelName(self.log_level), |
|
"stream": "ext://sys.stderr", |
|
}, |
|
}, |
|
"formatters": { |
|
"console": { |
|
"class": ( |
|
f"{self._log_formatter_cls.__module__}" |
|
f".{self._log_formatter_cls.__name__}" |
|
), |
|
"format": self.log_format, |
|
"datefmt": self.log_datefmt, |
|
}, |
|
}, |
|
"loggers": { |
|
self.__class__.__name__: { |
|
"level": "DEBUG", |
|
"handlers": ["console"], |
|
} |
|
}, |
|
"disable_existing_loggers": False, |
|
} |
|
|
|
if IS_PYTHONW: |
|
|
|
|
|
|
|
del config["handlers"] |
|
del config["loggers"] |
|
|
|
return config |
|
|
|
@observe("log_datefmt", "log_format", "log_level", "logging_config") |
|
def _observe_logging_change(self, change: Bunch) -> None: |
|
|
|
log_level = self.log_level |
|
if isinstance(log_level, str): |
|
self.log_level = t.cast(int, getattr(logging, log_level)) |
|
self._configure_logging() |
|
|
|
@observe("log", type="default") |
|
def _observe_logging_default(self, change: Bunch) -> None: |
|
self._configure_logging() |
|
|
|
def _configure_logging(self) -> None: |
|
config = self.get_default_logging_config() |
|
nested_update(config, self.logging_config or {}) |
|
dictConfig(config) |
|
|
|
self._logging_configured = True |
|
|
|
@default("log") |
|
def _log_default(self) -> AnyLogger: |
|
"""Start logging for this application.""" |
|
log = logging.getLogger(self.__class__.__name__) |
|
log.propagate = False |
|
_log = log |
|
while _log is not None: |
|
if _log.handlers: |
|
return log |
|
if not _log.propagate: |
|
break |
|
_log = _log.parent |
|
return log |
|
|
|
logging_config = Dict( |
|
help=""" |
|
Configure additional log handlers. |
|
|
|
The default stderr logs handler is configured by the |
|
log_level, log_datefmt and log_format settings. |
|
|
|
This configuration can be used to configure additional handlers |
|
(e.g. to output the log to a file) or for finer control over the |
|
default handlers. |
|
|
|
If provided this should be a logging configuration dictionary, for |
|
more information see: |
|
https://docs.python.org/3/library/logging.config.html#logging-config-dictschema |
|
|
|
This dictionary is merged with the base logging configuration which |
|
defines the following: |
|
|
|
* A logging formatter intended for interactive use called |
|
``console``. |
|
* A logging handler that writes to stderr called |
|
``console`` which uses the formatter ``console``. |
|
* A logger with the name of this application set to ``DEBUG`` |
|
level. |
|
|
|
This example adds a new handler that writes to a file: |
|
|
|
.. code-block:: python |
|
|
|
c.Application.logging_config = { |
|
"handlers": { |
|
"file": { |
|
"class": "logging.FileHandler", |
|
"level": "DEBUG", |
|
"filename": "<path/to/file>", |
|
} |
|
}, |
|
"loggers": { |
|
"<application-name>": { |
|
"level": "DEBUG", |
|
# NOTE: if you don't list the default "console" |
|
# handler here then it will be disabled |
|
"handlers": ["console", "file"], |
|
}, |
|
}, |
|
} |
|
|
|
""", |
|
).tag(config=True) |
|
|
|
|
|
|
|
|
|
|
|
|
|
aliases: StrDict = {"log-level": "Application.log_level"} |
|
|
|
|
|
|
|
|
|
|
|
flags: StrDict = { |
|
"debug": ( |
|
{ |
|
"Application": { |
|
"log_level": logging.DEBUG, |
|
}, |
|
}, |
|
"Set log-level to debug, for the most verbose logging.", |
|
), |
|
"show-config": ( |
|
{ |
|
"Application": { |
|
"show_config": True, |
|
}, |
|
}, |
|
"Show the application's configuration (human-readable format)", |
|
), |
|
"show-config-json": ( |
|
{ |
|
"Application": { |
|
"show_config_json": True, |
|
}, |
|
}, |
|
"Show the application's configuration (json format)", |
|
), |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
subcommands: dict[str, t.Any] | Dict[str, t.Any] = Dict() |
|
|
|
subapp = Instance("traitlets.config.application.Application", allow_none=True) |
|
|
|
|
|
extra_args = List(Unicode()) |
|
|
|
cli_config = Instance( |
|
Config, |
|
(), |
|
{}, |
|
help="""The subset of our configuration that came from the command-line |
|
|
|
We re-load this configuration after loading config files, |
|
to ensure that it maintains highest priority. |
|
""", |
|
) |
|
|
|
_loaded_config_files: List[str] = List() |
|
|
|
show_config = Bool( |
|
help="Instead of starting the Application, dump configuration to stdout" |
|
).tag(config=True) |
|
|
|
show_config_json = Bool( |
|
help="Instead of starting the Application, dump configuration to stdout (as JSON)" |
|
).tag(config=True) |
|
|
|
@observe("show_config_json") |
|
def _show_config_json_changed(self, change: Bunch) -> None: |
|
self.show_config = change.new |
|
|
|
@observe("show_config") |
|
def _show_config_changed(self, change: Bunch) -> None: |
|
if change.new: |
|
self._save_start = self.start |
|
self.start = self.start_show_config |
|
|
|
def __init__(self, **kwargs: t.Any) -> None: |
|
SingletonConfigurable.__init__(self, **kwargs) |
|
|
|
|
|
cls = self.__class__ |
|
if cls not in self.classes: |
|
if self.classes is cls.classes: |
|
|
|
self.classes = [cls, *self.classes] |
|
else: |
|
self.classes.insert(0, self.__class__) |
|
|
|
@observe("config") |
|
@observe_compat |
|
def _config_changed(self, change: Bunch) -> None: |
|
super()._config_changed(change) |
|
self.log.debug("Config changed: %r", change.new) |
|
|
|
@catch_config_error |
|
def initialize(self, argv: ArgvType = None) -> None: |
|
"""Do the basic steps to configure me. |
|
|
|
Override in subclasses. |
|
""" |
|
self.parse_command_line(argv) |
|
|
|
def start(self) -> None: |
|
"""Start the app mainloop. |
|
|
|
Override in subclasses. |
|
""" |
|
if self.subapp is not None: |
|
assert isinstance(self.subapp, Application) |
|
return self.subapp.start() |
|
|
|
def start_show_config(self) -> None: |
|
"""start function used when show_config is True""" |
|
config = self.config.copy() |
|
|
|
for cls in self.__class__.mro(): |
|
if cls.__name__ in config: |
|
cls_config = config[cls.__name__] |
|
cls_config.pop("show_config", None) |
|
cls_config.pop("show_config_json", None) |
|
|
|
if self.show_config_json: |
|
json.dump(config, sys.stdout, indent=1, sort_keys=True, default=repr) |
|
|
|
sys.stdout.write("\n") |
|
return |
|
|
|
if self._loaded_config_files: |
|
print("Loaded config files:") |
|
for f in self._loaded_config_files: |
|
print(" " + f) |
|
print() |
|
|
|
for classname in sorted(config): |
|
class_config = config[classname] |
|
if not class_config: |
|
continue |
|
print(classname) |
|
pformat_kwargs: StrDict = dict(indent=4, compact=True) |
|
|
|
for traitname in sorted(class_config): |
|
value = class_config[traitname] |
|
print(f" .{traitname} = {pprint.pformat(value, **pformat_kwargs)}") |
|
|
|
def print_alias_help(self) -> None: |
|
"""Print the alias parts of the help.""" |
|
print("\n".join(self.emit_alias_help())) |
|
|
|
def emit_alias_help(self) -> t.Generator[str, None, None]: |
|
"""Yield the lines for alias part of the help.""" |
|
if not self.aliases: |
|
return |
|
|
|
classdict: dict[str, type[Configurable]] = {} |
|
for cls in self.classes: |
|
|
|
for c in cls.mro()[:-3]: |
|
classdict[c.__name__] = t.cast(t.Type[Configurable], c) |
|
|
|
fhelp: str | None |
|
for alias, longname in self.aliases.items(): |
|
try: |
|
if isinstance(longname, tuple): |
|
longname, fhelp = longname |
|
else: |
|
fhelp = None |
|
classname, traitname = longname.split(".")[-2:] |
|
longname = classname + "." + traitname |
|
cls = classdict[classname] |
|
|
|
trait = cls.class_traits(config=True)[traitname] |
|
fhelp_lines = cls.class_get_trait_help(trait, helptext=fhelp).splitlines() |
|
|
|
if not isinstance(alias, tuple): |
|
alias = (alias,) |
|
alias = sorted(alias, key=len) |
|
alias = ", ".join(("--%s" if len(m) > 1 else "-%s") % m for m in alias) |
|
|
|
|
|
fhelp_lines[0] = fhelp_lines[0].replace("--" + longname, alias) |
|
yield from fhelp_lines |
|
yield indent("Equivalent to: [--%s]" % longname) |
|
except Exception as ex: |
|
self.log.error("Failed collecting help-message for alias %r, due to: %s", alias, ex) |
|
raise |
|
|
|
def print_flag_help(self) -> None: |
|
"""Print the flag part of the help.""" |
|
print("\n".join(self.emit_flag_help())) |
|
|
|
def emit_flag_help(self) -> t.Generator[str, None, None]: |
|
"""Yield the lines for the flag part of the help.""" |
|
if not self.flags: |
|
return |
|
|
|
for flags, (cfg, fhelp) in self.flags.items(): |
|
try: |
|
if not isinstance(flags, tuple): |
|
flags = (flags,) |
|
flags = sorted(flags, key=len) |
|
flags = ", ".join(("--%s" if len(m) > 1 else "-%s") % m for m in flags) |
|
yield flags |
|
yield indent(dedent(fhelp.strip())) |
|
cfg_list = " ".join( |
|
f"--{clname}.{prop}={val}" |
|
for clname, props_dict in cfg.items() |
|
for prop, val in props_dict.items() |
|
) |
|
cfg_txt = "Equivalent to: [%s]" % cfg_list |
|
yield indent(dedent(cfg_txt)) |
|
except Exception as ex: |
|
self.log.error("Failed collecting help-message for flag %r, due to: %s", flags, ex) |
|
raise |
|
|
|
def print_options(self) -> None: |
|
"""Print the options part of the help.""" |
|
print("\n".join(self.emit_options_help())) |
|
|
|
def emit_options_help(self) -> t.Generator[str, None, None]: |
|
"""Yield the lines for the options part of the help.""" |
|
if not self.flags and not self.aliases: |
|
return |
|
header = "Options" |
|
yield header |
|
yield "=" * len(header) |
|
for p in wrap_paragraphs(self.option_description): |
|
yield p |
|
yield "" |
|
|
|
yield from self.emit_flag_help() |
|
yield from self.emit_alias_help() |
|
yield "" |
|
|
|
def print_subcommands(self) -> None: |
|
"""Print the subcommand part of the help.""" |
|
print("\n".join(self.emit_subcommands_help())) |
|
|
|
def emit_subcommands_help(self) -> t.Generator[str, None, None]: |
|
"""Yield the lines for the subcommand part of the help.""" |
|
if not self.subcommands: |
|
return |
|
|
|
header = "Subcommands" |
|
yield header |
|
yield "=" * len(header) |
|
for p in wrap_paragraphs(self.subcommand_description.format(app=self.name)): |
|
yield p |
|
yield "" |
|
for subc, (_, help) in self.subcommands.items(): |
|
yield subc |
|
if help: |
|
yield indent(dedent(help.strip())) |
|
yield "" |
|
|
|
def emit_help_epilogue(self, classes: bool) -> t.Generator[str, None, None]: |
|
"""Yield the very bottom lines of the help message. |
|
|
|
If classes=False (the default), print `--help-all` msg. |
|
""" |
|
if not classes: |
|
yield "To see all available configurables, use `--help-all`." |
|
yield "" |
|
|
|
def print_help(self, classes: bool = False) -> None: |
|
"""Print the help for each Configurable class in self.classes. |
|
|
|
If classes=False (the default), only flags and aliases are printed. |
|
""" |
|
print("\n".join(self.emit_help(classes=classes))) |
|
|
|
def emit_help(self, classes: bool = False) -> t.Generator[str, None, None]: |
|
"""Yield the help-lines for each Configurable class in self.classes. |
|
|
|
If classes=False (the default), only flags and aliases are printed. |
|
""" |
|
yield from self.emit_description() |
|
yield from self.emit_subcommands_help() |
|
yield from self.emit_options_help() |
|
|
|
if classes: |
|
help_classes = self._classes_with_config_traits() |
|
if help_classes is not None: |
|
yield "Class options" |
|
yield "=============" |
|
for p in wrap_paragraphs(self.keyvalue_description): |
|
yield p |
|
yield "" |
|
|
|
for cls in help_classes: |
|
yield cls.class_get_help() |
|
yield "" |
|
yield from self.emit_examples() |
|
|
|
yield from self.emit_help_epilogue(classes) |
|
|
|
def document_config_options(self) -> str: |
|
"""Generate rST format documentation for the config options this application |
|
|
|
Returns a multiline string. |
|
""" |
|
return "\n".join(c.class_config_rst_doc() for c in self._classes_inc_parents()) |
|
|
|
def print_description(self) -> None: |
|
"""Print the application description.""" |
|
print("\n".join(self.emit_description())) |
|
|
|
def emit_description(self) -> t.Generator[str, None, None]: |
|
"""Yield lines with the application description.""" |
|
for p in wrap_paragraphs(self.description or self.__doc__ or ""): |
|
yield p |
|
yield "" |
|
|
|
def print_examples(self) -> None: |
|
"""Print usage and examples (see `emit_examples()`).""" |
|
print("\n".join(self.emit_examples())) |
|
|
|
def emit_examples(self) -> t.Generator[str, None, None]: |
|
"""Yield lines with the usage and examples. |
|
|
|
This usage string goes at the end of the command line help string |
|
and should contain examples of the application's usage. |
|
""" |
|
if self.examples: |
|
yield "Examples" |
|
yield "--------" |
|
yield "" |
|
yield indent(dedent(self.examples.strip())) |
|
yield "" |
|
|
|
def print_version(self) -> None: |
|
"""Print the version string.""" |
|
print(self.version) |
|
|
|
@catch_config_error |
|
def initialize_subcommand(self, subc: str, argv: ArgvType = None) -> None: |
|
"""Initialize a subcommand with argv.""" |
|
val = self.subcommands.get(subc) |
|
assert val is not None |
|
subapp, _ = val |
|
|
|
if isinstance(subapp, str): |
|
subapp = import_item(subapp) |
|
|
|
|
|
if isinstance(subapp, type) and issubclass(subapp, Application): |
|
|
|
self.__class__.clear_instance() |
|
|
|
self.subapp = subapp.instance(parent=self) |
|
elif callable(subapp): |
|
|
|
self.subapp = subapp(self) |
|
else: |
|
raise AssertionError("Invalid mappings for subcommand '%s'!" % subc) |
|
|
|
|
|
self.subapp.initialize(argv) |
|
|
|
def flatten_flags(self) -> tuple[dict[str, t.Any], dict[str, t.Any]]: |
|
"""Flatten flags and aliases for loaders, so cl-args override as expected. |
|
|
|
This prevents issues such as an alias pointing to InteractiveShell, |
|
but a config file setting the same trait in TerminalInteraciveShell |
|
getting inappropriate priority over the command-line arg. |
|
Also, loaders expect ``(key: longname)`` and not ``key: (longname, help)`` items. |
|
|
|
Only aliases with exactly one descendent in the class list |
|
will be promoted. |
|
|
|
""" |
|
|
|
|
|
|
|
mro_tree = defaultdict(list) |
|
for cls in self.classes: |
|
clsname = cls.__name__ |
|
for parent in cls.mro()[1:-3]: |
|
|
|
mro_tree[parent.__name__].append(clsname) |
|
|
|
|
|
aliases: dict[str, str] = {} |
|
for alias, longname in self.aliases.items(): |
|
if isinstance(longname, tuple): |
|
longname, _ = longname |
|
cls, trait = longname.split(".", 1) |
|
children = mro_tree[cls] |
|
if len(children) == 1: |
|
|
|
cls = children[0] |
|
if not isinstance(aliases, tuple): |
|
alias = (alias,) |
|
for al in alias: |
|
aliases[al] = ".".join([cls, trait]) |
|
|
|
|
|
|
|
flags = {} |
|
for key, (flagdict, help) in self.flags.items(): |
|
newflag: dict[t.Any, t.Any] = {} |
|
for cls, subdict in flagdict.items(): |
|
children = mro_tree[cls] |
|
|
|
if len(children) == 1: |
|
cls = children[0] |
|
|
|
if cls in newflag: |
|
newflag[cls].update(subdict) |
|
else: |
|
newflag[cls] = subdict |
|
|
|
if not isinstance(key, tuple): |
|
key = (key,) |
|
for k in key: |
|
flags[k] = (newflag, help) |
|
return flags, aliases |
|
|
|
def _create_loader( |
|
self, |
|
argv: list[str] | None, |
|
aliases: StrDict, |
|
flags: StrDict, |
|
classes: ClassesType | None, |
|
) -> KVArgParseConfigLoader: |
|
return KVArgParseConfigLoader( |
|
argv, aliases, flags, classes=classes, log=self.log, subcommands=self.subcommands |
|
) |
|
|
|
@classmethod |
|
def _get_sys_argv(cls, check_argcomplete: bool = False) -> list[str]: |
|
"""Get `sys.argv` or equivalent from `argcomplete` |
|
|
|
`argcomplete`'s strategy is to call the python script with no arguments, |
|
so ``len(sys.argv) == 1``, and run until the `ArgumentParser` is constructed |
|
and determine what completions are available. |
|
|
|
On the other hand, `traitlet`'s subcommand-handling strategy is to check |
|
``sys.argv[1]`` and see if it matches a subcommand, and if so then dynamically |
|
load the subcommand app and initialize it with ``sys.argv[1:]``. |
|
|
|
This helper method helps to take the current tokens for `argcomplete` and pass |
|
them through as `argv`. |
|
""" |
|
if check_argcomplete and "_ARGCOMPLETE" in os.environ: |
|
try: |
|
from traitlets.config.argcomplete_config import get_argcomplete_cwords |
|
|
|
cwords = get_argcomplete_cwords() |
|
assert cwords is not None |
|
return cwords |
|
except (ImportError, ModuleNotFoundError): |
|
pass |
|
return sys.argv |
|
|
|
@classmethod |
|
def _handle_argcomplete_for_subcommand(cls) -> None: |
|
"""Helper for `argcomplete` to recognize `traitlets` subcommands |
|
|
|
`argcomplete` does not know that `traitlets` has already consumed subcommands, |
|
as it only "sees" the final `argparse.ArgumentParser` that is constructed. |
|
(Indeed `KVArgParseConfigLoader` does not get passed subcommands at all currently.) |
|
We explicitly manipulate the environment variables used internally by `argcomplete` |
|
to get it to skip over the subcommand tokens. |
|
""" |
|
if "_ARGCOMPLETE" not in os.environ: |
|
return |
|
|
|
try: |
|
from traitlets.config.argcomplete_config import increment_argcomplete_index |
|
|
|
increment_argcomplete_index() |
|
except (ImportError, ModuleNotFoundError): |
|
pass |
|
|
|
@catch_config_error |
|
def parse_command_line(self, argv: ArgvType = None) -> None: |
|
"""Parse the command line arguments.""" |
|
assert not isinstance(argv, str) |
|
if argv is None: |
|
argv = self._get_sys_argv(check_argcomplete=bool(self.subcommands))[1:] |
|
self.argv = [cast_unicode(arg) for arg in argv] |
|
|
|
if argv and argv[0] == "help": |
|
|
|
argv = argv[1:] + ["-h"] |
|
|
|
if self.subcommands and len(argv) > 0: |
|
|
|
subc, subargv = argv[0], argv[1:] |
|
if re.match(r"^\w(\-?\w)*$", subc) and subc in self.subcommands: |
|
|
|
self._handle_argcomplete_for_subcommand() |
|
return self.initialize_subcommand(subc, subargv) |
|
|
|
|
|
|
|
|
|
|
|
try: |
|
interpreted_argv = argv[: argv.index("--")] |
|
except ValueError: |
|
interpreted_argv = argv |
|
|
|
if any(x in interpreted_argv for x in ("-h", "--help-all", "--help")): |
|
self.print_help("--help-all" in interpreted_argv) |
|
self.exit(0) |
|
|
|
if "--version" in interpreted_argv or "-V" in interpreted_argv: |
|
self.print_version() |
|
self.exit(0) |
|
|
|
|
|
flags, aliases = self.flatten_flags() |
|
classes = list(self._classes_with_config_traits()) |
|
loader = self._create_loader(argv, aliases, flags, classes=classes) |
|
try: |
|
self.cli_config = deepcopy(loader.load_config()) |
|
except SystemExit: |
|
|
|
|
|
raise |
|
self.update_config(self.cli_config) |
|
|
|
self.extra_args = loader.extra_args |
|
|
|
@classmethod |
|
def _load_config_files( |
|
cls, |
|
basefilename: str, |
|
path: str | t.Sequence[str | None] | None, |
|
log: AnyLogger | None = None, |
|
raise_config_file_errors: bool = False, |
|
) -> t.Generator[t.Any, None, None]: |
|
"""Load config files (py,json) by filename and path. |
|
|
|
yield each config object in turn. |
|
""" |
|
if isinstance(path, str) or path is None: |
|
path = [path] |
|
for current in reversed(path): |
|
|
|
pyloader = cls.python_config_loader_class(basefilename + ".py", path=current, log=log) |
|
if log: |
|
log.debug("Looking for %s in %s", basefilename, current or os.getcwd()) |
|
jsonloader = cls.json_config_loader_class(basefilename + ".json", path=current, log=log) |
|
loaded: list[t.Any] = [] |
|
filenames: list[str] = [] |
|
for loader in [pyloader, jsonloader]: |
|
config = None |
|
try: |
|
config = loader.load_config() |
|
except ConfigFileNotFound: |
|
pass |
|
except Exception: |
|
|
|
|
|
filename = loader.full_filename or basefilename |
|
|
|
if raise_config_file_errors: |
|
raise |
|
if log: |
|
log.error("Exception while loading config file %s", filename, exc_info=True) |
|
else: |
|
if log: |
|
log.debug("Loaded config file: %s", loader.full_filename) |
|
if config: |
|
for filename, earlier_config in zip(filenames, loaded): |
|
collisions = earlier_config.collisions(config) |
|
if collisions and log: |
|
log.warning( |
|
"Collisions detected in {0} and {1} config files." |
|
" {1} has higher priority: {2}".format( |
|
filename, |
|
loader.full_filename, |
|
json.dumps(collisions, indent=2), |
|
) |
|
) |
|
yield (config, loader.full_filename) |
|
loaded.append(config) |
|
filenames.append(loader.full_filename) |
|
|
|
@property |
|
def loaded_config_files(self) -> list[str]: |
|
"""Currently loaded configuration files""" |
|
return self._loaded_config_files[:] |
|
|
|
@catch_config_error |
|
def load_config_file( |
|
self, filename: str, path: str | t.Sequence[str | None] | None = None |
|
) -> None: |
|
"""Load config files by filename and path.""" |
|
filename, ext = os.path.splitext(filename) |
|
new_config = Config() |
|
for config, fname in self._load_config_files( |
|
filename, |
|
path=path, |
|
log=self.log, |
|
raise_config_file_errors=self.raise_config_file_errors, |
|
): |
|
new_config.merge(config) |
|
if ( |
|
fname not in self._loaded_config_files |
|
): |
|
self._loaded_config_files.append(fname) |
|
|
|
new_config.merge(self.cli_config) |
|
self.update_config(new_config) |
|
|
|
@catch_config_error |
|
def load_config_environ(self) -> None: |
|
"""Load config files by environment.""" |
|
PREFIX = self.name.upper().replace("-", "_") |
|
new_config = Config() |
|
|
|
self.log.debug('Looping through config variables with prefix "%s"', PREFIX) |
|
|
|
for k, v in os.environ.items(): |
|
if k.startswith(PREFIX): |
|
self.log.debug('Seeing environ "%s"="%s"', k, v) |
|
|
|
|
|
_, *path, key = k.split("__") |
|
section = new_config |
|
for p in path: |
|
section = section[p] |
|
setattr(section, key, DeferredConfigString(v)) |
|
|
|
new_config.merge(self.cli_config) |
|
self.update_config(new_config) |
|
|
|
def _classes_with_config_traits( |
|
self, classes: ClassesType | None = None |
|
) -> t.Generator[type[Configurable], None, None]: |
|
""" |
|
Yields only classes with configurable traits, and their subclasses. |
|
|
|
:param classes: |
|
The list of classes to iterate; if not set, uses :attr:`classes`. |
|
|
|
Thus, produced sample config-file will contain all classes |
|
on which a trait-value may be overridden: |
|
|
|
- either on the class owning the trait, |
|
- or on its subclasses, even if those subclasses do not define |
|
any traits themselves. |
|
""" |
|
if classes is None: |
|
classes = self.classes |
|
|
|
cls_to_config = OrderedDict( |
|
(cls, bool(cls.class_own_traits(config=True))) |
|
for cls in self._classes_inc_parents(classes) |
|
) |
|
|
|
def is_any_parent_included(cls: t.Any) -> bool: |
|
return any(b in cls_to_config and cls_to_config[b] for b in cls.__bases__) |
|
|
|
|
|
|
|
|
|
while True: |
|
to_incl_orig = cls_to_config.copy() |
|
cls_to_config = OrderedDict( |
|
(cls, inc_yes or is_any_parent_included(cls)) |
|
for cls, inc_yes in cls_to_config.items() |
|
) |
|
if cls_to_config == to_incl_orig: |
|
break |
|
for cl, inc_yes in cls_to_config.items(): |
|
if inc_yes: |
|
yield cl |
|
|
|
def generate_config_file(self, classes: ClassesType | None = None) -> str: |
|
"""generate default config file from Configurables""" |
|
lines = ["# Configuration file for %s." % self.name] |
|
lines.append("") |
|
lines.append("c = get_config() #" + "noqa") |
|
lines.append("") |
|
classes = self.classes if classes is None else classes |
|
config_classes = list(self._classes_with_config_traits(classes)) |
|
for cls in config_classes: |
|
lines.append(cls.class_config_section(config_classes)) |
|
return "\n".join(lines) |
|
|
|
def close_handlers(self) -> None: |
|
if getattr(self, "_logging_configured", False): |
|
|
|
|
|
|
|
for handler in self.log.handlers: |
|
with suppress(Exception): |
|
handler.close() |
|
self._logging_configured = False |
|
|
|
def exit(self, exit_status: int | str | None = 0) -> None: |
|
self.log.debug("Exiting application: %s", self.name) |
|
self.close_handlers() |
|
sys.exit(exit_status) |
|
|
|
def __del__(self) -> None: |
|
self.close_handlers() |
|
|
|
@classmethod |
|
def launch_instance(cls, argv: ArgvType = None, **kwargs: t.Any) -> None: |
|
"""Launch a global instance of this Application |
|
|
|
If a global instance already exists, this reinitializes and starts it |
|
""" |
|
app = cls.instance(**kwargs) |
|
app.initialize(argv) |
|
app.start() |
|
|
|
|
|
|
|
|
|
|
|
|
|
default_aliases = Application.aliases |
|
default_flags = Application.flags |
|
|
|
|
|
def boolean_flag(name: str, configurable: str, set_help: str = "", unset_help: str = "") -> StrDict: |
|
"""Helper for building basic --trait, --no-trait flags. |
|
|
|
Parameters |
|
---------- |
|
name : str |
|
The name of the flag. |
|
configurable : str |
|
The 'Class.trait' string of the trait to be set/unset with the flag |
|
set_help : unicode |
|
help string for --name flag |
|
unset_help : unicode |
|
help string for --no-name flag |
|
|
|
Returns |
|
------- |
|
cfg : dict |
|
A dict with two keys: 'name', and 'no-name', for setting and unsetting |
|
the trait, respectively. |
|
""" |
|
|
|
set_help = set_help or "set %s=True" % configurable |
|
unset_help = unset_help or "set %s=False" % configurable |
|
|
|
cls, trait = configurable.split(".") |
|
|
|
setter = {cls: {trait: True}} |
|
unsetter = {cls: {trait: False}} |
|
return {name: (setter, set_help), "no-" + name: (unsetter, unset_help)} |
|
|
|
|
|
def get_config() -> Config: |
|
"""Get the config object for the global Application instance, if there is one |
|
|
|
otherwise return an empty config object |
|
""" |
|
if Application.initialized(): |
|
return Application.instance().config |
|
else: |
|
return Config() |
|
|
|
|
|
if __name__ == "__main__": |
|
Application.launch_instance() |
|
|