wexample-cli 1.1.0__tar.gz → 1.3.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 (37) hide show
  1. {wexample_cli-1.1.0 → wexample_cli-1.3.0}/PKG-INFO +8 -8
  2. {wexample_cli-1.1.0 → wexample_cli-1.3.0}/README.md +4 -4
  3. {wexample_cli-1.1.0 → wexample_cli-1.3.0}/pyproject.toml +4 -4
  4. {wexample_cli-1.1.0 → wexample_cli-1.3.0}/src/wexample_cli/command/extended_command.py +20 -20
  5. {wexample_cli-1.1.0 → wexample_cli-1.3.0}/src/wexample_cli/common/command_method_wrapper.py +3 -3
  6. {wexample_cli-1.1.0 → wexample_cli-1.3.0}/src/wexample_cli/context/execution_context.py +9 -7
  7. {wexample_cli-1.1.0 → wexample_cli-1.3.0}/src/wexample_cli/decorator/as_sudo.py +5 -4
  8. {wexample_cli-1.1.0 → wexample_cli-1.3.0}/src/wexample_cli/decorator/middleware.py +2 -3
  9. {wexample_cli-1.1.0 → wexample_cli-1.3.0}/src/wexample_cli/decorator/option_stop_on_failure.py +2 -2
  10. {wexample_cli-1.1.0 → wexample_cli-1.3.0}/src/wexample_cli/decorator/screenable.py +8 -12
  11. {wexample_cli-1.1.0 → wexample_cli-1.3.0}/src/wexample_cli/decorator/webhook.py +4 -4
  12. {wexample_cli-1.1.0 → wexample_cli-1.3.0}/src/wexample_cli/helpers/extra_args.py +4 -4
  13. {wexample_cli-1.1.0 → wexample_cli-1.3.0}/src/wexample_cli/middleware/abstract_middleware.py +13 -14
  14. wexample_cli-1.3.0/tests/helpers/__init__.py +0 -0
  15. wexample_cli-1.3.0/tests/helpers/test_extra_args.py +38 -0
  16. {wexample_cli-1.1.0 → wexample_cli-1.3.0}/src/wexample_cli/__init__.py +0 -0
  17. {wexample_cli-1.1.0 → wexample_cli-1.3.0}/src/wexample_cli/command/__init__.py +0 -0
  18. {wexample_cli-1.1.0 → wexample_cli-1.3.0}/src/wexample_cli/common/__init__.py +0 -0
  19. {wexample_cli-1.1.0 → wexample_cli-1.3.0}/src/wexample_cli/const/__init__.py +0 -0
  20. {wexample_cli-1.1.0 → wexample_cli-1.3.0}/src/wexample_cli/const/middleware.py +0 -0
  21. {wexample_cli-1.1.0 → wexample_cli-1.3.0}/src/wexample_cli/const/tags.py +0 -0
  22. {wexample_cli-1.1.0 → wexample_cli-1.3.0}/src/wexample_cli/const/types.py +0 -0
  23. {wexample_cli-1.1.0 → wexample_cli-1.3.0}/src/wexample_cli/context/__init__.py +0 -0
  24. {wexample_cli-1.1.0 → wexample_cli-1.3.0}/src/wexample_cli/decorator/__init__.py +0 -0
  25. {wexample_cli-1.1.0 → wexample_cli-1.3.0}/src/wexample_cli/decorator/alias.py +0 -0
  26. {wexample_cli-1.1.0 → wexample_cli-1.3.0}/src/wexample_cli/decorator/command.py +0 -0
  27. {wexample_cli-1.1.0 → wexample_cli-1.3.0}/src/wexample_cli/decorator/option.py +0 -0
  28. {wexample_cli-1.1.0 → wexample_cli-1.3.0}/src/wexample_cli/exception/__init__.py +0 -0
  29. {wexample_cli-1.1.0 → wexample_cli-1.3.0}/src/wexample_cli/exception/abstract_command_option_exception.py +0 -0
  30. {wexample_cli-1.1.0 → wexample_cli-1.3.0}/src/wexample_cli/exception/command_option_missing_exception.py +0 -0
  31. {wexample_cli-1.1.0 → wexample_cli-1.3.0}/src/wexample_cli/exception/command_option_validation_exception.py +0 -0
  32. {wexample_cli-1.1.0 → wexample_cli-1.3.0}/src/wexample_cli/helpers/__init__.py +0 -0
  33. {wexample_cli-1.1.0 → wexample_cli-1.3.0}/src/wexample_cli/middleware/__init__.py +0 -0
  34. {wexample_cli-1.1.0 → wexample_cli-1.3.0}/src/wexample_cli/py.typed +0 -0
  35. {wexample_cli-1.1.0 → wexample_cli-1.3.0}/src/wexample_cli/testing/__init__.py +0 -0
  36. {wexample_cli-1.1.0 → wexample_cli-1.3.0}/src/wexample_cli/testing/kernel.py +0 -0
  37. {wexample_cli-1.1.0 → wexample_cli-1.3.0}/tests/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: wexample-cli
