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.
- click_extended/__init__.py +10 -6
- click_extended/classes.py +12 -8
- click_extended/core/__init__.py +10 -0
- click_extended/core/decorators/__init__.py +21 -0
- click_extended/core/decorators/argument.py +227 -0
- click_extended/core/decorators/command.py +93 -0
- click_extended/core/decorators/env.py +155 -0
- click_extended/core/decorators/group.py +96 -0
- click_extended/core/decorators/option.py +347 -0
- click_extended/core/decorators/prompt.py +69 -0
- click_extended/core/decorators/selection.py +155 -0
- click_extended/core/decorators/tag.py +109 -0
- click_extended/core/nodes/__init__.py +21 -0
- click_extended/core/nodes/_root_node.py +1012 -0
- click_extended/core/nodes/argument_node.py +165 -0
- click_extended/core/nodes/child_node.py +555 -0
- click_extended/core/nodes/child_validation_node.py +100 -0
- click_extended/core/nodes/node.py +55 -0
- click_extended/core/nodes/option_node.py +205 -0
- click_extended/core/nodes/parent_node.py +220 -0
- click_extended/core/nodes/validation_node.py +124 -0
- click_extended/core/other/__init__.py +7 -0
- click_extended/core/other/_click_command.py +60 -0
- click_extended/core/other/_click_group.py +246 -0
- click_extended/core/other/_tree.py +491 -0
- click_extended/core/other/context.py +496 -0
- click_extended/decorators/__init__.py +29 -0
- click_extended/decorators/check/__init__.py +57 -0
- click_extended/decorators/check/conflicts.py +149 -0
- click_extended/decorators/check/contains.py +69 -0
- click_extended/decorators/check/dependencies.py +115 -0
- click_extended/decorators/check/divisible_by.py +48 -0
- click_extended/decorators/check/ends_with.py +85 -0
- click_extended/decorators/check/exclusive.py +75 -0
- click_extended/decorators/check/falsy.py +37 -0
- click_extended/decorators/check/is_email.py +43 -0
- click_extended/decorators/check/is_hex_color.py +41 -0
- click_extended/decorators/check/is_hostname.py +47 -0
- click_extended/decorators/check/is_ipv4.py +46 -0
- click_extended/decorators/check/is_ipv6.py +46 -0
- click_extended/decorators/check/is_json.py +40 -0
- click_extended/decorators/check/is_mac_address.py +40 -0
- click_extended/decorators/check/is_negative.py +37 -0
- click_extended/decorators/check/is_non_zero.py +37 -0
- click_extended/decorators/check/is_port.py +39 -0
- click_extended/decorators/check/is_positive.py +37 -0
- click_extended/decorators/check/is_url.py +75 -0
- click_extended/decorators/check/is_uuid.py +40 -0
- click_extended/decorators/check/length.py +68 -0
- click_extended/decorators/check/not_empty.py +49 -0
- click_extended/decorators/check/regex.py +47 -0
- click_extended/decorators/check/requires.py +190 -0
- click_extended/decorators/check/starts_with.py +87 -0
- click_extended/decorators/check/truthy.py +37 -0
- click_extended/decorators/compare/__init__.py +15 -0
- click_extended/decorators/compare/at_least.py +57 -0
- click_extended/decorators/compare/at_most.py +57 -0
- click_extended/decorators/compare/between.py +119 -0
- click_extended/decorators/compare/greater_than.py +183 -0
- click_extended/decorators/compare/less_than.py +183 -0
- click_extended/decorators/convert/__init__.py +31 -0
- click_extended/decorators/convert/convert_angle.py +94 -0
- click_extended/decorators/convert/convert_area.py +123 -0
- click_extended/decorators/convert/convert_bits.py +211 -0
- click_extended/decorators/convert/convert_distance.py +154 -0
- click_extended/decorators/convert/convert_energy.py +155 -0
- click_extended/decorators/convert/convert_power.py +128 -0
- click_extended/decorators/convert/convert_pressure.py +131 -0
- click_extended/decorators/convert/convert_speed.py +122 -0
- click_extended/decorators/convert/convert_temperature.py +89 -0
- click_extended/decorators/convert/convert_time.py +108 -0
- click_extended/decorators/convert/convert_volume.py +218 -0
- click_extended/decorators/convert/convert_weight.py +158 -0
- click_extended/decorators/load/__init__.py +13 -0
- click_extended/decorators/load/load_csv.py +117 -0
- click_extended/decorators/load/load_json.py +61 -0
- click_extended/decorators/load/load_toml.py +47 -0
- click_extended/decorators/load/load_yaml.py +72 -0
- click_extended/decorators/math/__init__.py +37 -0
- click_extended/decorators/math/absolute.py +35 -0
- click_extended/decorators/math/add.py +48 -0
- click_extended/decorators/math/ceil.py +36 -0
- click_extended/decorators/math/clamp.py +51 -0
- click_extended/decorators/math/divide.py +42 -0
- click_extended/decorators/math/floor.py +36 -0
- click_extended/decorators/math/maximum.py +39 -0
- click_extended/decorators/math/minimum.py +39 -0
- click_extended/decorators/math/modulo.py +39 -0
- click_extended/decorators/math/multiply.py +51 -0
- click_extended/decorators/math/normalize.py +76 -0
- click_extended/decorators/math/power.py +39 -0
- click_extended/decorators/math/rounded.py +39 -0
- click_extended/decorators/math/sqrt.py +39 -0
- click_extended/decorators/math/subtract.py +39 -0
- click_extended/decorators/math/to_percent.py +63 -0
- click_extended/decorators/misc/__init__.py +17 -0
- click_extended/decorators/misc/choice.py +139 -0
- click_extended/decorators/misc/confirm_if.py +147 -0
- click_extended/decorators/misc/default.py +95 -0
- click_extended/decorators/misc/deprecated.py +131 -0
- click_extended/decorators/misc/experimental.py +79 -0
- click_extended/decorators/misc/now.py +42 -0
- click_extended/decorators/random/__init__.py +21 -0
- click_extended/decorators/random/random_bool.py +49 -0
- click_extended/decorators/random/random_choice.py +63 -0
- click_extended/decorators/random/random_datetime.py +140 -0
- click_extended/decorators/random/random_float.py +62 -0
- click_extended/decorators/random/random_integer.py +56 -0
- click_extended/decorators/random/random_prime.py +196 -0
- click_extended/decorators/random/random_string.py +77 -0
- click_extended/decorators/random/random_uuid.py +119 -0
- click_extended/decorators/transform/__init__.py +71 -0
- click_extended/decorators/transform/add_prefix.py +58 -0
- click_extended/decorators/transform/add_suffix.py +58 -0
- click_extended/decorators/transform/apply.py +35 -0
- click_extended/decorators/transform/basename.py +44 -0
- click_extended/decorators/transform/dirname.py +44 -0
- click_extended/decorators/transform/expand_vars.py +36 -0
- click_extended/decorators/transform/remove_prefix.py +57 -0
- click_extended/decorators/transform/remove_suffix.py +57 -0
- click_extended/decorators/transform/replace.py +46 -0
- click_extended/decorators/transform/slugify.py +45 -0
- click_extended/decorators/transform/split.py +43 -0
- click_extended/decorators/transform/strip.py +148 -0
- click_extended/decorators/transform/to_case.py +216 -0
- click_extended/decorators/transform/to_date.py +75 -0
- click_extended/decorators/transform/to_datetime.py +83 -0
- click_extended/decorators/transform/to_path.py +274 -0
- click_extended/decorators/transform/to_time.py +77 -0
- click_extended/decorators/transform/to_timestamp.py +114 -0
- click_extended/decorators/transform/truncate.py +47 -0
- click_extended/types.py +1 -1
- click_extended/utils/__init__.py +13 -0
- click_extended/utils/casing.py +169 -0
- click_extended/utils/checks.py +48 -0
- click_extended/utils/dispatch.py +1016 -0
- click_extended/utils/format.py +101 -0
- click_extended/utils/humanize.py +209 -0
- click_extended/utils/naming.py +238 -0
- click_extended/utils/process.py +294 -0
- click_extended/utils/selection.py +267 -0
- click_extended/utils/time.py +46 -0
- {click_extended-0.4.0.dist-info → click_extended-1.0.1.dist-info}/METADATA +100 -29
- click_extended-1.0.1.dist-info/RECORD +149 -0
- click_extended-0.4.0.dist-info/RECORD +0 -10
- {click_extended-0.4.0.dist-info → click_extended-1.0.1.dist-info}/WHEEL +0 -0
- {click_extended-0.4.0.dist-info → click_extended-1.0.1.dist-info}/licenses/AUTHORS.md +0 -0
- {click_extended-0.4.0.dist-info → click_extended-1.0.1.dist-info}/licenses/LICENSE +0 -0
- {click_extended-0.4.0.dist-info → click_extended-1.0.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Utility functions for processing child nodes."""
|
|
2
|
+
|
|
3
|
+
# pylint: disable=too-many-locals
|
|
4
|
+
# pylint: disable=broad-exception-caught
|
|
5
|
+
# pylint: disable=too-many-branches
|
|
6
|
+
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Mapping, cast
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from click_extended.core.other._tree import Tree
|
|
12
|
+
from click_extended.core.other.context import Context
|
|
13
|
+
from click_extended.errors import ContextAwareError
|
|
14
|
+
from click_extended.utils.dispatch import (
|
|
15
|
+
dispatch_to_child,
|
|
16
|
+
dispatch_to_child_async,
|
|
17
|
+
has_async_handlers,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from click_extended.core.decorators.tag import Tag
|
|
22
|
+
from click_extended.core.nodes._root_node import RootNode
|
|
23
|
+
from click_extended.core.nodes.child_node import ChildNode
|
|
24
|
+
from click_extended.core.nodes.parent_node import ParentNode
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def process_children(
|
|
28
|
+
value: Any,
|
|
29
|
+
children: Mapping[Any, Any],
|
|
30
|
+
parent: "ParentNode | Tag",
|
|
31
|
+
tags: dict[str, "Tag"] | None = None,
|
|
32
|
+
click_context: click.Context | None = None,
|
|
33
|
+
) -> Any:
|
|
34
|
+
"""
|
|
35
|
+
Process a value through a chain of child nodes.
|
|
36
|
+
This is a `phase 4` function and does the following:
|
|
37
|
+
|
|
38
|
+
1. Updates scope tracking for each child
|
|
39
|
+
2. Dispatches value to appropriate handler
|
|
40
|
+
3. Wraps handler execution to catch exceptions
|
|
41
|
+
4. Converts user exceptions to ProcessError with context
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
value (Any):
|
|
45
|
+
The initial value to process.
|
|
46
|
+
children (Mapping[Any, Any]):
|
|
47
|
+
Mapping of child nodes to process the value through.
|
|
48
|
+
parent (ParentNode | Tag):
|
|
49
|
+
The parent node that owns these children.
|
|
50
|
+
tags (dict[str, Tag]):
|
|
51
|
+
Dictionary mapping tag names to Tag instances.
|
|
52
|
+
context (click.Context):
|
|
53
|
+
The Click context for scope tracking and error reporting.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
The processed value after passing through all children.
|
|
57
|
+
|
|
58
|
+
Raises:
|
|
59
|
+
UnhandledTypeError:
|
|
60
|
+
If a child node doesn't implement a handler for the value type.
|
|
61
|
+
ProcessError:
|
|
62
|
+
If validation or transformation fails in a child node.
|
|
63
|
+
"""
|
|
64
|
+
child_nodes = [cast("ChildNode", child) for child in children.values()]
|
|
65
|
+
|
|
66
|
+
if tags is None:
|
|
67
|
+
tags = {}
|
|
68
|
+
|
|
69
|
+
is_container_tuple = False
|
|
70
|
+
if isinstance(value, tuple) and parent.__class__.__name__ in (
|
|
71
|
+
"Option",
|
|
72
|
+
"Argument",
|
|
73
|
+
):
|
|
74
|
+
parent_cast: click.Option | click.Argument
|
|
75
|
+
if parent.__class__.__name__ == "Option":
|
|
76
|
+
parent_cast = cast(click.Option, parent)
|
|
77
|
+
is_container_tuple = parent_cast.multiple or parent_cast.nargs != 1
|
|
78
|
+
else:
|
|
79
|
+
parent_cast = cast(click.Argument, parent)
|
|
80
|
+
is_container_tuple = parent_cast.nargs != 1
|
|
81
|
+
|
|
82
|
+
for child in child_nodes:
|
|
83
|
+
if click_context is not None:
|
|
84
|
+
Tree.update_scope(
|
|
85
|
+
click_context,
|
|
86
|
+
"child",
|
|
87
|
+
parent_node=(
|
|
88
|
+
cast("ParentNode", parent)
|
|
89
|
+
if parent.__class__.__name__ != "Tag"
|
|
90
|
+
else None
|
|
91
|
+
),
|
|
92
|
+
child_node=child,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
if "click_extended" in click_context.meta:
|
|
96
|
+
click_context.meta["click_extended"]["handler_value"] = value
|
|
97
|
+
click_context.meta["click_extended"][
|
|
98
|
+
"is_container_tuple"
|
|
99
|
+
] = is_container_tuple
|
|
100
|
+
|
|
101
|
+
root_node: "RootNode | None" = None
|
|
102
|
+
all_nodes: dict[str, Any] = {}
|
|
103
|
+
all_parents: dict[str, Any] = {}
|
|
104
|
+
all_tags: dict[str, Any] = {}
|
|
105
|
+
all_children: dict[str, Any] = {}
|
|
106
|
+
all_globals: dict[str, Any] = {}
|
|
107
|
+
meta: dict[str, Any] = {}
|
|
108
|
+
|
|
109
|
+
if click_context is not None and "click_extended" in click_context.meta:
|
|
110
|
+
meta = click_context.meta["click_extended"]
|
|
111
|
+
root_node = meta.get("root_node")
|
|
112
|
+
|
|
113
|
+
if "parents" in meta:
|
|
114
|
+
all_parents = meta["parents"]
|
|
115
|
+
all_nodes.update(all_parents)
|
|
116
|
+
|
|
117
|
+
if "tags" in meta:
|
|
118
|
+
all_tags = meta["tags"]
|
|
119
|
+
all_nodes.update(all_tags)
|
|
120
|
+
|
|
121
|
+
if "children" in meta:
|
|
122
|
+
all_children = meta["children"]
|
|
123
|
+
all_nodes.update(all_children)
|
|
124
|
+
|
|
125
|
+
if "globals" in meta:
|
|
126
|
+
all_globals = meta["globals"]
|
|
127
|
+
all_nodes.update(all_globals)
|
|
128
|
+
|
|
129
|
+
if root_node:
|
|
130
|
+
all_nodes[root_node.name] = root_node
|
|
131
|
+
|
|
132
|
+
context = Context(
|
|
133
|
+
root=cast("RootNode", root_node),
|
|
134
|
+
parent=parent,
|
|
135
|
+
current=child,
|
|
136
|
+
click_context=cast(click.Context, click_context),
|
|
137
|
+
nodes=all_nodes,
|
|
138
|
+
parents=all_parents,
|
|
139
|
+
tags=all_tags,
|
|
140
|
+
children=all_children,
|
|
141
|
+
data=meta.get("data", {}),
|
|
142
|
+
debug=meta.get("debug", False),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
if isinstance(child, type(ContextAwareError)):
|
|
146
|
+
raise child
|
|
147
|
+
|
|
148
|
+
value = dispatch_to_child(child, value, context)
|
|
149
|
+
|
|
150
|
+
return value # type: ignore
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
async def process_children_async(
|
|
154
|
+
value: Any,
|
|
155
|
+
children: Mapping[Any, Any],
|
|
156
|
+
parent: "ParentNode | Tag",
|
|
157
|
+
tags: dict[str, "Tag"] | None = None,
|
|
158
|
+
click_context: click.Context | None = None,
|
|
159
|
+
) -> Any:
|
|
160
|
+
"""
|
|
161
|
+
Async version of process_children for async handler support.
|
|
162
|
+
|
|
163
|
+
Process a value through a chain of child nodes (async).
|
|
164
|
+
This is a `phase 4` function and does the following:
|
|
165
|
+
|
|
166
|
+
1. Updates scope tracking for each child
|
|
167
|
+
2. Dispatches value to appropriate handler (with async support)
|
|
168
|
+
3. Wraps handler execution to catch exceptions
|
|
169
|
+
4. Converts user exceptions to ProcessError with context
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
value (Any):
|
|
173
|
+
The initial value to process.
|
|
174
|
+
children (Mapping[Any, Any]):
|
|
175
|
+
Mapping of child nodes to process the value through.
|
|
176
|
+
parent (ParentNode | Tag):
|
|
177
|
+
The parent node that owns these children.
|
|
178
|
+
tags (dict[str, Tag]):
|
|
179
|
+
Dictionary mapping tag names to Tag instances.
|
|
180
|
+
context (click.Context):
|
|
181
|
+
The Click context for scope tracking and error reporting.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
The processed value after passing through all children.
|
|
185
|
+
|
|
186
|
+
Raises:
|
|
187
|
+
UnhandledTypeError:
|
|
188
|
+
If a child node doesn't implement a handler for the value type.
|
|
189
|
+
ProcessError:
|
|
190
|
+
If validation or transformation fails in a child node.
|
|
191
|
+
"""
|
|
192
|
+
child_nodes = [cast("ChildNode", child) for child in children.values()]
|
|
193
|
+
|
|
194
|
+
if tags is None:
|
|
195
|
+
tags = {}
|
|
196
|
+
|
|
197
|
+
is_container_tuple = False
|
|
198
|
+
if isinstance(value, tuple) and parent.__class__.__name__ in (
|
|
199
|
+
"Option",
|
|
200
|
+
"Argument",
|
|
201
|
+
):
|
|
202
|
+
parent_cast: click.Option | click.Argument
|
|
203
|
+
if parent.__class__.__name__ == "Option":
|
|
204
|
+
parent_cast = cast(click.Option, parent)
|
|
205
|
+
is_container_tuple = parent_cast.multiple or parent_cast.nargs != 1
|
|
206
|
+
else:
|
|
207
|
+
parent_cast = cast(click.Argument, parent)
|
|
208
|
+
is_container_tuple = parent_cast.nargs != 1
|
|
209
|
+
|
|
210
|
+
for child in child_nodes:
|
|
211
|
+
if click_context is not None:
|
|
212
|
+
Tree.update_scope(
|
|
213
|
+
click_context,
|
|
214
|
+
"child",
|
|
215
|
+
parent_node=(
|
|
216
|
+
cast("ParentNode", parent)
|
|
217
|
+
if parent.__class__.__name__ != "Tag"
|
|
218
|
+
else None
|
|
219
|
+
),
|
|
220
|
+
child_node=child,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
if "click_extended" in click_context.meta:
|
|
224
|
+
click_context.meta["click_extended"]["handler_value"] = value
|
|
225
|
+
click_context.meta["click_extended"][
|
|
226
|
+
"is_container_tuple"
|
|
227
|
+
] = is_container_tuple
|
|
228
|
+
|
|
229
|
+
root_node: "RootNode | None" = None
|
|
230
|
+
all_nodes: dict[str, Any] = {}
|
|
231
|
+
all_parents: dict[str, Any] = {}
|
|
232
|
+
all_tags: dict[str, Any] = {}
|
|
233
|
+
all_children: dict[str, Any] = {}
|
|
234
|
+
all_globals: dict[str, Any] = {}
|
|
235
|
+
meta: dict[str, Any] = {}
|
|
236
|
+
|
|
237
|
+
if click_context is not None and "click_extended" in click_context.meta:
|
|
238
|
+
meta = click_context.meta["click_extended"]
|
|
239
|
+
root_node = meta.get("root_node")
|
|
240
|
+
|
|
241
|
+
if "parents" in meta:
|
|
242
|
+
all_parents = meta["parents"]
|
|
243
|
+
all_nodes.update(all_parents)
|
|
244
|
+
|
|
245
|
+
if "tags" in meta:
|
|
246
|
+
all_tags = meta["tags"]
|
|
247
|
+
all_nodes.update(all_tags)
|
|
248
|
+
|
|
249
|
+
if "children" in meta:
|
|
250
|
+
all_children = meta["children"]
|
|
251
|
+
all_nodes.update(all_children)
|
|
252
|
+
|
|
253
|
+
if "globals" in meta:
|
|
254
|
+
all_globals = meta["globals"]
|
|
255
|
+
all_nodes.update(all_globals)
|
|
256
|
+
|
|
257
|
+
if root_node:
|
|
258
|
+
all_nodes[root_node.name] = root_node
|
|
259
|
+
|
|
260
|
+
context = Context(
|
|
261
|
+
root=cast("RootNode", root_node),
|
|
262
|
+
parent=parent,
|
|
263
|
+
current=child,
|
|
264
|
+
click_context=cast(click.Context, click_context),
|
|
265
|
+
nodes=all_nodes,
|
|
266
|
+
parents=all_parents,
|
|
267
|
+
tags=all_tags,
|
|
268
|
+
children=all_children,
|
|
269
|
+
data=meta.get("data", {}),
|
|
270
|
+
debug=meta.get("debug", False),
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
if isinstance(child, type(ContextAwareError)):
|
|
274
|
+
raise child
|
|
275
|
+
|
|
276
|
+
value = await dispatch_to_child_async(child, value, context)
|
|
277
|
+
|
|
278
|
+
return value # type: ignore
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def check_has_async_handlers(children: Mapping[Any, Any]) -> bool:
|
|
282
|
+
"""
|
|
283
|
+
Check if any child in the collection has async handlers.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
children (Mapping[Any, Any]):
|
|
287
|
+
Mapping of child nodes to check.
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
bool:
|
|
291
|
+
`True` if any child has async handlers, `False` otherwise.
|
|
292
|
+
"""
|
|
293
|
+
child_nodes = [cast("ChildNode", child) for child in children.values()]
|
|
294
|
+
return any(has_async_handlers(child) for child in child_nodes)
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Interactive selection prompt for runtime use."""
|
|
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=too-many-statements
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
import termios
|
|
11
|
+
import tty
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def selection(
|
|
15
|
+
selections: list[str | tuple[str, str]],
|
|
16
|
+
prompt: str = "Select an option",
|
|
17
|
+
multiple: bool = False,
|
|
18
|
+
default: str | list[str] | None = None,
|
|
19
|
+
min_selections: int = 0,
|
|
20
|
+
max_selections: int | None = None,
|
|
21
|
+
cursor_style: str = ">",
|
|
22
|
+
checkbox_style: tuple[str, str] = ("◯", "◉"),
|
|
23
|
+
show_count: bool = False,
|
|
24
|
+
) -> str | list[str]:
|
|
25
|
+
"""
|
|
26
|
+
Interactive selection prompt with arrow key navigation.
|
|
27
|
+
|
|
28
|
+
Creates an interactive terminal prompt that allows users to select one or
|
|
29
|
+
more options using arrow keys (or j/k vim-style keys). The list wraps around
|
|
30
|
+
(carousel behavior) when scrolling past the first or last item.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
selections (list[str | tuple[str, str]]):
|
|
34
|
+
List of options. Each item can be:
|
|
35
|
+
- str: Used as both display text and value
|
|
36
|
+
- tuple[str, str]: (display_text, value)
|
|
37
|
+
prompt (str):
|
|
38
|
+
Text to display above the selection list.
|
|
39
|
+
Defaults to "Select an option".
|
|
40
|
+
multiple (bool):
|
|
41
|
+
If `True`, allows multiple selections with checkboxes.
|
|
42
|
+
If `False`, allows single selection only. Defaults to `False`.
|
|
43
|
+
default (str | list[str] | None):
|
|
44
|
+
Default selection(s). Should be a string for single mode,
|
|
45
|
+
or list of strings for multiple mode. Defaults to `None`.
|
|
46
|
+
min_selections (int):
|
|
47
|
+
Minimum number of selections required (multiple mode only).
|
|
48
|
+
Defaults to 0. User cannot confirm until minimum is met.
|
|
49
|
+
max_selections (int | None):
|
|
50
|
+
Maximum number of selections allowed (multiple mode only).
|
|
51
|
+
Defaults to `None` (unlimited). Prevents selecting more than max.
|
|
52
|
+
cursor_style (str):
|
|
53
|
+
The cursor indicator string. Defaults to ">".
|
|
54
|
+
Examples: ">", "→", "▶", "•"
|
|
55
|
+
checkbox_style (tuple[str, str]):
|
|
56
|
+
Tuple of (unselected, selected) checkbox indicators.
|
|
57
|
+
Defaults to ("◯", "◉").
|
|
58
|
+
Examples: ("☐", "☑"), ("○", "●"), ("[ ]", "[x]")
|
|
59
|
+
show_count (bool):
|
|
60
|
+
Whether to show selection count in the prompt.
|
|
61
|
+
Defaults to `False`. Shows "(X/Y selected)" when enabled.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
str | list[str]:
|
|
65
|
+
Selected value(s). `str` for single mode, `list[str]` for
|
|
66
|
+
multiple mode.
|
|
67
|
+
|
|
68
|
+
Raises:
|
|
69
|
+
ValueError:
|
|
70
|
+
If selections list is empty or invalid.
|
|
71
|
+
RuntimeError:
|
|
72
|
+
If not running in a TTY and no default is provided.
|
|
73
|
+
KeyboardInterrupt:
|
|
74
|
+
If user presses Ctrl+C.
|
|
75
|
+
|
|
76
|
+
Examples:
|
|
77
|
+
>>> from click_extended.interactive import selection
|
|
78
|
+
>>>
|
|
79
|
+
>>> # Simple single selection
|
|
80
|
+
>>> framework = selection(["React", "Vue", "Angular"])
|
|
81
|
+
>>> print(f"Selected: {framework}")
|
|
82
|
+
>>>
|
|
83
|
+
>>> # Multiple selection with constraints
|
|
84
|
+
>>> features = selection(
|
|
85
|
+
... [("TypeScript", "ts"), ("ESLint", "eslint"),
|
|
86
|
+
("Prettier", "prettier")],
|
|
87
|
+
... prompt="Select features",
|
|
88
|
+
... multiple=True,
|
|
89
|
+
... min_selections=1,
|
|
90
|
+
... max_selections=2,
|
|
91
|
+
... default=["eslint"]
|
|
92
|
+
... )
|
|
93
|
+
>>> print(f"Enabled: {features}")
|
|
94
|
+
"""
|
|
95
|
+
if not selections:
|
|
96
|
+
raise ValueError("Selections list cannot be empty")
|
|
97
|
+
|
|
98
|
+
normalized: list[tuple[str, str]] = []
|
|
99
|
+
for item in selections:
|
|
100
|
+
if isinstance(item, tuple):
|
|
101
|
+
if len(item) != 2:
|
|
102
|
+
raise ValueError(
|
|
103
|
+
"Tuple selections must have exactly "
|
|
104
|
+
f"2 elements, got {len(item)}"
|
|
105
|
+
)
|
|
106
|
+
display, value = item
|
|
107
|
+
normalized.append((str(display), str(value)))
|
|
108
|
+
else:
|
|
109
|
+
normalized.append((str(item), str(item)))
|
|
110
|
+
|
|
111
|
+
if multiple:
|
|
112
|
+
if min_selections < 0:
|
|
113
|
+
raise ValueError(
|
|
114
|
+
f"min_selections must be >= 0, got {min_selections}"
|
|
115
|
+
)
|
|
116
|
+
if max_selections is not None:
|
|
117
|
+
if max_selections < 1:
|
|
118
|
+
raise ValueError(
|
|
119
|
+
f"max_selections must be >= 1, got {max_selections}"
|
|
120
|
+
)
|
|
121
|
+
if max_selections < min_selections:
|
|
122
|
+
raise ValueError(
|
|
123
|
+
f"max_selections ({max_selections}) must be >= "
|
|
124
|
+
f"min_selections ({min_selections})"
|
|
125
|
+
)
|
|
126
|
+
if max_selections > len(normalized):
|
|
127
|
+
raise ValueError(
|
|
128
|
+
f"max_selections ({max_selections}) cannot exceed "
|
|
129
|
+
f"number of options ({len(normalized)})"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
if not sys.stdin.isatty():
|
|
133
|
+
if default is not None:
|
|
134
|
+
return default
|
|
135
|
+
raise RuntimeError(
|
|
136
|
+
"Interactive selection requires a TTY. "
|
|
137
|
+
"Please provide a default value."
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
cursor = 0
|
|
141
|
+
selected: set[int] = set()
|
|
142
|
+
|
|
143
|
+
if default is not None:
|
|
144
|
+
value_to_idx = {value: idx for idx, (_, value) in enumerate(normalized)}
|
|
145
|
+
|
|
146
|
+
if multiple and isinstance(default, list):
|
|
147
|
+
for val in default:
|
|
148
|
+
if val in value_to_idx:
|
|
149
|
+
selected.add(value_to_idx[val])
|
|
150
|
+
elif not multiple and isinstance(default, str):
|
|
151
|
+
if default in value_to_idx:
|
|
152
|
+
cursor = value_to_idx[default]
|
|
153
|
+
selected.add(cursor)
|
|
154
|
+
elif multiple and isinstance(default, str):
|
|
155
|
+
if default in value_to_idx:
|
|
156
|
+
selected.add(value_to_idx[default])
|
|
157
|
+
elif not multiple and isinstance(default, list) and len(default) > 0:
|
|
158
|
+
if default[0] in value_to_idx:
|
|
159
|
+
cursor = value_to_idx[default[0]]
|
|
160
|
+
selected.add(cursor)
|
|
161
|
+
|
|
162
|
+
num_options = len(normalized)
|
|
163
|
+
num_lines = 0
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
while True:
|
|
167
|
+
if num_lines > 0:
|
|
168
|
+
for _ in range(num_lines):
|
|
169
|
+
sys.stdout.write("\x1b[1A\x1b[2K")
|
|
170
|
+
sys.stdout.flush()
|
|
171
|
+
|
|
172
|
+
lines: list[str] = []
|
|
173
|
+
title = prompt.strip().rstrip(":")
|
|
174
|
+
|
|
175
|
+
if multiple:
|
|
176
|
+
count = len(selected)
|
|
177
|
+
if max_selections is not None:
|
|
178
|
+
title += f" ({count}/{max_selections} selected"
|
|
179
|
+
if min_selections > 0 and count < min_selections:
|
|
180
|
+
needed = min_selections - count
|
|
181
|
+
title += f", need {needed} more"
|
|
182
|
+
title += ")"
|
|
183
|
+
elif min_selections > 0 and count < min_selections:
|
|
184
|
+
needed = min_selections - count
|
|
185
|
+
title += f" ({count} selected, need {needed} more)"
|
|
186
|
+
elif show_count:
|
|
187
|
+
title += f" ({count} selected)"
|
|
188
|
+
|
|
189
|
+
title += ":"
|
|
190
|
+
lines.append(title)
|
|
191
|
+
|
|
192
|
+
for idx, (display, _) in enumerate(normalized):
|
|
193
|
+
is_cursor = idx == cursor
|
|
194
|
+
is_selected = idx in selected
|
|
195
|
+
|
|
196
|
+
if multiple:
|
|
197
|
+
checkbox = (
|
|
198
|
+
checkbox_style[1] if is_selected else checkbox_style[0]
|
|
199
|
+
)
|
|
200
|
+
prefix = f"{cursor_style} " if is_cursor else " "
|
|
201
|
+
lines.append(f"{prefix}{checkbox} {display}")
|
|
202
|
+
else:
|
|
203
|
+
prefix = f"{cursor_style} " if is_cursor else " "
|
|
204
|
+
lines.append(f"{prefix}{display}")
|
|
205
|
+
|
|
206
|
+
output = "\n".join(lines)
|
|
207
|
+
sys.stdout.write(output + "\n")
|
|
208
|
+
sys.stdout.flush()
|
|
209
|
+
num_lines = len(lines)
|
|
210
|
+
|
|
211
|
+
fd = sys.stdin.fileno()
|
|
212
|
+
old_settings = termios.tcgetattr(fd)
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
tty.setraw(fd)
|
|
216
|
+
ch = sys.stdin.read(1)
|
|
217
|
+
|
|
218
|
+
if ch == "\x1b":
|
|
219
|
+
next_chars = sys.stdin.read(2)
|
|
220
|
+
if next_chars == "[A":
|
|
221
|
+
key = "up"
|
|
222
|
+
elif next_chars == "[B":
|
|
223
|
+
key = "down"
|
|
224
|
+
else:
|
|
225
|
+
key = "other"
|
|
226
|
+
elif ch in ("\r", "\n"):
|
|
227
|
+
key = "enter"
|
|
228
|
+
elif ch == " ":
|
|
229
|
+
key = "space"
|
|
230
|
+
elif ch == "\x03": # Ctrl+C
|
|
231
|
+
key = "ctrl_c"
|
|
232
|
+
elif ch == "k":
|
|
233
|
+
key = "up"
|
|
234
|
+
elif ch == "j":
|
|
235
|
+
key = "down"
|
|
236
|
+
else:
|
|
237
|
+
key = "other"
|
|
238
|
+
finally:
|
|
239
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
240
|
+
|
|
241
|
+
if key == "up":
|
|
242
|
+
cursor = (cursor - 1) % num_options
|
|
243
|
+
elif key == "down":
|
|
244
|
+
cursor = (cursor + 1) % num_options
|
|
245
|
+
elif key == "space" and multiple:
|
|
246
|
+
if cursor in selected:
|
|
247
|
+
selected.remove(cursor)
|
|
248
|
+
else:
|
|
249
|
+
if max_selections is None or len(selected) < max_selections:
|
|
250
|
+
selected.add(cursor)
|
|
251
|
+
elif key == "enter":
|
|
252
|
+
if multiple:
|
|
253
|
+
if len(selected) < min_selections:
|
|
254
|
+
continue
|
|
255
|
+
result = [normalized[idx][1] for idx in sorted(selected)]
|
|
256
|
+
return result
|
|
257
|
+
return normalized[cursor][1]
|
|
258
|
+
elif key == "ctrl_c":
|
|
259
|
+
raise KeyboardInterrupt("Selection cancelled by user")
|
|
260
|
+
|
|
261
|
+
except KeyboardInterrupt:
|
|
262
|
+
if num_lines > 0:
|
|
263
|
+
for _ in range(num_lines):
|
|
264
|
+
sys.stdout.write("\x1b[1A\x1b[2K")
|
|
265
|
+
sys.stdout.write("\x1b[1A")
|
|
266
|
+
sys.stdout.flush()
|
|
267
|
+
raise
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Time, date, and datetime utilities."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def normalize_datetime_format(fmt: str) -> str:
|
|
5
|
+
"""
|
|
6
|
+
Convert simplified format strings to Python strptime format.
|
|
7
|
+
|
|
8
|
+
Supports both Python strptime format (e.g., %Y-%m-%d, %H:%M:%S)
|
|
9
|
+
and simplified format (e.g., YYYY-MM-DD, HH:mm:SS).
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
fmt (str):
|
|
13
|
+
A format string in either Python strptime format or
|
|
14
|
+
simplified format.
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
str:
|
|
18
|
+
The normalized Python strptime format string.
|
|
19
|
+
|
|
20
|
+
Example:
|
|
21
|
+
```python
|
|
22
|
+
>>> normalize_datetime_format("YYYY-MM-DD HH:mm:SS")
|
|
23
|
+
'%Y-%m-%d %H:%M:%S'
|
|
24
|
+
>>> normalize_datetime_format("%Y-%m-%d")
|
|
25
|
+
'%Y-%m-%d'
|
|
26
|
+
```
|
|
27
|
+
"""
|
|
28
|
+
if "%" in fmt:
|
|
29
|
+
return fmt
|
|
30
|
+
|
|
31
|
+
replacements = {
|
|
32
|
+
"YYYY": "%Y",
|
|
33
|
+
"YY": "%y",
|
|
34
|
+
"MM": "%m",
|
|
35
|
+
"DD": "%d",
|
|
36
|
+
"HH": "%H",
|
|
37
|
+
"mm": "%M",
|
|
38
|
+
"SS": "%S",
|
|
39
|
+
"ss": "%S",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
result = fmt
|
|
43
|
+
for simple, strp in replacements.items():
|
|
44
|
+
result = result.replace(simple, strp)
|
|
45
|
+
|
|
46
|
+
return result
|