wexample-cli 0.4.0__tar.gz → 0.5.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 (34) hide show
  1. {wexample_cli-0.4.0 → wexample_cli-0.5.0}/PKG-INFO +2 -2
  2. {wexample_cli-0.4.0 → wexample_cli-0.5.0}/README.md +1 -1
  3. {wexample_cli-0.4.0 → wexample_cli-0.5.0}/pyproject.toml +1 -1
  4. {wexample_cli-0.4.0 → wexample_cli-0.5.0}/src/wexample_cli/command/extended_command.py +119 -90
  5. {wexample_cli-0.4.0 → wexample_cli-0.5.0}/src/wexample_cli/common/command_method_wrapper.py +13 -0
  6. wexample_cli-0.5.0/src/wexample_cli/decorator/screenable.py +115 -0
  7. {wexample_cli-0.4.0 → wexample_cli-0.5.0}/src/wexample_cli/__init__.py +0 -0
  8. {wexample_cli-0.4.0 → wexample_cli-0.5.0}/src/wexample_cli/command/__init__.py +0 -0
  9. {wexample_cli-0.4.0 → wexample_cli-0.5.0}/src/wexample_cli/common/__init__.py +0 -0
  10. {wexample_cli-0.4.0 → wexample_cli-0.5.0}/src/wexample_cli/const/__init__.py +0 -0
  11. {wexample_cli-0.4.0 → wexample_cli-0.5.0}/src/wexample_cli/const/middleware.py +0 -0
  12. {wexample_cli-0.4.0 → wexample_cli-0.5.0}/src/wexample_cli/const/types.py +0 -0
  13. {wexample_cli-0.4.0 → wexample_cli-0.5.0}/src/wexample_cli/context/__init__.py +0 -0
  14. {wexample_cli-0.4.0 → wexample_cli-0.5.0}/src/wexample_cli/context/execution_context.py +0 -0
  15. {wexample_cli-0.4.0 → wexample_cli-0.5.0}/src/wexample_cli/decorator/__init__.py +0 -0
  16. {wexample_cli-0.4.0 → wexample_cli-0.5.0}/src/wexample_cli/decorator/alias.py +0 -0
  17. {wexample_cli-0.4.0 → wexample_cli-0.5.0}/src/wexample_cli/decorator/as_sudo.py +0 -0
  18. {wexample_cli-0.4.0 → wexample_cli-0.5.0}/src/wexample_cli/decorator/command.py +0 -0
  19. {wexample_cli-0.4.0 → wexample_cli-0.5.0}/src/wexample_cli/decorator/middleware.py +0 -0
  20. {wexample_cli-0.4.0 → wexample_cli-0.5.0}/src/wexample_cli/decorator/option.py +0 -0
  21. {wexample_cli-0.4.0 → wexample_cli-0.5.0}/src/wexample_cli/decorator/option_stop_on_failure.py +0 -0
  22. {wexample_cli-0.4.0 → wexample_cli-0.5.0}/src/wexample_cli/decorator/webhook.py +0 -0
  23. {wexample_cli-0.4.0 → wexample_cli-0.5.0}/src/wexample_cli/exception/__init__.py +0 -0
  24. {wexample_cli-0.4.0 → wexample_cli-0.5.0}/src/wexample_cli/exception/abstract_command_option_exception.py +0 -0
  25. {wexample_cli-0.4.0 → wexample_cli-0.5.0}/src/wexample_cli/exception/command_option_missing_exception.py +0 -0
  26. {wexample_cli-0.4.0 → wexample_cli-0.5.0}/src/wexample_cli/exception/command_option_validation_exception.py +0 -0
  27. {wexample_cli-0.4.0 → wexample_cli-0.5.0}/src/wexample_cli/helpers/__init__.py +0 -0
  28. {wexample_cli-0.4.0 → wexample_cli-0.5.0}/src/wexample_cli/helpers/extra_args.py +0 -0
  29. {wexample_cli-0.4.0 → wexample_cli-0.5.0}/src/wexample_cli/middleware/__init__.py +0 -0
  30. {wexample_cli-0.4.0 → wexample_cli-0.5.0}/src/wexample_cli/middleware/abstract_middleware.py +0 -0
  31. {wexample_cli-0.4.0 → wexample_cli-0.5.0}/src/wexample_cli/py.typed +0 -0
  32. {wexample_cli-0.4.0 → wexample_cli-0.5.0}/src/wexample_cli/testing/__init__.py +0 -0
  33. {wexample_cli-0.4.0 → wexample_cli-0.5.0}/src/wexample_cli/testing/kernel.py +0 -0
  34. {wexample_cli-0.4.0 → wexample_cli-0.5.0}/tests/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: wexample-cli
