click-extended 1.0.0__py3-none-any.whl → 1.0.2__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 (148) hide show
  1. click_extended/__init__.py +2 -0
  2. click_extended/core/__init__.py +10 -0
  3. click_extended/core/decorators/__init__.py +21 -0
  4. click_extended/core/decorators/argument.py +227 -0
  5. click_extended/core/decorators/command.py +93 -0
  6. click_extended/core/decorators/context.py +56 -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/utils/__init__.py +13 -0
  133. click_extended/utils/casing.py +169 -0
  134. click_extended/utils/checks.py +48 -0
  135. click_extended/utils/dispatch.py +1016 -0
  136. click_extended/utils/format.py +101 -0
  137. click_extended/utils/humanize.py +209 -0
  138. click_extended/utils/naming.py +238 -0
  139. click_extended/utils/process.py +294 -0
  140. click_extended/utils/selection.py +267 -0
  141. click_extended/utils/time.py +46 -0
  142. {click_extended-1.0.0.dist-info → click_extended-1.0.2.dist-info}/METADATA +2 -1
  143. click_extended-1.0.2.dist-info/RECORD +150 -0
  144. click_extended-1.0.0.dist-info/RECORD +0 -10
  145. {click_extended-1.0.0.dist-info → click_extended-1.0.2.dist-info}/WHEEL +0 -0
  146. {click_extended-1.0.0.dist-info → click_extended-1.0.2.dist-info}/licenses/AUTHORS.md +0 -0
  147. {click_extended-1.0.0.dist-info → click_extended-1.0.2.dist-info}/licenses/LICENSE +0 -0
  148. {click_extended-1.0.0.dist-info → click_extended-1.0.2.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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: click_extended
3
- Version: 1.0.0
3
+ Version: 1.0.2
4
4
  Summary: An extension to Click with additional features like automatic async support, aliasing and a modular decorator system.
5
5
  Author-email: Marcus Fredriksson <marcus@marcusfredriksson.com>
6
6
  License: MIT License
@@ -37,6 +37,7 @@ Classifier: Programming Language :: Python :: 3.10
37
37
  Classifier: Programming Language :: Python :: 3.11
38
38
  Classifier: Programming Language :: Python :: 3.12
39
39
  Classifier: Programming Language :: Python :: 3.13
40
+ Classifier: Programming Language :: Python :: 3.14
40
41
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
41
42
  Classifier: Topic :: System :: Shells
42
43
  Classifier: Topic :: Utilities