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,149 @@
1
+ """Conflicts decorator for enforcing parameter conflicts."""
2
+
3
+ from typing import Any
4
+
5
+ from click_extended.core.decorators.tag import Tag
6
+ from click_extended.core.nodes.child_node import ChildNode
7
+ from click_extended.core.nodes.parent_node import ParentNode
8
+ from click_extended.core.other.context import Context
9
+ from click_extended.types import Decorator
10
+ from click_extended.utils.humanize import humanize_iterable
11
+
12
+
13
+ class Conflicts(ChildNode):
14
+ """Child node to enforce that conflicting parameters are not provided."""
15
+
16
+ def handle_all(
17
+ self, value: Any, context: Context, *args: Any, **kwargs: Any
18
+ ) -> Any:
19
+ parent = context.parent
20
+ if parent is None or not isinstance(parent, ParentNode):
21
+ return value
22
+
23
+ if not parent.was_provided:
24
+ return value
25
+
26
+ if conflicting := self._get_conflicting_params(context, args):
27
+ parent_display = parent.get_display_name()
28
+ conflicting_display = humanize_iterable(
29
+ [self._get_display_name(name, context) for name in conflicting],
30
+ wrap="'",
31
+ )
32
+
33
+ raise ValueError(
34
+ f"'{parent_display}' conflicts with {conflicting_display}. "
35
+ f"They cannot be used together."
36
+ )
37
+
38
+ return value
39
+
40
+ def handle_tag(
41
+ self, value: dict[str, Any], context: Context, *args: Any, **kwargs: Any
42
+ ) -> None:
43
+ tag = context.parent
44
+ if tag is None or not isinstance(tag, Tag):
45
+ return
46
+
47
+ tagged_parents = getattr(tag, "parent_nodes", [])
48
+ if not tagged_parents:
49
+ return
50
+
51
+ for parent in tagged_parents:
52
+ if not parent.was_provided:
53
+ continue
54
+
55
+ if conflicting := self._get_conflicting_params(context, args):
56
+ parent_display = parent.get_display_name()
57
+ conflicting_display = humanize_iterable(
58
+ [
59
+ self._get_display_name(name, context)
60
+ for name in conflicting
61
+ ],
62
+ wrap="'",
63
+ )
64
+
65
+ raise ValueError(
66
+ f"'{parent_display}' conflicts with {conflicting_display}. "
67
+ f"They cannot be used together."
68
+ )
69
+
70
+ def _get_conflicting_params(
71
+ self, context: Context, names: tuple[str, ...]
72
+ ) -> list[str]:
73
+ conflicting: list[str] = []
74
+
75
+ for param_name in names:
76
+ parent = context.get_parent(param_name)
77
+ if parent is not None and parent.was_provided:
78
+ conflicting.append(param_name)
79
+
80
+ return conflicting
81
+
82
+ def _get_display_name(self, param_name: str, context: Context) -> str:
83
+ parent = context.get_parent(param_name)
84
+ if parent is not None:
85
+ return parent.get_display_name()
86
+ return param_name
87
+
88
+
89
+ def conflicts(*names: str) -> Decorator:
90
+ """
91
+ Enforce that parameters conflict with each other.
92
+
93
+ This decorator ensures that when the decorated parameter is provided,
94
+ none of the specified conflicting parameters can be provided.
95
+
96
+ Type: `ChildNode`
97
+
98
+ Supports: `any`, `tag`
99
+
100
+ Args:
101
+ *names (str):
102
+ Names of parameters that conflict with the decorated parameter.
103
+
104
+ Returns:
105
+ Decorator:
106
+ A decorator function that registers the conflicts validation.
107
+
108
+ Raises:
109
+ ValueError:
110
+ If the decorated parameter and any conflicting parameter
111
+ are both provided at runtime.
112
+
113
+ Examples:
114
+ ```python
115
+ from click_extended import option, conflicts
116
+
117
+ @option("--username")
118
+ @conflicts("api_key")
119
+ @option("--api-key")
120
+ def login(username, api_key):
121
+ '''Login using username or API key, but not both.'''
122
+ pass
123
+ ```
124
+
125
+ Multiple conflicts:
126
+ ```python
127
+ @option("--verbose", "-v")
128
+ @conflicts("quiet", "silent")
129
+ @option("--quiet", "-q")
130
+ @option("--silent", "-s")
131
+ def cmd(verbose, quiet, silent):
132
+ '''Verbose mode conflicts with quiet and silent modes.'''
133
+ pass
134
+ ```
135
+
136
+ Works with tags:
137
+ ```python
138
+ @option("--basic-auth")
139
+ @conflicts("oauth") # All params tagged "oauth"
140
+ @option("--oauth-token")
141
+ @tag("oauth")
142
+ def authenticate(basic_auth, oauth_token):
143
+ pass
144
+ ```
145
+ """
146
+ return Conflicts.as_decorator(*names)
147
+
148
+
149
+ __all__ = ["conflicts", "Conflicts"]
@@ -0,0 +1,69 @@
1
+ """Check if the string contains the specified text."""
2
+
3
+ # pylint: disable=redefined-builtin
4
+
5
+ from typing import Any
6
+
7
+ from click_extended.core.nodes.child_node import ChildNode
8
+ from click_extended.core.other.context import Context
9
+ from click_extended.types import Decorator
10
+ from click_extended.utils.humanize import humanize_iterable
11
+
12
+
13
+ class Contains(ChildNode):
14
+ """Check if the string contains the specified text."""
15
+
16
+ def handle_str(
17
+ self,
18
+ value: str,
19
+ context: Context,
20
+ *args: Any,
21
+ **kwargs: Any,
22
+ ) -> str:
23
+ require_all = kwargs.get("all", False)
24
+ matches = [t in value for t in args]
25
+
26
+ if require_all:
27
+ if not all(matches):
28
+ missing = [t for t, m in zip(args, matches) if not m]
29
+ humanized = humanize_iterable(
30
+ missing,
31
+ wrap="'",
32
+ prefix_singular="contain the required substring",
33
+ prefix_plural="contain all the required substrings",
34
+ )
35
+ raise ValueError(f"Value '{value}' does not {humanized}")
36
+ else:
37
+ if not any(matches):
38
+ humanized = humanize_iterable(
39
+ args,
40
+ wrap="'",
41
+ prefix_singular="contain the required substring",
42
+ prefix_plural="contain any of the required substrings",
43
+ )
44
+ raise ValueError(f"Value '{value}' does not {humanized}")
45
+
46
+ return value
47
+
48
+
49
+ def contains(*text: str, all: bool = False) -> Decorator:
50
+ """
51
+ Check if the string contains the specified text.
52
+
53
+ Type: `ChildNode`
54
+
55
+ Supports: `str`
56
+
57
+ Args:
58
+ *text (str):
59
+ The substrings to check for.
60
+ all (bool):
61
+ If `True`, all substrings must be present.
62
+ If `False`, at least one substring must be present.
63
+ Defaults to `False`.
64
+
65
+ Returns:
66
+ Decorator:
67
+ The decorated function.
68
+ """
69
+ return Contains.as_decorator(*text, all=all)
@@ -0,0 +1,115 @@
1
+ """Mutual dependency validation decorator."""
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ from click_extended.core.nodes.validation_node import ValidationNode
6
+ from click_extended.core.other.context import Context
7
+ from click_extended.types import Decorator
8
+ from click_extended.utils.humanize import humanize_iterable
9
+
10
+ if TYPE_CHECKING:
11
+ from click_extended.core.nodes.parent_node import ParentNode
12
+
13
+
14
+ class Dependencies(ValidationNode):
15
+ """
16
+ Validation node that ensures mutual dependencies between parameters.
17
+
18
+ If any parameter in a dependency group is provided, all parameters
19
+ in that group must be provided.
20
+
21
+ Examples:
22
+ # Single dependency group
23
+ @dependencies("username", "password")
24
+ def login(username, password): ...
25
+
26
+ # Multiple dependency groups
27
+ @dependencies("username", "password")
28
+ @dependencies("api_key", "api_secret")
29
+ def auth(username, password, api_key, api_secret): ...
30
+
31
+ # Works with tags too
32
+ @dependencies("credentials") # All params tagged "credentials"
33
+ def login(**kwargs): ...
34
+ """
35
+
36
+ def on_finalize(self, context: Context, *args: Any, **kwargs: Any) -> None:
37
+ """
38
+ Validate mutual dependencies at finalization.
39
+
40
+ Args:
41
+ context: The execution context
42
+ *args: Additional positional arguments
43
+ **kwargs: Additional keyword arguments
44
+
45
+ Raises:
46
+ ProcessError: If some but not all dependencies are provided
47
+ """
48
+ provided_params: list[str] = []
49
+ missing_params: list[str] = []
50
+ names = args
51
+ parent: "ParentNode | None"
52
+
53
+ for name in names:
54
+ tag = context.get_tag(name)
55
+ if tag is not None:
56
+ for parent in tag.parent_nodes:
57
+ if parent.was_provided:
58
+ provided_params.append(parent.get_display_name())
59
+ else:
60
+ missing_params.append(parent.get_display_name())
61
+ else:
62
+ if (parent := context.get_parent(name)) is not None:
63
+ if parent.was_provided:
64
+ provided_params.append(parent.get_display_name())
65
+ else:
66
+ missing_params.append(parent.get_display_name())
67
+
68
+ if provided_params and missing_params:
69
+ provided_str = humanize_iterable(provided_params, wrap="'")
70
+ missing_str = humanize_iterable(
71
+ missing_params,
72
+ wrap="'",
73
+ prefix_singular="requires",
74
+ prefix_plural="require",
75
+ )
76
+
77
+ raise ValueError(f"{provided_str} {missing_str} to be provided.")
78
+
79
+
80
+ def dependencies(*names: str) -> Decorator:
81
+ """
82
+ Decorator that validates mutual dependencies between parameters.
83
+
84
+ If any parameter in the dependency group is provided, all must be provided.
85
+
86
+ Type: `ValidationNode`
87
+
88
+ Args:
89
+ *names: Parameter names or tag names that are mutually dependent
90
+
91
+ Returns:
92
+ Decorator function
93
+
94
+ Examples:
95
+ @command()
96
+ @option("--username")
97
+ @option("--password")
98
+ @dependencies("username", "password")
99
+ def login(username, password):
100
+ # If username is provided, password must also be provided
101
+ # If password is provided, username must also be provided
102
+ pass
103
+
104
+ @command()
105
+ @option("--api-key")
106
+ @option("--api-secret")
107
+ @option("--username")
108
+ @option("--password")
109
+ @dependencies("api_key", "api_secret")
110
+ @dependencies("username", "password")
111
+ def auth(**kwargs):
112
+ # Two separate dependency groups
113
+ pass
114
+ """
115
+ return Dependencies.as_decorator(*names)
@@ -0,0 +1,48 @@
1
+ """Check if a value is divisible by a number."""
2
+
3
+ from decimal import Decimal, getcontext
4
+ from typing import Any
5
+
6
+ from click_extended.core.nodes.child_node import ChildNode
7
+ from click_extended.core.other.context import Context
8
+ from click_extended.types import Decorator
9
+
10
+ getcontext().prec = 35
11
+
12
+
13
+ class DivisibleBy(ChildNode):
14
+ """Check if a value is divisible by a number."""
15
+
16
+ def handle_numeric(
17
+ self,
18
+ value: int | float,
19
+ context: Context,
20
+ *args: Any,
21
+ **kwargs: Any,
22
+ ) -> int | float:
23
+ n = kwargs["n"]
24
+ val = Decimal(str(value))
25
+ div = Decimal(str(n))
26
+
27
+ if val % div != 0:
28
+ raise ValueError(f"Value '{value}' is not divisible by '{n}'.")
29
+
30
+ return value
31
+
32
+
33
+ def divisible_by(n: int | float) -> Decorator:
34
+ """
35
+ Check if a value is divisible by a number.
36
+
37
+ Type: `ChildNode`
38
+
39
+ Supports: `int`, `float`
40
+
41
+ Args:
42
+ n (int | float): The number to check divisibility against.
43
+
44
+ Returns:
45
+ Decorator:
46
+ The decorated function.
47
+ """
48
+ return DivisibleBy.as_decorator(n=n)
@@ -0,0 +1,85 @@
1
+ """Child decorator to check if a string ends with one or more substrings."""
2
+
3
+ import fnmatch
4
+ import re
5
+ from typing import Any
6
+
7
+ from click_extended.core.nodes.child_node import ChildNode
8
+ from click_extended.core.other.context import Context
9
+ from click_extended.types import Decorator
10
+ from click_extended.utils.humanize import humanize_iterable
11
+
12
+
13
+ class EndsWith(ChildNode):
14
+ """Child decorator to check if a string ends with one or more substrings."""
15
+
16
+ def handle_str(
17
+ self, value: str, context: Context, *args: Any, **kwargs: Any
18
+ ) -> Any:
19
+ patterns: tuple[str | re.Pattern[str], ...] = kwargs.get("text", ())
20
+ matches: list[str | re.Pattern[str]] = []
21
+
22
+ for pattern in patterns:
23
+ if isinstance(pattern, re.Pattern):
24
+ if pattern.search(value):
25
+ matches.append(pattern)
26
+ elif isinstance(pattern, str) and any(
27
+ char in pattern for char in ["*", "?", "[", "]"]
28
+ ):
29
+ if fnmatch.fnmatch(
30
+ value,
31
+ f"*{pattern}" if not pattern.startswith("*") else pattern,
32
+ ):
33
+ matches.append(pattern)
34
+ elif isinstance(pattern, str):
35
+ if value.endswith(pattern):
36
+ matches.append(pattern)
37
+
38
+ if not matches:
39
+ pattern_strs: list[str] = [
40
+ p.pattern if isinstance(p, re.Pattern) else p for p in patterns
41
+ ]
42
+ pattern_list = humanize_iterable(
43
+ pattern_strs,
44
+ wrap="'",
45
+ sep="or",
46
+ prefix_singular="the pattern",
47
+ prefix_plural="one of the patterns",
48
+ )
49
+ raise ValueError(f"Value must end with {pattern_list}")
50
+
51
+ return value
52
+
53
+
54
+ def ends_with(*text: str | re.Pattern[str]) -> Decorator:
55
+ """
56
+ Check if a string ends with one or more substrings or patterns.
57
+
58
+ Type: `ChildNode`
59
+
60
+ Supports: `str`
61
+
62
+ Args:
63
+ *text (str | re.Pattern[str]):
64
+ Patterns to check for.
65
+
66
+ Returns:
67
+ Decorator:
68
+ The decorated function.
69
+
70
+ Examples:
71
+ ```python
72
+ # Exact suffix
73
+ @ends_with(".com", ".org")
74
+
75
+ # Glob pattern
76
+ @ends_with("*_test", "*_prod")
77
+
78
+ # Regex matching
79
+ @ends_with(re.compile(r"\\.(com|org|net)$"))
80
+
81
+ # Mixed patterns
82
+ @ends_with(".txt", re.compile(r"\\.\\d+$"), "*.log")
83
+ ```
84
+ """
85
+ return EndsWith.as_decorator(text=text)
@@ -0,0 +1,75 @@
1
+ """Exclusive group decorator for validating mutual exclusivity."""
2
+
3
+ from typing import Any
4
+
5
+ from click_extended.core.nodes.validation_node import ValidationNode
6
+ from click_extended.core.other.context import Context
7
+ from click_extended.types import Decorator
8
+ from click_extended.utils.humanize import humanize_iterable
9
+
10
+
11
+ class ExclusiveGroup(ValidationNode):
12
+ """Validation node to enforce mutual exclusivity between parameters."""
13
+
14
+ def on_finalize(self, context: Context, *args: Any, **kwargs: Any) -> None:
15
+ names = args if args else kwargs.get("params", ())
16
+
17
+ if not names:
18
+ return
19
+
20
+ provided: list[str] = []
21
+ for param_name in names:
22
+ parent = context.get_parent(param_name)
23
+ if parent is not None and parent.was_provided:
24
+ provided.append(param_name)
25
+
26
+ if len(provided) > 1:
27
+ humanized = humanize_iterable(provided, wrap="'")
28
+ raise ValueError(
29
+ f"The parameters {humanized} are mutually exclusive. "
30
+ f"Only one can be provided, but {len(provided)} were given."
31
+ )
32
+
33
+
34
+ def exclusive(*names: str) -> Decorator:
35
+ """
36
+ Enforce mutual exclusivity between multiple parameters.
37
+
38
+ Type: `ValidationNode`
39
+
40
+ Args:
41
+ *names (str):
42
+ Names of parameters that should be mutually exclusive.
43
+
44
+ Returns:
45
+ Decorator:
46
+ A decorator function that registers the exclusive group validation.
47
+
48
+ Raises:
49
+ ValueError:
50
+ If more than one parameter from the group is provided at runtime.
51
+
52
+ Examples:
53
+ ```python
54
+ @command()
55
+ @exclusive_group("json", "xml", "yaml")
56
+ @option("--json", is_flag=True)
57
+ @option("--xml", is_flag=True)
58
+ @option("--yaml", is_flag=True)
59
+ def my_command(json: bool, xml: bool, yaml: bool):
60
+ # Only one format can be specified
61
+ pass
62
+ ```
63
+
64
+ ```python
65
+ @command()
66
+ @exclusive_group("p", "q", "r")
67
+ @random_prime("p")
68
+ @random_prime("q")
69
+ @random_prime("r")
70
+ def my_command(p: int, q: int, r: int):
71
+ # Only one prime can be provided
72
+ pass
73
+ ```
74
+ """
75
+ return ExclusiveGroup.as_decorator(*names)
@@ -0,0 +1,37 @@
1
+ """Check if a value is falsy."""
2
+
3
+ from typing import Any
4
+
5
+ from click_extended.core.nodes.child_node import ChildNode
6
+ from click_extended.core.other.context import Context
7
+ from click_extended.types import Decorator
8
+
9
+
10
+ class Falsy(ChildNode):
11
+ """Check if a value is falsy."""
12
+
13
+ def handle_all(
14
+ self,
15
+ value: Any,
16
+ context: Context,
17
+ *args: Any,
18
+ **kwargs: Any,
19
+ ) -> Any:
20
+ if value:
21
+ raise ValueError(f"Value '{value}' is not falsy.")
22
+ return value
23
+
24
+
25
+ def falsy() -> Decorator:
26
+ """
27
+ Check if a value is falsy.
28
+
29
+ Type: `ChildNode`
30
+
31
+ Supports: `Any`
32
+
33
+ Returns:
34
+ Decorator:
35
+ The decorated function.
36
+ """
37
+ return Falsy.as_decorator()
@@ -0,0 +1,43 @@
1
+ """Check if a value is a valid email address."""
2
+
3
+ from typing import Any
4
+
5
+ from email_validator import EmailNotValidError, validate_email
6
+
7
+ from click_extended.core.nodes.child_node import ChildNode
8
+ from click_extended.core.other.context import Context
9
+ from click_extended.types import Decorator
10
+
11
+
12
+ class IsEmail(ChildNode):
13
+ """Check if a value is a valid email address."""
14
+
15
+ def handle_str(
16
+ self,
17
+ value: str,
18
+ context: Context,
19
+ *args: Any,
20
+ **kwargs: Any,
21
+ ) -> Any:
22
+ try:
23
+ validate_email(value, check_deliverability=False)
24
+ except EmailNotValidError as e:
25
+ raise ValueError(
26
+ f"Value '{value}' is not a valid email address ({e})."
27
+ ) from e
28
+ return value
29
+
30
+
31
+ def is_email() -> Decorator:
32
+ """
33
+ Check if a value is a valid email address.
34
+
35
+ Type: `ChildNode`
36
+
37
+ Supports: `str`
38
+
39
+ Returns:
40
+ Decorator:
41
+ The decorated function.
42
+ """
43
+ return IsEmail.as_decorator()
@@ -0,0 +1,41 @@
1
+ """Check if a value is a valid hex color code."""
2
+
3
+ import re
4
+ from typing import Any
5
+
6
+ from click_extended.core.nodes.child_node import ChildNode
7
+ from click_extended.core.other.context import Context
8
+ from click_extended.types import Decorator
9
+
10
+ HEX_PATTERN = re.compile(r"^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$")
11
+
12
+
13
+ class IsHexColor(ChildNode):
14
+ """Check if a value is a valid hex color code."""
15
+
16
+ def handle_str(
17
+ self,
18
+ value: str,
19
+ context: Context,
20
+ *args: Any,
21
+ **kwargs: Any,
22
+ ) -> Any:
23
+ if not re.fullmatch(HEX_PATTERN, value):
24
+ raise ValueError(f"Value '{value}' is not a valid hex color.")
25
+
26
+ return value
27
+
28
+
29
+ def is_hex_color() -> Decorator:
30
+ """
31
+ Check if a value is a valid hex color code.
32
+
33
+ Type: `ChildNode`
34
+
35
+ Supports: `str`
36
+
37
+ Returns:
38
+ Decorator:
39
+ The decorated function.
40
+ """
41
+ return IsHexColor.as_decorator()