Source code for click_extra.config.export

# Copyright Kevin Deldycke <kevin@deldycke.com> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
"""Export the resolved configuration of a CLI as a configuration file.

Holds {class}`ExportConfigOption`, the `--export-config FORMAT` option, and
{func}`ensure_config_loaded`, which loads the configuration ahead of the
introspection flags that read it.
"""

from __future__ import annotations

import logging
from enum import Enum
from gettext import gettext as _

import click
from click import Choice, echo
from click._utils import UNSET

from .. import context
from .._utils import remove_blanks, set_nested
from ..output import MissingExtraError
from ..param_tools import (
    PARAM_PATH_SEP,
    ParamStructure,
    replay_raw_args,
    require_sibling_param,
    search_params,
)
from ..parameters import ExtraOption
from ..types import EnumChoice
from .formats import (
    SERIALIZABLE_FORMATS,
    ConfigFormat,
    _serialize_toml_with_unset,
    serialize_content,
)
from .option import ConfigOption

TYPE_CHECKING = False
if TYPE_CHECKING:
    from collections.abc import Sequence
    from typing import Any

logger = logging.getLogger(__name__)


_EXPORT_FORMAT_BY_TOKEN: dict[str, ConfigFormat] = {
    fmt.label.lower(): fmt for fmt in SERIALIZABLE_FORMATS
}
"""Mapping of `--export-config` choice tokens to their
{class}`~click_extra.config.formats.ConfigFormat`.

Built from {data}`~click_extra.config.formats.SERIALIZABLE_FORMATS`, so the
accepted tokens are exactly the formats
{func}`~click_extra.config.formats.serialize_content` can write: `toml`,
`yaml`, `json`, `json5`, `jsonc`, `hjson`, `xml` and `plist`.
"""


