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.
- {wexample_cli-0.0.3 → wexample_cli-0.2.0}/PKG-INFO +13 -2
- {wexample_cli-0.0.3 → wexample_cli-0.2.0}/README.md +8 -1
- {wexample_cli-0.0.3 → wexample_cli-0.2.0}/pyproject.toml +7 -2
- {wexample_cli-0.0.3/tests → wexample_cli-0.2.0/src/wexample_cli/command}/__init__.py +0 -0
- wexample_cli-0.2.0/src/wexample_cli/command/extended_command.py +344 -0
- wexample_cli-0.2.0/src/wexample_cli/common/command_method_wrapper.py +105 -0
- wexample_cli-0.2.0/src/wexample_cli/const/__init__.py +0 -0
- wexample_cli-0.2.0/src/wexample_cli/const/middleware.py +5 -0
- wexample_cli-0.2.0/src/wexample_cli/const/types.py +5 -0
- wexample_cli-0.2.0/src/wexample_cli/context/__init__.py +0 -0
- wexample_cli-0.2.0/src/wexample_cli/context/execution_context.py +78 -0
- wexample_cli-0.2.0/src/wexample_cli/decorator/__init__.py +0 -0
- wexample_cli-0.2.0/src/wexample_cli/decorator/alias.py +34 -0
- wexample_cli-0.2.0/src/wexample_cli/decorator/as_sudo.py +14 -0
- wexample_cli-0.2.0/src/wexample_cli/decorator/command.py +19 -0
- wexample_cli-0.2.0/src/wexample_cli/decorator/middleware.py +30 -0
- wexample_cli-0.2.0/src/wexample_cli/decorator/option.py +44 -0
- wexample_cli-0.2.0/src/wexample_cli/decorator/option_stop_on_failure.py +30 -0
- wexample_cli-0.2.0/src/wexample_cli/decorator/webhook.py +27 -0
- wexample_cli-0.2.0/src/wexample_cli/exception/__init__.py +0 -0
- wexample_cli-0.2.0/src/wexample_cli/exception/abstract_command_option_exception.py +31 -0
- wexample_cli-0.2.0/src/wexample_cli/exception/command_option_missing_exception.py +27 -0
- wexample_cli-0.2.0/src/wexample_cli/exception/command_option_validation_exception.py +17 -0
- wexample_cli-0.2.0/src/wexample_cli/middleware/__init__.py +0 -0
- wexample_cli-0.2.0/src/wexample_cli/middleware/abstract_middleware.py +154 -0
- wexample_cli-0.2.0/src/wexample_cli/py.typed +0 -0
- wexample_cli-0.2.0/tests/__init__.py +0 -0
- {wexample_cli-0.0.3 → wexample_cli-0.2.0}/src/wexample_cli/__init__.py +0 -0
- /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
|
+
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
|
|
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
|
+
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
|
|
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"
|
|
File without changes
|
|
@@ -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
|
|
File without changes
|
|
@@ -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
|
|
File without changes
|
|
@@ -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]
|
|
File without changes
|
|
@@ -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
|
|
File without changes
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|