wexample-cli 0.0.3__tar.gz → 0.2.0__tar.gz

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 (29) hide show
  1. {wexample_cli-0.0.3 → wexample_cli-0.2.0}/PKG-INFO +13 -2
  2. {wexample_cli-0.0.3 → wexample_cli-0.2.0}/README.md +8 -1
  3. {wexample_cli-0.0.3 → wexample_cli-0.2.0}/pyproject.toml +7 -2
  4. {wexample_cli-0.0.3/tests → wexample_cli-0.2.0/src/wexample_cli/command}/__init__.py +0 -0
  5. wexample_cli-0.2.0/src/wexample_cli/command/extended_command.py +344 -0
  6. wexample_cli-0.2.0/src/wexample_cli/common/command_method_wrapper.py +105 -0
  7. wexample_cli-0.2.0/src/wexample_cli/const/__init__.py +0 -0
  8. wexample_cli-0.2.0/src/wexample_cli/const/middleware.py +5 -0
  9. wexample_cli-0.2.0/src/wexample_cli/const/types.py +5 -0
  10. wexample_cli-0.2.0/src/wexample_cli/context/__init__.py +0 -0
  11. wexample_cli-0.2.0/src/wexample_cli/context/execution_context.py +78 -0
  12. wexample_cli-0.2.0/src/wexample_cli/decorator/__init__.py +0 -0
  13. wexample_cli-0.2.0/src/wexample_cli/decorator/alias.py +34 -0
  14. wexample_cli-0.2.0/src/wexample_cli/decorator/as_sudo.py +14 -0
  15. wexample_cli-0.2.0/src/wexample_cli/decorator/command.py +19 -0
  16. wexample_cli-0.2.0/src/wexample_cli/decorator/middleware.py +30 -0
  17. wexample_cli-0.2.0/src/wexample_cli/decorator/option.py +44 -0
  18. wexample_cli-0.2.0/src/wexample_cli/decorator/option_stop_on_failure.py +30 -0
  19. wexample_cli-0.2.0/src/wexample_cli/decorator/webhook.py +27 -0
  20. wexample_cli-0.2.0/src/wexample_cli/exception/__init__.py +0 -0
  21. wexample_cli-0.2.0/src/wexample_cli/exception/abstract_command_option_exception.py +31 -0
  22. wexample_cli-0.2.0/src/wexample_cli/exception/command_option_missing_exception.py +27 -0
  23. wexample_cli-0.2.0/src/wexample_cli/exception/command_option_validation_exception.py +17 -0
  24. wexample_cli-0.2.0/src/wexample_cli/middleware/__init__.py +0 -0
  25. wexample_cli-0.2.0/src/wexample_cli/middleware/abstract_middleware.py +154 -0
  26. wexample_cli-0.2.0/src/wexample_cli/py.typed +0 -0
  27. wexample_cli-0.2.0/tests/__init__.py +0 -0
  28. {wexample_cli-0.0.3 → wexample_cli-0.2.0}/src/wexample_cli/__init__.py +0 -0
  29. /wexample_cli-0.0.3/src/wexample_cli/py.typed → /wexample_cli-0.2.0/src/wexample_cli/common/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: wexample-cli
3
- Version: 0.0.3
3
+ Version: 0.2.0
4
4
  Summary: Reusable CLI primitives — command decorators, options, middlewares, and the enriched command runner — extracted from wex-core so any kernel built on wexample-app can opt in without depending on the full wex framework.
5
5
  Author-Email: weeger <contact@wexample.com>
6
6
  License: MIT
@@ -9,6 +9,10 @@ Classifier: License :: OSI Approved :: MIT License
9
9
  Classifier: Operating System :: OS Independent
10
10
  Project-URL: homepage, https://github.com/wexample/python-cli
11
11
  Requires-Python: >=3.10
12
+ Requires-Dist: attrs>=23.1.0
13
+ Requires-Dist: wexample-app>=15.1.0
14
+ Requires-Dist: wexample-helpers>=13.0.0
15
+ Requires-Dist: wexample-prompt>=9.0.0
12
16
  Provides-Extra: dev
13
17
  Requires-Dist: pytest; extra == "dev"
14
18
  Requires-Dist: pytest-cov; extra == "dev"
@@ -16,7 +20,7 @@ Description-Content-Type: text/markdown
16
20
 
17
21
  # cli
18
22
 
19
- Version: 0.0.3
23
+ Version: 0.2.0
20
24
 
21
25
  Reusable CLI primitives — command decorators, options, middlewares, and the enriched command runner — extracted from wex-core so any kernel built on wexample-app can opt in without depending on the full wex framework.
22
26
 
@@ -93,6 +97,13 @@ The suite includes packages for configuration management, file handling, prompts
93
97
 