[docs] def ensure_config_loaded(ctx: click.Context) -> None: """Run the sibling `ConfigOption`'s resolution if it has not run yet. Click processes eager parameters given on the command line before eager parameters left at their defaults, so an explicitly-passed introspection flag (`--params`, `--export-config`) fires before the `--config` option had a chance to discover and load the configuration file. The views those flags render would then miss the configuration layer entirely, showing defaults where the user's config applies. Idempotent: does nothing when the config option already resolved (its callback stamps {data}`~click_extra.context.CONF_SOURCE` on the context even when no file was found), when the command has no config option, or when the option carries no callback. """ if context.get(ctx, context.CONF_SOURCE, UNSET) is not UNSET: return config_option = search_params(ctx.command.params, ConfigOption) if config_option is None: return if config_option.callback is None: return opts = replay_raw_args(ctx) value, source = config_option.consume_value(ctx, opts) if value is UNSET: return # Record the provenance so the callback's explicit-vs-discovery logic sees # the same source as it would under normal parameter processing. if config_option.name is not None and source is not None: ctx.set_parameter_source(config_option.name, source) config_option.callback(ctx, config_option, value)
def _under_opaque_path(keys: Sequence[str], opaque_paths: frozenset[str]) -> bool: """Does the parameter reached by *keys* sit inside an opaque schema sub-tree? `keys` opens with the CLI name, which the opaque paths do not carry: they are relative to the app's configuration section. """ if not opaque_paths: return False relative = PARAM_PATH_SEP.join(keys[1:]) return any( relative == path or relative.startswith(f"{path}{PARAM_PATH_SEP}") for path in opaque_paths ) def _config_dump_value(param: click.Parameter, value: Any) -> Any: """Coerce a resolved parameter value into a config-serializable form. Produces what a user would write in a configuration file, so the dump round-trips back through `--config`: - native scalars (`str`, `int`, `float`, `bool`) and `None` pass through unchanged; - sequences are coerced element-wise into a `list`; - an {class}`~enum.Enum` member becomes its {class}`~click_extra.types.EnumChoice` token (or its value/name otherwise); - a scalar string whose parameter resolves to a numeric Python type is converted to that type, so `--count 7` dumps as `count = 7` rather than `count = "7"` (Click hands back raw strings for command-line and environment values, ahead of its own type conversion); - anything else (a {class}`~pathlib.Path`, a custom object) is stringified. """ # A repeatable boolean flag collects occurrences of a toggle rather than # values, so none of them means unset, not an empty list of booleans. if ( param.multiple and getattr(param, "is_bool_flag", False) and isinstance(value, (list, tuple)) and not value ): value = None if value is None: # An unset multi-value parameter reads naturally as an empty list, # which also survives serialization in null-less formats like TOML. if ParamStructure.get_param_type(param) is list: return [] return None if isinstance(value, (list, tuple, set, frozenset)): return [_config_dump_value(param, item) for item in value] # bool is an int subclass: handle it before the numeric coercion below. if isinstance(value, bool): return value if isinstance(value, Enum): param_type = getattr(param, "type", None) if isinstance(param_type, EnumChoice): return param_type.get_choice_string(value) member_value = value.value if isinstance(member_value, (str, int, float, bool)): return member_value return value.name if isinstance(value, str): python_type = ParamStructure.get_param_type(param) if python_type is int or python_type is float: try: return python_type(value) except ValueError: return value return value if isinstance(value, (int, float)): return value return str(value)
[docs] class ExportConfigOption(ExtraOption): """A pre-configured option adding `--export-config FORMAT`. Resolves the CLI's current parameter values following Click's precedence chain (command line, then environment variables, then configuration file, then defaults), renders them as a configuration file in the requested format on `<stdout>`, and exits. ```{hint} Combine the flag with other options or environment variables to capture them in the generated configuration. For example, `mycli --verbosity DEBUG --export-config toml` emits a configuration whose `verbosity` is already set to `DEBUG`. ``` Like {class}`~click_extra.config.option.ValidateConfigOption`, it relies on a sibling {class}`~click_extra.config.option.ConfigOption` to provide the parameter structure and the `excluded_params` / `included_params` filter, so the export contains exactly the parameters that can be loaded back from a configuration file. ```{note} The accepted formats are those {func}`~click_extra.config.formats.serialize_content` can write ({data}`~click_extra.config.formats.SERIALIZABLE_FORMATS`). `INI`, `SQLITE`, `ARGFILE` and `PYPROJECT_TOML` have no serializer and cannot be dumped. ``` """ def __init__( self, param_decls: Sequence[str] | None = None, *, type: click.ParamType | Any = None, metavar: str = "FORMAT", is_eager: bool = True, expose_value: bool = False, help: str = _( "Export the configuration in the selected format to <stdout>, then exit.", ), **kwargs: Any, ) -> None: if not param_decls: param_decls = ("--export-config",) # Restrict the choice to the writable formats, addressed by their # lower-case token (`toml`, `json`, ...). A short `FORMAT` metavar # keeps the token list out of the one-line help while the full set still # surfaces in the Choice error message. if type is None: type = Choice(tuple(_EXPORT_FORMAT_BY_TOKEN), case_sensitive=False) kwargs.setdefault("callback", self.export_config) super().__init__( param_decls=param_decls, type=type, metavar=metavar, is_eager=is_eager, expose_value=expose_value, help=help, **kwargs, )
[docs] def build_config( self, ctx: click.Context, config_option: ConfigOption, ) -> dict[str, Any]: """Resolve every config-eligible parameter into a dumpable tree. Walks the sibling {class}`~click_extra.config.option.ConfigOption`'s parameter structure, resolves each parameter's effective value by replaying {data}`~click_extra.context.RAW_ARGS` (falling back to defaults when the command did not capture them), drops the parameters {meth}`~click_extra.param_tools.ParamStructure.is_excluded` rejects, and layers the coerced values into the ``{cli-name: {param: value, ...}}`` shape a configuration file uses. Parameter keys are rendered in their kebab-case spelling, the canonical presentation for configuration files; either spelling loads back to the same parameter. Parameters without a default are kept as `None` leaves so the export names every key a configuration file can set: serializers render them as `null`, except TOML which comments them out (see `_serialize_toml_with_unset`). Loading `null` back is harmless: {meth}`~click_extra.config.option.ConfigOption._install_default_map` cleans blank values out of the merged result. """ opts = replay_raw_args(ctx) has_raw_args = context.get(ctx, context.RAW_ARGS) is not None if not has_raw_args: logger.warning( f"Cannot resolve parameter values: {ctx.command} does not " "inherit from Command; dumping defaults.", ) tree: dict[str, Any] = {} for keys, target in config_option.walk_params(): if config_option.is_excluded(PARAM_PATH_SEP.join(keys)): continue # A sub-tree the schema marks opaque holds app-owned data, not CLI # parameters: the loader hands it to the app's own validator, which # knows nothing of a subcommand sharing the namespace. Exporting # that subcommand's options there writes a file the same loader # refuses to read back. if _under_opaque_path(keys, config_option._opaque_paths): continue if has_raw_args: raw, _source = target.consume_value(ctx, opts) resolved = None if raw is UNSET else raw else: resolved = target.get_default(ctx) leaf = _config_dump_value(target, resolved) # Render the option under its kebab-case spelling, the canonical # presentation for configuration files (matching the CLI flags and # the TOML/YAML convention). Loading normalizes either spelling # back to the parameter ID. Section keys (the CLI and subcommand # names) are kept verbatim: the app-section lookup matches the # root exactly, and command names are displayed as invoked. set_nested(tree, (*keys[:-1], keys[-1].replace("_", "-")), leaf) return remove_blanks(tree, remove_none=False, remove_str=False)
[docs] def export_config( self, ctx: click.Context, param: click.Parameter, value: str | None, ) -> None: """Render the resolved configuration to `<stdout>` and exit.""" # Stay dormant during help rendering and shell completion, like Click's # own eager callbacks, so a typed `--export-config FORMAT` does not # export and exit mid-completion. if not value or ctx.resilient_parsing: return # Load the configuration file first, so the export reflects the full # precedence chain the docstring promises (config file included) even # when this flag was processed ahead of the --config option. ensure_config_loaded(ctx) fmt = _EXPORT_FORMAT_BY_TOKEN[value.lower()] config_option = require_sibling_param(ctx.command.params, param, ConfigOption) tree = self.build_config(ctx, config_option) try: # TOML cannot hold nulls: unset parameters are commented out. if fmt is ConfigFormat.TOML: output = _serialize_toml_with_unset(tree) elif fmt is ConfigFormat.PLIST: # plist has no null type either, and plistlib raises on None # values: unset parameters are dropped from the export. output = serialize_content(fmt, remove_blanks(tree, remove_str=False)) else: output = serialize_content(fmt, tree) except ImportError: raise MissingExtraError( fmt.label.lower(), subject=f"{fmt} export" ) from None echo(output.rstrip("\n")) ctx.exit()