Skip to content

Commit 0561f92

Browse files
committed
Add a --dump-config FORMAT option
1 parent c2012d9 commit 0561f92

14 files changed

Lines changed: 487 additions & 11 deletions

changelog.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
> [!WARNING]
66
> This version is **not released yet** and is under active development.
77
8+
- Add a `--dump-config FORMAT` option that renders the CLI's resolved configuration on `<stdout>` in any writable format (`toml`, `yaml`, `json`, `json5`, `jsonc`, `hjson`, `xml`), then exits. Added to the default option set of every `@command` and `@group`, and available as the `@dump_config_option` decorator.
9+
- Add the `DumpConfigOption` class and the `SERIALIZABLE_FORMATS` constant to the public API.
810
- Fix Carapace dynamic completion: an option's value or a subcommand argument with a custom `shell_complete` now resolves through the generated spec, instead of returning empty or root-level candidates.
911
- Verify generated Carapace specs against the real `carapace-spec` engine in the test suite.
1012

click_extra/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@
9999
ConfigFormat,
100100
ConfigOption,
101101
ConfigValidator,
102+
DumpConfigOption,
102103
NoConfigOption,
103104
ValidateConfigOption,
104105
ValidationError,
@@ -121,6 +122,7 @@
121122
columns_option,
122123
command,
123124
config_option,
125+
dump_config_option,
124126
group,
125127
help_option,
126128
jobs_option,
@@ -272,6 +274,7 @@
272274
"ConstraintMixin",
273275
"Context",
274276
"DateTime",
277+
"DumpConfigOption",
275278
"EnumChoice",
276279
"ExtraOption",
277280
"File",
@@ -346,6 +349,7 @@
346349
"constraint",
347350
"context",
348351
"dir_path",
352+
"dump_config_option",
349353
"echo",
350354
"echo_via_pager",
351355
"edit",

click_extra/commands.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
PREPEND_SUBCOMMANDS_KEY,
3939
ConfigOption,
4040
ConfigValidator,
41+
DumpConfigOption,
4142
NoConfigOption,
4243
ValidateConfigOption,
4344
make_schema_callable,
@@ -85,6 +86,7 @@ def default_params() -> list[click.Option]:
8586
behavior and value of the other options.
8687
#. ``--no-config``
8788
#. ``--validate-config CONFIG_PATH``
89+
#. ``--dump-config FORMAT``
8890
#. ``--accessible``
8991
.. hint::
9092
``--accessible`` is placed before ``--color`` and ``--table-format`` so it
@@ -127,6 +129,7 @@ def default_params() -> list[click.Option]:
127129
ConfigOption(),
128130
NoConfigOption(),
129131
ValidateConfigOption(),
132+
DumpConfigOption(),
130133
AccessibleOption(),
131134
ColorOption(),
132135
NoColorOption(),

click_extra/config/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
TestSuiteConfig,
4242
)
4343
from .formats import (
44+
SERIALIZABLE_FORMATS,
4445
ConfigFormat,
4546
format_from_path,
4647
parse_content,
@@ -51,6 +52,7 @@
5152
NO_CONFIG,
5253
VCS,
5354
ConfigOption,
55+
DumpConfigOption,
5456
NoConfigOption,
5557
ValidateConfigOption,
5658
)
@@ -77,12 +79,14 @@
7779
"NORMALIZE_KEYS_METADATA_KEY",
7880
"NO_CONFIG",
7981
"PREPEND_SUBCOMMANDS_KEY",
82+
"SERIALIZABLE_FORMATS",
8083
"THEMES_CONFIG_KEY",
8184
"VCS",
8285
"ClickExtraConfig",
8386
"ConfigFormat",
8487
"ConfigOption",
8588
"ConfigValidator",
89+
"DumpConfigOption",
8690
"NoConfigOption",
8791
"PrebakeConfig",
8892
"TestSuiteConfig",

click_extra/config/formats.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,27 @@ def parse_content(fmt: ConfigFormat, content: str) -> Any:
175175
raise ValueError(f"{fmt!r} is not handled by parse_content().")
176176

177177

178+
SERIALIZABLE_FORMATS: tuple[ConfigFormat, ...] = (
179+
ConfigFormat.TOML,
180+
ConfigFormat.YAML,
181+
ConfigFormat.JSON,
182+
ConfigFormat.JSON5,
183+
ConfigFormat.JSONC,
184+
ConfigFormat.HJSON,
185+
ConfigFormat.XML,
186+
)
187+
"""Configuration formats :func:`serialize_content` can write, in priority order.
188+
189+
Every :class:`ConfigFormat` except :attr:`~ConfigFormat.INI` and
190+
:attr:`~ConfigFormat.PYPROJECT_TOML`, which have no serializer. ``JSON``,
191+
``JSON5`` and ``JSONC`` are emitted as plain JSON through the standard library,
192+
so they need no optional dependency; the others require their format's extra.
193+
194+
.. caution::
195+
Keep this in sync with the ``match`` statement in :func:`serialize_content`.
196+
"""
197+
198+
178199
def serialize_content(fmt: ConfigFormat, data: Any, **kwargs: Any) -> str:
179200
"""Serialize a Python object to a string in the given format.
180201

click_extra/config/option.py

Lines changed: 208 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,6 @@
3131
3232
So yes, ``config`` is good enough.
3333
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-
4134
Dotted keys in configuration files (like ``"subcommand.option": value``) are
4235
automatically expanded into nested dicts before merging, so users can freely mix
4336
flat dot-notation and nested structures in any supported format.
@@ -67,6 +60,9 @@
6760

6861
from .. import (
6962
UNPROCESSED,
63+
UNSET,
64+
Choice,
65+
EnumChoice,
7066
ParameterSource,
7167
Path as ClickPath,
7268
context,
@@ -78,10 +74,17 @@
7874
PARAM_PATH_SEP,
7975
ExtraOption,
8076
ParamStructure,
77+
replay_raw_args,
8178
require_sibling_param,
8279
)
8380
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+
)
8588
from .schema import (
8689
ConfigValidator,
8790
_normalize_conf,
@@ -126,6 +129,7 @@
126129

127130
DEFAULT_EXCLUDED_PARAMS = (
128131
CONFIG_OPTION_NAME,
132+
"dump_config",
129133
"help",
130134
"show_params",
131135
"version",
@@ -136,6 +140,8 @@
136140
137141
- ``--config`` option, which cannot be used to recursively load another configuration
138142
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.
139145
- ``--help``, as it makes no sense to have the configurable file always forces a CLI to
140146
show the help and exit.
141147
- ``--show-params`` flag, which is like ``--help`` and stops the CLI execution.
@@ -1562,3 +1568,197 @@ def validate_config(
15621568

15631569
info_msg(f"Configuration file {value} is valid.")
15641570
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

Comments
 (0)