click-extended 1.0.7__py3-none-any.whl → 1.0.9__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.
@@ -664,7 +664,33 @@ class RootNode(Node):
664
664
  context,
665
665
  )
666
666
 
667
- for validation_node in root.tree.validations:
667
+ catch_nodes = [
668
+ v
669
+ for v in root.tree.validations
670
+ if v.__class__.__name__ == "Catch"
671
+ ]
672
+ other_nodes = [
673
+ v
674
+ for v in root.tree.validations
675
+ if v.__class__.__name__ != "Catch"
676
+ ]
677
+ root.tree.validations = catch_nodes + other_nodes
678
+
679
+ for i, validation_node in enumerate(root.tree.validations):
680
+ if validation_node.__class__.__name__ == "Catch":
681
+ if hasattr(
682
+ validation_node, "remaining_validations"
683
+ ):
684
+ validation_node.remaining_validations = (
685
+ root.tree.validations[i + 1 :]
686
+ )
687
+ validation_node.on_finalize(
688
+ custom_context,
689
+ *validation_node.process_args,
690
+ **validation_node.process_kwargs,
691
+ )
692
+ break
693
+
668
694
  validation_node.on_finalize(
669
695
  custom_context,
670
696
  *validation_node.process_args,
@@ -1,5 +1,8 @@
1
1
  """Initialization file for the `click_extended.decorators.misc` module."""
2
2
 
3
+ # pylint: disable=wrong-import-position
4
+
5
+ from click_extended.decorators.misc.catch import catch
3
6
  from click_extended.decorators.misc.choice import choice
4
7
  from click_extended.decorators.misc.confirm_if import confirm_if
5
8
  from click_extended.decorators.misc.default import default
@@ -8,6 +11,7 @@ from click_extended.decorators.misc.experimental import experimental
8
11
  from click_extended.decorators.misc.now import now
9
12
 
10
13
  __all__ = [
14
+ "catch",
11
15
  "choice",
12
16
  "confirm_if",
13
17
  "default",
@@ -0,0 +1,343 @@
1
+ """
2
+ Validation node to catch and handle exceptions from command/group functions.
3
+ """
4
+
5
+ # pylint: disable=wrong-import-position
6
+ # pylint: disable=wrong-import-order
7
+ # pylint: disable=ungrouped-imports
8
+
9
+ import asyncio
10
+ import inspect
11
+ from functools import wraps
12
+ from typing import Any, Callable
13
+ from weakref import WeakKeyDictionary
14
+
15
+ from click_extended.core.nodes.validation_node import ValidationNode
16
+ from click_extended.core.other._tree import Tree
17
+ from click_extended.core.other.context import Context
18
+ from click_extended.types import Decorator
19
+
20
+ _catch_handlers: WeakKeyDictionary[
21
+ Callable[..., Any],
22
+ list[
23
+ tuple[tuple[type[BaseException], ...], Callable[..., Any] | None, bool]
24
+ ],
25
+ ] = WeakKeyDictionary()
26
+
27
+
28
+ class Catch(ValidationNode):
29
+ """
30
+ Catch and handle exceptions from command/group functions and
31
+ validators.
32
+
33
+ Wraps both the validation phase and function execution in a
34
+ try-except block. When an exception is caught, an optional handler
35
+ is invoked. Without a handler, exceptions are silently suppressed.
36
+
37
+ Catches exceptions from:
38
+ - Validation decorators (e.g., @exclusive, @requires)
39
+ - The command/group function itself
40
+
41
+ Handler signatures supported:
42
+ - `handler()` - No arguments, just execute code
43
+ - `handler(exception)` - Receive the exception object
44
+ - `handler(exception, context)` - Receive exception and Context object
45
+
46
+ Examples:
47
+ ```py
48
+ # Catch validation errors from @exclusive
49
+ @command()
50
+ @exclusive("--stock", "--query")
51
+ @catch(ValueError, handler=lambda e: print(f"Error: {e}"))
52
+ def search(stock, query):
53
+ pass
54
+ ```
55
+
56
+ ```py
57
+ # Catch errors from command function
58
+ @command()
59
+ @catch(ValueError, handler=lambda e: print(f"Error: {e}"))
60
+ def cmd():
61
+ raise ValueError("Count must be positive")
62
+ ```
63
+
64
+ ```py
65
+ # Access context information
66
+ @command()
67
+ @catch(
68
+ ValueError,
69
+ handler=lambda e, ctx: print(f"{ctx.info_name}: {e}"),
70
+ )
71
+ def cmd():
72
+ raise ValueError("Failed!")
73
+ ```
74
+ """
75
+
76
+ def __init__(
77
+ self,
78
+ name: str,
79
+ process_args: tuple[Any, ...] | None = None,
80
+ process_kwargs: dict[str, Any] | None = None,
81
+ **kwargs: Any,
82
+ ) -> None:
83
+ """Initialize Catch validation node with function to wrap."""
84
+ super().__init__(name, process_args, process_kwargs, **kwargs)
85
+ self.wrapped_func: Callable[..., Any] | None = None
86
+ self.remaining_validations: list[ValidationNode] = []
87
+
88
+ def on_finalize(self, context: Context, *args: Any, **kwargs: Any) -> None:
89
+ """
90
+ Execute remaining validations wrapped in exception handling.
91
+
92
+ This catches exceptions from all validators that run after @catch,
93
+ allowing it to catch validation errors like those from @exclusive.
94
+
95
+ Args:
96
+ context: The execution context
97
+ *args: Contains exception types tuple at index 0
98
+ **kwargs: Contains handler, reraise parameters
99
+ """
100
+ exception_types: tuple[type[BaseException], ...] = args[0]
101
+ handler: Callable[..., Any] | None = kwargs.get("handler")
102
+ reraise: bool = kwargs.get("reraise", False)
103
+
104
+ for validation_node in self.remaining_validations:
105
+ try:
106
+ if asyncio.iscoroutinefunction(validation_node.on_finalize):
107
+ asyncio.run(
108
+ validation_node.on_finalize(
109
+ context,
110
+ *validation_node.process_args,
111
+ **validation_node.process_kwargs,
112
+ )
113
+ )
114
+ else:
115
+ validation_node.on_finalize(
116
+ context,
117
+ *validation_node.process_args,
118
+ **validation_node.process_kwargs,
119
+ )
120
+ except BaseException as exc:
121
+ if isinstance(exc, exception_types):
122
+ if handler is not None:
123
+ if asyncio.iscoroutinefunction(handler):
124
+ asyncio.run(_call_handler_async(handler, exc))
125
+ else:
126
+ _call_handler_sync(handler, exc)
127
+
128
+ if not reraise:
129
+ return
130
+
131
+ raise
132
+
133
+
134
+ def catch(
135
+ *exception_types: type[BaseException],
136
+ handler: Callable[..., Any] | None = None,
137
+ reraise: bool = False,
138
+ ) -> Decorator:
139
+ """
140
+ Catch and handle exceptions from command/group functions.
141
+
142
+ Wraps the function in a try-except block. When exceptions occur, an optional
143
+ handler is invoked. If no exception types are specified, catches Exception.
144
+
145
+ Type: `ValidationNode`
146
+
147
+ Args:
148
+ *exception_types: Exception types to catch (defaults to Exception)
149
+ handler: Optional function with signature `()`, `(exception)`, or
150
+ `(exception, context)` to handle caught exceptions
151
+ reraise: If True, re-raise after handling (default: False)
152
+
153
+ Returns:
154
+ Decorator function
155
+
156
+ Raises:
157
+ TypeError: If exception_types contains non-exception classes
158
+
159
+ Examples:
160
+ ```python
161
+ # Simple notification (no arguments)
162
+ @command()
163
+ @catch(ValueError, handler=lambda: print("Error occurred!"))
164
+ def cmd():
165
+ raise ValueError("Invalid input")
166
+ ```
167
+
168
+ ```python
169
+ # Log exception details (exception argument)
170
+ @command()
171
+ @catch(ValueError, handler=lambda e: print(f"Error: {e}"))
172
+ def cmd():
173
+ raise ValueError("Count must be positive")
174
+ ```
175
+
176
+ ```python
177
+ # Access context (exception + context arguments)
178
+ @command()
179
+ @catch(
180
+ ValueError,
181
+ handler=lambda e, ctx: print(f"{ctx.info_name}: {e}"),
182
+ )
183
+ def cmd():
184
+ raise ValueError("Failed!")
185
+ ```
186
+
187
+ ```python
188
+ # Catch multiple exception types
189
+ @command()
190
+ @catch(ValueError, TypeError, handler=lambda e: log_error(e))
191
+ def cmd():
192
+ raise ValueError("Something went wrong")
193
+ ```
194
+
195
+ ```python
196
+ # Silent suppression (no handler)
197
+ @command()
198
+ @catch(RuntimeError)
199
+ def cmd():
200
+ raise RuntimeError("Silently suppressed")
201
+ ```
202
+
203
+ ```python
204
+ # Log then re-raise
205
+ @command()
206
+ @catch(
207
+ ValueError,
208
+ handler=lambda e: print(f"Logging: {e}"),
209
+ reraise=True,
210
+ )
211
+ def cmd():
212
+ raise ValueError("This will be logged and re-raised")
213
+ ```
214
+ """
215
+ if not exception_types:
216
+ exception_types = (Exception,)
217
+
218
+ for exc_type in exception_types:
219
+ if not isinstance(exc_type, type) or not issubclass(
220
+ exc_type, BaseException
221
+ ):
222
+ raise TypeError(
223
+ f"catch() requires exception types, got {exc_type!r}"
224
+ )
225
+
226
+ def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
227
+ """The actual decorator that wraps the function."""
228
+
229
+ instance = Catch(
230
+ name="catch",
231
+ process_args=(exception_types,),
232
+ process_kwargs={"handler": handler, "reraise": reraise},
233
+ )
234
+
235
+ Tree.queue_validation(instance)
236
+
237
+ original_func = func
238
+ while hasattr(original_func, "__wrapped__"):
239
+ original_func = original_func.__wrapped__ # type: ignore
240
+
241
+ if original_func not in _catch_handlers:
242
+ _catch_handlers[original_func] = []
243
+ _catch_handlers[original_func].insert(
244
+ 0, (exception_types, handler, reraise)
245
+ )
246
+
247
+ # Only wrap if this is the first @catch (check handlers dict)
248
+ if len(_catch_handlers[original_func]) > 1:
249
+ return func
250
+
251
+ if asyncio.iscoroutinefunction(func):
252
+
253
+ @wraps(func)
254
+ async def async_wrapper(*call_args: Any, **call_kwargs: Any) -> Any:
255
+ """Async wrapper that catches exceptions."""
256
+ try:
257
+ return await func(*call_args, **call_kwargs)
258
+ except BaseException as exc:
259
+ for exc_types, hdlr, reraise_flag in _catch_handlers.get(
260
+ original_func, []
261
+ ):
262
+ if isinstance(exc, exc_types):
263
+ if hdlr is not None:
264
+ if asyncio.iscoroutinefunction(hdlr):
265
+ await _call_handler_async(hdlr, exc)
266
+ else:
267
+ _call_handler_sync(hdlr, exc)
268
+
269
+ if reraise_flag:
270
+ raise
271
+
272
+ return None
273
+ raise
274
+
275
+ return async_wrapper
276
+
277
+ @wraps(func)
278
+ def sync_wrapper(*call_args: Any, **call_kwargs: Any) -> Any:
279
+ """Sync wrapper that catches exceptions."""
280
+ try:
281
+ return func(*call_args, **call_kwargs)
282
+ except BaseException as exc:
283
+ for exc_types, hdlr, reraise_flag in _catch_handlers.get(
284
+ original_func, []
285
+ ):
286
+ if isinstance(exc, exc_types):
287
+ if hdlr is not None:
288
+ if asyncio.iscoroutinefunction(hdlr):
289
+ asyncio.run(_call_handler_async(hdlr, exc))
290
+ else:
291
+ _call_handler_sync(hdlr, exc)
292
+
293
+ if reraise_flag:
294
+ raise
295
+
296
+ return None
297
+ raise
298
+
299
+ return sync_wrapper
300
+
301
+ return decorator
302
+
303
+
304
+ async def _call_handler_async(
305
+ handler: Callable[..., Any], exc: BaseException
306
+ ) -> Any:
307
+ """Call async handler with appropriate number of arguments."""
308
+ sig = inspect.signature(handler)
309
+ params = list(sig.parameters.values())
310
+
311
+ if len(params) == 0:
312
+ return await handler()
313
+ if len(params) == 1:
314
+ return await handler(exc)
315
+
316
+ import click
317
+
318
+ try:
319
+ ctx = click.get_current_context()
320
+ custom_context = ctx.meta.get("click_extended", {}).get("context")
321
+ return await handler(exc, custom_context)
322
+ except RuntimeError:
323
+ return await handler(exc, None)
324
+
325
+
326
+ def _call_handler_sync(handler: Callable[..., Any], exc: BaseException) -> Any:
327
+ """Call sync handler with appropriate number of arguments."""
328
+ sig = inspect.signature(handler)
329
+ params = list(sig.parameters.values())
330
+
331
+ if len(params) == 0:
332
+ return handler()
333
+ if len(params) == 1:
334
+ return handler(exc)
335
+
336
+ import click
337
+
338
+ try:
339
+ ctx = click.get_current_context()
340
+ custom_context = ctx.meta.get("click_extended", {}).get("context")
341
+ return handler(exc, custom_context)
342
+ except RuntimeError:
343
+ return handler(exc, None)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: click_extended
3
- Version: 1.0.7
3
+ Version: 1.0.9
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
@@ -14,7 +14,7 @@ click_extended/core/decorators/prompt.py,sha256=QwySQ6ZK0j0iL_jlwH3AU0U4G1X3MKAJ
14
14
  click_extended/core/decorators/selection.py,sha256=5cjWpAwReu8EY0mkziwpqhi_T5ucIlAPx1p-t3MkGJQ,5589
15
15
  click_extended/core/decorators/tag.py,sha256=395e6GN59QBj-L0NSBPynQOX3_2LAOIXkj8XKNXrRPs,3055
16
16
  click_extended/core/nodes/__init__.py,sha256=jSsKTiHPUpqXdk54cRXDotT0YMmVGezIIb3nQ0rcWko,737
17
- click_extended/core/nodes/_root_node.py,sha256=PitK6GT7LPYugxPFdVO6blG0-mZV9S2apUupgWCsYI0,43060
17
+ click_extended/core/nodes/_root_node.py,sha256=cNmTPtREVjNi6WZj3chtYFmn9_2iqJh4l32NEmO0Db4,44233
18
18
  click_extended/core/nodes/argument_node.py,sha256=gqG4HTDR40Xru_U_beKsbjVtLtwmPKfwwAUmcf5-1iQ,5284
19
19
  click_extended/core/nodes/child_node.py,sha256=yIuXmUzdXN0EToR9ohBRvHFBriSZ-xaLkv7P27PbTrA,17095
20
20
  click_extended/core/nodes/child_validation_node.py,sha256=Xcqid9EL3pHVrgv_43zz-ZL4W4yFm0Esj2qkk9qhWMc,3807
@@ -97,7 +97,8 @@ click_extended/decorators/math/rounded.py,sha256=bHYZQbtd5zRCd3BOEnjxTGdFAmQAL5l
97
97
  click_extended/decorators/math/sqrt.py,sha256=CWZoQpVw2lbP_LNeg-tWW8fz9VEHTX8eQNPNuDVoTcg,834
98
98
  click_extended/decorators/math/subtract.py,sha256=A7TMByTUH3coSEe7iRN9NkHW2mv47kufb1H1adomG4A,806
99
99
  click_extended/decorators/math/to_percent.py,sha256=39vY13u7Z-pyob1vCoPBDy10FqJasJTVzHcrpTH6ERQ,1374
100
- click_extended/decorators/misc/__init__.py,sha256=TDLrR2mZj_oqAcWEqB_C19Hxn8ClrZW2CbuTmJNRblU,553
100
+ click_extended/decorators/misc/__init__.py,sha256=Cl2u81f_dkvHoMoSb82L1KuNVM-3b1lzKHUMAuxhmH0,662
101
+ click_extended/decorators/misc/catch.py,sha256=LkBxGHNo7_RrXjUP0LOTYOLvb9q1fTBIULJJIcYXo4Y,11095
101
102
  click_extended/decorators/misc/choice.py,sha256=RTVVjl0YUZ2Ti4j6yoHkUpJ0aqNwcnc3rTfmCVr0y0U,4354
102
103
  click_extended/decorators/misc/confirm_if.py,sha256=Rf3HcVVTRqz3sGSiPT02yz65_L351Fzkuw2V6pljlmY,4786
103
104
  click_extended/decorators/misc/default.py,sha256=TjZJKmHEqNThzyD6hOjQy2_QgHRenKILnAwfV1V5KAw,2473
@@ -143,9 +144,9 @@ click_extended/utils/naming.py,sha256=kNmzOqidgZZ1dE5aMYrxpWt4VwcLuEiFunpumpfHuZ
143
144
  click_extended/utils/process.py,sha256=sU3ZCMjBgjKcDnTRLKPdQ_TAjk0AT7Q8SatagD0JyHA,9729
144
145
  click_extended/utils/selection.py,sha256=_OQC88pGPUh29boxmS5ugXEi9jZGqAG180S27PeQaj0,9875
145
146
  click_extended/utils/time.py,sha256=H5m5caIEau_1GHkiYgKL_LcTtVdw2TkFVbkqJu7A9rQ,1067
146
- click_extended-1.0.7.dist-info/licenses/AUTHORS.md,sha256=NkShPinjqtnRDQVRyVnfJuOGM56sejauE3WRoYCcbtw,132
147
- click_extended-1.0.7.dist-info/licenses/LICENSE,sha256=gjO8hzM4mFSBXFikktaXVSgmXGcre91_GPJ-E_yP56E,1075
148
- click_extended-1.0.7.dist-info/METADATA,sha256=pVmPjkszY_IAx2DP-RM2i2IkhS28n_g6fkFU9itz8y4,10841
149
- click_extended-1.0.7.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
150
- click_extended-1.0.7.dist-info/top_level.txt,sha256=2G3bm6tCNv80okRm773jKTk-_z1ElY-seaozZrn_TxA,15
151
- click_extended-1.0.7.dist-info/RECORD,,
147
+ click_extended-1.0.9.dist-info/licenses/AUTHORS.md,sha256=NkShPinjqtnRDQVRyVnfJuOGM56sejauE3WRoYCcbtw,132
148
+ click_extended-1.0.9.dist-info/licenses/LICENSE,sha256=gjO8hzM4mFSBXFikktaXVSgmXGcre91_GPJ-E_yP56E,1075
149
+ click_extended-1.0.9.dist-info/METADATA,sha256=nY4Le_lb7iE550nRKd8EoLDp1Jp5nsXqg2gAPgc6jy8,10841
150
+ click_extended-1.0.9.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
151
+ click_extended-1.0.9.dist-info/top_level.txt,sha256=2G3bm6tCNv80okRm773jKTk-_z1ElY-seaozZrn_TxA,15
152
+ click_extended-1.0.9.dist-info/RECORD,,