3
- Version: 0.4.0
3
+ Version: 0.5.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
@@ -20,7 +20,7 @@ Description-Content-Type: text/markdown
20
20
 
21
21
  # cli
22
22
 
23
- Version: 0.4.0
23
+ Version: 0.5.0
24
24
 
25
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.
26
26
 
@@ -1,6 +1,6 @@
1
1
  # cli
2
2
 
3
- Version: 0.4.0
3
+ Version: 0.5.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
 
@@ -6,7 +6,7 @@ build-backend = "pdm.backend"
6
6
 
7
7
  [project]
8
8
  name = "wexample-cli"
9
- version = "0.4.0"
9
+ version = "0.5.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" },
@@ -25,16 +25,14 @@ class ExtendedCommand(Command):
25
25
  # Now override function based on command_wrapper
26
26
  self.function = self.command_wrapper.function
27
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
28
+ @staticmethod
29
+ def _wrap_dispatch(wrapper_fn: Any, inner: Any, request: CommandRequest) -> Any:
30
+ def wrapped(function_kwargs: dict[str, Any]) -> Any:
31
+ return wrapper_fn(inner, function_kwargs, request)
32
32
 
33
- from wexample_cli.const.middleware import (
34
- MIDDLEWARE_OPTION_VALUE_ALLWAYS,
35
- MIDDLEWARE_OPTION_VALUE_OPTIONAL,
36
- )
33
+ return wrapped
37
34
 
35
+ def execute_request(self, request: CommandRequest) -> Any:
38
36
  # Instantiate middlewares first so their contributed options are visible
39
37
  # to --help below and to option validation in _build_function_kwargs.
40
38
  middlewares_attributes = self.command_wrapper.middlewares_attributes
@@ -55,6 +53,111 @@ class ExtendedCommand(Command):
55
53
 
56
54
  function_kwargs = self._build_function_kwargs(request=request)
57
55
 