3
- Version: 1.1.0
3
+ Version: 1.3.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
@@ -10,9 +10,9 @@ Classifier: Operating System :: OS Independent
10
10
  Project-URL: homepage, https://github.com/wexample/python-cli
11
11
  Requires-Python: >=3.10
12
12
  Requires-Dist: attrs>=23.1.0
13
- Requires-Dist: wexample-app>=18.0.0
14
- Requires-Dist: wexample-helpers>=16.0.0
15
- Requires-Dist: wexample-prompt>=13.1.0
13
+ Requires-Dist: wexample-app>=18.2.0
14
+ Requires-Dist: wexample-helpers>=17.0.0
15
+ Requires-Dist: wexample-prompt>=13.3.0
16
16
  Provides-Extra: dev
17
17
  Requires-Dist: pytest; extra == "dev"
18
18
  Requires-Dist: pytest-cov; extra == "dev"
@@ -20,7 +20,7 @@ Description-Content-Type: text/markdown
20
20
 
21
21
  # cli
22
22
 
23
- Version: 1.1.0
23
+ Version: 1.3.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
 
@@ -100,9 +100,9 @@ Visit the [Wexample Suite documentation](https://docs.wexample.com) for the comp
100
100
  ## Dependencies
101
101
 
102
102
  - attrs: >=23.1.0
103
- - wexample-app: >=18.0.0
104
- - wexample-helpers: >=16.0.0
105
- - wexample-prompt: >=13.1.0
103
+ - wexample-app: >=18.2.0
104
+ - wexample-helpers: >=17.0.0
105
+ - wexample-prompt: >=13.3.0
106
106
 
107
107
  ## Versioning & Compatibility Policy
108
108
 
@@ -1,6 +1,6 @@
1
1
  # cli
2
2
 
3
- Version: 1.1.0
3
+ Version: 1.3.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
 
@@ -80,9 +80,9 @@ Visit the [Wexample Suite documentation](https://docs.wexample.com) for the comp
80
80
  ## Dependencies
81
81
 
82
82
  - attrs: >=23.1.0
83
- - wexample-app: >=18.0.0
84
- - wexample-helpers: >=16.0.0
85
- - wexample-prompt: >=13.1.0
83
+ - wexample-app: >=18.2.0
84
+ - wexample-helpers: >=17.0.0
85
+ - wexample-prompt: >=13.3.0
86
86
 
87
87
  ## Versioning & Compatibility Policy
88
88
 
@@ -6,7 +6,7 @@ build-backend = "pdm.backend"
6
6
 
7
7
  [project]
8
8
  name = "wexample-cli"
9
- version = "1.1.0"
9
+ version = "1.3.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" },
@@ -19,9 +19,9 @@ classifiers = [
19
19
  ]
20
20
  dependencies = [
21
21
  "attrs>=23.1.0",
22
- "wexample-app>=18.0.0",
23
- "wexample-helpers>=16.0.0",
24
- "wexample-prompt>=13.1.0",
22
+ "wexample-app>=18.2.0",
23
+ "wexample-helpers>=17.0.0",
24
+ "wexample-prompt>=13.3.0",
25
25
  ]
26
26
 
27
27
  [project.readme]
@@ -38,9 +38,9 @@ class ExtendedCommand(Command):
38
38
  middlewares_attributes = self.command_wrapper.middlewares_attributes
39
39
  middlewares_registry = self.kernel.get_registry("middlewares")
40
40
 
41
- for name in middlewares_attributes:
41
+ for name, attributes in middlewares_attributes.items():
42
42
  middleware_class = middlewares_registry.get_class(name)
43
- middleware = middleware_class(**middlewares_attributes[name])
43
+ middleware = middleware_class(**attributes)
44
44
  self.command_wrapper.set_middleware(middleware)
45
45
 
46
46
  # Universal --help / -h handling — render the command's options instead
@@ -85,32 +85,33 @@ class ExtendedCommand(Command):
85
85
 
86
86
  # Process all declared options
87
87
  for option in self.command_wrapper.options:
88
+ name = option.name
88
89
  value = None
89
90
 
90
91
  # If the option is in parsed args, use that value
91
- if option.name in parsed_args:
92
- value = parsed_args[option.name]
92
+ if name in parsed_args:
93
+ value = parsed_args[name]
93
94
  # Otherwise, use the default value if available
94
95
  elif option.default is not None:
95
96
  value = option.default
96
97
  # If the option is required but not provided, raise an error
97
98
  elif option.required:
98
- raise CommandOptionMissingException(option_name=option.name)
99
+ raise CommandOptionMissingException(option_name=name)
99
100
 
100
101
  # Validate the value if validators are defined and value is not None
101
102
  if value is not None and option.validators:
103
+ from wexample_cli.exception.command_option_validation_exception import (
104
+ CommandOptionValidationException,
105
+ )
106
+
102
107
  # For multiple values, validate each item individually
103
108
  values_to_validate = value if isinstance(value, list) else [value]
104
109
 
105
110
  for val in values_to_validate:
106
111
  for validator in option.validators:
107
112
  if not validator.validate(val):
108
- from wexample_cli.exception.command_option_validation_exception import (
109
- CommandOptionValidationException,
110
- )
111
-
112
113
  raise CommandOptionValidationException(
113
- option_name=option.name,
114
+ option_name=name,
114
115
  value=val,
115
116
  error_message=validator.get_error_message(val),
116
117
  )
@@ -121,7 +122,7 @@ class ExtendedCommand(Command):
121
122
 
122
123
  # Assign the validated value
123
124
  if value is not None:
124
- option.value = function_kwargs[option.name] = value
125
+ option.value = function_kwargs[name] = value
125
126
 
126
127
  # `--` passthrough: if the user supplied extra args after `--`, the
127
128
  # parser put them in parsed_args["__extra_args__"]. Forward them as
@@ -158,10 +159,11 @@ class ExtendedCommand(Command):
158
159
  MIDDLEWARE_OPTION_VALUE_OPTIONAL,
159
160
  )
