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,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()
|