|
31 | 31 |
|
32 | 32 | So yes, ``config`` is good enough. |
33 | 33 |
|
34 | | -.. todo:: |
35 | | - Add a ``--dump-config`` or ``--export-config`` option to write down the current |
36 | | - configuration (or a template) into a file or ``<stdout>``. |
37 | | -
|
38 | | - Help message would be: *you can use this option with other options or environment |
39 | | - variables to have them set in the generated configuration*. |
40 | | -
|
41 | 34 | Dotted keys in configuration files (like ``"subcommand.option": value``) are |
42 | 35 | automatically expanded into nested dicts before merging, so users can freely mix |
43 | 36 | flat dot-notation and nested structures in any supported format. |
|
67 | 60 |
|
68 | 61 | from .. import ( |
69 | 62 | UNPROCESSED, |
| 63 | + UNSET, |
| 64 | + Choice, |
| 65 | + EnumChoice, |
70 | 66 | ParameterSource, |
71 | 67 | Path as ClickPath, |
72 | 68 | context, |
|
78 | 74 | PARAM_PATH_SEP, |
79 | 75 | ExtraOption, |
80 | 76 | ParamStructure, |
| 77 | + replay_raw_args, |
81 | 78 | require_sibling_param, |
82 | 79 | ) |
83 | 80 | from .builtin import THEMES_CONFIG_KEY, _builtin_config_validators |
84 | | -from .formats import ConfigFormat, parse_content |
| 81 | +from .formats import ( |
| 82 | + SERIALIZABLE_FORMATS, |
| 83 | + ConfigFormat, |
| 84 | + disabled_format_message, |
| 85 | + parse_content, |
| 86 | + serialize_content, |
| 87 | +) |
85 | 88 | from .schema import ( |
86 | 89 | ConfigValidator, |
87 | 90 | _normalize_conf, |
|
126 | 129 |
|
127 | 130 | DEFAULT_EXCLUDED_PARAMS = ( |
128 | 131 | CONFIG_OPTION_NAME, |
| 132 | + "dump_config", |
129 | 133 | "help", |
130 | 134 | "show_params", |
131 | 135 | "version", |
|
136 | 140 |
|
137 | 141 | - ``--config`` option, which cannot be used to recursively load another configuration |
138 | 142 | file. |
| 143 | +- ``--dump-config`` flag, which like ``--show-params`` introspects the CLI and exits, |
| 144 | + so it has no place in the configuration it would dump. |
139 | 145 | - ``--help``, as it makes no sense to have the configurable file always forces a CLI to |
140 | 146 | show the help and exit. |
141 | 147 | - ``--show-params`` flag, which is like ``--help`` and stops the CLI execution. |
@@ -1562,3 +1568,197 @@ def validate_config( |
1562 | 1568 |
|
1563 | 1569 | info_msg(f"Configuration file {value} is valid.") |
1564 | 1570 | ctx.exit(0) |
| 1571 | + |
| 1572 | + |
| 1573 | +_DUMP_FORMAT_BY_TOKEN: dict[str, ConfigFormat] = { |
| 1574 | + fmt.label.lower(): fmt for fmt in SERIALIZABLE_FORMATS |
| 1575 | +} |
| 1576 | +"""Mapping of ``--dump-config`` choice tokens to their :class:`ConfigFormat`. |
| 1577 | +
|
| 1578 | +Built from :data:`~click_extra.config.formats.SERIALIZABLE_FORMATS`, so the |
| 1579 | +accepted tokens are exactly the formats |
| 1580 | +:func:`~click_extra.config.formats.serialize_content` can write: ``toml``, |
| 1581 | +``yaml``, ``json``, ``json5``, ``jsonc``, ``hjson`` and ``xml``. |
| 1582 | +""" |
| 1583 | + |
| 1584 | + |
| 1585 | +def _config_dump_value(param: click.Parameter, value: Any) -> Any: |
| 1586 | + """Coerce a resolved parameter value into a config-serializable form. |
| 1587 | +
|
| 1588 | + Produces what a user would write in a configuration file, so the dump |
| 1589 | + round-trips back through ``--config``: |
| 1590 | +
|
| 1591 | + - native scalars (``str``, ``int``, ``float``, ``bool``) and ``None`` pass |
| 1592 | + through unchanged; |
| 1593 | + - sequences are coerced element-wise into a ``list``; |
| 1594 | + - an :class:`~enum.Enum` member becomes its |
| 1595 | + :class:`~click_extra.types.EnumChoice` token (or its value/name otherwise); |
| 1596 | + - a scalar string whose parameter resolves to a numeric Python type is |
| 1597 | + converted to that type, so ``--count 7`` dumps as ``count = 7`` rather than |
| 1598 | + ``count = "7"`` (Click hands back raw strings for command-line and |
| 1599 | + environment values, ahead of its own type conversion); |
| 1600 | + - anything else (a :class:`~pathlib.Path`, a custom object) is stringified. |
| 1601 | + """ |
| 1602 | + if value is None: |
| 1603 | + return None |
| 1604 | + if isinstance(value, (list, tuple, set, frozenset)): |
| 1605 | + return [_config_dump_value(param, item) for item in value] |
| 1606 | + # bool is an int subclass: handle it before the numeric coercion below. |
| 1607 | + if isinstance(value, bool): |
| 1608 | + return value |
| 1609 | + if isinstance(value, Enum): |
| 1610 | + param_type = getattr(param, "type", None) |
| 1611 | + if isinstance(param_type, EnumChoice): |
| 1612 | + return param_type.get_choice_string(value) |
| 1613 | + member_value = value.value |
| 1614 | + if isinstance(member_value, (str, int, float, bool)): |
| 1615 | + return member_value |
| 1616 | + return value.name |
| 1617 | + if isinstance(value, str): |
| 1618 | + python_type = ParamStructure.get_param_type(param) |
| 1619 | + if python_type is int: |
| 1620 | + try: |
| 1621 | + return int(value) |
| 1622 | + except ValueError: |
| 1623 | + return value |
| 1624 | + if python_type is float: |
| 1625 | + try: |
| 1626 | + return float(value) |
| 1627 | + except ValueError: |
| 1628 | + return value |
| 1629 | + return value |
| 1630 | + if isinstance(value, (int, float)): |
| 1631 | + return value |
| 1632 | + return str(value) |
| 1633 | + |
| 1634 | + |
| 1635 | +class DumpConfigOption(ExtraOption): |
| 1636 | + """A pre-configured option adding ``--dump-config FORMAT``. |
| 1637 | +
|
| 1638 | + Resolves the CLI's current parameter values following Click's precedence |
| 1639 | + chain (command line, then environment variables, then configuration file, |
| 1640 | + then defaults), renders them as a configuration file in the requested format |
| 1641 | + on ``<stdout>``, and exits. |
| 1642 | +
|
| 1643 | + .. hint:: |
| 1644 | + Combine the flag with other options or environment variables to capture |
| 1645 | + them in the generated configuration. For example, ``mycli --verbosity |
| 1646 | + DEBUG --dump-config toml`` emits a configuration whose ``verbosity`` is |
| 1647 | + already set to ``DEBUG``. |
| 1648 | +
|
| 1649 | + Like :class:`ValidateConfigOption`, it relies on a sibling |
| 1650 | + :class:`ConfigOption` to provide the parameter structure and the |
| 1651 | + ``excluded_params`` / ``included_params`` filter, so the dump contains |
| 1652 | + exactly the parameters that can be loaded back from a configuration file. |
| 1653 | +
|
| 1654 | + .. note:: |
| 1655 | + The accepted formats are those |
| 1656 | + :func:`~click_extra.config.formats.serialize_content` can write |
| 1657 | + (:data:`~click_extra.config.formats.SERIALIZABLE_FORMATS`). ``INI`` and |
| 1658 | + ``pyproject.toml`` have no serializer and cannot be dumped. |
| 1659 | + """ |
| 1660 | + |
| 1661 | + def __init__( |
| 1662 | + self, |
| 1663 | + param_decls: Sequence[str] | None = None, |
| 1664 | + type: click.ParamType | Any = None, |
| 1665 | + metavar: str = "FORMAT", |
| 1666 | + is_eager: bool = True, |
| 1667 | + expose_value: bool = False, |
| 1668 | + help: str = _( |
| 1669 | + "Dump the configuration in the selected format to <stdout>, then exit.", |
| 1670 | + ), |
| 1671 | + **kwargs: Any, |
| 1672 | + ) -> None: |
| 1673 | + if not param_decls: |
| 1674 | + param_decls = ("--dump-config",) |
| 1675 | + |
| 1676 | + # Restrict the choice to the writable formats, addressed by their |
| 1677 | + # lower-case token (``toml``, ``json``, ...). A short ``FORMAT`` metavar |
| 1678 | + # keeps the token list out of the one-line help while the full set still |
| 1679 | + # surfaces in the Choice error message. |
| 1680 | + if type is None: |
| 1681 | + type = Choice(tuple(_DUMP_FORMAT_BY_TOKEN), case_sensitive=False) |
| 1682 | + |
| 1683 | + kwargs.setdefault("callback", self.dump_config) |
| 1684 | + |
| 1685 | + super().__init__( |
| 1686 | + param_decls=param_decls, |
| 1687 | + type=type, |
| 1688 | + metavar=metavar, |
| 1689 | + is_eager=is_eager, |
| 1690 | + expose_value=expose_value, |
| 1691 | + help=help, |
| 1692 | + **kwargs, |
| 1693 | + ) |
| 1694 | + |
| 1695 | + def build_config( |
| 1696 | + self, |
| 1697 | + ctx: click.Context, |
| 1698 | + config_option: ConfigOption, |
| 1699 | + ) -> dict[str, Any]: |
| 1700 | + """Resolve every config-eligible parameter into a dumpable tree. |
| 1701 | +
|
| 1702 | + Walks the sibling :class:`ConfigOption`'s parameter structure, resolves |
| 1703 | + each parameter's effective value by replaying |
| 1704 | + :data:`~click_extra.context.RAW_ARGS` (falling back to defaults when the |
| 1705 | + command did not capture them), drops the |
| 1706 | + :attr:`~click_extra.parameters.ParamStructure.excluded_params`, and |
| 1707 | + layers the coerced values into the ``{cli-name: {param: value, ...}}`` |
| 1708 | + shape a configuration file uses. Blank values are removed, mirroring the |
| 1709 | + clean-up :meth:`ConfigOption._install_default_map` applies on load. |
| 1710 | + """ |
| 1711 | + # Force the included_params -> excluded_params resolution that happens |
| 1712 | + # the first time the parameter tree is built. |
| 1713 | + config_option.params_objects # noqa: B018 |
| 1714 | + excluded = config_option.excluded_params |
| 1715 | + |
| 1716 | + opts = replay_raw_args(ctx) |
| 1717 | + has_raw_args = context.get(ctx, context.RAW_ARGS) is not None |
| 1718 | + if not has_raw_args: |
| 1719 | + logger.warning( |
| 1720 | + f"Cannot resolve parameter values: {ctx.command} does not " |
| 1721 | + "inherit from Command; dumping defaults.", |
| 1722 | + ) |
| 1723 | + |
| 1724 | + tree: dict[str, Any] = {} |
| 1725 | + for keys, target in config_option.walk_params(): |
| 1726 | + if PARAM_PATH_SEP.join(keys) in excluded: |
| 1727 | + continue |
| 1728 | + if has_raw_args: |
| 1729 | + raw, _source = target.consume_value(ctx, opts) |
| 1730 | + resolved = None if raw is UNSET else raw |
| 1731 | + else: |
| 1732 | + resolved = target.get_default(ctx) |
| 1733 | + leaf = _config_dump_value(target, resolved) |
| 1734 | + tree = always_merger.merge( |
| 1735 | + tree, ParamStructure.init_tree_dict(*keys, leaf=leaf) |
| 1736 | + ) |
| 1737 | + |
| 1738 | + return _remove_blanks(tree, remove_str=False) |
| 1739 | + |
| 1740 | + def dump_config( |
| 1741 | + self, |
| 1742 | + ctx: click.Context, |
| 1743 | + param: click.Parameter, |
| 1744 | + value: str | None, |
| 1745 | + ) -> None: |
| 1746 | + """Render the resolved configuration to ``<stdout>`` and exit.""" |
| 1747 | + # Stay dormant during help rendering and shell completion, like Click's |
| 1748 | + # own eager callbacks, so a typed ``--dump-config FORMAT`` does not dump |
| 1749 | + # and exit mid-completion. |
| 1750 | + if not value or ctx.resilient_parsing: |
| 1751 | + return |
| 1752 | + |
| 1753 | + fmt = _DUMP_FORMAT_BY_TOKEN[value.lower()] |
| 1754 | + config_option = require_sibling_param(ctx.command.params, param, ConfigOption) |
| 1755 | + tree = self.build_config(ctx, config_option) |
| 1756 | + |
| 1757 | + try: |
| 1758 | + output = serialize_content(fmt, tree) |
| 1759 | + except ImportError: |
| 1760 | + echo(disabled_format_message(fmt), err=True) |
| 1761 | + ctx.exit(1) |
| 1762 | + |
| 1763 | + echo(output.rstrip("\n")) |
| 1764 | + ctx.exit() |
0 commit comments