56
+ # Compose pipeline wrappers around the dispatch. Each wrapper has
57
+ # signature (next_dispatch, function_kwargs, request) -> response and
58
+ # decides whether to call next_dispatch (passthrough), call it multiple
59
+ # times (refresh loop), modify kwargs, etc. Generic mechanism — knows
60
+ # nothing about specific wrappers like @screenable.
61
+ dispatch = self._make_dispatch(request=request)
62
+ for wrapper_fn in reversed(self.command_wrapper.pipeline_wrappers):
63
+ dispatch = self._wrap_dispatch(wrapper_fn, dispatch, request)
64
+
65
+ return dispatch(function_kwargs)
66
+
67
+ def _build_function_kwargs(self, request: CommandRequest) -> dict[str, Any]:
68
+ from wexample_cli.exception.command_option_missing_exception import (
69
+ CommandOptionMissingException,
70
+ )
71
+
72
+ # Allow middleware to add extra options.
73
+ for middleware in self.command_wrapper.middlewares:
74
+ middleware.append_options(
75
+ request=request,
76
+ command_wrapper=self.command_wrapper,
77
+ )
78
+
79
+ """Execute the command with the given request arguments."""
80
+ # Parse and convert arguments to appropriate types
81
+ parsed_args = self._parse_arguments(request.arguments)
82
+
83
+ """Build the final kwargs dictionary for the function call."""
84
+ function_kwargs = {}
85
+
86
+ # Process all declared options
87
+ for option in self.command_wrapper.options:
88
+ value = None
89
+
90
+ # If the option is in parsed args, use that value
91
+ if option.name in parsed_args:
92
+ value = parsed_args[option.name]
93
+ # Otherwise, use the default value if available
94
+ elif option.default is not None:
95
+ value = option.default
96
+ # If the option is required but not provided, raise an error
97
+ elif option.required:
98
+ raise CommandOptionMissingException(option_name=option.name)
99
+
100
+ # Validate the value if validators are defined and value is not None
101
+ if value is not None and option.validators:
102
+ # For multiple values, validate each item individually
103
+ values_to_validate = value if isinstance(value, list) else [value]
104
+
105
+ for val in values_to_validate:
106
+ for validator in option.validators:
107
+ if not validator.validate(val):
108
+ from wexample_cli.exception.command_option_validation_exception import (
109
+ CommandOptionValidationException,
110
+ )
111
+
112
+ raise CommandOptionValidationException(
113
+ option_name=option.name,
114
+ value=val,
115
+ error_message=validator.get_error_message(val),
116
+ )
117
+
118
+ # Normalize to list if always_list is True
119
+ if value is not None and option.always_list and not isinstance(value, list):
120
+ value = [value]
121
+
122
+ # Assign the validated value
123
+ if value is not None:
124
+ option.value = function_kwargs[option.name] = value
125
+
126
+ # `--` passthrough: if the user supplied extra args after `--`, the
127
+ # parser put them in parsed_args["__extra_args__"]. Forward them as
128
+ # `extra_args` if the wrapped function declares that parameter;
129
+ # otherwise raise so misuse fails loudly instead of silently dropping
130
+ # input.
131
+ if "__extra_args__" in parsed_args:
132
+ import inspect
133
+
134
+ sig = inspect.signature(self.command_wrapper.function)
135
+ if "extra_args" in sig.parameters:
136
+ function_kwargs["extra_args"] = parsed_args["__extra_args__"]
137
+ else:
138
+ from wexample_app.exception.command_unexpected_argument_exception import (
139
+ CommandUnexpectedArgumentException,
140
+ )
141
+
142
+ raise CommandUnexpectedArgumentException(
143
+ argument="--",
144
+ allowed_arguments=self.command_wrapper.get_options_names(),
145
+ )
146
+
147
+ return function_kwargs
148
+
149
+ def _execute_dispatch(
150
+ self, request: CommandRequest, function_kwargs: dict[str, Any]
151
+ ) -> Any:
152
+ from wexample_app.helpers.response import response_normalize
153
+ from wexample_app.response.failure_response import FailureResponse
154
+ from wexample_app.response.multiple_response import MultipleResponse
155
+
156
+ from wexample_cli.const.middleware import (
157
+ MIDDLEWARE_OPTION_VALUE_ALLWAYS,
158
+ MIDDLEWARE_OPTION_VALUE_OPTIONAL,
159
+ )
160
+
58
161
  if len(self.command_wrapper.middlewares) > 0:
59
162
  output = MultipleResponse(kernel=self.kernel)
60
163
 
@@ -167,88 +270,6 @@ class ExtendedCommand(Command):
167
270
  kernel=self.kernel, response=function_to_execute(**context.function_kwargs)
168
271
  )
169
272
 
