click-extended 0.4.0__py3-none-any.whl → 1.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. click_extended/__init__.py +10 -6
  2. click_extended/classes.py +12 -8
  3. click_extended/core/__init__.py +10 -0
  4. click_extended/core/decorators/__init__.py +21 -0
  5. click_extended/core/decorators/argument.py +227 -0
  6. click_extended/core/decorators/command.py +93 -0
  7. click_extended/core/decorators/env.py +155 -0
  8. click_extended/core/decorators/group.py +96 -0
  9. click_extended/core/decorators/option.py +347 -0
  10. click_extended/core/decorators/prompt.py +69 -0
  11. click_extended/core/decorators/selection.py +155 -0
  12. click_extended/core/decorators/tag.py +109 -0
  13. click_extended/core/nodes/__init__.py +21 -0
  14. click_extended/core/nodes/_root_node.py +1012 -0
  15. click_extended/core/nodes/argument_node.py +165 -0
  16. click_extended/core/nodes/child_node.py +555 -0
  17. click_extended/core/nodes/child_validation_node.py +100 -0
  18. click_extended/core/nodes/node.py +55 -0
  19. click_extended/core/nodes/option_node.py +205 -0
  20. click_extended/core/nodes/parent_node.py +220 -0
  21. click_extended/core/nodes/validation_node.py +124 -0
  22. click_extended/core/other/__init__.py +7 -0
  23. click_extended/core/other/_click_command.py +60 -0
  24. click_extended/core/other/_click_group.py +246 -0
  25. click_extended/core/other/_tree.py +491 -0
  26. click_extended/core/other/context.py +496 -0
  27. click_extended/decorators/__init__.py +29 -0
  28. click_extended/decorators/check/__init__.py +57 -0
  29. click_extended/decorators/check/conflicts.py +149 -0
  30. click_extended/decorators/check/contains.py +69 -0
  31. click_extended/decorators/check/dependencies.py +115 -0
  32. click_extended/decorators/check/divisible_by.py +48 -0
  33. click_extended/decorators/check/ends_with.py +85 -0
  34. click_extended/decorators/check/exclusive.py +75 -0
  35. click_extended/decorators/check/falsy.py +37 -0
  36. click_extended/decorators/check/is_email.py +43 -0
  37. click_extended/decorators/check/is_hex_color.py +41 -0
  38. click_extended/decorators/check/is_hostname.py +47 -0
  39. click_extended/decorators/check/is_ipv4.py +46 -0
  40. click_extended/decorators/check/is_ipv6.py +46 -0
  41. click_extended/decorators/check/is_json.py +40 -0
  42. click_extended/decorators/check/is_mac_address.py +40 -0
  43. click_extended/decorators/check/is_negative.py +37 -0
  44. click_extended/decorators/check/is_non_zero.py +37 -0
  45. click_extended/decorators/check/is_port.py +39 -0
  46. click_extended/decorators/check/is_positive.py +37 -0
  47. click_extended/decorators/check/is_url.py +75 -0
  48. click_extended/decorators/check/is_uuid.py +40 -0
  49. click_extended/decorators/check/length.py +68 -0
  50. click_extended/decorators/check/not_empty.py +49 -0
  51. click_extended/decorators/check/regex.py +47 -0
  52. click_extended/decorators/check/requires.py +190 -0
  53. click_extended/decorators/check/starts_with.py +87 -0
  54. click_extended/decorators/check/truthy.py +37 -0
  55. click_extended/decorators/compare/__init__.py +15 -0
  56. click_extended/decorators/compare/at_least.py +57 -0
  57. click_extended/decorators/compare/at_most.py +57 -0
  58. click_extended/decorators/compare/between.py +119 -0
  59. click_extended/decorators/compare/greater_than.py +183 -0
  60. click_extended/decorators/compare/less_than.py +183 -0
  61. click_extended/decorators/convert/__init__.py +31 -0
  62. click_extended/decorators/convert/convert_angle.py +94 -0
  63. click_extended/decorators/convert/convert_area.py +123 -0
  64. click_extended/decorators/convert/convert_bits.py +211 -0
  65. click_extended/decorators/convert/convert_distance.py +154 -0
  66. click_extended/decorators/convert/convert_energy.py +155 -0
  67. click_extended/decorators/convert/convert_power.py +128 -0
  68. click_extended/decorators/convert/convert_pressure.py +131 -0
  69. click_extended/decorators/convert/convert_speed.py +122 -0
  70. click_extended/decorators/convert/convert_temperature.py +89 -0
  71. click_extended/decorators/convert/convert_time.py +108 -0
  72. click_extended/decorators/convert/convert_volume.py +218 -0
  73. click_extended/decorators/convert/convert_weight.py +158 -0
  74. click_extended/decorators/load/__init__.py +13 -0
  75. click_extended/decorators/load/load_csv.py +117 -0
  76. click_extended/decorators/load/load_json.py +61 -0
  77. click_extended/decorators/load/load_toml.py +47 -0
  78. click_extended/decorators/load/load_yaml.py +72 -0
  79. click_extended/decorators/math/__init__.py +37 -0
  80. click_extended/decorators/math/absolute.py +35 -0
  81. click_extended/decorators/math/add.py +48 -0
  82. click_extended/decorators/math/ceil.py +36 -0
  83. click_extended/decorators/math/clamp.py +51 -0
  84. click_extended/decorators/math/divide.py +42 -0
  85. click_extended/decorators/math/floor.py +36 -0
  86. click_extended/decorators/math/maximum.py +39 -0
  87. click_extended/decorators/math/minimum.py +39 -0
  88. click_extended/decorators/math/modulo.py +39 -0
  89. click_extended/decorators/math/multiply.py +51 -0
  90. click_extended/decorators/math/normalize.py +76 -0
  91. click_extended/decorators/math/power.py +39 -0
  92. click_extended/decorators/math/rounded.py +39 -0
  93. click_extended/decorators/math/sqrt.py +39 -0
  94. click_extended/decorators/math/subtract.py +39 -0
  95. click_extended/decorators/math/to_percent.py +63 -0
  96. click_extended/decorators/misc/__init__.py +17 -0
  97. click_extended/decorators/misc/choice.py +139 -0
  98. click_extended/decorators/misc/confirm_if.py +147 -0
  99. click_extended/decorators/misc/default.py +95 -0
  100. click_extended/decorators/misc/deprecated.py +131 -0
  101. click_extended/decorators/misc/experimental.py +79 -0
  102. click_extended/decorators/misc/now.py +42 -0
  103. click_extended/decorators/random/__init__.py +21 -0
  104. click_extended/decorators/random/random_bool.py +49 -0
  105. click_extended/decorators/random/random_choice.py +63 -0
  106. click_extended/decorators/random/random_datetime.py +140 -0
  107. click_extended/decorators/random/random_float.py +62 -0
  108. click_extended/decorators/random/random_integer.py +56 -0
  109. click_extended/decorators/random/random_prime.py +196 -0
  110. click_extended/decorators/random/random_string.py +77 -0
  111. click_extended/decorators/random/random_uuid.py +119 -0
  112. click_extended/decorators/transform/__init__.py +71 -0
  113. click_extended/decorators/transform/add_prefix.py +58 -0
  114. click_extended/decorators/transform/add_suffix.py +58 -0
  115. click_extended/decorators/transform/apply.py +35 -0
  116. click_extended/decorators/transform/basename.py +44 -0
  117. click_extended/decorators/transform/dirname.py +44 -0
  118. click_extended/decorators/transform/expand_vars.py +36 -0
  119. click_extended/decorators/transform/remove_prefix.py +57 -0
  120. click_extended/decorators/transform/remove_suffix.py +57 -0
  121. click_extended/decorators/transform/replace.py +46 -0
  122. click_extended/decorators/transform/slugify.py +45 -0
  123. click_extended/decorators/transform/split.py +43 -0
  124. click_extended/decorators/transform/strip.py +148 -0
  125. click_extended/decorators/transform/to_case.py +216 -0
  126. click_extended/decorators/transform/to_date.py +75 -0
  127. click_extended/decorators/transform/to_datetime.py +83 -0
  128. click_extended/decorators/transform/to_path.py +274 -0
  129. click_extended/decorators/transform/to_time.py +77 -0
  130. click_extended/decorators/transform/to_timestamp.py +114 -0
  131. click_extended/decorators/transform/truncate.py +47 -0
  132. click_extended/types.py +1 -1
  133. click_extended/utils/__init__.py +13 -0
  134. click_extended/utils/casing.py +169 -0
  135. click_extended/utils/checks.py +48 -0
  136. click_extended/utils/dispatch.py +1016 -0
  137. click_extended/utils/format.py +101 -0
  138. click_extended/utils/humanize.py +209 -0
  139. click_extended/utils/naming.py +238 -0
  140. click_extended/utils/process.py +294 -0
  141. click_extended/utils/selection.py +267 -0
  142. click_extended/utils/time.py +46 -0
  143. {click_extended-0.4.0.dist-info → click_extended-1.0.1.dist-info}/METADATA +100 -29
  144. click_extended-1.0.1.dist-info/RECORD +149 -0
  145. click_extended-0.4.0.dist-info/RECORD +0 -10
  146. {click_extended-0.4.0.dist-info → click_extended-1.0.1.dist-info}/WHEEL +0 -0
  147. {click_extended-0.4.0.dist-info → click_extended-1.0.1.dist-info}/licenses/AUTHORS.md +0 -0
  148. {click_extended-0.4.0.dist-info → click_extended-1.0.1.dist-info}/licenses/LICENSE +0 -0
  149. {click_extended-0.4.0.dist-info → click_extended-1.0.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,347 @@
1
+ """`ParentNode` that represents a Click option."""
2
+
3
+ # pylint: disable=too-many-arguments
4
+ # pylint: disable=too-many-positional-arguments
5
+ # pylint: disable=too-many-locals
6
+ # pylint: disable=too-many-branches
7
+ # pylint: disable=redefined-builtin
8
+
9
+ import asyncio
10
+ from builtins import type as builtins_type
11
+ from functools import wraps
12
+ from typing import Any, Callable, ParamSpec, Type, TypeVar, cast
13
+
14
+ from click_extended.core.nodes.option_node import OptionNode
15
+ from click_extended.core.other.context import Context
16
+ from click_extended.utils.humanize import humanize_type
17
+ from click_extended.utils.naming import (
18
+ is_long_flag,
19
+ is_short_flag,
20
+ is_valid_name,
21
+ validate_name,
22
+ )
23
+
24
+ P = ParamSpec("P")
25
+ T = TypeVar("T")
26
+
27
+ SUPPORTED_TYPES = (str, int, float, bool)
28
+
29
+
30
+ class Option(OptionNode):
31
+ """`OptionNode` that represents a Click option."""
32
+
33
+ def __init__(
34
+ self,
35
+ name: str,
36
+ *flags: str,
37
+ param: str | None = None,
38
+ is_flag: bool = False,
39
+ type: Any = None,
40
+ nargs: int = 1,
41
+ multiple: bool = False,
42
+ help: str | None = None,
43
+ required: bool = False,
44
+ default: Any = None,
45
+ tags: str | list[str] | None = None,
46
+ **kwargs: Any,
47
+ ):
48
+ """
49
+ Initialize a new `Option` instance.
50
+
51
+ Args:
52
+ name (str):
53
+ The option name (parameter name) in snake_case,
54
+ SCREAMING_SNAKE_CASE, or kebab-case. Examples: \"config_file\",
55
+ \"CONFIG_FILE\", \"config-file\"
56
+ *flags (str):
57
+ Optional flags for the option. Can include any number of short
58
+ flags (e.g., \"-p\", \"-P\") and long flags (e.g., \"--port\",
59
+ \"--p\"). If no long flags provided, auto-generates
60
+ "--kebab-case(name)".
61
+ param (str, optional):
62
+ Custom parameter name for the function.
63
+ If not provided, uses the name directly.
64
+ is_flag (bool):
65
+ Whether this is a boolean flag (no value needed).
66
+ Defaults to `False`.
67
+ type (Any, optional):
68
+ The type to convert the value to (int, str, float, etc.).
69
+ nargs (int):
70
+ Number of arguments each occurrence accepts. Defaults to `1`.
71
+ multiple (bool):
72
+ Whether the option can be provided multiple times.
73
+ Defaults to `False`.
74
+ help (str, optional):
75
+ Help text for this option.
76
+ required (bool):
77
+ Whether this option is required. Defaults to `False`.
78
+ default (Any):
79
+ Default value if not provided. Defaults to None.
80
+ tags (str | list[str], optional):
81
+ Tag(s) to associate with this option for grouping.
82
+ **kwargs (Any):
83
+ Additional Click option parameters.
84
+ """
85
+ if name.startswith("--"):
86
+ if is_long_flag(name):
87
+ derived_name = name[2:]
88
+ if not is_valid_name(derived_name):
89
+ raise ValueError(
90
+ f"Invalid option name '{name}'. When using a long "
91
+ "flag as name, it must be snake_case after removing "
92
+ f"'--'. Use '{name.replace('-', '_')}' or provide an "
93
+ "explicit snake_case name parameter."
94
+ )
95
+ if not flags or not any(f.startswith("--") for f in flags):
96
+ flags = (name,) + flags
97
+ else:
98
+ raise ValueError(
99
+ f"Invalid option name '{name}'. "
100
+ f"Must be snake_case (e.g., my_option, config_file)"
101
+ )
102
+ else:
103
+ validate_name(name, "option name")
104
+ derived_name = name
105
+
106
+ short_flags_list: list[str] = []
107
+ long_flags_list: list[str] = []
108
+
109
+ for flag in flags:
110
+ if not flag.startswith("-"):
111
+ raise ValueError(
112
+ f"Invalid flag '{flag}'. Flags must start with '-' or '--'."
113
+ )
114
+
115
+ if flag.startswith("--"):
116
+ if not is_long_flag(flag):
117
+ raise ValueError(
118
+ f"Invalid long flag '{flag}'. "
119
+ "Must be format: --word "
120
+ "(lowercase, hyphens allowed, e.g., --port, "
121
+ "--config-file)"
122
+ )
123
+ long_flags_list.append(flag)
124
+ else:
125
+ if not is_short_flag(flag):
126
+ raise ValueError(
127
+ f"Invalid short flag '{flag}'. Must be format: -X "
128
+ f"(single letter, e.g., -p, -v, -h)"
129
+ )
130
+ short_flags_list.append(flag)
131
+
132
+ param_name = param if param is not None else derived_name
133
+
134
+ validate_name(param_name, "parameter name")
135
+
136
+ if is_flag and type is not None and type != bool:
137
+ raise ValueError(
138
+ f"Cannot specify both is_flag=True and "
139
+ f"type={type.__name__ if hasattr(type, '__name__') else type}. "
140
+ f"Flags are always boolean."
141
+ )
142
+
143
+ if is_flag and default is None:
144
+ default = False
145
+
146
+ if type is None:
147
+ if is_flag:
148
+ type = bool
149
+ elif default is not None and not (multiple and default == ()):
150
+ type = cast(Type[Any], builtins_type(default)) # type: ignore
151
+ else:
152
+ type = str
153
+
154
+ if type not in SUPPORTED_TYPES:
155
+ types = humanize_type(
156
+ type.__name__ if hasattr(type, "__name__") else type
157
+ )
158
+
159
+ raise ValueError(
160
+ f"Option '{derived_name}' has unsupported type '{types}'. "
161
+ "Only basic primitives are supported: str, int, float, bool. "
162
+ "For complex types, use child decorators (e.g., @to_path, "
163
+ "@to_datetime, ...)."
164
+ )
165
+
166
+ if multiple and default is None:
167
+ default = ()
168
+
169
+ super().__init__(
170
+ name=derived_name,
171
+ param=param_name,
172
+ short_flags=short_flags_list,
173
+ long_flags=long_flags_list,
174
+ is_flag=is_flag,
175
+ type=type,
176
+ nargs=nargs,
177
+ multiple=multiple,
178
+ help=help,
179
+ required=required,
180
+ default=default,
181
+ tags=tags,
182
+ )
183
+ self.extra_kwargs = kwargs
184
+
185
+ def get_display_name(self) -> str:
186
+ """
187
+ Get a formatted display name for error messages.
188
+
189
+ Returns:
190
+ str:
191
+ The first long flag if available, otherwise the first flag.
192
+ """
193
+ if self.long_flags:
194
+ return self.long_flags[0]
195
+ if self.short_flags:
196
+ return self.short_flags[0]
197
+ return self.name
198
+
199
+ def load(
200
+ self,
201
+ value: str | int | float | bool | None,
202
+ context: Context,
203
+ *args: Any,
204
+ **kwargs: Any,
205
+ ) -> Any:
206
+ """
207
+ Load and return the CLI option value.
208
+
209
+ Args:
210
+ value (str | int | float | bool | None):
211
+ The parsed CLI option value from Click.
212
+ context (Context):
213
+ The current context instance.
214
+ *args (Any):
215
+ Optional positional arguments.
216
+ **kwargs (Any):
217
+ Optional keyword arguments.
218
+
219
+ Returns:
220
+ Any:
221
+ The option value to inject into the function.
222
+ """
223
+ return value
224
+
225
+
226
+ def option(
227
+ name: str,
228
+ *flags: str,
229
+ param: str | None = None,
230
+ is_flag: bool = False,
231
+ type: Type[str | int | float | bool] | None = None,
232
+ nargs: int = 1,
233
+ multiple: bool = False,
234
+ help: str | None = None,
235
+ required: bool = False,
236
+ default: Any = None,
237
+ tags: str | list[str] | None = None,
238
+ **kwargs: Any,
239
+ ) -> Callable[[Callable[P, T]], Callable[P, T]]:
240
+ """
241
+ A `ParentNode` decorator to create a Click option with value injection.
242
+
243
+ Args:
244
+ name (str):
245
+ The option name (parameter name) in snake_case.
246
+ Examples: "verbose", "config_file"
247
+ *flags (str):
248
+ Optional flags for the option. Can include any number of short flags
249
+ (e.g., "-v", "-V") and long flags (e.g., "--verbose", "--verb").
250
+ If no long flags provided, auto-generates "--kebab-case(name)".
251
+ Examples:
252
+ @option("verbose", "-v")
253
+ @option("config", "-c", "--cfg", "--config")
254
+ @option("verbose", "-v", "-V", "--verbose", "--verb")
255
+ param (str, optional):
256
+ Custom parameter name for the function.
257
+ If not provided, uses the name directly.
258
+ is_flag (bool):
259
+ Whether this is a boolean flag (no value needed).
260
+ Defaults to `False`.
261
+ type (Type[str | int | float | bool] | None, optional):
262
+ The type to convert the value to.
263
+ nargs (int):
264
+ Number of arguments each occurrence accepts. Defaults to `1`.
265
+ multiple (bool):
266
+ Whether the option can be provided multiple times.
267
+ Defaults to `False`.
268
+ help (str, optional):
269
+ Help text for this option.
270
+ required (bool):
271
+ Whether this option is required. Defaults to `False`.
272
+ default (Any):
273
+ Default value if not provided. Defaults to None.
274
+ tags (str | list[str], optional):
275
+ Tag(s) to associate with this option for grouping.
276
+ **kwargs (Any):
277
+ Additional Click option parameters.
278
+
279
+ Returns:
280
+ Callable:
281
+ A decorator function that registers the option parent node.
282
+
283
+ Examples:
284
+
285
+ ```python
286
+ # Simple: name only (auto-generates --verbose)
287
+ @option("verbose", is_flag=True)
288
+ def my_func(verbose):
289
+ print(f"Verbose: {verbose}")
290
+
291
+ # Name with short flag
292
+ @option("port", "-p", type=int, default=8080)
293
+ def my_func(port):
294
+ print(f"Port: {port}")
295
+
296
+ # Multiple short and long flags
297
+ @option("verbose", "-v", "-V", "--verb", is_flag=True)
298
+ def my_func(verbose): # Accepts: -v, -V, --verbose, --verb
299
+ print("Verbose mode")
300
+
301
+ # Custom parameter name
302
+ @option("configuration_file", "-c", param="cfg")
303
+ def my_func(cfg): # param: cfg, CLI: -c, --configuration-file
304
+ print(f"Config: {cfg}")
305
+ ```
306
+ """
307
+
308
+ def decorator(func: Callable[P, T]) -> Callable[P, T]:
309
+ """The actual decorator that wraps the function."""
310
+ from click_extended.core.other._tree import Tree
311
+
312
+ instance = Option(
313
+ name,
314
+ *flags,
315
+ param=param,
316
+ is_flag=is_flag,
317
+ type=type,
318
+ nargs=nargs,
319
+ multiple=multiple,
320
+ help=help,
321
+ required=required,
322
+ default=default,
323
+ tags=tags,
324
+ **kwargs,
325
+ )
326
+ Tree.queue_parent(instance)
327
+
328
+ if asyncio.iscoroutinefunction(func):
329
+
330
+ @wraps(func)
331
+ async def async_wrapper(
332
+ *call_args: P.args, **call_kwargs: P.kwargs
333
+ ) -> T:
334
+ """Async wrapper that preserves the original function."""
335
+ result = await func(*call_args, **call_kwargs)
336
+ return cast(T, result)
337
+
338
+ return cast(Callable[P, T], async_wrapper)
339
+
340
+ @wraps(func)
341
+ def wrapper(*call_args: P.args, **call_kwargs: P.kwargs) -> T:
342
+ """Wrapper that preserves the original function."""
343
+ return func(*call_args, **call_kwargs)
344
+
345
+ return wrapper
346
+
347
+ return decorator
@@ -0,0 +1,69 @@
1
+ """Parent decorator that prompts the user for a value."""
2
+
3
+ from getpass import getpass
4
+ from typing import Any
5
+
6
+ from click_extended.core.nodes.parent_node import ParentNode
7
+ from click_extended.core.other.context import Context
8
+ from click_extended.types import Decorator
9
+
10
+
11
+ class Prompt(ParentNode):
12
+ """Parent decorator that prompts the user for a value."""
13
+
14
+ def get_input(self, text: str, hide: bool) -> str:
15
+ """
16
+ Get input from the user.
17
+
18
+ Args:
19
+ text (str):
20
+ The prompt.
21
+ hide (bool):
22
+ Whether to hide the user input.
23
+
24
+ Returns:
25
+ str:
26
+ The value provided from the user.
27
+ """
28
+ if hide:
29
+ return getpass(text)
30
+ return input(text)
31
+
32
+ def load(
33
+ self,
34
+ context: Context,
35
+ *args: Any,
36
+ **kwargs: Any,
37
+ ) -> str:
38
+ while True:
39
+ answer = self.get_input(kwargs["text"], kwargs["hide"])
40
+ if answer or kwargs["allow_empty"]:
41
+ return answer
42
+
43
+
44
+ def prompt(
45
+ name: str, text: str = "", hide: bool = False, allow_empty: bool = False
46
+ ) -> Decorator:
47
+ """
48
+ A `ParentNode` decorator to prompt the user for input.
49
+
50
+ Args:
51
+ name (str):
52
+ The name of the parent node.
53
+ text (str):
54
+ The text to show.
55
+ hide (bool):
56
+ Whether to hide the input, defaults to `False`.
57
+ allow_empty (bool):
58
+ Whether to allow the input to be empty, defaults to `False`.
59
+
60
+ Returns:
61
+ Decorator:
62
+ The decorator function.
63
+ """
64
+ return Prompt.as_decorator(
65
+ name=name,
66
+ text=text,
67
+ hide=hide,
68
+ allow_empty=allow_empty,
69
+ )
@@ -0,0 +1,155 @@
1
+ """Choose one or more options from a list of selections."""
2
+
3
+ # pylint: disable=too-many-arguments
4
+ # pylint: disable=too-many-positional-arguments
5
+ # pylint: disable=redefined-builtin
6
+
7
+ from typing import Any
8
+
9
+ from click_extended.core.nodes.parent_node import ParentNode
10
+ from click_extended.core.other.context import Context
11
+ from click_extended.types import Decorator
12
+ from click_extended.utils.selection import selection as fn
13
+
14
+
15
+ class Selection(ParentNode):
16
+ """Choose one or more options from a list of selections."""
17
+
18
+ def load(
19
+ self, context: Context, *args: Any, **kwargs: Any
20
+ ) -> str | list[str]:
21
+ """
22
+ Load the selection value by prompting the user.
23
+
24
+ Args:
25
+ context (Context):
26
+ The current context.
27
+ *args (Any):
28
+ Additional positional arguments.
29
+ **kwargs (Any):
30
+ Additional keyword arguments
31
+
32
+ Returns:
33
+ str|list[str]:
34
+ Selected value(s). `str` for single mode, `list[str]`
35
+ for multiple.
36
+
37
+ Raises:
38
+ ValueError:
39
+ If selections is empty or invalid.
40
+ """
41
+ return fn(
42
+ selections=kwargs.get("selections", []),
43
+ prompt=kwargs.get("prompt", "Select an option"),
44
+ multiple=kwargs.get("multiple", False),
45
+ default=kwargs.get("default"),
46
+ min_selections=kwargs.get("min_selections", 0),
47
+ max_selections=kwargs.get("max_selections"),
48
+ cursor_style=kwargs.get("cursor_style", ">"),
49
+ checkbox_style=kwargs.get("checkbox_style", ("◯", "◉")),
50
+ show_count=kwargs.get("show_count", False),
51
+ )
52
+
53
+
54
+ def selection(
55
+ name: str,
56
+ selections: list[str | tuple[str, str]],
57
+ multiple: bool = False,
58
+ default: str | list[str] | None = None,
59
+ prompt: str = "Select an option:",
60
+ min_selections: int = 0,
61
+ max_selections: int | None = None,
62
+ cursor_style: str = ">",
63
+ checkbox_style: tuple[str, str] = ("◯", "◉"),
64
+ show_count: bool = False,
65
+ param: str | None = None,
66
+ help: str | None = None,
67
+ required: bool = False,
68
+ tags: str | list[str] | None = None,
69
+ ) -> Decorator:
70
+ """
71
+ A `ParentNode` decorator for an interactive selection prompt
72
+ with arrow key navigation.
73
+
74
+ Creates an interactive terminal prompt that allows users to select one or
75
+ more options using arrow keys (or j/k vim-style keys). The list wraps around
76
+ (carousel behavior) when scrolling past the first or last item.
77
+
78
+ Args:
79
+ name (str):
80
+ The name of the parameter for injection.
81
+ selections (list[str | tuple[str, str]]):
82
+ List of options. Each item can be:
83
+ - str: Used as both display text and value
84
+ - tuple[str, str]: (display_text, value)
85
+ multiple (bool):
86
+ If `True`, allows multiple selections with checkboxes.
87
+ If `False`, allows single selection only. Defaults to `False`.
88
+ default (str | list[str] | None):
89
+ Default selection(s). Should be a string for single mode,
90
+ or list of strings for multiple mode. Defaults to `None`.
91
+ prompt (str):
92
+ Text to display above the selection list.
93
+ Defaults to "Select an option:".
94
+ min_selections (int):
95
+ Minimum number of selections required (multiple mode only).
96
+ Defaults to `0`. User cannot confirm until minimum is met.
97
+ max_selections (int | None):
98
+ Maximum number of selections allowed (multiple mode only).
99
+ Defaults to `None` (unlimited). Prevents selecting more than max.
100
+ cursor_style (str):
101
+ The cursor indicator string. Defaults to ">".
102
+ Examples: ">", "→", "▶", "•"
103
+ checkbox_style (tuple[str, str]):
104
+ Tuple of (unselected, selected) checkbox indicators.
105
+ Defaults to ("◯", "◉").
106
+ Examples: ("☐", "☑"), ("○", "●"), ("[ ]", "[x]")
107
+ show_count (bool):
108
+ Whether to show selection count in the prompt.
109
+ Defaults to `False`. Shows "(X/Y selected)" when enabled.
110
+ param (str | None):
111
+ The parameter name to inject into the function.
112
+ If not provided, uses name.
113
+ help (str | None):
114
+ Help text for this parameter.
115
+ required (bool):
116
+ Whether this parameter is required. Defaults to `False`.
117
+ tags (str | list[str] | None):
118
+ Tag(s) to associate with this parameter for grouping.
119
+
120
+ Returns:
121
+ Decorator:
122
+ The decorator function.
123
+
124
+ Examples:
125
+ >>> @command()
126
+ >>> @selection("framework", ["React", "Vue", "Angular"])
127
+ >>> def setup(framework: str):
128
+ ... print(f"Setting up {framework}...")
129
+
130
+ >>> @command()
131
+ >>> @selection(
132
+ ... "features",
133
+ ... [("TypeScript", "ts"), ("ESLint", "eslint")],
134
+ ... multiple=True,
135
+ ... default=["eslint"]
136
+ ... )
137
+ >>> def configure(features: list[str]):
138
+ ... print(f"Enabled: {features}")
139
+ """
140
+ return Selection.as_decorator(
141
+ name=name,
142
+ param=param,
143
+ help=help,
144
+ required=required,
145
+ default=default,
146
+ tags=tags,
147
+ selections=selections,
148
+ multiple=multiple,
149
+ prompt=prompt,
150
+ min_selections=min_selections,
151
+ max_selections=max_selections,
152
+ cursor_style=cursor_style,
153
+ checkbox_style=checkbox_style,
154
+ show_count=show_count,
155
+ )
@@ -0,0 +1,109 @@
1
+ """Tag implementation for the `click_extended` library."""
2
+
3
+ from typing import TYPE_CHECKING, Any, Callable, ParamSpec, TypeVar
4
+
5
+ from click_extended.core.nodes.node import Node
6
+ from click_extended.core.other._tree import Tree
7
+
8
+ if TYPE_CHECKING:
9
+ from click_extended.core.nodes.parent_node import ParentNode
10
+
11
+ P = ParamSpec("P")
12
+ T = TypeVar("T")
13
+
14
+
15
+ class Tag(Node):
16
+ """Tag class for grouping multiple ParentNodes together."""
17
+
18
+ def __init__(self, name: str):
19
+ """
20
+ Initialize a new `Tag` instance.
21
+
22
+ Args:
23
+ name (str):
24
+ The name of the tag for grouping ParentNodes.
25
+ """
26
+ super().__init__(name=name, children={})
27
+ self.parent_nodes: list["ParentNode"] = []
28
+
29
+ def get_provided_values(self) -> list[str]:
30
+ """
31
+ Get the names of all ParentNodes in this tag
32
+ that were provided by the user.
33
+
34
+ Returns:
35
+ list[str]:
36
+ List of parameter names that were provided.
37
+ """
38
+ return [node.name for node in self.parent_nodes if node.was_provided]
39
+
40
+ def get_value(self) -> dict[str, Any]:
41
+ """
42
+ Get a dictionary of values from all parent nodes.
43
+
44
+ Returns a dictionary mapping parameter names to their values from all
45
+ parent nodes that reference this tag. Each key is the parent
46
+ node's name, and the value is `None` if not provided.
47
+
48
+ Returns:
49
+ dict[str, Any]:
50
+ Dictionary mapping parameter names to values.
51
+ """
52
+ return {
53
+ parent_node.name: parent_node.get_value()
54
+ for parent_node in self.parent_nodes
55
+ }
56
+
57
+ @classmethod
58
+ def as_decorator(
59
+ cls, name: str
60
+ ) -> Callable[[Callable[P, T]], Callable[P, T]]:
61
+ """
62
+ Return a decorator representation of the tag.
63
+
64
+ Args:
65
+ name (str):
66
+ The name of the tag.
67
+
68
+ Returns:
69
+ Callable:
70
+ A decorator function that registers the tag.
71
+ """
72
+
73
+ def decorator(func: Callable[P, T]) -> Callable[P, T]:
74
+ """The actual decorator that registers the tag."""
75
+ instance = cls(name=name)
76
+ Tree.queue_tag(instance)
77
+ return func
78
+
79
+ return decorator
80
+
81
+
82
+ def tag(name: str) -> Callable[[Callable[P, T]], Callable[P, T]]:
83
+ """
84
+ A special `Tag` decorator to create a tag for grouping parent nodes.
85
+
86
+ Tags allow you to apply child nodes to multiple parent nodes at once
87
+ and perform cross-node validation.
88
+
89
+ Args:
90
+ name (str):
91
+ The name of the tag.
92
+
93
+ Returns:
94
+ Callable:
95
+ A decorator function that registers the tag.
96
+
97
+ Examples:
98
+
99
+ ```python
100
+ @command()
101
+ @option("--api-key", tags="api-config")
102
+ @option("--api-url", tags="api-config")
103
+ @tag("api-config")
104
+ @at_least(1)
105
+ def my_function(api_key: str, api_url: str):
106
+ print(f"API Key: {api_key}, URL: {api_url}")
107
+ ```
108
+ """
109
+ return Tag.as_decorator(name=name)
@@ -0,0 +1,21 @@
1
+ """Initialization file for the `click_extended.core.nodes` module."""
2
+
3
+ from click_extended.core.nodes._root_node import RootNode
4
+ from click_extended.core.nodes.argument_node import ArgumentNode
5
+ from click_extended.core.nodes.child_node import ChildNode
6
+ from click_extended.core.nodes.child_validation_node import ChildValidationNode
7
+ from click_extended.core.nodes.node import Node
8
+ from click_extended.core.nodes.option_node import OptionNode
9
+ from click_extended.core.nodes.parent_node import ParentNode
10
+ from click_extended.core.nodes.validation_node import ValidationNode
11
+
12
+ __all__ = [
13
+ "RootNode",
14
+ "ArgumentNode",
15
+ "ChildNode",
16
+ "ChildValidationNode",
17
+ "Node",
18
+ "OptionNode",
19
+ "ParentNode",
20
+ "ValidationNode",
21
+ ]