-
-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy pathparameters.py
More file actions
1330 lines (1142 loc) · 54.6 KB
/
Copy pathparameters.py
File metadata and controls
1330 lines (1142 loc) · 54.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# 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.
"""Our own flavor of ``Option``, ``Argument`` and ``parameters``."""
from __future__ import annotations
import logging
from contextlib import contextmanager, nullcontext
from functools import cached_property, reduce
from gettext import gettext as _
from operator import getitem
from typing import TypeVar
import click
import cloup
from deepmerge import always_merger
from . import UNSET, EnumChoice, ParamType, Style, context, get_current_context
from .envvar import param_envvar_ids
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Iterable, Iterator, Sequence
from typing import Any, ClassVar, Literal
logger = logging.getLogger(__name__)
P = TypeVar("P", bound=click.Parameter)
"""Type variable bound to :class:`click.Parameter`, letting
:func:`require_sibling_param` return the exact subclass it was asked to find."""
#: Separator joining the keys of a parameter's fully-qualified path
#: (``cli.subcommand.param``).
PARAM_PATH_SEP = "."
@contextmanager
def patch_attr(obj: object, name: str, value: Any) -> Iterator[None]:
"""Temporarily set ``obj.name`` to ``value``, restoring the original on exit.
A minimal, dependency-free stand-in for ``unittest.mock.patch.object`` for
the simple save-set-restore monkeypatching Click Extra performs at runtime
(in :mod:`~click_extra.logging`, :mod:`~click_extra.parameters` and
:mod:`~click_extra.testing`).
.. note::
``unittest.mock`` drags the whole test framework, and its heavy
transitive imports, into the startup path of every CLI built with Click
Extra. Reimplementing the single feature actually used keeps that cost
out of import time. Do not swap this back for ``unittest.mock``.
Like ``patch.object`` without ``create=True``, the attribute must already
exist: a missing ``name`` raises :exc:`AttributeError`.
"""
original = getattr(obj, name)
setattr(obj, name, value)
try:
yield
finally:
setattr(obj, name, original)
def search_params(
params: Iterable[click.Parameter],
klass: type[click.Parameter],
include_subclasses: bool = True,
unique: bool = True,
) -> list[click.Parameter] | click.Parameter | None:
"""Search a particular class of parameter in a list and return them.
:param params: list of parameter instances to search in.
:param klass: the class of the parameters to look for.
:param include_subclasses: if ``True``, includes in the results all parameters subclassing
the provided ``klass``. If ``False``, only matches parameters which are strictly instances of ``klass``.
Defaults to ``True``.
:param unique: if ``True``, raise an error if more than one parameter of the
provided ``klass`` is found. Defaults to ``True``.
"""
param_list = [
p
for p in params
if (include_subclasses and isinstance(p, klass))
or (not include_subclasses and p.__class__ is klass)
]
if not param_list:
return None
if unique:
if len(param_list) != 1:
raise RuntimeError(
f"More than one {klass.__name__} parameters found on command: "
f"{param_list}"
)
return param_list.pop()
return param_list
def last_param(
params: Iterable[click.Parameter],
klass: type[click.Parameter],
) -> click.Parameter | None:
"""Return the last parameter of exactly ``klass`` in *params*, or ``None``.
Unlike :func:`search_params`, this matches the exact ``klass`` (no subclasses)
and tolerates duplicates: when an option is declared more than once (like an
explicit ``@verbosity_option`` stacked on a Click Extra command that already
ships one), Click keeps the last occurrence, so this mirrors that here instead
of erroring out on the ambiguity.
:param params: the command's parameter list to scan.
:param klass: the exact parameter class to look for.
"""
options = search_params(params, klass, include_subclasses=False, unique=False)
return options[-1] if options else None # type: ignore[index]
def require_sibling_param(
params: Iterable[click.Parameter],
requester: click.Parameter,
klass: type[P],
) -> P:
"""Return the sibling *klass* parameter declared on the same command, or raise.
Some options are inert on their own: they drive machinery owned by a sibling
option. ``--no-config`` and ``--validate-config``, for instance, both depend on
the ``--config`` option (:class:`~click_extra.config.option.ConfigOption`). This
helper centralizes the lookup so every such option raises the same
``RuntimeError`` when its required sibling is missing, naming the offending flag.
:param params: the command's parameter list to scan.
:param requester: the parameter requiring the sibling, used to build the error
message from its flag names and class.
:param klass: the sibling parameter class to look for.
"""
sibling = search_params(params, klass)
if not isinstance(sibling, klass):
# RuntimeError (not the type-implied TypeError) is intentional: it keeps
# the historical --no-config contract and unifies all call sites on one
# exception type for a missing-or-wrong-type sibling.
raise RuntimeError( # noqa: TRY004
f"{'/'.join(requester.opts)} {type(requester).__name__} must be used "
f"alongside {klass.__name__}."
)
return sibling
def full_short_help(command: click.Command) -> str:
"""Return the command's canonical one-line short help, untruncated.
Click's :meth:`click.Command.get_short_help_str` truncates to 45 characters by
default with a trailing ``"..."`` so subcommand listings fit a terminal column.
That bound is wrong for generated documentation and completion specs, where the
NAME / COMMANDS sections carry the full description and the renderer wraps text
on its own.
The lookup mirrors Click's order: an explicit ``short_help`` wins, otherwise the
first paragraph of ``command.help`` is joined into one line. A truthy
``deprecated`` flag prepends ``(Deprecated)`` so the flag stays visible.
"""
if command.short_help:
text = command.short_help.strip()
elif command.help:
# Click already stores ``help`` after ``inspect.cleandoc``: split on the
# first blank line to grab the leading paragraph, then squash internal
# newlines so the result is one line.
paragraph = command.help.split("\n\n", 1)[0]
text = paragraph.strip().replace("\n", " ")
else:
text = ""
if command.deprecated:
text = f"(Deprecated) {text}".strip()
return text
def param_spellings(param: click.Parameter) -> tuple[str, ...]:
"""All literal spellings of a parameter: primary ``opts`` then ``secondary_opts``.
A boolean flag pair yields both forms (``--foo``, ``--no-foo``); a plain option
yields just its declared names.
"""
return tuple(param.opts) + tuple(param.secondary_opts)
def short_long_opts(opts: Sequence[str]) -> tuple[str, str]:
"""Split option spellings into the first short (``-x``) and long (``--xy``) form.
Either element is the empty string when that form is absent.
"""
short = next((o for o in opts if o.startswith("-") and not o.startswith("--")), "")
long = next((o for o in opts if o.startswith("--")), "")
return short, long
def option_value_kind(
param: click.Parameter,
) -> Literal["flag", "optional", "required"]:
"""Classify how an option consumes a value, the basis for rendering its metavar.
- ``"flag"``: takes no value. A boolean switch (``--foo``, ``--foo/--no-foo``), a
flag with a custom ``flag_value`` (``--no-config``), or a counter (``-v``).
- ``"optional"``: the value may be omitted. Click models this as
``is_flag=False`` with a ``flag_value`` set, so a bare ``--color`` stands for
the flag value while ``--color=never`` passes an explicit one.
- ``"required"``: consumes a value (``--config CONFIG_PATH``).
.. note::
The discriminator is Click's ``is_flag`` (plus ``count``), not
``is_bool_flag``: a flag carrying a custom ``flag_value`` such as
:class:`~click_extra.config.option.NoConfigOption` reports
``is_bool_flag=False`` yet still takes no value.
"""
if getattr(param, "count", False) or getattr(param, "is_flag", False):
return "flag"
if getattr(param, "secondary_opts", None):
return "flag"
if getattr(param, "flag_value", None) is not None:
return "optional"
return "required"
def is_repeatable(param: click.Parameter) -> bool:
"""Whether the parameter may be supplied several times (``multiple`` or ``count``)."""
return bool(getattr(param, "multiple", False) or getattr(param, "count", False))
def missing_extra_message(
extra: str,
*,
package: str = "click-extra",
subject: str = "This feature",
) -> str:
"""Build the uniform "install the optional extra" error message.
``subject`` names what needs the dependency, ``extra`` is the optional
dependency group and ``package`` its distribution name. Every feature gated
behind an extra (the documentation integrations, the Carapace exporter, the
table formatters) routes through this so they all point at the same canonical
``pip install package[extra]`` target, with the hyphenated distribution name.
"""
return (
f"{subject} requires an optional dependency. "
f"Install it with: pip install {package}[{extra}]"
)
class _ParameterMixin:
"""Mixin providing shared functionality for Click Extra parameters.
.. warning::
If we want to override any method from Click's ``Parameter`` class, we have to
use that mixin and have it inherited first in the ``Option`` and ``Argument``
classes below.
Because:
- Cloup does not provide its own ``Parameter`` class.
- Multiple inheritance cannot be used because of MRO issues.
"""
def get_default(self, ctx: click.Context, call: bool = True):
"""Override ``click.Parameter.get_default()`` to support ``EnumChoice`` types.
Reuse the ``EnumChoice.get_choice_string()`` method to convert an ``Enum``
default value to its string representation, to bypass `Click's default behavior
of returning the Enum.name <https://github.com/pallets/click/pull/3004>`_.
.. note::
A ``multiple`` option or a variadic (``nargs=-1``) parameter carries a
*sequence* of members as its default, so each member is resolved on its
own. Converting the sequence as a whole would stringify the tuple itself
(``str((MyEnum.FOO,))``) and later trip Click's ``Value must be an
iterable`` check on the default-value path.
"""
default_value = super().get_default(ctx, call) # type: ignore[misc]
if (
hasattr(self, "type")
and isinstance(self.type, EnumChoice)
# Turns out UNSET is also an Enum member, so we need to ignore it.
and default_value is not UNSET
):
if self.multiple or self.nargs == -1:
# A ``None`` default is not iterable; leave it for Click to turn
# into an empty tuple.
if default_value is not None:
default_value = tuple(
self.type.get_choice_string(member) for member in default_value
)
else:
default_value = self.type.get_choice_string(default_value)
return default_value
class Argument(_ParameterMixin, cloup.Argument):
"""Wrap ``cloup.Argument``, itself inheriting from ``click.Argument``.
Inherits first from ``_ParameterMixin`` to allow future overrides of Click's
``Parameter`` methods.
"""
class Option(_ParameterMixin, cloup.Option):
"""Wrap ``cloup.Option``, itself inheriting from ``click.Option``.
Inherits first from ``_ParameterMixin`` to allow future overrides of Click's
``Parameter`` methods.
"""
class ExtraOption(Option):
"""Dedicated to option implemented by ``click-extra`` itself.
Provides a way to identify Click Extra's own options with certainty, and
restores the pre-Click-8.4.0 contract that eager callbacks can introspect
their own parameter source from within their own callback.
.. note::
This is the one click-extra class that deliberately keeps the ``Extra``
prefix. The ``8.0.0`` cleanup dropped it everywhere else (``ExtraCommand``
became ``Command``, ``ExtraContext`` became ``Context``, and so on), shadowing
the matching Cloup or Click class. Here the plain ``Option`` name is already
taken by the user-facing enhanced wrapper this class subclasses, so the prefix
is not legacy baggage but a real distinction: ``ExtraOption`` marks
click-extra's *own* built-in options. That marker is load-bearing, since
:class:`~click_extra.commands.Command` sorts parameters with
``isinstance(param, ExtraOption)`` to push the built-in options to the end.
.. note::
Bracket fields (envvar, default, range, required) cannot be pre-styled in
``get_help_record()`` because Click's text wrapper splits lines *after* the
record is returned, which would break ANSI codes that span wrapped boundaries.
Styling is instead applied post-wrapping in
``HelpFormatter._style_bracket_fields()``, which uses the structured data
from ``Option.get_help_extra()`` to identify each field by its label.
.. note::
Built-in option subclasses share a common shape: their ``__init__``
defaults ``param_decls`` to the option's canonical flags and wires an
eager callback via ``kwargs.setdefault("callback", self.<callback>)``.
Every callback name encodes its role with a verb prefix. The common
roles are:
- ``set_<key>`` publishes a resolved value to ``ctx.meta`` (``set_color``,
``set_no_color``, ``set_theme``, ``set_telemetry``, ``set_progress``,
``set_accessible``, ``set_zero_exit``, the verbosity options'
``set_level``);
- ``init_<system>`` additionally installs a ``ctx`` helper or records a
snapshot (``init_timer``, ``init_formatter``, ``init_columns``,
``init_sort``);
- ``validate_<thing>`` coerces and validates the raw input
(``validate_jobs``, ``validate_config``);
- ``print_*`` renders output and exits (``print_man``, ``print_params``,
``print_and_exit``).
A few options own a richer operation and name it with its own verb
rather than forcing one of the above. ``ConfigOption`` wires
``load_conf`` to read, parse, and merge a configuration file, and
``NoConfigOption`` wires ``check_sibling_config_option`` to assert that
a sibling ``--config`` option exists.
"""
def handle_parse_result(self, ctx, opts, args):
"""Record the parameter source before delegating to the base implementation.
.. warning::
Click ``8.4.0`` (PR `pallets/click#3404
<https://github.com/pallets/click/pull/3404>`_) reordered
``Parameter.handle_parse_result`` so ``ctx.set_parameter_source`` runs
*after* ``process_value``. Eager callbacks that introspect their own
provenance via ``ctx.get_parameter_source(self.name)`` therefore read
``None`` instead of the actual source. ``ColorOption``, ``ConfigOption``,
and ``ShowParamsOption`` all rely on this introspection to decide whether
an env var should override the default (``--color``), whether the
``--config`` path was user-supplied, and what to render in the ``Source``
column of ``--show-params``.
Click ``8.4.1`` restored the pre-``8.4.0`` contract upstream (PR
`pallets/click#3484 <https://github.com/pallets/click/pull/3484>`_), so
this override only matters for Click ``8.4.0`` itself, which sits inside
click-extra's supported ``>= 8.3.1`` range. Pre-recording the source here
for eager options keeps that contract on every supported Click.
``super().handle_parse_result`` re-records the same value at the canonical
time, so the slot arbitration logic introduced by #3404 is unaffected:
``slot_empty`` is computed from ``ctx.params``, not from
``_parameter_source``.
``consume_value`` runs twice as a side effect: once here and once in
``super``. Both calls are pure for click-extra's existing eager
flag-style options (no env var side effects, no prompt). Should a future
eager subclass need prompt behavior, this override would need to cache
the result instead.
The pre-record is skipped when the slot already carries a source from
an earlier option sharing the same ``name`` (Click's feature-switch
pattern), so the arbitration logic in ``super`` still sees the original
``existing_source`` rather than a stale rewrite from this option.
"""
if self.is_eager and ctx.get_parameter_source(self.name) is None:
_value, source = self.consume_value(ctx, opts)
ctx.set_parameter_source(self.name, source)
return super().handle_parse_result(ctx, opts, args)
class ParamStructure:
"""Utilities to introspect CLI options and commands structure.
Structures are represented by a tree-like ``dict``.
Access to a node is available using a serialized path string composed of the keys to
descend to that node, separated by a dot ``.``.
"""
excluded_params: frozenset[str]
"""Fully-qualified IDs of the parameters to block from the structure.
Set by subclasses: :class:`ShowParamsOption` freezes an empty set, while
``ConfigOption`` resolves a dynamic default (or the user-provided list) within
the active context. The two filters are mutually exclusive, a constraint each
subclass enforces in its own constructor.
"""
included_params: frozenset[str] | None
"""Allowlist of parameter IDs, mutually exclusive with ``excluded_params``.
``None`` disables the allowlist. It is resolved into ``excluded_params`` by
:meth:`~click_extra.parameters.ParamStructure.build_param_trees`, once every
parameter ID is known.
"""
@staticmethod
def init_tree_dict(*path: str, leaf: Any = None) -> Any:
"""Utility method to recursively create a nested dict structure whose keys are
provided by ``path`` list and at the end is populated by a copy of ``leaf``."""
def dive(levels):
if levels:
return {levels[0]: dive(levels[1:])}
return leaf
return dive(path)
@staticmethod
def get_tree_value(tree_dict: dict[str, Any], *path: str) -> Any:
"""Get in the ``tree_dict`` the value located at the ``path``.
Raises ``KeyError`` if no item is found at the provided ``path``.
"""
return reduce(getitem, path, tree_dict)
def walk_params(self) -> Iterator[tuple[tuple[str, ...], click.Parameter]]:
"""Generate an unfiltered list of all CLI parameters.
Everything is included, from top-level groups to subcommands, and from
options to arguments.
Yields a 2-element tuple:
- a tuple of keys leading to the parameter;
- the parameter object itself.
Thin adapter over :func:`~click_extra.parameters.walk_command_params`: it
resolves the root CLI from the active context and drops the per-parameter
context that the free function also yields.
"""
ctx = get_current_context()
cli = ctx.find_root().command
assert cli.name is not None
for keys, param, _ctx in walk_command_params(cli, ctx, (cli.name,)):
yield keys, param
TYPE_MAP: ClassVar[dict[type[ParamType], type[str | int | float | bool | list]]] = {
click.types.StringParamType: str,
click.types.IntParamType: int,
click.types.FloatParamType: float,
click.types.BoolParamType: bool,
click.types.UUIDParameterType: str,
click.types.UnprocessedParamType: str,
click.types.File: str,
click.types.Path: str,
click.types.Choice: str,
click.types.IntRange: int,
click.types.FloatRange: float,
click.types.DateTime: str,
click.types.Tuple: list,
}
"""Map Click types to their Python equivalent.
Keys are subclasses of ``click.types.ParamType``. Values are expected to be simple
builtins Python types.
This mapping can be seen as a reverse of the ``click.types.convert_type()`` method.
"""
@staticmethod
def get_param_type(
param: click.Parameter,
) -> type[str | int | float | bool | list]:
"""Get the Python type of a Click parameter.
Returns ``str`` for unrecognised custom types, since command-line
parameters are strings by default.
See the list of
`custom types provided by Click <https://click.palletsprojects.com/en/stable/api/#types>`_.
"""
if param.multiple or param.nargs != 1:
return list
if hasattr(param, "is_bool_flag") and param.is_bool_flag:
return bool
# Try to directly map the Click type to a Python type.
py_type = ParamStructure.TYPE_MAP.get(param.type.__class__)
if py_type is not None:
return py_type
# Try to indirectly map the type by looking at inheritance.
for click_type, py_type in ParamStructure.TYPE_MAP.items():
if isinstance(param.type, click_type):
return py_type
# Custom parameters are expected to convert from strings, as that's
# the default type of command lines.
# See: https://click.palletsprojects.com/en/stable/api/#click.ParamType
return str
def build_param_trees(self) -> None:
"""Build the parameters tree structure and cache it.
This removes parameters whose fully-qualified IDs are in the ``excluded_params``
blocklist.
If ``included_params`` was provided, it is resolved into ``excluded_params``
here, where all parameter IDs are available.
"""
# Resolve included_params into excluded_params before filtering.
if self.included_params is not None:
all_param_ids = frozenset(
PARAM_PATH_SEP.join(keys) for keys, _ in self.walk_params()
)
self.excluded_params = all_param_ids - self.included_params
objects: dict[str, Any] = {}
for keys, param in self.walk_params():
if PARAM_PATH_SEP.join(keys) in self.excluded_params:
continue
objects = always_merger.merge(
objects, self.init_tree_dict(*keys, leaf=[param])
)
self.params_objects = objects
@staticmethod
def _nullify_leaves(tree: dict[str, Any]) -> dict[str, Any]:
"""Derive a template shape from a tree by replacing all leaves with ``None``."""
return {
k: ParamStructure._nullify_leaves(v) if isinstance(v, dict) else None
for k, v in tree.items()
}
@cached_property
def params_template(self) -> dict[str, Any]:
"""Returns a tree-like dictionary whose keys shadows the CLI options and
subcommands and values are ``None``.
Perfect to serve as a template for configuration files.
"""
return self._nullify_leaves(self.params_objects)
@cached_property
def params_objects(self) -> dict[str, Any]:
"""Returns a tree-like dictionary whose keys shadows the CLI options and
subcommands and values are parameter objects.
Perfect to parse configuration files and user-provided parameters.
"""
self.build_param_trees()
return self.params_objects
def get_param_spec(param: click.Parameter, ctx: click.Context) -> str | None:
"""Extract the option-spec string (like ``-v, --verbose``) from a parameter.
Temporarily unhides hidden options so their help record can be produced.
.. note::
The ``hidden`` property is only supported by ``Option``, not ``Argument``.
.. todo::
Submit a PR to Click to separate production of param spec and help
record. That way we can always produce the param spec even if the
parameter is hidden.
See: https://github.com/kdeldycke/click-extra/issues/689
"""
if not hasattr(param, "hidden"):
return None
with patch_attr(param, "hidden", False) if param.hidden else nullcontext():
help_record = param.get_help_record(ctx)
return help_record[0] if help_record else None
def _structured_value(value: Any) -> Any:
"""Coerce a value to a natively serializable form for structured output.
Scalars and lists (``str``, ``int``, ``float``, ``bool``, ``list``, ``None``)
pass through unchanged so JSON, YAML and TOML renderers keep their native
types. Anything else (a Click ``ParamType``, a ``Path``, an arbitrary
object) is rendered with :func:`repr` so it survives serialization as a
readable string.
"""
if isinstance(value, (str, int, float, bool, list, type(None))):
return value
return repr(value)
def format_param_row(
param: click.Parameter,
ctx: click.Context,
path: str,
is_structured: bool,
) -> dict[str, Any]:
"""Compute the *structural* table cells for a Click parameter.
Returns a ``dict[column_id, cell]`` covering every column that can be
derived from the parameter object alone (no runtime invocation state or
config-file context). Specifically: ``id``, ``spec``, ``class``,
``param_type``, ``python_type``, ``hidden``, ``exposed``, ``envvars``,
``default``, ``is_flag``, ``flag_value``, ``is_bool_flag``, ``multiple``,
``nargs``, ``prompt``, and ``confirmation_prompt``.
Attributes only defined on ``click.Option`` (``hidden``, ``is_flag``,
``flag_value``, ``is_bool_flag``, ``prompt``, ``confirmation_prompt``)
yield ``None`` for ``click.Argument`` parameters: empty cell in visual
formats, ``null`` in structured ones.
For structured formats (JSON, YAML, etc.), values are native Python types.
For visual formats, values are themed strings matching help-screen styling.
The remaining table columns (``allowed_in_conf``, ``value``, ``source``)
require live context and are filled in by
:func:`~click_extra.parameters.render_params_table`.
"""
param_spec = get_param_spec(param, ctx)
param_class = param.__class__
class_str = f"{param_class.__module__}.{param_class.__qualname__}"
type_str = f"{param.type.__module__}.{param.type.__class__.__name__}"
python_type_name = ParamStructure.get_param_type(param).__name__
hidden = getattr(param, "hidden", None)
is_flag = getattr(param, "is_flag", None)
flag_value = getattr(param, "flag_value", None)
is_bool_flag = getattr(param, "is_bool_flag", None)
prompt = getattr(param, "prompt", None)
confirmation_prompt = getattr(param, "confirmation_prompt", None)
# Click 8.4 returns the UNSET sentinel (not None) for parameters that have no
# default. Present it as None, mirroring the normalization
# click.Command.parse_args applies to ctx.params. See the RAW_ARGS dossier in
# click_extra.context for the full rationale.
default_val = param.get_default(ctx)
if default_val is UNSET:
default_val = None
if is_structured:
default_val = _structured_value(default_val)
flag_value = _structured_value(flag_value)
return {
"id": path,
"spec": param_spec,
"class": class_str,
"param_type": type_str,
"python_type": python_type_name,
"hidden": hidden,
"exposed": param.expose_value,
"envvars": list(param_envvar_ids(param, ctx)),
"default": default_val,
"is_flag": is_flag,
"flag_value": flag_value,
"is_bool_flag": is_bool_flag,
"multiple": param.multiple,
"nargs": param.nargs,
"prompt": prompt,
"confirmation_prompt": confirmation_prompt,
}
# Lazy import to avoid circular dependency with theme.
from .theme import KO_GLYPH, OK_GLYPH, get_current_theme
active_theme = get_current_theme()
def styled_bool(value):
"""Render a boolean attribute as a themed glyph, or ``None`` if absent."""
if value is None:
return None
return (
active_theme.success(OK_GLYPH)
if value is True
else active_theme.error(KO_GLYPH)
)
return {
"id": active_theme.invoked_command(path),
"spec": active_theme.option(param_spec) if param_spec else param_spec,
"class": class_str,
"param_type": type_str,
"python_type": active_theme.metavar(python_type_name),
"hidden": styled_bool(hidden),
"exposed": styled_bool(param.expose_value),
"envvars": ", ".join(map(active_theme.envvar, param_envvar_ids(param, ctx))),
"default": active_theme.default(repr(default_val)),
"is_flag": styled_bool(is_flag),
"flag_value": (
active_theme.default(repr(flag_value)) if flag_value is not None else None
),
"is_bool_flag": styled_bool(is_bool_flag),
"multiple": styled_bool(param.multiple),
"nargs": str(param.nargs),
"prompt": prompt,
"confirmation_prompt": styled_bool(confirmation_prompt),
}
def make_resilient_context(
command: click.Command,
info_name: str | None = None,
parent: click.Context | None = None,
) -> click.Context:
"""Build an introspection context for a command.
Parses no arguments and sets ``resilient_parsing=True`` so required-argument
errors, prompts and eager-option side effects stay dormant: the canonical way
to materialize a :class:`click.Context` purely to read a command's structure
(its parameters, env-var prefix and subcommands), shared by the man-page and
Carapace exporters.
"""
return command.make_context(info_name, [], parent=parent, resilient_parsing=True)
def iter_subcommands(
command: click.Command,
ctx: click.Context,
*,
skip_hidden: bool = True,
) -> Iterator[tuple[str, click.Command]]:
"""Yield a group's direct subcommands as ``(name, command)`` pairs.
Subcommands are discovered dynamically through
:meth:`click.Group.list_commands` / :meth:`~click.Group.get_command`, in
listing order, so lazily-registered commands are included. A non-group yields
nothing, a name resolving to ``None`` is skipped, and hidden subcommands are
skipped unless ``skip_hidden`` is ``False`` (completion specs keep them,
flagged hidden; documentation drops them).
"""
if not isinstance(command, click.Group):
return
for name in command.list_commands(ctx):
sub = command.get_command(ctx, name)
if sub is None:
continue
if skip_hidden and getattr(sub, "hidden", False):
continue
yield name, sub
def walk_command_params(
cmd: click.Command,
ctx: click.Context,
parent_keys: tuple[str, ...] = (),
) -> Iterator[tuple[tuple[str, ...], click.Parameter, click.Context]]:
"""Walk the parameter tree of a Click command and all its subcommands.
Yields ``(path_keys, param, owning_ctx)`` for every parameter found on
*cmd* and, recursively, on each subcommand. Each subcommand is walked under
its own freshly-built child context, so context-sensitive metadata (notably
the auto-generated environment variable, which derives from
``Context.auto_envvar_prefix``) is computed at the correct nesting level
rather than inherited from the root.
A subcommand whose name collides with a sibling parameter at the same level
is skipped: a single fully-qualified path cannot address both an option and
a subcommand at once.
"""
level_param_names = set()
for param in cmd.get_params(ctx):
if param.name is not None:
level_param_names.add(param.name)
yield (*parent_keys, param.name), param, ctx
if isinstance(cmd, click.Group):
for subcmd_name in sorted(cmd.list_commands(ctx)):
if subcmd_name in level_param_names:
logger.debug(
f"{cmd.name}{PARAM_PATH_SEP}{subcmd_name} subcommand shadows a "
f"top-level parameter; excluded from parameter tree.",
)
continue
subcmd = cmd.get_command(ctx, subcmd_name)
if subcmd is None:
continue
subcmd_ctx = click.Context(subcmd, parent=ctx, info_name=subcmd_name)
yield from walk_command_params(
subcmd, subcmd_ctx, (*parent_keys, subcmd_name)
)
def render_params_table(
subject_ctx: click.Context,
*,
default_columns: Sequence[str] | None = None,
) -> None:
"""Introspect ``subject_ctx.command`` and print its parameter metadata table.
Walks the command and any nested subcommands, emitting one row per
parameter. The table format and column selection are read from
``subject_ctx.meta`` (see :data:`~click_extra.context.TABLE_FORMAT` and
:data:`~click_extra.context.COLUMNS`); when neither is set, a sibling
``--table-format`` / ``--columns`` option on the command is consulted, then
the *default_columns* fallback, then the canonical order.
When ``subject_ctx.meta`` carries pre-parsed
:data:`~click_extra.context.RAW_ARGS`, the ``value`` and ``source`` columns
are resolved by replaying those arguments against the command parser;
otherwise they fall back to the parameter defaults.
This is the shared rendering core behind both
:meth:`~click_extra.parameters.ShowParamsOption.print_params` (introspecting
the live CLI) and the ``click-extra wrap --show-params`` path (introspecting a
foreign target).
The caller is responsible for exiting the context afterwards.
.. important::
Click does not keep the raw, pre-parsed arguments around, so values and
their provenance cannot be read back directly. The workaround replays
:data:`~click_extra.context.RAW_ARGS` (captured on the context by
``Command``/``Group``) through the command parser, calling
``consume_value()`` rather than ``handle_parse_result()`` so eager
callbacks are not re-triggered.
"""
# Imported here to avoid circular imports with the table module.
from .config import ConfigOption
from .table import (
DEFAULT_FORMAT,
SERIALIZATION_FORMATS,
ColumnsOption,
TableFormatOption,
print_table,
select_columns,
select_row,
)
from .theme import KO_GLYPH, OK_GLYPH, get_current_theme
active_theme = get_current_theme()
ok_styled = active_theme.success(OK_GLYPH)
ko_styled = active_theme.error(KO_GLYPH)
cmd = subject_ctx.command
# Resolve the value getter. When the original arguments are available we
# replay them through the command parser to recover each value and its
# provenance; otherwise we only know the parameter defaults.
opts: dict = {}
raw_args = context.get(subject_ctx, context.RAW_ARGS)
if raw_args is not None:
logger.debug(f"{context.RAW_ARGS}: {raw_args}")
parser = cmd.make_parser(subject_ctx)
opts, _, _ = parser.parse_args(args=list(raw_args))
def get_param_value(param):
# consume_value() can return the UNSET sentinel for a parameter with
# no user input and no default. Normalize it to None, mirroring the
# step click.Command.parse_args runs after parsing, which this
# re-parse bypasses. See the RAW_ARGS dossier in click_extra.context.
value, source = param.consume_value(subject_ctx, opts)
return (None if value is UNSET else value), source
else:
def get_param_value(param):
return None, subject_ctx.get_parameter_source(param.name)
# Locate a --config option to fill the "allowed in conf?" column.
config_option = search_params(cmd.get_params(subject_ctx), ConfigOption)
assert config_option is None or isinstance(config_option, ConfigOption)
# Resolve the table format: an explicit context entry wins, else a sibling
# --table-format option, else the default.
if context.get(subject_ctx, context.TABLE_FORMAT) is None:
table_option = search_params(cmd.get_params(subject_ctx), TableFormatOption)
if table_option and isinstance(table_option, TableFormatOption):
table_fmt, _ = table_option.consume_value(subject_ctx, opts)
table_option.init_formatter(
subject_ctx,
table_option,
table_option.type.convert(table_fmt, table_option, subject_ctx)
if table_fmt
else table_option.get_default(subject_ctx),
)
table_format = context.get(subject_ctx, context.TABLE_FORMAT) or DEFAULT_FORMAT
is_structured = table_format in SERIALIZATION_FORMATS
# Resolve the column selection: an explicit context entry wins, else a
# sibling --columns option, else the provided default.
if context.get(subject_ctx, context.COLUMNS) is None:
cols_option = search_params(cmd.get_params(subject_ctx), ColumnsOption)
if cols_option and isinstance(cols_option, ColumnsOption):
cols_value, _ = cols_option.consume_value(subject_ctx, opts)
cols_option.init_columns(
subject_ctx,
cols_option,
cols_option.type.convert(cols_value, cols_option, subject_ctx)
if cols_value
else (),
)
selected_ids: tuple[str, ...] = context.get(subject_ctx, context.COLUMNS) or ()
if not selected_ids and default_columns:
selected_ids = tuple(default_columns)
# Validate the requested IDs against the column registry so unknown IDs
# become a clear, actionable UsageError.
canonical_ids = ShowParamsOption.column_ids()
known_ids = set(canonical_ids)
unknown = [col_id for col_id in selected_ids if col_id not in known_ids]
if unknown:
joined = ", ".join(repr(c) for c in unknown)
accepted = ", ".join(canonical_ids)
raise click.UsageError(
f"Unknown --columns ID(s): {joined}. Accepted: {accepted}.",
ctx=subject_ctx,
)
table: list[tuple[Any, ...]] = []
for keys, param, owning_ctx in walk_command_params(
cmd, subject_ctx, (cmd.name or "",)
):
path = PARAM_PATH_SEP.join(keys)
param_value, source = get_param_value(param)
# Whether the parameter is reachable from a configuration file.
allowed_in_conf_bool = None
if config_option:
config_option.params_template # noqa: B018
allowed_in_conf_bool = path not in config_option.excluded_params
row = format_param_row(param, owning_ctx, path, is_structured)
if is_structured:
param_value = _structured_value(param_value)
row["allowed_in_conf"] = allowed_in_conf_bool
row["value"] = param_value
row["source"] = source.name if source else None
else:
allowed_in_conf = None
if allowed_in_conf_bool is not None:
allowed_in_conf = ok_styled if allowed_in_conf_bool else ko_styled
row["allowed_in_conf"] = allowed_in_conf
row["value"] = repr(param_value)
row["source"] = source.name if source else None
table.append(select_row(row, selected_ids, canonical_ids))
def sort_by_depth(line: Sequence[Any]) -> tuple[int, Any]:
"""Sort by depth first, then path, keeping top-level params on top."""
param_path = line[0]
return len(param_path.split(PARAM_PATH_SEP)), param_path
selected_columns = select_columns(ShowParamsOption.TABLE_HEADERS, selected_ids)
labels = tuple(col.label for col in selected_columns)
header_labels: tuple[Any, ...]
if is_structured:
header_labels = labels
else:
header_style = Style(bold=True)
header_labels = tuple(map(header_style, labels))
print_table(
sorted(table, key=sort_by_depth),
headers=header_labels,
table_format=table_format,
)
class ShowParamsOption(ExtraOption, ParamStructure):
"""A pre-configured option adding a ``--show-params`` option.
Between configuration files, default values and environment variables, it might be
hard to guess under which set of parameters the CLI will be executed. This option
print information about the parameters that will be fed to the CLI.