170
- def _build_function_kwargs(self, request: CommandRequest) -> dict[str, Any]:
171
- from wexample_cli.exception.command_option_missing_exception import (
172
- CommandOptionMissingException,
173
- )
174
-
175
- # Allow middleware to add extra options.
176
- for middleware in self.command_wrapper.middlewares:
177
- middleware.append_options(
178
- request=request,
179
- command_wrapper=self.command_wrapper,
180
- )
181
-
182
- """Execute the command with the given request arguments."""
183
- # Parse and convert arguments to appropriate types
184
- parsed_args = self._parse_arguments(request.arguments)
185
-
186
- """Build the final kwargs dictionary for the function call."""
187
- function_kwargs = {}
188
-
189
- # Process all declared options
190
- for option in self.command_wrapper.options:
191
- value = None
192
-
193
- # If the option is in parsed args, use that value
194
- if option.name in parsed_args:
195
- value = parsed_args[option.name]
196
- # Otherwise, use the default value if available
197
- elif option.default is not None:
198
- value = option.default
199
- # If the option is required but not provided, raise an error
200
- elif option.required:
201
- raise CommandOptionMissingException(option_name=option.name)
202
-
203
- # Validate the value if validators are defined and value is not None
204
- if value is not None and option.validators:
205
- # For multiple values, validate each item individually
206
- values_to_validate = value if isinstance(value, list) else [value]
207
-
208
- for val in values_to_validate:
209
- for validator in option.validators:
210
- if not validator.validate(val):
211
- from wexample_cli.exception.command_option_validation_exception import (
212
- CommandOptionValidationException,
213
- )
214
-
215
- raise CommandOptionValidationException(
216
- option_name=option.name,
217
- value=val,
218
- error_message=validator.get_error_message(val),
219
- )
220
-
221
- # Normalize to list if always_list is True
222
- if value is not None and option.always_list and not isinstance(value, list):
223
- value = [value]
224
-
225
- # Assign the validated value
226
- if value is not None:
227
- option.value = function_kwargs[option.name] = value
228
-
229
- # `--` passthrough: if the user supplied extra args after `--`, the
230
- # parser put them in parsed_args["__extra_args__"]. Forward them as
231
- # `extra_args` if the wrapped function declares that parameter;
232
- # otherwise raise so misuse fails loudly instead of silently dropping
233
- # input.
234
- if "__extra_args__" in parsed_args:
235
- import inspect
236
-
237
- sig = inspect.signature(self.command_wrapper.function)
238
- if "extra_args" in sig.parameters:
239
- function_kwargs["extra_args"] = parsed_args["__extra_args__"]
240
- else:
241
- from wexample_app.exception.command_unexpected_argument_exception import (
242
- CommandUnexpectedArgumentException,
243
- )
244
-
245
- raise CommandUnexpectedArgumentException(
246
- argument="--",
247
- allowed_arguments=self.command_wrapper.get_options_names(),
248
- )
249
-
250
- return function_kwargs
251
-
252
273
  async def _execute_passes_parallel(
253
274
  self, execution_contexts: list[ExecutionContext]
254
275
  ) -> list[Any]:
@@ -311,6 +332,14 @@ class ExtendedCommand(Command):
311
332
 
312
333
  return responses
313
334
 
335
+ def _make_dispatch(self, request: CommandRequest) -> Any:
336
+ def dispatch(function_kwargs: dict[str, Any]) -> Any:
337
+ return self._execute_dispatch(
338
+ request=request, function_kwargs=function_kwargs
339
+ )
340
+
341
+ return dispatch
342
+
314
343
  def _parse_arguments(self, arguments: list[str] | dict) -> ParsedArgs:
