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,101 @@
1
+ """Format utility functions."""
2
+
3
+ from typing import Any
4
+
5
+
6
+ def format_list(
7
+ value: list[Any],
8
+ prefix_singular: str | None = None,
9
+ prefix_plural: str | None = None,
10
+ wrap: str | tuple[str, str] | None = None,
11
+ ) -> str:
12
+ """
13
+ Format a list of primitives to a human-readable format.
14
+
15
+ - **Single item**: "x" or "Prefix Singular: x"
16
+ - **Two items**: "x and y" or "Prefix Plural: x and y"
17
+ - **Three+ items**: "x, y and z" or "Prefix Plural: x, y and z"
18
+
19
+ Args:
20
+ value (list[Any]):
21
+ The list of primitives to format. All items must be
22
+ primitives (str, int, float, bool).
23
+ prefix_singular (str, optional):
24
+ A prefix to use if the list only contains a single element.
25
+ If this parameter is set, `prefix_plural` must also be set.
26
+ prefix_plural (str, optional):
27
+ A prefix to use if the list contains zero or multiple elements.
28
+ If this parameter is set, `prefix_singular` must also be set.
29
+ wrap (str | tuple[str, str], optional):
30
+ A string to wrap each element with (applied to both sides),
31
+ or a tuple of (left, right) strings for asymmetric wrapping.
32
+ Returns:
33
+ str:
34
+ The formatted string.
35
+
36
+ Raises:
37
+ ValueError:
38
+ If only one prefix is provided or if the list is empty.
39
+ TypeError:
40
+ If a value in the list is not a primitive.
41
+
42
+ Examples:
43
+ >>> format_list(["str"])
44
+ str
45
+ >>> format_list(["str", "int"])
46
+ str and int
47
+ >>> format_list(["str", "int", "float"])
48
+ str, int and float
49
+ >>> format_list(
50
+ >>> ["str"],
51
+ >>> prefix_singular="Type: ",
52
+ >>> prefix_plural="Types: ",
53
+ >>> )
54
+ Type: str
55
+ >>> format_list(
56
+ >>> ["str", "int"],
57
+ >>> prefix_singular="Type: ",
58
+ >>> prefix_plural="Types: ",
59
+ >>> )
60
+ Types: str and int
61
+ >>> format_list(["Alice", "Bob", "Charlie"], wrap="'")
62
+ 'Alice', 'Bob' and 'Charlie'
63
+ >>> format_list(["str", "int"], wrap=("<", ">"))
64
+ <str> and <int>
65
+ """
66
+ if (prefix_singular is None) != (prefix_plural is None):
67
+ raise ValueError(
68
+ "Both prefix_singular and prefix_plural must be provided together, "
69
+ "or neither should be provided."
70
+ )
71
+
72
+ if not value:
73
+ raise ValueError("Cannot format an empty list.")
74
+
75
+ for item in value:
76
+ if not isinstance(item, (str, int, float, bool)):
77
+ raise TypeError(
78
+ f"All items must be primitives (str, int, float, bool). "
79
+ f"Got {type(item).__name__}: {item!r}"
80
+ )
81
+
82
+ str_items = [str(item) for item in value]
83
+
84
+ if wrap is not None:
85
+ if isinstance(wrap, tuple):
86
+ left, right = wrap
87
+ str_items = [f"{left}{item}{right}" for item in str_items]
88
+ else:
89
+ str_items = [f"{wrap}{item}{wrap}" for item in str_items]
90
+
91
+ if len(str_items) == 1:
92
+ formatted = str_items[0]
93
+ prefix = prefix_singular if prefix_singular is not None else ""
94
+ elif len(str_items) == 2:
95
+ formatted = f"{str_items[0]} and {str_items[1]}"
96
+ prefix = prefix_plural if prefix_plural is not None else ""
97
+ else:
98
+ formatted = ", ".join(str_items[:-1]) + f" and {str_items[-1]}"
99
+ prefix = prefix_plural if prefix_plural is not None else ""
100
+
101
+ return prefix + formatted
@@ -0,0 +1,209 @@
1
+ """Format utility functions."""
2
+
3
+ # pylint: disable=too-many-return-statements
4
+ # pylint: disable=too-many-arguments
5
+ # pylint: disable=too-many-positional-arguments
6
+
7
+ from types import UnionType
8
+ from typing import Any, Iterable, cast, get_args, get_origin
9
+
10
+
11
+ def humanize_iterable(
12
+ value: Iterable[str | int | float | bool],
13
+ prefix_singular: str | None = None,
14
+ prefix_plural: str | None = None,
15
+ suffix_singular: str | None = None,
16
+ suffix_plural: str | None = None,
17
+ wrap: str | tuple[str, str] | None = None,
18
+ sep: str = "and",
19
+ ) -> str:
20
+ """
21
+ Format an iterable of primitives to a human-readable format.
22
+
23
+ - **Single item**: "x"
24
+ - **Two items**: "x and y"
25
+ - **Three+ items**: "x, y and z"
26
+
27
+ Args:
28
+ value (Iterable[str | int | float | bool]):
29
+ The iterable of primitives to format. All items must be
30
+ primitives (str, int, float, bool).
31
+ prefix_singular (str, optional):
32
+ A prefix to use if the list only contains a single element.
33
+ If this parameter is set, `prefix_plural` must also be set.
34
+ prefix_plural (str, optional):
35
+ A prefix to use if the list contains zero or multiple elements.
36
+ If this parameter is set, `prefix_singular` must also be set.
37
+ suffix_singular (str, optional):
38
+ A suffix to use if the list only contains a single element.
39
+ If this parameter is set, `suffix_plural` must also be set.
40
+ suffix_plural (str, optional):
41
+ A suffix to use if the list contains zero or multiple elements.
42
+ If this parameter is set, `prefix_singular` must also be set.
43
+ wrap (str | tuple[str, str], optional):
44
+ A string to wrap each element with (applied to both sides),
45
+ or a tuple of (left, right) strings for asymmetric wrapping.
46
+ sep (str, optional):
47
+ The sep when the length is greater than 2 (X, Y <sep> Z).
48
+ Returns:
49
+ str:
50
+ The formatted string.
51
+
52
+ Raises:
53
+ ValueError:
54
+ If only one prefix is provided or if the list is empty.
55
+ TypeError:
56
+ If a value in the list is not a primitive.
57
+
58
+ Examples:
59
+ >>> humanize_iterable(["str"])
60
+ str
61
+ >>> humanize_iterable(["str", "int"])
62
+ str and int
63
+ >>> humanize_iterable(["str", "int", "float"])
64
+ str, int and float
65
+ >>> humanize_iterable(
66
+ >>> ["str"],
67
+ >>> prefix_singular="Type: ",
68
+ >>> prefix_plural="Types: ",
69
+ >>> )
70
+ Type: str
71
+ >>> humanize_iterable(
72
+ >>> ["str", "int"],
73
+ >>> prefix_singular="Type: ",
74
+ >>> prefix_plural="Types: ",
75
+ >>> )
76
+ Types: str and int
77
+ >>> humanize_iterable(["Alice", "Bob", "Charlie"], wrap="'")
78
+ 'Alice', 'Bob' and 'Charlie'
79
+ >>> humanize_iterable(["str", "int"], wrap=("<", ">"))
80
+ <str> and <int>
81
+ """
82
+ if (prefix_singular is None) != (prefix_plural is None):
83
+ raise ValueError(
84
+ "Both prefix_singular and prefix_plural must be provided together, "
85
+ "or neither should be provided."
86
+ )
87
+
88
+ if (suffix_singular is None) != (suffix_plural is None):
89
+ raise ValueError(
90
+ "Both suffix_singular and suffix_plural must be provided together, "
91
+ "or neither should be provided."
92
+ )
93
+
94
+ if not value:
95
+ raise ValueError("Cannot format an empty iterable.")
96
+
97
+ for item in value:
98
+ if not isinstance(item, (str, int, float, bool)):
99
+ raise TypeError(
100
+ f"All items must be primitives (str, int, float, bool). "
101
+ f"Got {type(item).__name__}: {item!r}"
102
+ )
103
+
104
+ str_items = [str(item) for item in value]
105
+
106
+ if wrap is not None:
107
+ if isinstance(wrap, tuple):
108
+ left, right = wrap
109
+ str_items = [f"{left}{item}{right}" for item in str_items]
110
+ else:
111
+ str_items = [f"{wrap}{item}{wrap}" for item in str_items]
112
+
113
+ if len(str_items) == 1:
114
+ formatted = str_items[0]
115
+ prefix = prefix_singular if prefix_singular is not None else ""
116
+ suffix = suffix_singular if suffix_singular is not None else ""
117
+ elif len(str_items) == 2:
118
+ formatted = f"{str_items[0]} {sep} {str_items[1]}"
119
+ prefix = prefix_plural if prefix_plural is not None else ""
120
+ suffix = suffix_plural if suffix_plural is not None else ""
121
+ else:
122
+ formatted = ", ".join(str_items[:-1]) + f" {sep} {str_items[-1]}"
123
+ prefix = prefix_plural if prefix_plural is not None else ""
124
+ suffix = suffix_plural if suffix_plural is not None else ""
125
+
126
+ return str(prefix.strip() + " " + formatted + " " + suffix.strip()).strip()
127
+
128
+
129
+ def humanize_type(value: Any) -> str:
130
+ """
131
+ Humanize the output of a `type` instance in the same format as
132
+ `humanize_iterable`.
133
+
134
+ Args:
135
+ value (type):
136
+ The type to humanize. This can be simple types like `str` and more
137
+ complex types like `typing.Union` or `types.UnionType`.
138
+
139
+ Returns:
140
+ str:
141
+ The formatted string.
142
+
143
+ Examples:
144
+ >>> humanize_type(str)
145
+ str
146
+ >>> humanize_type(int | float)
147
+ int and float
148
+ >>> humanize_type(int | float | bool)
149
+ int, float and bool
150
+ >>> humanize_type(list[int | float])
151
+ list[int | float]
152
+ >>> humanize_type(tuple[str] | list[str])
153
+ tuple[str] and list[str]
154
+ >>> humanize_type(str | list[list[str]])
155
+ str and list[list[str]]
156
+ """
157
+
158
+ def format_type(t: Any, inside_generic: bool = False) -> str:
159
+ """
160
+ Format a type into a string.
161
+
162
+ Args:
163
+ t (Any):
164
+ The type to format
165
+ inside_generic (bool):
166
+ If `True`, unions use `|` instead of `humanize_iterable`
167
+ """
168
+ if t is type(None):
169
+ return "None"
170
+
171
+ origin = get_origin(t)
172
+ args = get_args(t)
173
+
174
+ is_union = origin is UnionType or str(origin).startswith("typing.Union")
175
+ if is_union:
176
+ members: list[str] = []
177
+ for arg in args:
178
+ members.append(format_type(arg, inside_generic=True))
179
+
180
+ if inside_generic:
181
+ return " | ".join(members)
182
+
183
+ return humanize_iterable(members)
184
+
185
+ if origin is not None and args:
186
+ origin_name = getattr(origin, "__name__", str(origin))
187
+
188
+ if args[-1] is Ellipsis:
189
+ inner_types: list[str] = []
190
+ for arg in args[:-1]:
191
+ inner_types.append(format_type(arg, inside_generic=True))
192
+ inner = ", ".join(inner_types)
193
+ return f"{origin_name}[{inner}, ...]"
194
+
195
+ inner_types = []
196
+ for arg in args:
197
+ inner_types.append(format_type(arg, inside_generic=True))
198
+ inner = ", ".join(inner_types)
199
+ return f"{origin_name}[{inner}]"
200
+
201
+ if hasattr(t, "__name__"):
202
+ return cast(str, t.__name__)
203
+
204
+ return str(t)
205
+
206
+ return format_type(value, inside_generic=False)
207
+
208
+
209
+ __all__ = ["humanize_iterable", "humanize_type"]
@@ -0,0 +1,238 @@
1
+ """Utilities for validating and parsing naming conventions."""
2
+
3
+ import re
4
+ import sys
5
+
6
+ import click
7
+
8
+ from click_extended.utils.humanize import humanize_iterable
9
+
10
+ SNAKE_CASE_PATTERN = re.compile(r"^[a-z][a-z0-9]*(_[a-z0-9]+)*$")
11
+ SCREAMING_SNAKE_CASE_PATTERN = re.compile(r"^[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$")
12
+ KEBAB_CASE_PATTERN = re.compile(r"^[a-z][a-z0-9]*(-[a-z0-9]+)*$")
13
+
14
+ LONG_FLAG_PATTERN = re.compile(r"^--[a-z][a-z0-9-]*$")
15
+ SHORT_FLAG_PATTERN = re.compile(r"^-[a-zA-Z]$")
16
+
17
+
18
+ def is_valid_name(name: str) -> bool:
19
+ """
20
+ Check if a name is a valid name, in this case only `snake_case` formats
21
+ are considered valid.
22
+
23
+ Args:
24
+ name (str):
25
+ The name to validate.
26
+
27
+ Returns:
28
+ bool:
29
+ `True` if the name is valid, `False` otherwise.
30
+ """
31
+ return bool(SNAKE_CASE_PATTERN.match(name))
32
+
33
+
34
+ def is_long_flag(value: str) -> bool:
35
+ """
36
+ Check if a value is a long flag (e.g. --option).
37
+
38
+ Args:
39
+ value (str):
40
+ The value to check.
41
+
42
+ Returns:
43
+ `True` if the value is a long flag, `False` otherwise.
44
+ """
45
+ return bool(LONG_FLAG_PATTERN.match(value))
46
+
47
+
48
+ def is_short_flag(value: str) -> bool:
49
+ """
50
+ Check if a value is a short flag (e.g. -o).
51
+
52
+ Args:
53
+ value (str):
54
+ The value to check.
55
+
56
+ Returns:
57
+ `True` if the value is a short flag, `False` otherwise.
58
+ """
59
+ return bool(SHORT_FLAG_PATTERN.match(value))
60
+
61
+
62
+ def validate_name(name: str, context: str = "name") -> None:
63
+ """
64
+ Validate a name follows naming conventions.
65
+
66
+ Args:
67
+ name (str):
68
+ The name to validate.
69
+ context (Context):
70
+ Context string for error messages
71
+ (e.g. "option name", "argument name").
72
+
73
+ Raises:
74
+ ValueError:
75
+ If the name doesn't follow valid conventions.
76
+ """
77
+ if not is_valid_name(name):
78
+
79
+ def _exit_program(message: str, tip: str) -> None:
80
+ click.echo(f"InvalidNameError: {message}", err=True)
81
+ click.echo(f"Tip: {tip}", err=True)
82
+ sys.exit(1)
83
+
84
+ # Empty name
85
+ if not name:
86
+ _exit_program(
87
+ "The name cannot be empty.",
88
+ "Provide a valid snake_case name (e.g. name, my_name)",
89
+ )
90
+
91
+ # Starts with number
92
+ if re.match(r"^\d", name):
93
+ _exit_program(
94
+ f"The name '{name}' cannot start with a number.",
95
+ f"Try with a letter prefix (e.g. 'var_{name}' or 'opt_{name}')",
96
+ )
97
+
98
+ # Starts with underscore
99
+ if name[0] == "_":
100
+ _exit_program(
101
+ f"The name '{name}' cannot start with an underscore.",
102
+ f"Try '{name[1:]}' without the leading underscore",
103
+ )
104
+
105
+ # Contains uppercase
106
+ if re.search(r"[A-Z]", name):
107
+ suggested = re.sub(r"(?<!^)(?=[A-Z])", "_", name).lower()
108
+ _exit_program(
109
+ f"The name '{name}' contains uppercase letters.",
110
+ f"Only use lowercase characters such as '{suggested}'",
111
+ )
112
+
113
+ # Contains hyphens
114
+ if "-" in name:
115
+ _exit_program(
116
+ f"The name '{name}' contains hyphens.",
117
+ f"Use underscores instead like '{name.replace('-', '_')}'",
118
+ )
119
+
120
+ # Contains spaces
121
+ if re.search(r"\s", name):
122
+ suggested_name = re.sub(r"\s+", "_", name).lower()
123
+ _exit_program(
124
+ f"The name '{name}' contains whitespace.",
125
+ f"Use underscores instead like '{suggested_name}'",
126
+ )
127
+
128
+ # Contains consecutive underscores
129
+ if re.search(r"__+", name):
130
+ _exit_program(
131
+ f"The name '{name}' contains consecutive underscores.",
132
+ f"Use single underscores like '{re.sub(r'__+', '_', name)}'",
133
+ )
134
+
135
+ # Contains invalid characters
136
+ invalid_match = re.search(r"[^a-z0-9_]", name)
137
+ if invalid_match:
138
+ invalid_chars = set(re.findall(r"[^a-z0-9_]", name))
139
+ contains_chars = humanize_iterable(
140
+ invalid_chars,
141
+ wrap="'",
142
+ prefix_singular="an invalid character",
143
+ prefix_plural="invalid characters",
144
+ )
145
+ remove_chars = humanize_iterable(
146
+ invalid_chars,
147
+ wrap="'",
148
+ prefix_singular="the character",
149
+ prefix_plural="the characters",
150
+ )
151
+ _exit_program(
152
+ f"The name '{name}' is invalid and contains {contains_chars}",
153
+ f"Remove {remove_chars}",
154
+ )
155
+
156
+ # General fallback
157
+ _exit_program(
158
+ f"The name '{name}' is invalid.",
159
+ "Names must be in snake_case (e.g. name, my_name, my_name1)",
160
+ )
161
+
162
+
163
+ def parse_option_args(*args: str) -> tuple[str | None, str | None, str | None]:
164
+ """
165
+ Parse option decorator arguments intelligently.
166
+
167
+ Supports three forms:
168
+
169
+ 1. **@option("my_option")**: name only
170
+ 2. **@option("--my-option")**: long flag (derives name)
171
+ 3. **@option("-m", "--my-option")**: short and long flags
172
+
173
+ Args:
174
+ *args (str):
175
+ Positional arguments passed to `@option` decorator.
176
+
177
+ Returns:
178
+ Tuple of (name, short, long):
179
+
180
+ - **name**: The parameter name (`None` if not provided)
181
+ - **short**: The short flag (`None` if not provided)
182
+ - **long**: The long flag (`None` if not provided)
183
+
184
+ Raises:
185
+ ValueError:
186
+ If arguments are invalid or ambiguous.
187
+ """
188
+
189
+ if len(args) == 0:
190
+ return None, None, None
191
+
192
+ if len(args) == 1:
193
+ arg = args[0]
194
+
195
+ # @option("--my-option")
196
+ if is_long_flag(arg):
197
+ return None, None, arg
198
+
199
+ # @option("-m")
200
+ if is_short_flag(arg):
201
+ raise ValueError(
202
+ f"Short flag '{arg}' provided without long flag or name. "
203
+ f"Use one of:\n"
204
+ f" @option('{arg}', '--flag') # with long flag\n"
205
+ f" @option('name', short='{arg}') # with name parameter"
206
+ )
207
+
208
+ # @option("my_option")
209
+ validate_name(arg, "option name")
210
+ return arg, None, None
211
+
212
+ if len(args) == 2:
213
+ first, second = args
214
+
215
+ # @option("-m", "--my-option")
216
+ if is_short_flag(first) and is_long_flag(second):
217
+ return None, first, second
218
+
219
+ # @option("--my-option", "-m")
220
+ if is_long_flag(first) and is_short_flag(second):
221
+ raise ValueError(
222
+ "Invalid argument order. "
223
+ "Short flag must come before long flag:\n"
224
+ f" Use: @option('{second}', '{first}')"
225
+ )
226
+
227
+ raise ValueError(
228
+ f"Invalid arguments: '{first}', '{second}'. "
229
+ f"Expected one of:\n"
230
+ f" @option('name') # name only\n"
231
+ f" @option('--flag') # long flag only\n"
232
+ f" @option('-f', '--flag') # short and long flags"
233
+ )
234
+
235
+ raise ValueError(
236
+ f"Too many positional arguments ({len(args)}). "
237
+ f"Maximum is 2 (short and long flags)."
238
+ )