94
98
  Visit the [Wexample Suite documentation](https://docs.wexample.com) for the complete package ecosystem.
95
99
 
100
+ ## Dependencies
101
+
102
+ - attrs: >=23.1.0
103
+ - wexample-app: >=15.1.0
104
+ - wexample-helpers: >=13.0.0
105
+ - wexample-prompt: >=9.0.0
106
+
96
107
  ## Versioning & Compatibility Policy
97
108
 
98
109
  Wexample packages follow **Semantic Versioning** (SemVer):
@@ -1,6 +1,6 @@
1
1
  # cli
2
2
 
3
- Version: 0.0.3
3
+ Version: 0.2.0
4
4
 
5
5
  Reusable CLI primitives — command decorators, options, middlewares, and the enriched command runner — extracted from wex-core so any kernel built on wexample-app can opt in without depending on the full wex framework.
6
6
 
@@ -77,6 +77,13 @@ The suite includes packages for configuration management, file handling, prompts
77
77
 
78
78
  Visit the [Wexample Suite documentation](https://docs.wexample.com) for the complete package ecosystem.
79
79
 
80
+ ## Dependencies
81
+
82
+ - attrs: >=23.1.0
83
+ - wexample-app: >=15.1.0
84
+ - wexample-helpers: >=13.0.0
85
+ - wexample-prompt: >=9.0.0
86
+
80
87
  ## Versioning & Compatibility Policy
81
88
 
82
89
  Wexample packages follow **Semantic Versioning** (SemVer):
@@ -6,7 +6,7 @@ build-backend = "pdm.backend"
6
6
 
7
7
  [project]
8
8
  name = "wexample-cli"
9
- version = "0.0.3"
9
+ version = "0.2.0"
10
10
  description = "Reusable CLI primitives — command decorators, options, middlewares, and the enriched command runner — extracted from wex-core so any kernel built on wexample-app can opt in without depending on the full wex framework."
11
11
  authors = [
12
12
  { name = "weeger", email = "contact@wexample.com" },
@@ -17,7 +17,12 @@ classifiers = [
17
17
  "License :: OSI Approved :: MIT License",
18
18
  "Operating System :: OS Independent",
19
19
  ]
20
- dependencies = []
20
+ dependencies = [
21
+ "attrs>=23.1.0",
22
+ "wexample-app>=15.1.0",
23
+ "wexample-helpers>=13.0.0",
24
+ "wexample-prompt>=9.0.0",
25
+ ]
21
26
 
22
27
  [project.readme]
23
28
  file = "README.md"
@@ -0,0 +1,344 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ from wexample_app.common.command import Command
7
+ from wexample_helpers.classes.field import public_field
8
+ from wexample_helpers.decorator.base_class import base_class
9
+
10
+ if TYPE_CHECKING:
11
+ from wexample_app.common.command_request import CommandRequest
12
+
13
+ from wexample_cli.common.command_method_wrapper import CommandMethodWrapper
14
+ from wexample_cli.const.types import ParsedArgs
15
+ from wexample_cli.context.execution_context import ExecutionContext
16
+
17
+
18
+ @base_class
19
+ class ExtendedCommand(Command):
20
+ command_wrapper: CommandMethodWrapper = public_field(
21
+ description="The wrapper holding the command function"
22
+ )
23
+
24
+ def __attrs_post_init__(self) -> None:
25
+ # Now override function based on command_wrapper
26
+ self.function = self.command_wrapper.function
27
+
28
+ def execute_request(self, request: CommandRequest) -> Any:
29
+ from wexample_app.helpers.response import response_normalize
30
+ from wexample_app.response.failure_response import FailureResponse
31
+ from wexample_app.response.multiple_response import MultipleResponse
32
+
33
+ from wexample_cli.const.middleware import (
34
+ MIDDLEWARE_OPTION_VALUE_ALLWAYS,
35
+ MIDDLEWARE_OPTION_VALUE_OPTIONAL,
36
+ )
37
+
38
+ # Universal --help / -h handling — render the command's options instead
39
+ # of dispatching. Done before middleware/option validation so it works
40
+ # even when required options are missing.
41
+ if isinstance(request.arguments, list) and any(
42
+ arg in ("--help", "-h") for arg in request.arguments
43
+ ):
44
+ return self._render_help(request)
45
+
46
+ middlewares_attributes = self.command_wrapper.middlewares_attributes
47
+ middlewares_registry = self.kernel.get_registry("middlewares")
48
+
49
+ for name in middlewares_attributes:
50
+ middleware_class = middlewares_registry.get_class(name)
51
+ middleware = middleware_class(**middlewares_attributes[name])
52
+ self.command_wrapper.set_middleware(middleware)
53
+
54
+ function_kwargs = self._build_function_kwargs(request=request)
55
+
56
+ if len(self.command_wrapper.middlewares) > 0:
57
+ output = MultipleResponse(kernel=self.kernel)
58
+
59
+ for middleware in self.command_wrapper.middlewares:
60
+ show_progress = (
61
+ middleware.show_progress == MIDDLEWARE_OPTION_VALUE_ALLWAYS
62
+ or (
63
+ middleware.show_progress == MIDDLEWARE_OPTION_VALUE_OPTIONAL
64
+ and function_kwargs["show_progress"]
65
+ )
66
+ )
67
+
68
+ # Each middleware can multiply the executions,
69
+ # e.g. executing the command on every file of a list.
70
+ execution_contexts = middleware.build_execution_contexts(
71
+ command_wrapper=self.command_wrapper,
72
+ request=request,
73
+ function_kwargs=function_kwargs,
74
+ )
75
+
76
+ # Apply limit if specified
77
+ if (
78
+ isinstance(middleware.max_iterations, int)
79
+ and middleware.max_iterations > 0
80
+ ):
81
+ execution_contexts = execution_contexts[: middleware.max_iterations]
82
+ self.kernel.io.info(
83
+ f'Middleware "{middleware.get_short_class_name()}" truncated list to {middleware.max_iterations} items'
84
+ )
85
+
86
+ # Check if middleware should run in parallel
87
+ if middleware.parallel == MIDDLEWARE_OPTION_VALUE_ALLWAYS or (
88
+ middleware.parallel == MIDDLEWARE_OPTION_VALUE_OPTIONAL
89
+ and "parallel" in function_kwargs
90
+ and function_kwargs["parallel"]
91
+ ):
92
+ # Execute passes in parallel using asyncio
93
+ responses = asyncio.run(
94
+ self._execute_passes_parallel(
95
+ execution_contexts=execution_contexts,
96
+ )
97
+ )
98
+
99
+ # Add all responses to output
100
+ for response in responses:
101
+ output.append(response)
102
+
103
+ # Check if we should stop on failure
104
+ if (
105
+ isinstance(response, FailureResponse)
106
+ and middleware.stop_on_failure
107
+ ):
108
+ # "Stop" does not mean "fail", so we just stop the process.
109
+ return output
110
+ else:
111
+ i = 0
112
+ length = len(execution_contexts)
113
+
114
+ if show_progress:
115
+ # First bar.
116
+ request.kernel.io.progress(length, i)
117
+
118
+ # Execute passes sequentially
119
+ for context in execution_contexts:
120
+ # Use context.function if provided, otherwise use command's function
121
+ function_to_execute = context.function or self.function
122
+
123
+ response = response_normalize(
124
+ kernel=self.kernel,
125
+ response=function_to_execute(**context.function_kwargs),
126
+ )
127
+
128
+ output.append(response)
129
+ i += 1
130
+
131
+ if show_progress:
132
+ request.kernel.io.progress(length, i)
133
+
134
+ if isinstance(response, FailureResponse) and (
135
+ middleware.stop_on_failure
136
+ == MIDDLEWARE_OPTION_VALUE_ALLWAYS
137
+ or (
138
+ middleware.stop_on_failure
139
+ == MIDDLEWARE_OPTION_VALUE_OPTIONAL
140
+ and "stop_on_failure" in function_kwargs
141
+ and function_kwargs["stop_on_failure"]
142
+ )
143
+ ):
144
+ # "Stop" does not mean "fail", so we just stop the process.
145
+ return output
146
+
147
+ # If only one response, return it directly instead of wrapping in MultipleResponse
148
+ if len(output.responses) == 1:
149
+ return output.responses[0]
150
+
151
+ return output
152
+
153
+ # Delegate context creation to resolver.
154
+ context = request.resolver.build_execution_context(
155
+ middleware=None,
156
+ command_wrapper=self.command_wrapper,
157
+ request=request,
158
+ function_kwargs=function_kwargs,
159
+ )
160
+
161
+ # Use context.function if provided, otherwise use command's function
162
+ function_to_execute = context.function or self.function
163
+
164
+ return response_normalize(
165
+ kernel=self.kernel, response=function_to_execute(**context.function_kwargs)
166
+ )
167
+
168
+ def _build_function_kwargs(self, request: CommandRequest) -> dict[str, Any]:
169
+ from wexample_cli.exception.command_option_missing_exception import (
170
+ CommandOptionMissingException,
171
+ )
172
+
173
+ # Allow middleware to add extra options.
174
+ for middleware in self.command_wrapper.middlewares:
175
+ middleware.append_options(
176
+ request=request,
177
+ command_wrapper=self.command_wrapper,
178
+ )
179
+
180
+ """Execute the command with the given request arguments."""
181
+ # Parse and convert arguments to appropriate types
182
+ parsed_args = self._parse_arguments(request.arguments)
183
+
184
+ """Build the final kwargs dictionary for the function call."""
185
+ function_kwargs = {}
186
+
187
+ # Process all declared options
188
+ for option in self.command_wrapper.options:
189
+ value = None
190
+
191
+ # If the option is in parsed args, use that value
192
+ if option.name in parsed_args:
193
+ value = parsed_args[option.name]
194
+ # Otherwise, use the default value if available
195
+ elif option.default is not None:
196
+ value = option.default
197
+ # If the option is required but not provided, raise an error
198
+ elif option.required:
199
+ raise CommandOptionMissingException(option_name=option.name)
200
+
201
+ # Validate the value if validators are defined and value is not None
202
+ if value is not None and option.validators:
203
+ # For multiple values, validate each item individually
204
+ values_to_validate = value if isinstance(value, list) else [value]
205
+
206
+ for val in values_to_validate:
207
+ for validator in option.validators:
208
+ if not validator.validate(val):
209
+ from wexample_cli.exception.command_option_validation_exception import (
210
+ CommandOptionValidationException,
211
+ )
212
+
213
+ raise CommandOptionValidationException(
214
+ option_name=option.name,
215
+ value=val,
216
+ error_message=validator.get_error_message(val),
217
+ )
218
+
219
+ # Normalize to list if always_list is True
220
+ if value is not None and option.always_list and not isinstance(value, list):
221
+ value = [value]
222
+
223
+ # Assign the validated value
224
+ if value is not None:
225
+ option.value = function_kwargs[option.name] = value
226
+
227
+ return function_kwargs
228
+
229
+ async def _execute_passes_parallel(
230
+ self, execution_contexts: list[ExecutionContext]
231
+ ) -> list[Any]:
232
+ """Execute multiple passes in parallel using asyncio.
233
+
234
+ Args:
235
+ execution_contexts: List of ExecutionPass objects to execute in parallel
236
+
237
+ Returns:
238
+ List of normalized responses from all executions
239
+ """
240
+ from concurrent.futures import ThreadPoolExecutor
241
+
242
+ from wexample_app.helpers.response import response_normalize
243
+ from wexample_app.response.abstract_response import AbstractResponse
244
+
245
+ # Create a list to store all tasks
246
+ tasks = []
247
+
248
+ # Create an executor for running CPU-bound functions in a thread pool
249
+ executor = ThreadPoolExecutor(max_workers=min(32, len(execution_contexts)))
250
+
251
+ # Define a coroutine that executes a single pass
252
+ async def execute_single_pass(
253
+ execution_context: ExecutionContext,
254
+ ) -> AbstractResponse:
255
+ from wexample_prompt.output.prompt_buffer_output_handler import (
256
+ PromptBufferOutputHandler,
257
+ )
258
+
259
+ output = PromptBufferOutputHandler()
260
+ # Detach io manager to print log result at the end.
261
+ execution_context._init_io_manager(output=output)
262
+
263
+ # Use context.function if provided, otherwise use command's function
264
+ function_to_execute = execution_context.function or self.function
265
+
266
+ # Run the function in a thread pool to avoid blocking the event loop
267
+ loop = asyncio.get_event_loop()
268
+ result = await loop.run_in_executor(
269
+ executor,
270
+ lambda: function_to_execute(**execution_context.function_kwargs),
271
+ )
272
+
273
+ self.kernel.io.print_responses(output.buffer)
274
+
275
+ # Normalize the response
276
+ return response_normalize(kernel=self.kernel, response=result)
277
+
278
+ # Create a task for each pass
279
+ for execution_context in execution_contexts:
280
+ task = asyncio.create_task(execute_single_pass(execution_context))
281
+ tasks.append(task)
282
+
283
+ # Wait for all tasks to complete
284
+ responses = await asyncio.gather(*tasks)
285
+
286
+ # Close the executor
287
+ executor.shutdown(wait=False)
288
+
289
+ return responses
290
+
291
+ def _parse_arguments(self, arguments: list[str] | dict) -> ParsedArgs:
292
+ """Parse raw command line arguments into a dictionary of option name to value.
293
+
294
+ Accepts either a CLI-style list (["--type", "dict"]) or a dict ({"type": "dict"})
295
+ which is used directly without conversion.
296
+ """
297
+ if isinstance(arguments, dict):
298
+ return arguments
299
+
300
+ from wexample_app.helpers.argument import argument_parse_options
301
+
302
+ return argument_parse_options(
303
+ arguments=arguments,
304
+ options=self.command_wrapper.options,
305
+ allowed_option_names=self.command_wrapper.get_options_names(),
306
+ )
307
+
308
+ def _render_help(self, request: CommandRequest) -> Any:
309
+ """Render usage + options for the command and return a NullResponse."""
310
+ from wexample_app.response.null_response import NullResponse
311
+
312
+ io = self.kernel.io
313
+ wrapper = self.command_wrapper
314
+
315
+ io.log(f"Usage: {request.name} [OPTIONS]")
316
+ if wrapper.description:
317
+ io.log("")
318
+ io.log(wrapper.description)
319
+
320
+ # Options listed in the order the user declared them (reverse of
321
+ # decorator application). Each line shows --kebab/-short, type, and
322
+ # required/default state.
323
+ if wrapper.options:
324
+ io.log("")
325
+ io.log("Options:")
326
+ for option in reversed(wrapper.options):
327
+ flag = f"--{option.kebab_name}"
328
+ if option.short_name:
329
+ flag = f"{flag}, -{option.short_name}"
330
+ type_name = getattr(option.type, "__name__", str(option.type))
331
+ meta = []
332
+ if option.required:
333
+ meta.append("required")
334
+ elif option.default is not None:
335
+ meta.append(f"default={option.default!r}")
336
+ if option.is_flag:
337
+ meta.append("flag")
338
+ meta_str = f" [{', '.join(meta)}]" if meta else ""
339
+ desc = f" {option.description}" if option.description else ""
340
+ io.log(f" {flag} <{type_name}>{meta_str}{desc}")
341
+ io.log("")
342
+ io.log(" --help, -h Show this message and exit.")
343
+
344
+ return NullResponse(kernel=self.kernel)
@@ -0,0 +1,105 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from wexample_helpers.classes.base_class import BaseClass
6
+ from wexample_helpers.classes.field import public_field
7
+ from wexample_helpers.decorator.base_class import base_class
8
+
9
+ if TYPE_CHECKING:
10
+ from wexample_app.command.option import Option
11
+ from wexample_helpers.const.types import AnyCallable, Kwargs
12
+
13
+ from wexample_cli.middleware.abstract_middleware import AbstractMiddleware
14
+
15
+
16
+ @base_class
17
+ class CommandMethodWrapper(BaseClass):
18
+ aliases: list[str] = public_field(
19
+ factory=list,
20
+ description="Alternative names to invoke this command",
21
+ )
22
+ attachments: dict[str, list[dict]] = public_field(
23
+ factory=lambda: {"before": [], "after": [], "always_after": []},
24
+ description="Commands attached before/after this command executes",
25
+ )
26
+ description: str | None = public_field(
27
+ default=None,
28
+ description="Optional human-readable description of the command method",
29
+ )
30
+ extra: dict = public_field(
31
+ factory=dict,
32
+ description="Generic key/value storage for addon-specific metadata",
33
+ )
34
+ function: AnyCallable = public_field(
35
+ description="Callable object implementing the command method",
36
+ )
37
+ middlewares: list[AbstractMiddleware] = public_field(
38
+ factory=list,
39
+ description="List of middleware instances applied to the command method",
40
+ )
41
+ middlewares_attributes: dict[str, Kwargs] = public_field(
42
+ factory=dict,
43
+ description="Mapping of middleware names to their initialization attributes",
44
+ )
45
+ options: list[Option] = public_field(
46
+ factory=list,
47
+ description="List of command options available for this method",
48
+ )
49
+ sudo: bool = public_field(
50
+ default=False,
51
+ description="If True, re-exec the entire process under sudo if not already root",
52
+ )
53
+ type: str | None = public_field(
54
+ description="The command type",
55
+ )
56
+ webhook: bool = public_field(
57
+ default=False,
58
+ description="If True, this command is accessible via the webhook daemon",
59
+ )
60
+
61
+ def find_option_by_kebab_name(self, kabab_name: str) -> Option | None:
62
+ """Find an option by its name."""
63
+ for option in self.options:
64
+ if option.kebab_name == kabab_name:
65
+ return option
66
+ return None
67
+
68
+ def find_option_by_name(self, name: str) -> Option | None:
69
+ """Find an option by its name."""
70
+ for option in self.options:
71
+ if option.name == name:
72
+ return option
73
+ return None
74
+
75
+ def find_option_by_short_name(self, short_name: str) -> Option | None:
76
+ """Find an option by its short name."""
77
+ for option in self.options:
78
+ if option.short_name == short_name:
79
+ return option
80
+ return None
81
+
82
+ def get_options_names(self) -> list[str]:
83
+ from wexample_helpers.helpers.string import string_to_kebab_case
84
+
85
+ return [string_to_kebab_case(option.name) for option in self.options]
86
+
87
+ def register_middleware(
88
+ self, name: str | type[AbstractMiddleware], middleware_kwargs: Kwargs
89
+ ) -> None:
90
+ # Extract name from class if needed
91
+ if not isinstance(name, str):
92
+ middleware_name = name.get_name()
93
+ else:
94
+ middleware_name = name
95
+
96
+ self.middlewares_attributes[middleware_name] = middleware_kwargs
97
+
98
+ def set_middleware(self, middleware: AbstractMiddleware) -> None:
99
+ self.middlewares.append(middleware)
100
+
101
+ for option in middleware.normalized_options:
102
+ self.set_option(option)
103
+
104
+ def set_option(self, option: Option) -> None:
105
+ self.options.append(option)
File without changes
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+ # filestate: python-constant-sort
4
+ MIDDLEWARE_OPTION_VALUE_ALLWAYS: str = "allways"
5
+ MIDDLEWARE_OPTION_VALUE_OPTIONAL: str = "optional"
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ ParsedArgs = dict[str, Any]
@@ -0,0 +1,78 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from wexample_app.common.abstract_kernel_child import AbstractKernelChild
6
+ from wexample_helpers.classes.base_class import BaseClass
7
+ from wexample_helpers.classes.field import public_field
8
+ from wexample_helpers.classes.mixin.printable_mixin import PrintableMixin
9
+ from wexample_helpers.classes.private_field import private_field
10
+ from wexample_helpers.decorator.base_class import base_class
11
+ from wexample_prompt.mixins.with_io_manager import WithIoManager
12
+
13
+ if TYPE_CHECKING:
14
+ from wexample_app.common.command_request import CommandRequest
15
+ from wexample_app.common.mixins.command_runner_kernel import CommandRunnerKernel
16
+ from wexample_helpers.const.types import AnyCallable, Kwargs
17
+ from wexample_prompt.common.progress.progress_handle import ProgressHandle
18
+
19
+ from wexample_cli.common.command_method_wrapper import CommandMethodWrapper
20
+ from wexample_cli.middleware.abstract_middleware import AbstractMiddleware
21
+
22
+
23
+ @base_class
24
+ class ExecutionContext(
25
+ AbstractKernelChild,
26
+ WithIoManager,
27
+ PrintableMixin,
28
+ BaseClass,
29
+ ):
30
+ command_wrapper: CommandMethodWrapper = public_field(
31
+ description="Wrapper around the command method being executed",
32
+ )
33
+ function: AnyCallable | None = public_field(
34
+ default=None,
35
+ description="Optional custom function to execute instead of command_wrapper.function",
36
+ )
37
+ function_kwargs: Kwargs = public_field(
38
+ factory=dict,
39
+ description="Keyword arguments passed to the command function",
40
+ )
41
+ kernel: CommandRunnerKernel = public_field(
42
+ description="The kernel is extracted from request", default=None
43
+ )
44
+ middleware: AbstractMiddleware | None = public_field(
45
+ description="Optional middleware applied in this execution context",
46
+ )
47
+ request: CommandRequest = public_field(
48
+ description="The command request associated with this execution",
49
+ )
50
+ _current_progress: ProgressHandle | None = private_field(
51
+ default=None,
52
+ description="Internal progress handle used to track execution progress",
53
+ )
54
+
55
+ def __attrs_post_init__(self) -> None:
56
+ self.function_kwargs["context"] = self
57
+ self.kernel = self.request.kernel
58
+ self.io = self.kernel.io
59
+
60
+ def create_progress_range(self, **kwargs) -> ProgressHandle:
61
+ self._current_progress = self.get_or_create_progress().create_range_handle(
62
+ **kwargs
63
+ )
64
+ return self._current_progress
65
+
66
+ def finish_progress(self, **kwargs) -> ProgressHandle:
67
+ self._current_progress.finish()
68
+
69
+ self._current_progress = self._current_progress.parent
70
+ self._current_progress.update(**kwargs)
71
+ return self._current_progress
72
+
73
+ def get_or_create_progress(self, **kwargs) -> ProgressHandle:
74
+ if self._current_progress is None:
75
+ self._current_progress = self.io.progress(
76
+ **kwargs, context=self.io.create_context()
77
+ ).get_handle()
78
+ return self._current_progress
@@ -0,0 +1,34 @@
1
+ """@alias decorator — attach one or more invocation aliases to a command wrapper.
2
+
3
+ Originally lived in ``wexample_cli.decorator.alias``; moved here so any
4
+ kernel using the wexample-cli command primitives can register aliases without
5
+ depending on wex-core. ``wex-core`` re-exports this symbol unchanged.
6
+
7
+ The decorator simply appends to the wrapper's ``aliases`` list — it does not
8
+ import the concrete wrapper class (which still lives in wex-core for now) to
9
+ avoid a circular dependency. Any object exposing an ``aliases: list[str]``
10
+ attribute is acceptable, which is the de facto contract.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import TypeVar
16
+
17
+ T = TypeVar("T")
18
+
19
+
20
+ def alias(*aliases: str):
21
+ """Append ``aliases`` to ``wrapper.aliases`` and return the wrapper.
22
+
23
+ Intended to be stacked on top of @command::
24
+
25
+ @alias("version")
26
+ @command(...)
27
+ def core__version__get(...): ...
28
+ """
29
+
30
+ def decorator(wrapper: T) -> T:
31
+ wrapper.aliases.extend(aliases)
32
+ return wrapper
33
+
34
+ return decorator
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ if TYPE_CHECKING:
6
+ from wexample_cli.common.command_method_wrapper import CommandMethodWrapper
7
+
8
+
9
+ def as_sudo() -> CommandMethodWrapper:
10
+ def decorator(wrapper: CommandMethodWrapper) -> CommandMethodWrapper:
11
+ wrapper.sudo = True
12
+ return wrapper
13
+
14
+ return decorator
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ if TYPE_CHECKING:
6
+ from wexample_helpers.const.types import AnyCallable
7
+
8
+ from wexample_cli.common.command_method_wrapper import CommandMethodWrapper
9
+
10
+
11
+ def command(type: str, description: str | None = None):
12
+ def decorator(function: AnyCallable) -> CommandMethodWrapper:
13
+ from wexample_cli.common.command_method_wrapper import CommandMethodWrapper
14
+
15
+ return CommandMethodWrapper(
16
+ type=type, function=function, description=description
17
+ )
18
+
19
+ return decorator
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ if TYPE_CHECKING:
6
+ from wexample_helpers.const.types import AnyCallable
7
+
8
+ from wexample_cli.common.command_method_wrapper import CommandMethodWrapper
9
+ from wexample_cli.middleware.abstract_middleware import AbstractMiddleware
10
+
11
+
12
+ def middleware(
13
+ name: str | type[AbstractMiddleware] | None = None,
14
+ middleware: type[AbstractMiddleware] | None = None,
15
+ **kwargs,
16
+ ) -> AnyCallable:
17
+ def decorator(command_wrapper: CommandMethodWrapper) -> CommandMethodWrapper:
18
+ # Type safety check
19
+ from wexample_cli.common.command_method_wrapper import CommandMethodWrapper
20
+
21
+ if not isinstance(command_wrapper, CommandMethodWrapper):
22
+ raise TypeError(
23
+ f"Invalid middleware usage: @middleware must decorate a {CommandMethodWrapper.__name__} "
24
+ "object (produced by @command). Make sure @command is applied *before* @middleware."
25
+ )
26
+
27
+ command_wrapper.register_middleware(middleware or name, kwargs)
28
+ return command_wrapper
29
+
30
+ return decorator
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ if TYPE_CHECKING:
6
+ from wexample_helpers.const.types import AnyCallable
7
+ from wexample_helpers.validator.abstract_validator import AbstractValidator
8
+
9
+ from wexample_cli.common.command_method_wrapper import CommandMethodWrapper
10
+
11
+
12
+ def option(
13
+ name: str,
14
+ type: type,
15
+ short_name: str | bool | None = None,
16
+ description: str | None = None,
17
+ required: bool = False,
18
+ default: Any = None,
19
+ is_flag: bool = False,
20
+ multiple: bool = False,
21
+ always_list: bool = False,
22
+ validators: list[AbstractValidator] | None = None,
23
+ ) -> AnyCallable:
24
+ def decorator(command_wrapper: CommandMethodWrapper) -> CommandMethodWrapper:
25
+ from wexample_app.command.option import Option
26
+
27
+ command_wrapper.set_option(
28
+ Option(
29
+ name=name,
30
+ short_name=short_name,
31
+ type=type,
32
+ description=description,
33
+ required=required,
34
+ default=default,
35
+ is_flag=is_flag,
36
+ multiple=multiple,
37
+ always_list=always_list,
38
+ validators=validators or [],
39
+ )
40
+ )
41
+
42
+ return command_wrapper
43
+
44
+ return decorator
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ if TYPE_CHECKING:
6
+ from wexample_helpers.const.types import AnyCallable
7
+
8
+ from wexample_cli.common.command_method_wrapper import CommandMethodWrapper
9
+
10
+ OPTION_NAME_STOP_ON_FAILURE: str = "stop_on_failure"
11
+
12
+
13
+ def option_stop_on_failure() -> AnyCallable:
14
+ def decorator(command_wrapper: CommandMethodWrapper) -> CommandMethodWrapper:
15
+ from wexample_app.command.option import Option
16
+
17
+ command_wrapper.set_option(
18
+ Option(
19
+ name=OPTION_NAME_STOP_ON_FAILURE,
20
+ type=bool,
21
+ description="Stop execution when exception occurs",
22
+ required=False,
23
+ default=False,
24
+ is_flag=True,
25
+ )
26
+ )
27
+
28
+ return command_wrapper
29
+
30
+ return decorator
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ if TYPE_CHECKING:
6
+ from wexample_cli.common.command_method_wrapper import CommandMethodWrapper
7
+
8
+
9
+ def webhook() -> type[CommandMethodWrapper]:
10
+ """Mark a command as webhook-accessible.
11
+
12
+ Sets the ``_wex_webhook`` attribute on the underlying function so the
13
+ webhook system can discover it. Token generation is explicit via
14
+ ``wex webhook/token-show``.
15
+
16
+ Usage::
17
+
18
+ @webhook()
19
+ @command(type=COMMAND_TYPE_ADDON)
20
+ def my__group__command(context): ...
21
+ """
22
+
23
+ def decorator(wrapper: CommandMethodWrapper) -> CommandMethodWrapper:
24
+ wrapper.webhook = True
25
+ return wrapper
26
+
27
+ return decorator # type: ignore[return-value]
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from wexample_helpers.exception.undefined_exception import UndefinedException
6
+
7
+
8
+ class AbstractCommandOptionException(UndefinedException):
9
+ """Base exception class for command option related errors."""
10
+
11
+ error_code: str = "COMMAND_OPTION_ERROR"
12
+
13
+ def __init__(
14
+ self,
15
+ option_name: str,
16
+ message: str,
17
+ data: dict[str, Any] | None = None,
18
+ cause: Exception | None = None,
19
+ previous: Exception | None = None,
20
+ ) -> None:
21
+ # Merge provided data with base data
22
+ merged_data = {option_name: option_name}
23
+ if data:
24
+ merged_data.update(data)
25
+
26
+ # Store option_name as instance attribute
27
+ self.option_name = option_name
28
+
29
+ super().__init__(
30
+ message=message, data=merged_data, cause=cause, previous=previous
31
+ )
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ from wexample_cli.exception.abstract_command_option_exception import (
4
+ AbstractCommandOptionException,
5
+ )
6
+
7
+
8
+ class CommandOptionMissingException(AbstractCommandOptionException):
9
+ """Exception raised when a required command option is missing."""
10
+
11
+ error_code: str = "COMMAND_OPTION_MISSING"
12
+
13
+ def __init__(
14
+ self,
15
+ option_name: str,
16
+ cause: Exception | None = None,
17
+ previous: Exception | None = None,
18
+ ) -> None:
19
+ # Store option_name as instance attribute
20
+ self.option_name = option_name
21
+
22
+ super().__init__(
23
+ option_name=option_name,
24
+ message=f"Required option '{option_name}' is missing",
25
+ cause=cause,
26
+ previous=previous,
27
+ )
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from wexample_cli.exception.abstract_command_option_exception import (
6
+ AbstractCommandOptionException,
7
+ )
8
+
9
+
10
+ class CommandOptionValidationException(AbstractCommandOptionException):
11
+ def __init__(self, option_name: str, value: Any, error_message: str) -> None:
12
+ super().__init__(
13
+ message=f'Option "{option_name}" validation failed: {error_message}',
14
+ option_name=option_name,
15
+ )
16
+ self.value = value
17
+ self.error_message = error_message
@@ -0,0 +1,154 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from wexample_helpers.classes.base_class import BaseClass
6
+ from wexample_helpers.classes.field import public_field
7
+ from wexample_helpers.classes.mixin.has_class_dependencies import HasClassDependencies
8
+ from wexample_helpers.classes.mixin.has_snake_short_class_name_class_mixin import (
9
+ HasSnakeShortClassNameClassMixin,
10
+ )
11
+ from wexample_helpers.classes.mixin.has_two_steps_init import HasTwoStepInit
12
+ from wexample_helpers.decorator.base_class import base_class
13
+
14
+ if TYPE_CHECKING:
15
+ from wexample_app.command.option import Option
16
+ from wexample_app.common.command_request import CommandRequest
17
+ from wexample_helpers.const.types import Kwargs
18
+
19
+ from wexample_cli.common.command_method_wrapper import CommandMethodWrapper
20
+ from wexample_cli.context.execution_context import ExecutionContext
21
+
22
+
23
+ @base_class
24
+ class AbstractMiddleware(
25
+ HasSnakeShortClassNameClassMixin,
26
+ HasTwoStepInit,
27
+ HasClassDependencies,
28
+ BaseClass,
29
+ ):
30
+ max_iterations: int | None = public_field(
31
+ default=None,
32
+ description="Maximum number of iterations allowed for this middleware",
33
+ )
34
+ normalized_options: list[Option] = public_field(
35
+ factory=list,
36
+ description="List of normalized option objects for middleware configuration",
37
+ )
38
+ options: list[Option] = public_field(
39
+ factory=list,
40
+ description="Option objects provided to the middleware",
41
+ )
42
+ parallel: None | bool | str = public_field(
43
+ default=False,
44
+ description="Whether the middleware should run in parallel (bool, str, or None)",
45
+ )
46
+ show_progress: None | bool | str = public_field(
47
+ default=False,
48
+ description="Whether to display a progress indicator during execution",
49
+ )
50
+ stop_on_failure: None | bool | str = public_field(
51
+ default=False,
52
+ description="Whether to stop execution immediately if a failure occurs",
53
+ )
54
+
55
+ def __attrs_post_init__(self) -> None:
56
+ self.normalized_options = self._init_options()
57
+
58
+ @classmethod
59
+ def get_class_name_suffix(cls) -> str | None:
60
+ return "Middleware"
61
+
62
+ def append_options(
63
+ self, request: CommandRequest, command_wrapper: CommandMethodWrapper
64
+ ) -> None:
65
+ from wexample_app.command.option import Option
66
+
67
+ from wexample_cli.const.middleware import MIDDLEWARE_OPTION_VALUE_OPTIONAL
68
+
69
+ if self.parallel:
70
+ self.stop_on_failure = False
71
+ request.kernel.io.log(
72
+ 'Option "stop_on_failure" will be ignored due to parallelization'
73
+ )
74
+
75
+ if self.parallel == MIDDLEWARE_OPTION_VALUE_OPTIONAL:
76
+ command_wrapper.set_option(
77
+ Option(
78
+ name="parallel",
79
+ short_name="pll",
80
+ type=bool,
81
+ description="Execute async when possible",
82
+ default=False,
83
+ is_flag=True,
84
+ )
85
+ )
86
+
87
+ if self.show_progress == MIDDLEWARE_OPTION_VALUE_OPTIONAL:
88
+ command_wrapper.set_option(
89
+ Option(
90
+ name="progress",
91
+ short_name="prgs",
92
+ type=bool,
93
+ description="Display progress bar",
94
+ default=False,
95
+ is_flag=True,
96
+ )
97
+ )
98
+
99
+ if self.stop_on_failure == MIDDLEWARE_OPTION_VALUE_OPTIONAL:
100
+ command_wrapper.set_option(
101
+ Option(
102
+ name="stop-on-failure",
103
+ short_name="sof",
104
+ type=bool,
105
+ description="Stop at first failure response if not async",
106
+ default=False,
107
+ is_flag=True,
108
+ )
109
+ )
110
+
111
+ def build_execution_contexts(
112
+ self,
113
+ command_wrapper: CommandMethodWrapper,
114
+ request: CommandRequest,
115
+ function_kwargs: Kwargs,
116
+ ) -> list[ExecutionContext]:
117
+ from wexample_cli.context.execution_context import ExecutionContext
118
+
119
+ self.validate_options(
120
+ command_wrapper=command_wrapper,
121
+ request=request,
122
+ function_kwargs=function_kwargs,
123
+ )
124
+
125
+ return [
126
+ ExecutionContext(
127
+ middleware=self,
128
+ command_wrapper=command_wrapper,
129
+ request=request,
130
+ function_kwargs=function_kwargs,
131
+ )
132
+ ]
133
+
134
+ def get_option_by_name(self, name: str) -> Option | None:
135
+ """Get an option by its name from the normalized options."""
136
+ for option in self.normalized_options:
137
+ if option.name == name:
138
+ return option
139
+ return None
140
+
141
+ def validate_options(
142
+ self,
143
+ command_wrapper: CommandMethodWrapper,
144
+ request: CommandRequest,
145
+ function_kwargs: Kwargs,
146
+ ) -> bool:
147
+ return True
148
+
149
+ def _get_middleware_options(self) -> list[Option]:
150
+ return []
151
+
152
+ def _init_options(self) -> list[Option]:
153
+ """Get middleware options."""
154
+ return self._get_middleware_options()
File without changes
File without changes