315
344
  """Parse raw command line arguments into a dictionary of option name to value.
316
345
 
@@ -46,6 +46,15 @@ class CommandMethodWrapper(BaseClass):
46
46
  factory=list,
47
47
  description="List of command options available for this method",
48
48
  )
49
+ pipeline_wrappers: list[AnyCallable] = public_field(
50
+ factory=list,
51
+ description=(
52
+ "Pipeline wrappers applied around the full dispatch. Each wrapper "
53
+ "has signature `(next_dispatch, function_kwargs, request) -> response` "
54
+ "and decides whether/how to invoke `next_dispatch(kwargs)`. "
55
+ "Registered in outer-to-inner order: the first registered wraps everything."
56
+ ),
57
+ )
49
58
  sudo: bool = public_field(
50
59
  default=False,
51
60
  description="If True, re-exec the entire process under sudo if not already root",
@@ -95,6 +104,10 @@ class CommandMethodWrapper(BaseClass):
95
104
 
96
105
  self.middlewares_attributes[middleware_name] = middleware_kwargs
97
106
 
107
+ def register_pipeline_wrapper(self, wrapper_fn: AnyCallable) -> None:
108
+ """Register a pipeline wrapper. See `pipeline_wrappers` field doc."""
109
+ self.pipeline_wrappers.append(wrapper_fn)
110
+
98
111
  def set_middleware(self, middleware: AbstractMiddleware) -> None:
99
112
  self.middlewares.append(middleware)
100
113
 
@@ -0,0 +1,115 @@
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
+
8
+ from wexample_cli.common.command_method_wrapper import CommandMethodWrapper
9
+
10
+ OPTION_NAME_SCREEN: str = "screen"
11
+ OPTION_NAME_SCREEN_INTERVAL: str = "screen_interval"
12
+
13
+
14
+ def screenable(interval: float = 1.0, height: int = 30) -> AnyCallable:
15
+ """Make a command refreshable via `--screen`.
16
+
17
+ Adds two CLI options to the wrapped command:
18
+ - `--screen` (flag): opt-in to continuous refresh.
19
+ - `--screen-interval <float>`: override the default interval.
20
+
21
+ Registers a pipeline wrapper that handles the refresh loop. The wrapper
22
+ pops the screen kwargs unconditionally (so they never leak to the user
23
+ function) and, when enabled, drives a `ScreenPromptResponse` whose callback
24
+ invokes the rest of the dispatch on every frame.
25
+ """
26
+
27
+ def decorator(command_wrapper: CommandMethodWrapper) -> CommandMethodWrapper:
28
+ from wexample_app.command.option import Option
29
+
30
+ from wexample_cli.common.command_method_wrapper import CommandMethodWrapper
31
+
32
+ if not isinstance(command_wrapper, CommandMethodWrapper):
33
+ raise TypeError(
34
+ "@screenable must decorate a CommandMethodWrapper produced by @command. "
35
+ "Apply @command first."
36
+ )
37
+
38
+ command_wrapper.set_option(
39
+ Option(
40
+ name=OPTION_NAME_SCREEN,
41
+ type=bool,
42
+ description="Refresh the command continuously like `watch`.",
43
+ default=False,
44
+ is_flag=True,
45
+ )
46
+ )
47
+ command_wrapper.set_option(
48
+ Option(
49
+ name=OPTION_NAME_SCREEN_INTERVAL,
50
+ type=float,
51
+ description="Override the refresh interval in seconds.",
52
+ default=None,
53
+ required=False,
54
+ )
55
+ )
56
+
57
+ command_wrapper.register_pipeline_wrapper(
58
+ _build_screen_wrapper(default_interval=float(interval), height=int(height))
59
+ )
60
+
61
+ return command_wrapper
62
+
63
+ return decorator
64
+
65
+
66
+ def _build_screen_wrapper(default_interval: float, height: int) -> AnyCallable:
67
+ """Build the pipeline wrapper closure for one decorated command."""
68
+
69
+ def screen_wrapper(
70
+ next_dispatch: AnyCallable,
71
+ function_kwargs: dict[str, Any],
72
+ request: Any,
73
+ ) -> Any:
74
+ # Always strip screen kwargs so they cannot leak to the user function
75
+ # or to inner pipeline wrappers, whether or not --screen was passed.
76
+ enabled = bool(function_kwargs.pop(OPTION_NAME_SCREEN, False))
77
+ interval_override = function_kwargs.pop(OPTION_NAME_SCREEN_INTERVAL, None)
78
+
79
+ if not enabled:
80
+ return next_dispatch(function_kwargs)
81
+
82
+ import time
83
+
84
+ from wexample_app.response.null_response import NullResponse
85
+
86
+ interval = (
87
+ float(interval_override)
88
+ if interval_override is not None
89
+ else default_interval
90
+ )
91
+ kernel_io = request.kernel.io
92
+ # Skip the inter-frame sleep on the very first frame so the user sees
93
+ # output immediately instead of waiting `interval` seconds before the
94
+ # first render.
95
+ is_first_frame = [True]
96
+
97
+ def _frame(response) -> None:
98
+ if not is_first_frame[0]:
99
+ time.sleep(interval)
100
+ is_first_frame[0] = False
101
+
102
+ response.clear()
103
+ saved_output = kernel_io.output
104
+ kernel_io.output = response.io.output
105
+ try:
106
+ next_dispatch(function_kwargs)
107
+ finally:
108
+ kernel_io.output = saved_output
109
+
110
+ response.reload()
111
+
112
+ kernel_io.screen(callback=_frame, height=height)
113
+ return NullResponse(kernel=request.kernel)
114
+
115
+ return screen_wrapper