160
161
 
161
- if len(self.command_wrapper.middlewares) > 0:
162
+ middlewares = self.command_wrapper.middlewares
163
+ if middlewares:
162
164
  output = MultipleResponse(kernel=self.kernel)
163
165
 
164
- for middleware in self.command_wrapper.middlewares:
166
+ for middleware in middlewares:
165
167
  show_progress = (
166
168
  middleware.show_progress == MIDDLEWARE_OPTION_VALUE_ALLWAYS
167
169
  or (
@@ -286,9 +288,6 @@ class ExtendedCommand(Command):
286
288
  from wexample_app.helpers.response import response_normalize
287
289
  from wexample_app.response.abstract_response import AbstractResponse
288
290
 
289
- # Create a list to store all tasks
290
- tasks = []
291
-
292
291
  # Create an executor for running CPU-bound functions in a thread pool
293
292
  executor = ThreadPoolExecutor(max_workers=min(32, len(execution_contexts)))
294
293
 
@@ -308,7 +307,7 @@ class ExtendedCommand(Command):
308
307
  function_to_execute = execution_context.function or self.function
309
308
 
310
309
  # Run the function in a thread pool to avoid blocking the event loop
311
- loop = asyncio.get_event_loop()
310
+ loop = asyncio.get_running_loop()
312
311
  result = await loop.run_in_executor(
313
312
  executor,
314
313
  lambda: function_to_execute(**execution_context.function_kwargs),
@@ -320,9 +319,10 @@ class ExtendedCommand(Command):
320
319
  return response_normalize(kernel=self.kernel, response=result)
321
320
 
322
321
  # Create a task for each pass
323
- for execution_context in execution_contexts:
324
- task = asyncio.create_task(execute_single_pass(execution_context))
325
- tasks.append(task)
322
+ tasks = [
323
+ asyncio.create_task(execute_single_pass(execution_context))
324
+ for execution_context in execution_contexts
325
+ ]
326
326
 
327
327
  # Wait for all tasks to complete
328
328
  responses = await asyncio.gather(*tasks)
@@ -114,9 +114,9 @@ class CommandMethodWrapper(BaseClass):
114
114
 
115
115
  def set_middleware(self, middleware: AbstractMiddleware) -> None:
116
116
  self.middlewares.append(middleware)
117
-
118
- for option in middleware.normalized_options:
119
- self.set_option(option)
117
+ # extend is a single C-level call; avoids N Python method-dispatch
118
+ # round-trips that self.set_option() would incur per option.
119
+ self.options.extend(middleware.normalized_options)
120
120
 
121
121
  def set_option(self, option: Option) -> None:
122
122
  self.options.append(option)
@@ -64,15 +64,17 @@ class ExecutionContext(
64
64
  return self._current_progress
65
65
 
66
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
67
+ progress = self._current_progress
68
+ progress.finish()
69
+ parent = progress.parent
70
+ parent.update(**kwargs)
71
+ self._current_progress = parent
72
+ return parent
72
73
 
73
74
  def get_or_create_progress(self, **kwargs) -> ProgressHandle:
74
75
  if self._current_progress is None:
75
- self._current_progress = self.io.progress(
76
- **kwargs, context=self.io.create_context()
76
+ io = self.io
77
+ self._current_progress = io.progress(
78
+ **kwargs, context=io.create_context()
77
79
  ).get_handle()
78
80
  return self._current_progress
@@ -7,8 +7,9 @@ if TYPE_CHECKING:
7
7
 
8
8
 
9
9
  def as_sudo() -> CommandMethodWrapper:
10
- def decorator(wrapper: CommandMethodWrapper) -> CommandMethodWrapper:
11
- wrapper.sudo = True
12
- return wrapper
10
+ return _sudo_decorator
13
11
 
14
- return decorator
12
+
13
+ def _sudo_decorator(wrapper: CommandMethodWrapper) -> CommandMethodWrapper:
14
+ wrapper.sudo = True
15
+ return wrapper
@@ -2,10 +2,11 @@ from __future__ import annotations
2
2
 
3
3
  from typing import TYPE_CHECKING
4
4
 
5
+ from wexample_cli.common.command_method_wrapper import CommandMethodWrapper
6
+
5
7
  if TYPE_CHECKING:
6
8
  from wexample_helpers.const.types import AnyCallable
7
9
 
8
- from wexample_cli.common.command_method_wrapper import CommandMethodWrapper
9
10
  from wexample_cli.middleware.abstract_middleware import AbstractMiddleware
10
11
 
11
12
 
@@ -16,8 +17,6 @@ def middleware(
16
17
  ) -> AnyCallable:
17
18
  def decorator(command_wrapper: CommandMethodWrapper) -> CommandMethodWrapper:
18
19
  # Type safety check
19
- from wexample_cli.common.command_method_wrapper import CommandMethodWrapper
20
-
21
20
  if not isinstance(command_wrapper, CommandMethodWrapper):
22
21
  raise TypeError(
23
22
  f"Invalid middleware usage: @middleware must decorate a {CommandMethodWrapper.__name__} "
@@ -11,9 +11,9 @@ OPTION_NAME_STOP_ON_FAILURE: str = "stop_on_failure"
11
11
 
12
12
 
13
13
  def option_stop_on_failure() -> AnyCallable:
14
- def decorator(command_wrapper: CommandMethodWrapper) -> CommandMethodWrapper:
15
- from wexample_app.command.option import Option
14
+ from wexample_app.command.option import Option
16
15
 
16
+ def decorator(command_wrapper: CommandMethodWrapper) -> CommandMethodWrapper:
17
17
  command_wrapper.set_option(
18
18
  Option(
19
19
  name=OPTION_NAME_STOP_ON_FAILURE,
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import time
3
4
  from typing import TYPE_CHECKING, Any
4
5
 
5
6
  if TYPE_CHECKING:
@@ -55,7 +56,7 @@ def screenable(interval: float = 1.0, height: int = 30) -> AnyCallable:
55
56
  )
56
57
 
57
58
  command_wrapper.register_pipeline_wrapper(
58
- _build_screen_wrapper(default_interval=float(interval), height=int(height))
59
+ _build_screen_wrapper(default_interval=interval, height=height)
59
60
  )
60
61
 
61
62
  return command_wrapper
@@ -73,31 +74,26 @@ def _build_screen_wrapper(default_interval: float, height: int) -> AnyCallable:
73
74
  ) -> Any:
74
75
  # Always strip screen kwargs so they cannot leak to the user function
75
76
  # or to inner pipeline wrappers, whether or not --screen was passed.
76
- enabled = bool(function_kwargs.pop(OPTION_NAME_SCREEN, False))
77
+ enabled = function_kwargs.pop(OPTION_NAME_SCREEN, False)
77
78
  interval_override = function_kwargs.pop(OPTION_NAME_SCREEN_INTERVAL, None)
78
79
 
79
80
  if not enabled:
80
81
  return next_dispatch(function_kwargs)
81
82
 
82
- import time
83
-
84
83
  from wexample_app.response.null_response import NullResponse
85
84
 
86
- interval = (
87
- float(interval_override)
88
- if interval_override is not None
89
- else default_interval
90
- )
85
+ interval = interval_override if interval_override is not None else default_interval
91
86
  kernel_io = request.kernel.io
92
87
  # Skip the inter-frame sleep on the very first frame so the user sees
93
88
  # output immediately instead of waiting `interval` seconds before the
94
89
  # first render.
95
- is_first_frame = [True]
90
+ is_first_frame = True
96
91
 
97
92
  def _frame(response) -> None:
98
- if not is_first_frame[0]:
93
+ nonlocal is_first_frame
94
+ if not is_first_frame:
99
95
  time.sleep(interval)
100
- is_first_frame[0] = False
96
+ is_first_frame = False
101
97
 
102
98
  response.clear()
103
99
  saved_output = kernel_io.output
@@ -19,9 +19,9 @@ def webhook() -> type[CommandMethodWrapper]:
19
19
  @command(type=COMMAND_TYPE_ADDON)
20
20
  def my__group__command(context): ...
21
21
  """
22
+ return _webhook_decorator # type: ignore[return-value]
22
23
 
23
- def decorator(wrapper: CommandMethodWrapper) -> CommandMethodWrapper:
24
- wrapper.webhook = True
25
- return wrapper
26
24
 
27
- return decorator # type: ignore[return-value]
25
+ def _webhook_decorator(wrapper: CommandMethodWrapper) -> CommandMethodWrapper:
26
+ wrapper.webhook = True
27
+ return wrapper
@@ -27,11 +27,11 @@ def resolve_shell_command(
27
27
  shell, which is the safer default. Use the legacy `--command "..."`
28
28
  form if you intentionally want remote expansion.
29
29
  """
30
- if extra_args and command:
31
- context.io.warning(
32
- "Both --command and `-- <args>` were given; using `-- <args>`."
33
- )
34
30
  if extra_args:
31
+ if command:
32
+ context.io.warning(
33
+ "Both --command and `-- <args>` were given; using `-- <args>`."
34
+ )
35
35
  return shlex.join(extra_args)
36
36
  if command:
37
37
  return command
@@ -72,17 +72,17 @@ class AbstractMiddleware(
72
72
  'Option "stop_on_failure" will be ignored due to parallelization'
73
73
  )
74
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,
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
+ )
84
85
  )
85
- )
86
86
 
87
87
  if self.show_progress == MIDDLEWARE_OPTION_VALUE_OPTIONAL:
88
88
  command_wrapper.set_option(
@@ -133,10 +133,9 @@ class AbstractMiddleware(
133
133
 
134
134
  def get_option_by_name(self, name: str) -> Option | None:
135
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
136
+ return next(
137
+ (opt for opt in self.normalized_options if opt.name == name), None
138
+ )
140
139
 
141
140
  def validate_options(
142
141
  self,
File without changes
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ import types
4
+
5
+
6
+ def test_resolve_shell_command_joins_extra_args_with_quoting() -> None:
7
+ from wexample_cli.helpers.extra_args import resolve_shell_command
8
+
9
+ result = resolve_shell_command(
10
+ context=None, command=None, extra_args=["echo", "hi there"]
11
+ )
12
+ assert result == "echo 'hi there'"
13
+
14
+
15
+ def test_resolve_shell_command_returns_none_when_nothing_given() -> None:
16
+ from wexample_cli.helpers.extra_args import resolve_shell_command
17
+
18
+ result = resolve_shell_command(context=None, command=None, extra_args=None)
19
+ assert result is None
20
+
21
+
22
+ def test_resolve_shell_command_uses_command_when_no_extra_args() -> None:
23
+ from wexample_cli.helpers.extra_args import resolve_shell_command
24
+
25
+ result = resolve_shell_command(context=None, command="ls -la", extra_args=None)
26
+ assert result == "ls -la"
27
+
28
+
29
+ def test_resolve_shell_command_warns_when_both_given() -> None:
30
+ from wexample_prompt.common.io_manager import IoManager
31
+
32
+ from wexample_cli.helpers.extra_args import resolve_shell_command
33
+
34
+ context = types.SimpleNamespace(io=IoManager())
35
+ result = resolve_shell_command(
36
+ context=context, command="legacy", extra_args=["run"]
37
+ )
38
+ assert result == "run"