smart-tests-cli 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- smart_tests/__init__.py +0 -0
- smart_tests/__main__.py +60 -0
- smart_tests/app.py +67 -0
- smart_tests/args4p/README.md +102 -0
- smart_tests/args4p/__init__.py +13 -0
- smart_tests/args4p/argument.py +45 -0
- smart_tests/args4p/command.py +593 -0
- smart_tests/args4p/converters/__init__.py +75 -0
- smart_tests/args4p/decorators.py +98 -0
- smart_tests/args4p/exceptions.py +12 -0
- smart_tests/args4p/option.py +85 -0
- smart_tests/args4p/parameter.py +84 -0
- smart_tests/args4p/typer/__init__.py +42 -0
- smart_tests/commands/__init__.py +0 -0
- smart_tests/commands/compare/__init__.py +11 -0
- smart_tests/commands/compare/subsets.py +58 -0
- smart_tests/commands/detect_flakes.py +105 -0
- smart_tests/commands/inspect/__init__.py +13 -0
- smart_tests/commands/inspect/model.py +52 -0
- smart_tests/commands/inspect/subset.py +138 -0
- smart_tests/commands/record/__init__.py +19 -0
- smart_tests/commands/record/attachment.py +38 -0
- smart_tests/commands/record/build.py +356 -0
- smart_tests/commands/record/case_event.py +190 -0
- smart_tests/commands/record/commit.py +157 -0
- smart_tests/commands/record/session.py +120 -0
- smart_tests/commands/record/tests.py +498 -0
- smart_tests/commands/stats/__init__.py +11 -0
- smart_tests/commands/stats/test_sessions.py +45 -0
- smart_tests/commands/subset.py +567 -0
- smart_tests/commands/test_path_writer.py +51 -0
- smart_tests/commands/verify.py +153 -0
- smart_tests/jar/exe_deploy.jar +0 -0
- smart_tests/plugins/__init__.py +0 -0
- smart_tests/test_runners/__init__.py +0 -0
- smart_tests/test_runners/adb.py +24 -0
- smart_tests/test_runners/ant.py +35 -0
- smart_tests/test_runners/bazel.py +103 -0
- smart_tests/test_runners/behave.py +62 -0
- smart_tests/test_runners/codeceptjs.py +33 -0
- smart_tests/test_runners/ctest.py +164 -0
- smart_tests/test_runners/cts.py +189 -0
- smart_tests/test_runners/cucumber.py +451 -0
- smart_tests/test_runners/cypress.py +46 -0
- smart_tests/test_runners/dotnet.py +106 -0
- smart_tests/test_runners/file.py +20 -0
- smart_tests/test_runners/flutter.py +251 -0
- smart_tests/test_runners/go_test.py +99 -0
- smart_tests/test_runners/googletest.py +34 -0
- smart_tests/test_runners/gradle.py +96 -0
- smart_tests/test_runners/jest.py +52 -0
- smart_tests/test_runners/maven.py +149 -0
- smart_tests/test_runners/minitest.py +40 -0
- smart_tests/test_runners/nunit.py +190 -0
- smart_tests/test_runners/playwright.py +252 -0
- smart_tests/test_runners/prove.py +74 -0
- smart_tests/test_runners/pytest.py +358 -0
- smart_tests/test_runners/raw.py +238 -0
- smart_tests/test_runners/robot.py +125 -0
- smart_tests/test_runners/rspec.py +5 -0
- smart_tests/test_runners/smart_tests.py +235 -0
- smart_tests/test_runners/vitest.py +49 -0
- smart_tests/test_runners/xctest.py +79 -0
- smart_tests/testpath.py +154 -0
- smart_tests/utils/__init__.py +0 -0
- smart_tests/utils/authentication.py +78 -0
- smart_tests/utils/ci_provider.py +7 -0
- smart_tests/utils/commands.py +14 -0
- smart_tests/utils/commit_ingester.py +59 -0
- smart_tests/utils/common_tz.py +12 -0
- smart_tests/utils/edit_distance.py +11 -0
- smart_tests/utils/env_keys.py +19 -0
- smart_tests/utils/exceptions.py +34 -0
- smart_tests/utils/fail_fast_mode.py +99 -0
- smart_tests/utils/file_name_pattern.py +4 -0
- smart_tests/utils/git_log_parser.py +53 -0
- smart_tests/utils/glob.py +44 -0
- smart_tests/utils/gzipgen.py +46 -0
- smart_tests/utils/http_client.py +169 -0
- smart_tests/utils/java.py +61 -0
- smart_tests/utils/link.py +149 -0
- smart_tests/utils/logger.py +53 -0
- smart_tests/utils/no_build.py +2 -0
- smart_tests/utils/sax.py +119 -0
- smart_tests/utils/session.py +73 -0
- smart_tests/utils/smart_tests_client.py +134 -0
- smart_tests/utils/subprocess.py +12 -0
- smart_tests/utils/tracking.py +95 -0
- smart_tests/utils/typer_types.py +241 -0
- smart_tests/version.py +7 -0
- smart_tests_cli-2.0.0.dist-info/METADATA +168 -0
- smart_tests_cli-2.0.0.dist-info/RECORD +96 -0
- smart_tests_cli-2.0.0.dist-info/WHEEL +5 -0
- smart_tests_cli-2.0.0.dist-info/entry_points.txt +2 -0
- smart_tests_cli-2.0.0.dist-info/licenses/LICENSE.txt +202 -0
- smart_tests_cli-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Annotated, Any, Callable, List, Optional, Sequence, cast, get_args, get_origin
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from ..utils.edit_distance import edit_distance
|
|
12
|
+
from . import decorator
|
|
13
|
+
from .argument import Argument
|
|
14
|
+
from .exceptions import BadCmdLineException, BadConfigException
|
|
15
|
+
from .option import NO_DEFAULT, Option
|
|
16
|
+
from .parameter import Parameter, normalize_type, to_type
|
|
17
|
+
from .typer import Exit
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Command:
|
|
21
|
+
parent: Group | None = None # if this is a sub-command of another command, this is the parent command
|
|
22
|
+
options: list[Option]
|
|
23
|
+
arguments: list[Argument]
|
|
24
|
+
name: str
|
|
25
|
+
callback: Callable
|
|
26
|
+
help: str | None
|
|
27
|
+
|
|
28
|
+
def __init__(self, callback: Callable, name: str | None = None, help: str | None = None, params: Sequence[Parameter] = ()):
|
|
29
|
+
self.name = name or callback.__name__.lower().replace("_", "-")
|
|
30
|
+
self.help = help
|
|
31
|
+
self.callback = callback
|
|
32
|
+
|
|
33
|
+
params = list(params)
|
|
34
|
+
try:
|
|
35
|
+
params += reversed(callback.__args4p_params__) # type: ignore
|
|
36
|
+
except AttributeError:
|
|
37
|
+
# if __args4p_params__ doesn't exist that's OK
|
|
38
|
+
pass
|
|
39
|
+
else:
|
|
40
|
+
del callback.__args4p_params__ # type: ignore
|
|
41
|
+
|
|
42
|
+
self.options = []
|
|
43
|
+
self.arguments = []
|
|
44
|
+
for p in params:
|
|
45
|
+
self.add_param(p)
|
|
46
|
+
|
|
47
|
+
# pick up parameters declared in annotations
|
|
48
|
+
sig = inspect.signature(callback)
|
|
49
|
+
for pname, param in sig.parameters.items():
|
|
50
|
+
if get_origin(param.annotation) == Annotated:
|
|
51
|
+
args = get_args(param.annotation)
|
|
52
|
+
for a in args:
|
|
53
|
+
if isinstance(a, Parameter):
|
|
54
|
+
if a.name is None:
|
|
55
|
+
a.name = pname
|
|
56
|
+
if isinstance(a, Option):
|
|
57
|
+
if a.option_names is None or len(a.option_names) == 0:
|
|
58
|
+
a.option_names = [f"--{a.name.replace('_', '-')}"]
|
|
59
|
+
self.add_param(a)
|
|
60
|
+
|
|
61
|
+
def add_param(self, param: Parameter, prepend: bool = False):
|
|
62
|
+
'''
|
|
63
|
+
Attach an option/argument to this command. Use this to programmatically construct Command with parameters.
|
|
64
|
+
It is possible to attach the same parameter to different commands simultaneously.
|
|
65
|
+
|
|
66
|
+
:param prepend
|
|
67
|
+
when we are adding parameter from decorators, things show up in the reverse order, so we need to prepend,
|
|
68
|
+
not append.
|
|
69
|
+
'''
|
|
70
|
+
param.attach_to_command(self)
|
|
71
|
+
col = self.options if isinstance(param, Option) else self.arguments
|
|
72
|
+
if prepend:
|
|
73
|
+
col.insert(0, param) # type: ignore[arg-type]
|
|
74
|
+
else:
|
|
75
|
+
col.append(param) # type: ignore[arg-type]
|
|
76
|
+
|
|
77
|
+
def __call__(self, *_args: str) -> Any:
|
|
78
|
+
'''
|
|
79
|
+
Given the command line arguments, parse them, bind them to the user function parameters,
|
|
80
|
+
and invoke the function. This method returns the return value of the user function.
|
|
81
|
+
'''
|
|
82
|
+
self.check_consistency()
|
|
83
|
+
|
|
84
|
+
invoker = _Invoker(self)
|
|
85
|
+
args = ArgList(list(_args))
|
|
86
|
+
|
|
87
|
+
while args.has_more():
|
|
88
|
+
a = args.eat(None)
|
|
89
|
+
if a == "--":
|
|
90
|
+
# everything after this is a positional argument
|
|
91
|
+
while args.has_more():
|
|
92
|
+
invoker.eat_arg(args.eat(None))
|
|
93
|
+
elif a.startswith("-"):
|
|
94
|
+
# Handle built-in help options
|
|
95
|
+
if a in ["--help", "-h"]:
|
|
96
|
+
print(invoker.command.format_help())
|
|
97
|
+
raise Exit(0)
|
|
98
|
+
if a.startswith("--") and '=' in a:
|
|
99
|
+
# --long-format=value
|
|
100
|
+
a, val = a.split('=', 1)
|
|
101
|
+
args.insert_front(val)
|
|
102
|
+
|
|
103
|
+
invoker.eat_options(a, args)
|
|
104
|
+
elif isinstance(invoker.command, Group):
|
|
105
|
+
invoker = invoker.sub_command(a)
|
|
106
|
+
else:
|
|
107
|
+
invoker.eat_arg(a)
|
|
108
|
+
|
|
109
|
+
r = invoker.invoke()
|
|
110
|
+
if r is None:
|
|
111
|
+
r = 0 # if no return value is provided, assume success
|
|
112
|
+
|
|
113
|
+
if isinstance(invoker.command, Group):
|
|
114
|
+
# group invoked without sub-command. we want to deal with this after `invoker.invoke()`
|
|
115
|
+
# to give the parent command callbacks the opportunity to execute.
|
|
116
|
+
click.secho("Command is missing", fg='red', err=True)
|
|
117
|
+
print(invoker.command.format_help())
|
|
118
|
+
raise Exit(1)
|
|
119
|
+
|
|
120
|
+
return r
|
|
121
|
+
|
|
122
|
+
def main(self, args=sys.argv[1:], prog_name=None):
|
|
123
|
+
'''
|
|
124
|
+
Use this Command as the main entry point for a script.
|
|
125
|
+
|
|
126
|
+
prog_name parameter is a hack to reuse click.CliRunner
|
|
127
|
+
'''
|
|
128
|
+
try:
|
|
129
|
+
self(*args)
|
|
130
|
+
sys.exit(0)
|
|
131
|
+
except Exit as e:
|
|
132
|
+
sys.exit(e.code)
|
|
133
|
+
except BadCmdLineException as e:
|
|
134
|
+
click.secho(str(e), fg='red', err=True)
|
|
135
|
+
sys.exit(1)
|
|
136
|
+
|
|
137
|
+
def check_consistency(self):
|
|
138
|
+
"""
|
|
139
|
+
Validate that the command configuration is consistent and well-formed.
|
|
140
|
+
Raises BadConfigException if any issues are found.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
# Get function signature for parameter analysis
|
|
144
|
+
sig = inspect.signature(self.callback)
|
|
145
|
+
func_params = list(sig.parameters.keys())
|
|
146
|
+
|
|
147
|
+
# For Group sub-commands, first parameter is context from parent
|
|
148
|
+
# For regular commands, all parameters should be covered by decorators
|
|
149
|
+
context_param_offset = 1 if self.parent is not None else 0
|
|
150
|
+
expected_func_params = func_params[context_param_offset:]
|
|
151
|
+
|
|
152
|
+
def error(msg: str) -> BadConfigException:
|
|
153
|
+
return BadConfigException(
|
|
154
|
+
f"{msg} in function '{self.callback.__name__}' signature: "
|
|
155
|
+
f"{inspect.getsourcefile(self.callback)}:{inspect.getsourcelines(self.callback)[1]}")
|
|
156
|
+
|
|
157
|
+
# Check for missing function parameters
|
|
158
|
+
for p in self.options:
|
|
159
|
+
if p.name not in func_params:
|
|
160
|
+
raise error(f"@option names '{p.name}' but no such parameter exists")
|
|
161
|
+
|
|
162
|
+
for a in self.arguments:
|
|
163
|
+
if a.name not in func_params:
|
|
164
|
+
raise error(f"@argument names '{a.name}' but no such parameter exists")
|
|
165
|
+
|
|
166
|
+
# Collect all parameter names from decorators
|
|
167
|
+
decorator_param_names = set()
|
|
168
|
+
for p in self.options + self.arguments:
|
|
169
|
+
if p.name in decorator_param_names:
|
|
170
|
+
raise error(f"Duplicate parameter name '{p.name}' found in command '{self.name}' decorators")
|
|
171
|
+
decorator_param_names.add(p.name)
|
|
172
|
+
|
|
173
|
+
# Check boolean option conflicts
|
|
174
|
+
for p in self.options:
|
|
175
|
+
if p.type == bool:
|
|
176
|
+
if p.required:
|
|
177
|
+
raise error(f"It makes no sense to require a boolean option '{p.name}'")
|
|
178
|
+
|
|
179
|
+
# Check for required parameter with default value
|
|
180
|
+
for p in self.options + self.arguments:
|
|
181
|
+
if p.required and p.default != NO_DEFAULT:
|
|
182
|
+
raise error(f"'{p.name}' is marked as required but with default value '{p.default}'")
|
|
183
|
+
|
|
184
|
+
# Check for uncovered function parameters
|
|
185
|
+
for p in expected_func_params:
|
|
186
|
+
if p not in decorator_param_names:
|
|
187
|
+
raise error(f"Function parameter '{p}' is not covered by any @option or @argument decorator")
|
|
188
|
+
|
|
189
|
+
# Type system checks
|
|
190
|
+
for p in self.options + self.arguments:
|
|
191
|
+
fp = sig.parameters[p.name]
|
|
192
|
+
|
|
193
|
+
# Check if multiple=True is used correctly with List type
|
|
194
|
+
if p.multiple:
|
|
195
|
+
t = normalize_type(to_type(fp))
|
|
196
|
+
if t is None:
|
|
197
|
+
raise error(f"Parameter '{p.name}' with multiple=True requires a type annotation")
|
|
198
|
+
if not (hasattr(t, '__origin__') and t.__origin__ is list):
|
|
199
|
+
raise error(f"Parameter '{p.name}' with multiple=True requires a List[T]")
|
|
200
|
+
|
|
201
|
+
# similarly, if type is List, must be multiple=True
|
|
202
|
+
t = to_type(fp)
|
|
203
|
+
if hasattr(t, '__origin__') and t.__origin__ is list and not p.multiple:
|
|
204
|
+
raise error(f"Parameter '{p.name}' is typed as List but missing multiple=True")
|
|
205
|
+
|
|
206
|
+
# Check default value type compatibility
|
|
207
|
+
if p.default != NO_DEFAULT and not isinstance(p.default, p.type):
|
|
208
|
+
raise error(
|
|
209
|
+
f"Default value '{p.default}' for parameter '{p.name}' is incompatible with type '{p.type.__name__}'")
|
|
210
|
+
|
|
211
|
+
if not p.required and p.default == NO_DEFAULT and fp.default == inspect.Parameter.empty:
|
|
212
|
+
if p.type != bool: # boolean parameters get implicit default value of False
|
|
213
|
+
raise error(f"Parameter '{p.name}' is optional but has no default value")
|
|
214
|
+
|
|
215
|
+
# Check for duplicate option names
|
|
216
|
+
all_option_names = set()
|
|
217
|
+
for p in self.options:
|
|
218
|
+
for name in p.option_names:
|
|
219
|
+
if name in all_option_names:
|
|
220
|
+
raise error(f"Duplicate option name '{name}' found")
|
|
221
|
+
all_option_names.add(name)
|
|
222
|
+
|
|
223
|
+
# Check option name formats
|
|
224
|
+
for p in self.options:
|
|
225
|
+
for opt_name in p.option_names:
|
|
226
|
+
if not re.match(r'^-[a-zA-Z]$|^--[a-zA-Z][-a-zA-Z0-9]*$', opt_name):
|
|
227
|
+
raise error(f"Invalid option name '{opt_name}'")
|
|
228
|
+
|
|
229
|
+
# Check argument ordering (required after optional)
|
|
230
|
+
found_optional = False
|
|
231
|
+
for a in self.arguments:
|
|
232
|
+
if not a.required:
|
|
233
|
+
found_optional = True
|
|
234
|
+
elif found_optional:
|
|
235
|
+
raise error(f"Required argument '{a.name}' cannot appear after optional arguments")
|
|
236
|
+
|
|
237
|
+
# Check multiple arguments placement and count
|
|
238
|
+
multiple_args = [arg.name for arg in self.arguments if arg.multiple]
|
|
239
|
+
if len(multiple_args) > 1:
|
|
240
|
+
raise error(f"Cannot have more than one multiple=True argument, found: {multiple_args}")
|
|
241
|
+
|
|
242
|
+
if len(multiple_args) == 1:
|
|
243
|
+
# multiple=True argument must be the last argument
|
|
244
|
+
if self.arguments and self.arguments[-1].name != multiple_args[0]:
|
|
245
|
+
raise error(f"Argument '{multiple_args[0]}' with multiple=True must be the last argument")
|
|
246
|
+
|
|
247
|
+
# Group-specific checks
|
|
248
|
+
if isinstance(self, Group):
|
|
249
|
+
# Group can have up to one argument can be used to capture sub-command
|
|
250
|
+
if len(self.arguments) > 1:
|
|
251
|
+
raise error(f"Group command '{self.name}' can have at most one argument to capture sub-command name")
|
|
252
|
+
|
|
253
|
+
# Check for empty groups (only if this is a Group)
|
|
254
|
+
if not self.commands:
|
|
255
|
+
raise error(f"Group command '{self.name}' has no subcommands defined")
|
|
256
|
+
|
|
257
|
+
# Check subcommand name conflicts
|
|
258
|
+
subcommand_names = [cmd.name for cmd in self.commands]
|
|
259
|
+
if len(subcommand_names) != len(set(subcommand_names)):
|
|
260
|
+
duplicates = [name for name in set(subcommand_names) if subcommand_names.count(name) > 1]
|
|
261
|
+
raise error(f"Duplicate subcommand names found in group '{self.name}': {duplicates}")
|
|
262
|
+
|
|
263
|
+
# Recursively check subcommands for Groups
|
|
264
|
+
for c in self.commands:
|
|
265
|
+
c.check_consistency()
|
|
266
|
+
|
|
267
|
+
def format_help(self, program_name: str = os.path.basename(sys.argv[0])) -> str:
|
|
268
|
+
"""
|
|
269
|
+
Generate and return a formatted help message for this command.
|
|
270
|
+
|
|
271
|
+
:param program_name
|
|
272
|
+
Name of the program to display in the usage line. Defaults to the name of the running script.
|
|
273
|
+
"""
|
|
274
|
+
def usage_line() -> str:
|
|
275
|
+
parts = ["Usage:"]
|
|
276
|
+
|
|
277
|
+
# Program name
|
|
278
|
+
parts.append(program_name)
|
|
279
|
+
|
|
280
|
+
# Build command path (for subcommands)
|
|
281
|
+
command_path: List[str] = []
|
|
282
|
+
current = self
|
|
283
|
+
while current.parent is not None:
|
|
284
|
+
command_path.insert(0, current.name)
|
|
285
|
+
current = current.parent
|
|
286
|
+
if len(command_path) > 0:
|
|
287
|
+
parts.append(" ".join(command_path))
|
|
288
|
+
|
|
289
|
+
# Add options placeholder if we have options
|
|
290
|
+
if self.options:
|
|
291
|
+
parts.append("[OPTIONS]")
|
|
292
|
+
|
|
293
|
+
# Add arguments
|
|
294
|
+
for a in self.arguments:
|
|
295
|
+
if a.required:
|
|
296
|
+
if a.multiple:
|
|
297
|
+
parts.append(f"<{a.metavar}>...")
|
|
298
|
+
else:
|
|
299
|
+
parts.append(f"<{a.metavar}>")
|
|
300
|
+
else:
|
|
301
|
+
if a.multiple:
|
|
302
|
+
parts.append(f"[{a.metavar}...]")
|
|
303
|
+
else:
|
|
304
|
+
parts.append(f"[{a.metavar}]")
|
|
305
|
+
if isinstance(self, Group):
|
|
306
|
+
if len(self.arguments) == 0:
|
|
307
|
+
# Add subcommand placeholder for groups
|
|
308
|
+
parts.append("COMMAND")
|
|
309
|
+
parts.append("...")
|
|
310
|
+
|
|
311
|
+
return " ".join(parts)
|
|
312
|
+
|
|
313
|
+
lines = [usage_line()]
|
|
314
|
+
|
|
315
|
+
# Description from docstring
|
|
316
|
+
if self.callback.__doc__:
|
|
317
|
+
lines.append("")
|
|
318
|
+
# Clean up the docstring - remove leading/trailing whitespace and dedent
|
|
319
|
+
doc_lines = self.callback.__doc__.strip().split('\n')
|
|
320
|
+
# Remove common leading whitespace
|
|
321
|
+
import textwrap
|
|
322
|
+
description = textwrap.dedent('\n'.join(doc_lines)).strip()
|
|
323
|
+
lines.append(description)
|
|
324
|
+
|
|
325
|
+
# Arguments section
|
|
326
|
+
if self.arguments:
|
|
327
|
+
lines.append("")
|
|
328
|
+
lines.append("Arguments:")
|
|
329
|
+
for arg in self.arguments:
|
|
330
|
+
arg_line = f" {arg.metavar}"
|
|
331
|
+
|
|
332
|
+
# Add type info
|
|
333
|
+
if arg.type != str:
|
|
334
|
+
type_name = getattr(arg.type, '__name__', str(arg.type))
|
|
335
|
+
arg_line += f" ({type_name})"
|
|
336
|
+
|
|
337
|
+
# Add required/optional indicator and default
|
|
338
|
+
if not arg.required:
|
|
339
|
+
if arg.default != NO_DEFAULT:
|
|
340
|
+
arg_line += f" [default: {arg.default}]"
|
|
341
|
+
else:
|
|
342
|
+
arg_line += " [optional]"
|
|
343
|
+
# Add multiple indicator
|
|
344
|
+
if arg.multiple:
|
|
345
|
+
arg_line += " (multiple)"
|
|
346
|
+
lines.append(arg_line)
|
|
347
|
+
|
|
348
|
+
# Add help text if available
|
|
349
|
+
if arg.help:
|
|
350
|
+
help_lines = arg.help.strip().split('\n')
|
|
351
|
+
for help_line in help_lines:
|
|
352
|
+
lines.append(f" {help_line}")
|
|
353
|
+
|
|
354
|
+
# Options section
|
|
355
|
+
self._format_options("Options", lines)
|
|
356
|
+
|
|
357
|
+
# Commands section (for Groups)
|
|
358
|
+
if isinstance(self, Group) and self.commands:
|
|
359
|
+
lines.append("")
|
|
360
|
+
lines.append("Commands:")
|
|
361
|
+
for cmd in self.commands:
|
|
362
|
+
cmd_line = f" {cmd.name}"
|
|
363
|
+
lines.append(cmd_line)
|
|
364
|
+
|
|
365
|
+
# Add command description from docstring
|
|
366
|
+
if cmd.callback.__doc__:
|
|
367
|
+
# Get first line of docstring as brief description
|
|
368
|
+
first_line = cmd.callback.__doc__.strip().split('\n')[0]
|
|
369
|
+
lines.append(f" {first_line}")
|
|
370
|
+
|
|
371
|
+
return '\n'.join(lines)
|
|
372
|
+
|
|
373
|
+
def _format_options(self, caption: str, lines: list[str]):
|
|
374
|
+
options = [opt for opt in self.options if not opt.hidden]
|
|
375
|
+
if options:
|
|
376
|
+
lines.append("")
|
|
377
|
+
lines.append(f"{caption}:")
|
|
378
|
+
for opt in options:
|
|
379
|
+
# Format option names
|
|
380
|
+
opt_names = ", ".join(opt.option_names)
|
|
381
|
+
opt_line = f" {opt_names}"
|
|
382
|
+
|
|
383
|
+
# Add metavar for non-boolean options
|
|
384
|
+
if opt.type == bool:
|
|
385
|
+
pass
|
|
386
|
+
elif opt.metavar:
|
|
387
|
+
opt_line += f" {opt.metavar}"
|
|
388
|
+
else:
|
|
389
|
+
type_name = getattr(opt.type, '__name__', str(opt.type))
|
|
390
|
+
opt_line += f" {type_name.upper()}"
|
|
391
|
+
|
|
392
|
+
lines.append(opt_line)
|
|
393
|
+
|
|
394
|
+
# Add description line with help and metadata
|
|
395
|
+
desc_parts = []
|
|
396
|
+
if opt.help:
|
|
397
|
+
desc_parts.append(opt.help)
|
|
398
|
+
|
|
399
|
+
# Add default value info
|
|
400
|
+
if opt.default != NO_DEFAULT and opt.type != bool:
|
|
401
|
+
desc_parts.append(f"[default: {opt.default}]")
|
|
402
|
+
elif not opt.required and opt.type != bool:
|
|
403
|
+
desc_parts.append("[optional]")
|
|
404
|
+
|
|
405
|
+
# Add required indicator
|
|
406
|
+
if opt.required:
|
|
407
|
+
desc_parts.append("[required]")
|
|
408
|
+
|
|
409
|
+
# Add multiple indicator
|
|
410
|
+
if opt.multiple:
|
|
411
|
+
desc_parts.append("(multiple)")
|
|
412
|
+
|
|
413
|
+
if desc_parts:
|
|
414
|
+
lines.append(f" {' '.join(desc_parts)}")
|
|
415
|
+
|
|
416
|
+
if self.parent:
|
|
417
|
+
self.parent._format_options(f"Options (common to {self.parent.name})", lines)
|
|
418
|
+
|
|
419
|
+
def __repr__(self):
|
|
420
|
+
return f"<Command name={self.name!r} options={self.options!r} arguments={self.arguments!r}>"
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
class Group(Command):
|
|
424
|
+
'''
|
|
425
|
+
Special type of command that has sub-commands, e.g. 'git commit', 'git push', where 'git' is a group command.
|
|
426
|
+
|
|
427
|
+
A sub-command receives the return value of its parent command as the first argument to its callback function,
|
|
428
|
+
which is how we expect the parent to pass the context to the child.
|
|
429
|
+
'''
|
|
430
|
+
commands: List[Command]
|
|
431
|
+
|
|
432
|
+
def __init__(self, callback: Callable, name: str | None = None, help: str | None = None, params: Sequence[Parameter] = ()):
|
|
433
|
+
super().__init__(callback, name, help, params)
|
|
434
|
+
self.commands = []
|
|
435
|
+
|
|
436
|
+
def add_command(self, c: Command):
|
|
437
|
+
self.commands.append(c)
|
|
438
|
+
if c.parent is not None:
|
|
439
|
+
raise BadConfigException(f"Command '{c.name}' is already a sub-command of '{c.parent.name}'")
|
|
440
|
+
c.parent = self
|
|
441
|
+
|
|
442
|
+
@decorator
|
|
443
|
+
def command(self, name: Optional[str] = None, help: Optional[str] = None) -> Callable[..., Command]:
|
|
444
|
+
from .decorators import _command
|
|
445
|
+
|
|
446
|
+
def decorator(f: Callable) -> Command:
|
|
447
|
+
c = _command(name, help, Command)(f)
|
|
448
|
+
self.add_command(c)
|
|
449
|
+
return c
|
|
450
|
+
return decorator
|
|
451
|
+
|
|
452
|
+
@decorator
|
|
453
|
+
def group(self, name: Optional[str] = None, help: Optional[str] = None) -> Callable[..., Group]:
|
|
454
|
+
from .decorators import _command
|
|
455
|
+
|
|
456
|
+
def decorator(f: Callable) -> Group:
|
|
457
|
+
g = _command(name, help, Group)(f)
|
|
458
|
+
self.add_command(g)
|
|
459
|
+
return g
|
|
460
|
+
return decorator
|
|
461
|
+
|
|
462
|
+
def find_subcommand(self, name: str) -> Command:
|
|
463
|
+
for c in self.commands:
|
|
464
|
+
if c.name == name:
|
|
465
|
+
return c
|
|
466
|
+
msg = f"Unknown command: {name}"
|
|
467
|
+
maybe = _maybe(name, [c.name for c in self.commands])
|
|
468
|
+
if maybe:
|
|
469
|
+
msg += f" (did you mean '{maybe}'?)"
|
|
470
|
+
raise BadCmdLineException(msg)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
class ArgList:
|
|
474
|
+
'''
|
|
475
|
+
This class represents a list of arguments, and provides methods to consume arguments from the front of the list
|
|
476
|
+
'''
|
|
477
|
+
args: list[str]
|
|
478
|
+
|
|
479
|
+
def __init__(self, args: list[str]):
|
|
480
|
+
self.args = args
|
|
481
|
+
|
|
482
|
+
def peek(self, caller: Any) -> str:
|
|
483
|
+
if len(self.args) == 0:
|
|
484
|
+
raise BadCmdLineException(f"{caller} is missing an argument")
|
|
485
|
+
return self.args[0]
|
|
486
|
+
|
|
487
|
+
def eat(self, caller: Any) -> str:
|
|
488
|
+
if len(self.args) == 0:
|
|
489
|
+
raise BadCmdLineException(f"{caller} is missing an argument")
|
|
490
|
+
return self.args.pop(0)
|
|
491
|
+
|
|
492
|
+
def has_more(self) -> bool:
|
|
493
|
+
return len(self.args) > 0
|
|
494
|
+
|
|
495
|
+
def insert_front(self, arg):
|
|
496
|
+
self.args.insert(0, arg)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
class _Invoker:
|
|
500
|
+
'''
|
|
501
|
+
This class builds up data needed to invoke a command
|
|
502
|
+
'''
|
|
503
|
+
command: Command
|
|
504
|
+
parent: _Invoker | None = None
|
|
505
|
+
kwargs: dict[str, Any]
|
|
506
|
+
|
|
507
|
+
nargs = 0 # number of arguments consumed, used to identify the processor of the next argument
|
|
508
|
+
|
|
509
|
+
def __init__(self, command: Command):
|
|
510
|
+
self.command = command
|
|
511
|
+
self.kwargs = {}
|
|
512
|
+
|
|
513
|
+
def eat_arg(self, arg: str):
|
|
514
|
+
l = self.command.arguments
|
|
515
|
+
|
|
516
|
+
if self.nargs < len(l):
|
|
517
|
+
a = l[self.nargs]
|
|
518
|
+
else:
|
|
519
|
+
if len(l) > 0 and l[-1].multiple:
|
|
520
|
+
a = l[-1]
|
|
521
|
+
else:
|
|
522
|
+
raise BadCmdLineException(f"Too many arguments for '{self.command.name}' command: {arg}")
|
|
523
|
+
|
|
524
|
+
self.kwargs[a.name] = a.append(self.kwargs.get(a.name), arg)
|
|
525
|
+
self.nargs += 1
|
|
526
|
+
|
|
527
|
+
def eat_options(self, option_name: str, args: ArgList):
|
|
528
|
+
inv: _Invoker | None = self
|
|
529
|
+
option_names = []
|
|
530
|
+
while inv is not None:
|
|
531
|
+
for o in inv.command.options:
|
|
532
|
+
if option_name in o.option_names:
|
|
533
|
+
inv.kwargs[o.name] = o.append(inv.kwargs.get(o.name), option_name, args)
|
|
534
|
+
return
|
|
535
|
+
else:
|
|
536
|
+
option_names += o.option_names
|
|
537
|
+
inv = inv.parent
|
|
538
|
+
|
|
539
|
+
msg = f"No such option '{option_name}' for '{self.command.name}' command"
|
|
540
|
+
maybe = _maybe(option_name, option_names)
|
|
541
|
+
if maybe:
|
|
542
|
+
msg += f" (did you mean '{maybe}'?)"
|
|
543
|
+
raise BadCmdLineException(msg)
|
|
544
|
+
|
|
545
|
+
def sub_command(self, name: str) -> _Invoker:
|
|
546
|
+
c = cast(Group, self.command).find_subcommand(name)
|
|
547
|
+
i = _Invoker(c)
|
|
548
|
+
i.parent = self
|
|
549
|
+
if len(self.command.arguments) > 0:
|
|
550
|
+
self.eat_arg(name) # this allows the group to see the selected sub-command as an argument
|
|
551
|
+
return i
|
|
552
|
+
|
|
553
|
+
def invoke(self) -> Any:
|
|
554
|
+
'''
|
|
555
|
+
Invoke the user defined methods with the right parameter bindings from options and arguments,
|
|
556
|
+
then return what the function returned
|
|
557
|
+
'''
|
|
558
|
+
|
|
559
|
+
# fill in default values
|
|
560
|
+
for a in self.command.arguments:
|
|
561
|
+
if a.name not in self.kwargs:
|
|
562
|
+
if a.required:
|
|
563
|
+
raise BadCmdLineException(f"Missing required argument '{a.name}' for command '{self.command.name}'")
|
|
564
|
+
if a.default != NO_DEFAULT:
|
|
565
|
+
self.kwargs[a.name] = a.default
|
|
566
|
+
|
|
567
|
+
for o in self.command.options:
|
|
568
|
+
if o.name not in self.kwargs:
|
|
569
|
+
if o.required:
|
|
570
|
+
raise BadCmdLineException(f"Missing required option '{o.option_names[0]}' for command '{self.command.name}'")
|
|
571
|
+
if o.default != NO_DEFAULT:
|
|
572
|
+
self.kwargs[o.name] = o.default
|
|
573
|
+
|
|
574
|
+
if self.parent is not None:
|
|
575
|
+
return self.command.callback(self.parent.invoke(), **self.kwargs)
|
|
576
|
+
else:
|
|
577
|
+
return self.command.callback(**self.kwargs)
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _maybe(given: str, candidates: Sequence[str]) -> Optional[str]:
|
|
581
|
+
'''
|
|
582
|
+
Typo recovery suggestion. Find the best match from the given candidates,
|
|
583
|
+
but only if it's close enough.
|
|
584
|
+
'''
|
|
585
|
+
|
|
586
|
+
if len(candidates) == 0:
|
|
587
|
+
return None # min() doesn't work if arg is empty
|
|
588
|
+
|
|
589
|
+
c = min(candidates, key=lambda c: edit_distance(given, c))
|
|
590
|
+
if edit_distance(c, given) <= 4:
|
|
591
|
+
return c
|
|
592
|
+
else:
|
|
593
|
+
return None
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Defines a series of "converter" functions used in @option(type=...) to bind options/arguments
|
|
3
|
+
for common scenarios.
|
|
4
|
+
|
|
5
|
+
Exposed from the args4p package.
|
|
6
|
+
"""
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import IO, Callable, Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def path(exists: bool = False,
|
|
12
|
+
file_okay: bool = True,
|
|
13
|
+
dir_okay: bool = True,
|
|
14
|
+
resolve_path: bool = False, ) -> Callable[[str], Path]:
|
|
15
|
+
'''
|
|
16
|
+
Use it as @option(type=path(...)) to convert an option value/argument to a Path object.
|
|
17
|
+
'''
|
|
18
|
+
def convert(value: str) -> Path:
|
|
19
|
+
p = Path(value)
|
|
20
|
+
if resolve_path:
|
|
21
|
+
p = p.resolve()
|
|
22
|
+
if exists and not p.exists():
|
|
23
|
+
raise ValueError(f'Path {p} does not exist')
|
|
24
|
+
if not file_okay and p.is_file():
|
|
25
|
+
raise ValueError(f'Path {p} is a file, but a directory is expected')
|
|
26
|
+
if not dir_okay and p.is_dir():
|
|
27
|
+
raise ValueError(f'Path {p} is a directory, but a file is expected')
|
|
28
|
+
return p
|
|
29
|
+
|
|
30
|
+
return convert
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def floatType(min: Optional[float] = None, max: Optional[float] = None) -> Callable[[str], float]:
|
|
34
|
+
'''
|
|
35
|
+
Use it as @option(type=floatType(...)) to convert an option value/argument to a float.
|
|
36
|
+
'''
|
|
37
|
+
def convert(value: str) -> float:
|
|
38
|
+
try:
|
|
39
|
+
f = float(value)
|
|
40
|
+
except ValueError:
|
|
41
|
+
raise ValueError(f'\'{value}\' is not a valid float')
|
|
42
|
+
if min is not None and f < min:
|
|
43
|
+
raise ValueError(f'\'{value}\' cannot be smaller than {min}')
|
|
44
|
+
if max is not None and f > max:
|
|
45
|
+
raise ValueError(f'\'{value}\' cannot be larger than {max}')
|
|
46
|
+
return f
|
|
47
|
+
|
|
48
|
+
return convert
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def intType(min: Optional[int] = None, max: Optional[int] = None) -> Callable[[str], int]:
|
|
52
|
+
'''
|
|
53
|
+
Use it as @option(type=intType(...)) to convert an option value/argument to an int.
|
|
54
|
+
'''
|
|
55
|
+
def convert(value: str) -> int:
|
|
56
|
+
try:
|
|
57
|
+
i = int(value)
|
|
58
|
+
except ValueError:
|
|
59
|
+
raise ValueError(f'\'{value}\' is not a valid integer')
|
|
60
|
+
if min is not None and i < min:
|
|
61
|
+
raise ValueError(f'\'{value}\' cannot be smaller than {min}')
|
|
62
|
+
if max is not None and i > max:
|
|
63
|
+
raise ValueError(f'\'{value}\' cannot be larger than {max}')
|
|
64
|
+
return i
|
|
65
|
+
|
|
66
|
+
return convert
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def fileText(mode: str = "r") -> Callable[[str], IO]:
|
|
70
|
+
'''
|
|
71
|
+
Open a file specified by argument/option for reading/writing
|
|
72
|
+
'''
|
|
73
|
+
def convert(value: str) -> IO:
|
|
74
|
+
return open(value, mode)
|
|
75
|
+
return convert
|