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.
Files changed (96) hide show
  1. smart_tests/__init__.py +0 -0
  2. smart_tests/__main__.py +60 -0
  3. smart_tests/app.py +67 -0
  4. smart_tests/args4p/README.md +102 -0
  5. smart_tests/args4p/__init__.py +13 -0
  6. smart_tests/args4p/argument.py +45 -0
  7. smart_tests/args4p/command.py +593 -0
  8. smart_tests/args4p/converters/__init__.py +75 -0
  9. smart_tests/args4p/decorators.py +98 -0
  10. smart_tests/args4p/exceptions.py +12 -0
  11. smart_tests/args4p/option.py +85 -0
  12. smart_tests/args4p/parameter.py +84 -0
  13. smart_tests/args4p/typer/__init__.py +42 -0
  14. smart_tests/commands/__init__.py +0 -0
  15. smart_tests/commands/compare/__init__.py +11 -0
  16. smart_tests/commands/compare/subsets.py +58 -0
  17. smart_tests/commands/detect_flakes.py +105 -0
  18. smart_tests/commands/inspect/__init__.py +13 -0
  19. smart_tests/commands/inspect/model.py +52 -0
  20. smart_tests/commands/inspect/subset.py +138 -0
  21. smart_tests/commands/record/__init__.py +19 -0
  22. smart_tests/commands/record/attachment.py +38 -0
  23. smart_tests/commands/record/build.py +356 -0
  24. smart_tests/commands/record/case_event.py +190 -0
  25. smart_tests/commands/record/commit.py +157 -0
  26. smart_tests/commands/record/session.py +120 -0
  27. smart_tests/commands/record/tests.py +498 -0
  28. smart_tests/commands/stats/__init__.py +11 -0
  29. smart_tests/commands/stats/test_sessions.py +45 -0
  30. smart_tests/commands/subset.py +567 -0
  31. smart_tests/commands/test_path_writer.py +51 -0
  32. smart_tests/commands/verify.py +153 -0
  33. smart_tests/jar/exe_deploy.jar +0 -0
  34. smart_tests/plugins/__init__.py +0 -0
  35. smart_tests/test_runners/__init__.py +0 -0
  36. smart_tests/test_runners/adb.py +24 -0
  37. smart_tests/test_runners/ant.py +35 -0
  38. smart_tests/test_runners/bazel.py +103 -0
  39. smart_tests/test_runners/behave.py +62 -0
  40. smart_tests/test_runners/codeceptjs.py +33 -0
  41. smart_tests/test_runners/ctest.py +164 -0
  42. smart_tests/test_runners/cts.py +189 -0
  43. smart_tests/test_runners/cucumber.py +451 -0
  44. smart_tests/test_runners/cypress.py +46 -0
  45. smart_tests/test_runners/dotnet.py +106 -0
  46. smart_tests/test_runners/file.py +20 -0
  47. smart_tests/test_runners/flutter.py +251 -0
  48. smart_tests/test_runners/go_test.py +99 -0
  49. smart_tests/test_runners/googletest.py +34 -0
  50. smart_tests/test_runners/gradle.py +96 -0
  51. smart_tests/test_runners/jest.py +52 -0
  52. smart_tests/test_runners/maven.py +149 -0
  53. smart_tests/test_runners/minitest.py +40 -0
  54. smart_tests/test_runners/nunit.py +190 -0
  55. smart_tests/test_runners/playwright.py +252 -0
  56. smart_tests/test_runners/prove.py +74 -0
  57. smart_tests/test_runners/pytest.py +358 -0
  58. smart_tests/test_runners/raw.py +238 -0
  59. smart_tests/test_runners/robot.py +125 -0
  60. smart_tests/test_runners/rspec.py +5 -0
  61. smart_tests/test_runners/smart_tests.py +235 -0
  62. smart_tests/test_runners/vitest.py +49 -0
  63. smart_tests/test_runners/xctest.py +79 -0
  64. smart_tests/testpath.py +154 -0
  65. smart_tests/utils/__init__.py +0 -0
  66. smart_tests/utils/authentication.py +78 -0
  67. smart_tests/utils/ci_provider.py +7 -0
  68. smart_tests/utils/commands.py +14 -0
  69. smart_tests/utils/commit_ingester.py +59 -0
  70. smart_tests/utils/common_tz.py +12 -0
  71. smart_tests/utils/edit_distance.py +11 -0
  72. smart_tests/utils/env_keys.py +19 -0
  73. smart_tests/utils/exceptions.py +34 -0
  74. smart_tests/utils/fail_fast_mode.py +99 -0
  75. smart_tests/utils/file_name_pattern.py +4 -0
  76. smart_tests/utils/git_log_parser.py +53 -0
  77. smart_tests/utils/glob.py +44 -0
  78. smart_tests/utils/gzipgen.py +46 -0
  79. smart_tests/utils/http_client.py +169 -0
  80. smart_tests/utils/java.py +61 -0
  81. smart_tests/utils/link.py +149 -0
  82. smart_tests/utils/logger.py +53 -0
  83. smart_tests/utils/no_build.py +2 -0
  84. smart_tests/utils/sax.py +119 -0
  85. smart_tests/utils/session.py +73 -0
  86. smart_tests/utils/smart_tests_client.py +134 -0
  87. smart_tests/utils/subprocess.py +12 -0
  88. smart_tests/utils/tracking.py +95 -0
  89. smart_tests/utils/typer_types.py +241 -0
  90. smart_tests/version.py +7 -0
  91. smart_tests_cli-2.0.0.dist-info/METADATA +168 -0
  92. smart_tests_cli-2.0.0.dist-info/RECORD +96 -0
  93. smart_tests_cli-2.0.0.dist-info/WHEEL +5 -0
  94. smart_tests_cli-2.0.0.dist-info/entry_points.txt +2 -0
  95. smart_tests_cli-2.0.0.dist-info/licenses/LICENSE.txt +202 -0
  96. 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