falyx 0.1.27__py3-none-any.whl → 0.1.28__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.
falyx/argparse.py ADDED
@@ -0,0 +1,596 @@
1
+ # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
2
+ from copy import deepcopy
3
+ from dataclasses import dataclass
4
+ from enum import Enum
5
+ from typing import Any, Iterable
6
+
7
+ from rich.console import Console
8
+ from rich.table import Table
9
+
10
+ from falyx.exceptions import CommandArgumentError
11
+ from falyx.signals import HelpSignal
12
+
13
+
14
+ class ArgumentAction(Enum):
15
+ """Defines the action to be taken when the argument is encountered."""
16
+
17
+ STORE = "store"
18
+ STORE_TRUE = "store_true"
19
+ STORE_FALSE = "store_false"
20
+ APPEND = "append"
21
+ EXTEND = "extend"
22
+ COUNT = "count"
23
+ HELP = "help"
24
+
25
+
26
+ @dataclass
27
+ class Argument:
28
+ """Represents a command-line argument."""
29
+
30
+ flags: list[str]
31
+ dest: str # Destination name for the argument
32
+ action: ArgumentAction = (
33
+ ArgumentAction.STORE
34
+ ) # Action to be taken when the argument is encountered
35
+ type: Any = str # Type of the argument (e.g., str, int, float) or callable
36
+ default: Any = None # Default value if the argument is not provided
37
+ choices: list[str] | None = None # List of valid choices for the argument
38
+ required: bool = False # True if the argument is required
39
+ help: str = "" # Help text for the argument
40
+ nargs: int | str = 1 # int, '?', '*', '+'
41
+ positional: bool = False # True if no leading - or -- in flags
42
+
43
+
44
+ class CommandArgumentParser:
45
+ """
46
+ Custom argument parser for Falyx Commands.
47
+ It is used to create a command-line interface for Falyx
48
+ commands, allowing users to specify options and arguments
49
+ when executing commands.
50
+ It is not intended to be a full-featured replacement for
51
+ argparse, but rather a lightweight alternative for specific use
52
+ cases within the Falyx framework.
53
+
54
+ Features:
55
+ - Customizable argument parsing.
56
+ - Type coercion for arguments.
57
+ - Support for positional and keyword arguments.
58
+ - Support for default values.
59
+ - Support for boolean flags.
60
+ - Exception handling for invalid arguments.
61
+ - Render Help using Rich library.
62
+ """
63
+
64
+ def __init__(self) -> None:
65
+ """Initialize the CommandArgumentParser."""
66
+ self.command_description: str = ""
67
+ self._arguments: list[Argument] = []
68
+ self._flag_map: dict[str, Argument] = {}
69
+ self._dest_set: set[str] = set()
70
+ self._add_help()
71
+ self.console = Console(color_system="auto")
72
+
73
+ def _add_help(self):
74
+ """Add help argument to the parser."""
75
+ self.add_argument(
76
+ "--help",
77
+ "-h",
78
+ action=ArgumentAction.HELP,
79
+ help="Show this help message and exit.",
80
+ dest="help",
81
+ )
82
+
83
+ def _is_positional(self, flags: tuple[str, ...]) -> bool:
84
+ """Check if the flags are positional."""
85
+ positional = False
86
+ if any(not flag.startswith("-") for flag in flags):
87
+ positional = True
88
+
89
+ if positional and len(flags) > 1:
90
+ raise CommandArgumentError("Positional arguments cannot have multiple flags")
91
+ return positional
92
+
93
+ def _get_dest_from_flags(
94
+ self, flags: tuple[str, ...], dest: str | None
95
+ ) -> str | None:
96
+ """Convert flags to a destination name."""
97
+ if dest:
98
+ if not dest.replace("_", "").isalnum():
99
+ raise CommandArgumentError(
100
+ "dest must be a valid identifier (letters, digits, and underscores only)"
101
+ )
102
+ if dest[0].isdigit():
103
+ raise CommandArgumentError("dest must not start with a digit")
104
+ return dest
105
+ dest = None
106
+ for flag in flags:
107
+ if flag.startswith("--"):
108
+ dest = flag.lstrip("-").replace("-", "_").lower()
109
+ break
110
+ elif flag.startswith("-"):
111
+ dest = flag.lstrip("-").replace("-", "_").lower()
112
+ else:
113
+ dest = flag.replace("-", "_").lower()
114
+ assert dest is not None, "dest should not be None"
115
+ if not dest.replace("_", "").isalnum():
116
+ raise CommandArgumentError(
117
+ "dest must be a valid identifier (letters, digits, and underscores only)"
118
+ )
119
+ if dest[0].isdigit():
120
+ raise CommandArgumentError("dest must not start with a digit")
121
+ return dest
122
+
123
+ def _determine_required(
124
+ self, required: bool, positional: bool, nargs: int | str
125
+ ) -> bool:
126
+ """Determine if the argument is required."""
127
+ if required:
128
+ return True
129
+ if positional:
130
+ if isinstance(nargs, int):
131
+ return nargs > 0
132
+ elif isinstance(nargs, str):
133
+ if nargs in ("+"):
134
+ return True
135
+ elif nargs in ("*", "?"):
136
+ return False
137
+ else:
138
+ raise CommandArgumentError(f"Invalid nargs value: {nargs}")
139
+
140
+ return required
141
+
142
+ def _validate_nargs(self, nargs: int | str) -> int | str:
143
+ allowed_nargs = ("?", "*", "+")
144
+ if isinstance(nargs, int):
145
+ if nargs <= 0:
146
+ raise CommandArgumentError("nargs must be a positive integer")
147
+ elif isinstance(nargs, str):
148
+ if nargs not in allowed_nargs:
149
+ raise CommandArgumentError(f"Invalid nargs value: {nargs}")
150
+ else:
151
+ raise CommandArgumentError(f"nargs must be an int or one of {allowed_nargs}")
152
+ return nargs
153
+
154
+ def _normalize_choices(self, choices: Iterable, expected_type: Any) -> list[Any]:
155
+ if choices is not None:
156
+ if isinstance(choices, dict):
157
+ raise CommandArgumentError("choices cannot be a dict")
158
+ try:
159
+ choices = list(choices)
160
+ except TypeError:
161
+ raise CommandArgumentError(
162
+ "choices must be iterable (like list, tuple, or set)"
163
+ )
164
+ else:
165
+ choices = []
166
+ for choice in choices:
167
+ if not isinstance(choice, expected_type):
168
+ try:
169
+ expected_type(choice)
170
+ except Exception:
171
+ raise CommandArgumentError(
172
+ f"Invalid choice {choice!r}: not coercible to {expected_type.__name__}"
173
+ )
174
+ return choices
175
+
176
+ def _validate_default_type(
177
+ self, default: Any, expected_type: type, dest: str
178
+ ) -> None:
179
+ """Validate the default value type."""
180
+ if default is not None and not isinstance(default, expected_type):
181
+ try:
182
+ expected_type(default)
183
+ except Exception:
184
+ raise CommandArgumentError(
185
+ f"Default value {default!r} for '{dest}' cannot be coerced to {expected_type.__name__}"
186
+ )
187
+
188
+ def _validate_default_list_type(
189
+ self, default: list[Any], expected_type: type, dest: str
190
+ ) -> None:
191
+ if isinstance(default, list):
192
+ for item in default:
193
+ if not isinstance(item, expected_type):
194
+ try:
195
+ expected_type(item)
196
+ except Exception:
197
+ raise CommandArgumentError(
198
+ f"Default list value {default!r} for '{dest}' cannot be coerced to {expected_type.__name__}"
199
+ )
200
+
201
+ def _resolve_default(
202
+ self, action: ArgumentAction, default: Any, nargs: str | int
203
+ ) -> Any:
204
+ """Get the default value for the argument."""
205
+ if default is None:
206
+ if action == ArgumentAction.STORE_TRUE:
207
+ return False
208
+ elif action == ArgumentAction.STORE_FALSE:
209
+ return True
210
+ elif action == ArgumentAction.COUNT:
211
+ return 0
212
+ elif action in (ArgumentAction.APPEND, ArgumentAction.EXTEND):
213
+ return []
214
+ elif nargs in ("+", "*"):
215
+ return []
216
+ else:
217
+ return None
218
+ return default
219
+
220
+ def _validate_flags(self, flags: tuple[str, ...]) -> None:
221
+ """Validate the flags provided for the argument."""
222
+ if not flags:
223
+ raise CommandArgumentError("No flags provided")
224
+ for flag in flags:
225
+ if not isinstance(flag, str):
226
+ raise CommandArgumentError(f"Flag '{flag}' must be a string")
227
+ if flag.startswith("--") and len(flag) < 3:
228
+ raise CommandArgumentError(
229
+ f"Flag '{flag}' must be at least 3 characters long"
230
+ )
231
+ if flag.startswith("-") and not flag.startswith("--") and len(flag) > 2:
232
+ raise CommandArgumentError(
233
+ f"Flag '{flag}' must be a single character or start with '--'"
234
+ )
235
+
236
+ def add_argument(self, *flags, **kwargs):
237
+ """Add an argument to the parser.
238
+ Args:
239
+ name or flags: Either a name or prefixed flags (e.g. 'faylx', '-f', '--falyx').
240
+ action: The action to be taken when the argument is encountered.
241
+ nargs: The number of arguments expected.
242
+ default: The default value if the argument is not provided.
243
+ type: The type to which the command-line argument should be converted.
244
+ choices: A container of the allowable values for the argument.
245
+ required: Whether or not the argument is required.
246
+ help: A brief description of the argument.
247
+ dest: The name of the attribute to be added to the object returned by parse_args().
248
+ """
249
+ self._validate_flags(flags)
250
+ positional = self._is_positional(flags)
251
+ dest = self._get_dest_from_flags(flags, kwargs.get("dest"))
252
+ if dest in self._dest_set:
253
+ raise CommandArgumentError(
254
+ f"Destination '{dest}' is already defined.\n"
255
+ "Merging multiple arguments into the same dest (e.g. positional + flagged) "
256
+ "is not supported. Define a unique 'dest' for each argument."
257
+ )
258
+ self._dest_set.add(dest)
259
+ action = kwargs.get("action", ArgumentAction.STORE)
260
+ if not isinstance(action, ArgumentAction):
261
+ try:
262
+ action = ArgumentAction(action)
263
+ except ValueError:
264
+ raise CommandArgumentError(
265
+ f"Invalid action '{action}' is not a valid ArgumentAction"
266
+ )
267
+ flags = list(flags)
268
+ nargs = self._validate_nargs(kwargs.get("nargs", 1))
269
+ default = self._resolve_default(action, kwargs.get("default"), nargs)
270
+ expected_type = kwargs.get("type", str)
271
+ if (
272
+ action in (ArgumentAction.STORE, ArgumentAction.APPEND, ArgumentAction.EXTEND)
273
+ and default is not None
274
+ ):
275
+ if isinstance(default, list):
276
+ self._validate_default_list_type(default, expected_type, dest)
277
+ else:
278
+ self._validate_default_type(default, expected_type, dest)
279
+ choices = self._normalize_choices(kwargs.get("choices"), expected_type)
280
+ if default is not None and choices and default not in choices:
281
+ raise CommandArgumentError(
282
+ f"Default value '{default}' not in allowed choices: {choices}"
283
+ )
284
+ required = self._determine_required(
285
+ kwargs.get("required", False), positional, nargs
286
+ )
287
+ argument = Argument(
288
+ flags=flags,
289
+ dest=dest,
290
+ action=action,
291
+ type=expected_type,
292
+ default=default,
293
+ choices=choices,
294
+ required=required,
295
+ help=kwargs.get("help", ""),
296
+ nargs=nargs,
297
+ positional=positional,
298
+ )
299
+ for flag in flags:
300
+ if flag in self._flag_map:
301
+ existing = self._flag_map[flag]
302
+ raise CommandArgumentError(
303
+ f"Flag '{flag}' is already used by argument '{existing.dest}'"
304
+ )
305
+ self._flag_map[flag] = argument
306
+ self._arguments.append(argument)
307
+
308
+ def get_argument(self, dest: str) -> Argument | None:
309
+ return next((a for a in self._arguments if a.dest == dest), None)
310
+
311
+ def _consume_nargs(
312
+ self, args: list[str], start: int, spec: Argument
313
+ ) -> tuple[list[str], int]:
314
+ values = []
315
+ i = start
316
+ if isinstance(spec.nargs, int):
317
+ # assert i + spec.nargs <= len(
318
+ # args
319
+ # ), "Not enough arguments provided: shouldn't happen"
320
+ values = args[i : i + spec.nargs]
321
+ return values, i + spec.nargs
322
+ elif spec.nargs == "+":
323
+ if i >= len(args):
324
+ raise CommandArgumentError(
325
+ f"Expected at least one value for '{spec.dest}'"
326
+ )
327
+ while i < len(args) and not args[i].startswith("-"):
328
+ values.append(args[i])
329
+ i += 1
330
+ assert values, "Expected at least one value for '+' nargs: shouldn't happen"
331
+ return values, i
332
+ elif spec.nargs == "*":
333
+ while i < len(args) and not args[i].startswith("-"):
334
+ values.append(args[i])
335
+ i += 1
336
+ return values, i
337
+ elif spec.nargs == "?":
338
+ if i < len(args) and not args[i].startswith("-"):
339
+ return [args[i]], i + 1
340
+ return [], i
341
+ else:
342
+ assert False, "Invalid nargs value: shouldn't happen"
343
+
344
+ def _consume_all_positional_args(
345
+ self,
346
+ args: list[str],
347
+ result: dict[str, Any],
348
+ positional_args: list[Argument],
349
+ consumed_positional_indicies: set[int],
350
+ ) -> int:
351
+ remaining_positional_args = [
352
+ (j, spec)
353
+ for j, spec in enumerate(positional_args)
354
+ if j not in consumed_positional_indicies
355
+ ]
356
+ i = 0
357
+
358
+ for j, spec in remaining_positional_args:
359
+ # estimate how many args the remaining specs might need
360
+ is_last = j == len(positional_args) - 1
361
+ remaining = len(args) - i
362
+ min_required = 0
363
+ for next_spec in positional_args[j + 1 :]:
364
+ if isinstance(next_spec.nargs, int):
365
+ min_required += next_spec.nargs
366
+ elif next_spec.nargs == "+":
367
+ min_required += 1
368
+ elif next_spec.nargs == "?":
369
+ min_required += 0
370
+ elif next_spec.nargs == "*":
371
+ min_required += 0
372
+ else:
373
+ assert False, "Invalid nargs value: shouldn't happen"
374
+
375
+ slice_args = args[i:] if is_last else args[i : i + (remaining - min_required)]
376
+ values, new_i = self._consume_nargs(slice_args, 0, spec)
377
+ i += new_i
378
+
379
+ try:
380
+ typed = [spec.type(v) for v in values]
381
+ except Exception:
382
+ raise CommandArgumentError(
383
+ f"Invalid value for '{spec.dest}': expected {spec.type.__name__}"
384
+ )
385
+
386
+ if spec.action == ArgumentAction.APPEND:
387
+ assert result.get(spec.dest) is not None, "dest should not be None"
388
+ if spec.nargs in (None, 1):
389
+ result[spec.dest].append(typed[0])
390
+ else:
391
+ result[spec.dest].append(typed)
392
+ elif spec.action == ArgumentAction.EXTEND:
393
+ assert result.get(spec.dest) is not None, "dest should not be None"
394
+ result[spec.dest].extend(typed)
395
+ elif spec.nargs in (None, 1, "?"):
396
+ result[spec.dest] = typed[0] if len(typed) == 1 else typed
397
+ else:
398
+ result[spec.dest] = typed
399
+
400
+ if spec.nargs not in ("*", "+"):
401
+ consumed_positional_indicies.add(j)
402
+
403
+ if i < len(args):
404
+ raise CommandArgumentError(f"Unexpected positional argument: {args[i:]}")
405
+
406
+ return i
407
+
408
+ def parse_args(self, args: list[str] | None = None) -> dict[str, Any]:
409
+ """Parse Falyx Command arguments."""
410
+ if args is None:
411
+ args = []
412
+
413
+ result = {arg.dest: deepcopy(arg.default) for arg in self._arguments}
414
+ positional_args = [arg for arg in self._arguments if arg.positional]
415
+ consumed_positional_indices: set[int] = set()
416
+
417
+ consumed_indices: set[int] = set()
418
+ i = 0
419
+ while i < len(args):
420
+ token = args[i]
421
+ if token in self._flag_map:
422
+ spec = self._flag_map[token]
423
+ action = spec.action
424
+
425
+ if action == ArgumentAction.HELP:
426
+ self.render_help()
427
+ raise HelpSignal()
428
+ elif action == ArgumentAction.STORE_TRUE:
429
+ result[spec.dest] = True
430
+ consumed_indices.add(i)
431
+ i += 1
432
+ elif action == ArgumentAction.STORE_FALSE:
433
+ result[spec.dest] = False
434
+ consumed_indices.add(i)
435
+ i += 1
436
+ elif action == ArgumentAction.COUNT:
437
+ result[spec.dest] = result.get(spec.dest, 0) + 1
438
+ consumed_indices.add(i)
439
+ i += 1
440
+ elif action == ArgumentAction.APPEND:
441
+ assert result.get(spec.dest) is not None, "dest should not be None"
442
+ values, new_i = self._consume_nargs(args, i + 1, spec)
443
+ try:
444
+ typed_values = [spec.type(value) for value in values]
445
+ except ValueError:
446
+ raise CommandArgumentError(
447
+ f"Invalid value for '{spec.dest}': expected {spec.type.__name__}"
448
+ )
449
+ if spec.nargs in (None, 1):
450
+ try:
451
+ result[spec.dest].append(spec.type(values[0]))
452
+ except ValueError:
453
+ raise CommandArgumentError(
454
+ f"Invalid value for '{spec.dest}': expected {spec.type.__name__}"
455
+ )
456
+ else:
457
+ result[spec.dest].append(typed_values)
458
+ consumed_indices.update(range(i, new_i))
459
+ i = new_i
460
+ elif action == ArgumentAction.EXTEND:
461
+ assert result.get(spec.dest) is not None, "dest should not be None"
462
+ values, new_i = self._consume_nargs(args, i + 1, spec)
463
+ try:
464
+ typed_values = [spec.type(value) for value in values]
465
+ except ValueError:
466
+ raise CommandArgumentError(
467
+ f"Invalid value for '{spec.dest}': expected {spec.type.__name__}"
468
+ )
469
+ result[spec.dest].extend(typed_values)
470
+ consumed_indices.update(range(i, new_i))
471
+ i = new_i
472
+ else:
473
+ values, new_i = self._consume_nargs(args, i + 1, spec)
474
+ try:
475
+ typed_values = [spec.type(v) for v in values]
476
+ except ValueError:
477
+ raise CommandArgumentError(
478
+ f"Invalid value for '{spec.dest}': expected {spec.type.__name__}"
479
+ )
480
+ if (
481
+ spec.nargs in (None, 1, "?")
482
+ and spec.action != ArgumentAction.APPEND
483
+ ):
484
+ result[spec.dest] = (
485
+ typed_values[0] if len(typed_values) == 1 else typed_values
486
+ )
487
+ else:
488
+ result[spec.dest] = typed_values
489
+ consumed_indices.update(range(i, new_i))
490
+ i = new_i
491
+ else:
492
+ # Get the next flagged argument index if it exists
493
+ next_flagged_index = -1
494
+ for index, arg in enumerate(args[i:], start=i):
495
+ if arg.startswith("-"):
496
+ next_flagged_index = index
497
+ break
498
+ if next_flagged_index == -1:
499
+ next_flagged_index = len(args)
500
+
501
+ args_consumed = self._consume_all_positional_args(
502
+ args[i:next_flagged_index],
503
+ result,
504
+ positional_args,
505
+ consumed_positional_indices,
506
+ )
507
+ i += args_consumed
508
+
509
+ # Required validation
510
+ for spec in self._arguments:
511
+ if spec.dest == "help":
512
+ continue
513
+ if spec.required and not result.get(spec.dest):
514
+ raise CommandArgumentError(f"Missing required argument: {spec.dest}")
515
+
516
+ if spec.choices and result.get(spec.dest) not in spec.choices:
517
+ raise CommandArgumentError(
518
+ f"Invalid value for {spec.dest}: must be one of {spec.choices}"
519
+ )
520
+
521
+ if isinstance(spec.nargs, int) and spec.nargs > 1:
522
+ if not isinstance(result.get(spec.dest), list):
523
+ raise CommandArgumentError(
524
+ f"Invalid value for {spec.dest}: expected a list"
525
+ )
526
+ if spec.action == ArgumentAction.APPEND:
527
+ if not isinstance(result[spec.dest], list):
528
+ raise CommandArgumentError(
529
+ f"Invalid value for {spec.dest}: expected a list"
530
+ )
531
+ for group in result[spec.dest]:
532
+ if len(group) % spec.nargs != 0:
533
+ raise CommandArgumentError(
534
+ f"Invalid number of values for {spec.dest}: expected a multiple of {spec.nargs}"
535
+ )
536
+ elif spec.action == ArgumentAction.EXTEND:
537
+ if not isinstance(result[spec.dest], list):
538
+ raise CommandArgumentError(
539
+ f"Invalid value for {spec.dest}: expected a list"
540
+ )
541
+ if len(result[spec.dest]) % spec.nargs != 0:
542
+ raise CommandArgumentError(
543
+ f"Invalid number of values for {spec.dest}: expected a multiple of {spec.nargs}"
544
+ )
545
+ elif len(result[spec.dest]) != spec.nargs:
546
+ raise CommandArgumentError(
547
+ f"Invalid number of values for {spec.dest}: expected {spec.nargs}, got {len(result[spec.dest])}"
548
+ )
549
+
550
+ result.pop("help", None)
551
+ return result
552
+
553
+ def parse_args_split(self, args: list[str]) -> tuple[tuple[Any, ...], dict[str, Any]]:
554
+ """
555
+ Returns:
556
+ tuple[args, kwargs] - Positional arguments in defined order,
557
+ followed by keyword argument mapping.
558
+ """
559
+ parsed = self.parse_args(args)
560
+ args_list = []
561
+ kwargs_dict = {}
562
+ for arg in self._arguments:
563
+ if arg.dest == "help":
564
+ continue
565
+ if arg.positional:
566
+ args_list.append(parsed[arg.dest])
567
+ else:
568
+ kwargs_dict[arg.dest] = parsed[arg.dest]
569
+ return tuple(args_list), kwargs_dict
570
+
571
+ def render_help(self):
572
+ table = Table(title=f"{self.command_description} Help")
573
+ table.add_column("Flags")
574
+ table.add_column("Help")
575
+ for arg in self._arguments:
576
+ if arg.dest == "help":
577
+ continue
578
+ flag_str = ", ".join(arg.flags) if not arg.positional else arg.dest
579
+ table.add_row(flag_str, arg.help or "")
580
+ table.add_section()
581
+ arg = self.get_argument("help")
582
+ flag_str = ", ".join(arg.flags) if not arg.positional else arg.dest
583
+ table.add_row(flag_str, arg.help or "")
584
+ self.console.print(table)
585
+
586
+ def __str__(self) -> str:
587
+ positional = sum(arg.positional for arg in self._arguments)
588
+ required = sum(arg.required for arg in self._arguments)
589
+ return (
590
+ f"CommandArgumentParser(args={len(self._arguments)}, "
591
+ f"flags={len(self._flag_map)}, dests={len(self._dest_set)}, "
592
+ f"required={required}, positional={positional})"
593
+ )
594
+
595
+ def __repr__(self) -> str:
596
+ return str(self)
falyx/command.py CHANGED
@@ -18,6 +18,7 @@ in building robust interactive menus.
18
18
  """
19
19
  from __future__ import annotations
20
20
 
21
+ import shlex
21
22
  from functools import cached_property
22
23
  from typing import Any, Callable
23
24
 
@@ -28,6 +29,7 @@ from rich.tree import Tree
28
29
 
29
30
  from falyx.action.action import Action, ActionGroup, BaseAction, ChainedAction
30
31
  from falyx.action.io_action import BaseIOAction
32
+ from falyx.argparse import CommandArgumentParser
31
33
  from falyx.context import ExecutionContext
32
34
  from falyx.debug import register_debug_hooks
33
35
  from falyx.execution_registry import ExecutionRegistry as er
@@ -35,6 +37,7 @@ from falyx.hook_manager import HookManager, HookType
35
37
  from falyx.logger import logger
36
38
  from falyx.options_manager import OptionsManager
37
39
  from falyx.prompt_utils import confirm_async, should_prompt_user
40
+ from falyx.protocols import ArgParserProtocol
38
41
  from falyx.retry import RetryPolicy
39
42
  from falyx.retry_utils import enable_retries_recursively
40
43
  from falyx.signals import CancelSignal
@@ -121,11 +124,24 @@ class Command(BaseModel):
121
124
  logging_hooks: bool = False
122
125
  requires_input: bool | None = None
123
126
  options_manager: OptionsManager = Field(default_factory=OptionsManager)
127
+ arg_parser: CommandArgumentParser = Field(default_factory=CommandArgumentParser)
128
+ custom_parser: ArgParserProtocol | None = None
129
+ custom_help: Callable[[], str | None] | None = None
124
130
 
125
131
  _context: ExecutionContext | None = PrivateAttr(default=None)
126
132
 
127
133
  model_config = ConfigDict(arbitrary_types_allowed=True)
128
134
 
135
+ def parse_args(self, raw_args: list[str] | str) -> tuple[tuple, dict]:
136
+ if self.custom_parser:
137
+ if isinstance(raw_args, str):
138
+ raw_args = shlex.split(raw_args)
139
+ return self.custom_parser(raw_args)
140
+
141
+ if isinstance(raw_args, str):
142
+ raw_args = shlex.split(raw_args)
143
+ return self.arg_parser.parse_args_split(raw_args)
144
+
129
145
  @field_validator("action", mode="before")
130
146
  @classmethod
131
147
  def wrap_callable_as_async(cls, action: Any) -> Any:
@@ -137,6 +153,9 @@ class Command(BaseModel):
137
153
 
138
154
  def model_post_init(self, _: Any) -> None:
139
155
  """Post-initialization to set up the action and hooks."""
156
+ if isinstance(self.arg_parser, CommandArgumentParser):
157
+ self.arg_parser.command_description = self.description
158
+
140
159
  if self.retry and isinstance(self.action, Action):
141
160
  self.action.enable_retry()
142
161
  elif self.retry_policy and isinstance(self.action, Action):
@@ -269,6 +288,18 @@ class Command(BaseModel):
269
288
  if self._context:
270
289
  self._context.log_summary()
271
290
 
291
+ def show_help(self) -> bool:
292
+ """Display the help message for the command."""
293
+ if self.custom_help:
294
+ output = self.custom_help()
295
+ if output:
296
+ console.print(output)
297
+ return True
298
+ if isinstance(self.arg_parser, CommandArgumentParser):
299
+ self.arg_parser.render_help()
300
+ return True
301
+ return False
302
+
272
303
  async def preview(self) -> None:
273
304
  label = f"[{OneColors.GREEN_b}]Command:[/] '{self.key}' — {self.description}"
274
305
 
falyx/exceptions.py CHANGED
@@ -28,3 +28,7 @@ class CircuitBreakerOpen(FalyxError):
28
28
 
29
29
  class EmptyChainError(FalyxError):
30
30
  """Exception raised when the chain is empty."""
31
+
32
+
33
+ class CommandArgumentError(FalyxError):
34
+ """Exception raised when there is an error in the command argument parser."""
falyx/falyx.py CHANGED
@@ -23,6 +23,7 @@ from __future__ import annotations
23
23
 
24
24
  import asyncio
25
25
  import logging
26
+ import shlex
26
27
  import sys
27
28
  from argparse import Namespace
28
29
  from difflib import get_close_matches
@@ -34,7 +35,8 @@ from prompt_toolkit import PromptSession
34
35
  from prompt_toolkit.completion import WordCompleter
35
36
  from prompt_toolkit.formatted_text import AnyFormattedText
36
37
  from prompt_toolkit.key_binding import KeyBindings
37
- from prompt_toolkit.validation import Validator
38
+ from prompt_toolkit.patch_stdout import patch_stdout
39
+ from prompt_toolkit.validation import ValidationError, Validator
38
40
  from rich import box
39
41
  from rich.console import Console
40
42
  from rich.markdown import Markdown
@@ -47,6 +49,7 @@ from falyx.context import ExecutionContext
47
49
  from falyx.debug import log_after, log_before, log_error, log_success
48
50
  from falyx.exceptions import (
49
51
  CommandAlreadyExistsError,
52
+ CommandArgumentError,
50
53
  FalyxError,
51
54
  InvalidActionError,
52
55
  NotAFalyxError,
@@ -57,19 +60,39 @@ from falyx.logger import logger
57
60
  from falyx.options_manager import OptionsManager
58
61
  from falyx.parsers import get_arg_parsers
59
62
  from falyx.retry import RetryPolicy
60
- from falyx.signals import BackSignal, CancelSignal, QuitSignal
63
+ from falyx.signals import BackSignal, CancelSignal, HelpSignal, QuitSignal
61
64
  from falyx.themes import OneColors, get_nord_theme
62
65
  from falyx.utils import CaseInsensitiveDict, _noop, chunks, get_program_invocation
63
66
  from falyx.version import __version__
64
67
 
65
68
 
66
- class FalyxMode(str, Enum):
69
+ class FalyxMode(Enum):
67
70
  MENU = "menu"
68
71
  RUN = "run"
69
72
  PREVIEW = "preview"
70
73
  RUN_ALL = "run-all"
71
74
 
72
75
 
76
+ class CommandValidator(Validator):
77
+ """Validator to check if the input is a valid command or toggle key."""
78
+
79
+ def __init__(self, falyx: Falyx, error_message: str) -> None:
80
+ super().__init__()
81
+ self.falyx = falyx
82
+ self.error_message = error_message
83
+
84
+ def validate(self, document) -> None:
85
+ text = document.text
86
+ is_preview, choice, _, __ = self.falyx.get_command(text, from_validate=True)
87
+ if is_preview:
88
+ return None
89
+ if not choice:
90
+ raise ValidationError(
91
+ message=self.error_message,
92
+ cursor_position=document.get_end_of_document_position(),
93
+ )
94
+
95
+
73
96
  class Falyx:
74
97
  """
75
98
  Main menu controller for Falyx CLI applications.
@@ -325,7 +348,7 @@ class Falyx:
325
348
  keys.extend(cmd.aliases)
326
349
  return WordCompleter(keys, ignore_case=True)
327
350
 
328
- def _get_validator(self) -> Validator:
351
+ def _get_validator_error_message(self) -> str:
329
352
  """Validator to check if the input is a valid command or toggle key."""
330
353
  keys = {self.exit_command.key.upper()}
331
354
  keys.update({alias.upper() for alias in self.exit_command.aliases})
@@ -354,18 +377,7 @@ class Falyx:
354
377
  if toggle_keys:
355
378
  message_lines.append(f" Toggles: {toggles_str}")
356
379
  error_message = " ".join(message_lines)
357
-
358
- def validator(text):
359
- is_preview, choice = self.get_command(text, from_validate=True)
360
- if is_preview and choice is None:
361
- return True
362
- return bool(choice)
363
-
364
- return Validator.from_callable(
365
- validator,
366
- error_message=error_message,
367
- move_cursor_to_end=True,
368
- )
380
+ return error_message
369
381
 
370
382
  def _invalidate_prompt_session_cache(self):
371
383
  """Forces the prompt session to be recreated on the next access."""
@@ -428,9 +440,10 @@ class Falyx:
428
440
  multiline=False,
429
441
  completer=self._get_completer(),
430
442
  reserve_space_for_menu=1,
431
- validator=self._get_validator(),
443
+ validator=CommandValidator(self, self._get_validator_error_message()),
432
444
  bottom_toolbar=self._get_bottom_bar_render(),
433
445
  key_bindings=self.key_bindings,
446
+ validate_while_typing=False,
434
447
  )
435
448
  return self._prompt_session
436
449
 
@@ -694,32 +707,52 @@ class Falyx:
694
707
  return False, input_str.strip()
695
708
 
696
709
  def get_command(
697
- self, choice: str, from_validate=False
698
- ) -> tuple[bool, Command | None]:
710
+ self, raw_choices: str, from_validate=False
711
+ ) -> tuple[bool, Command | None, tuple, dict[str, Any]]:
699
712
  """
700
713
  Returns the selected command based on user input.
701
714
  Supports keys, aliases, and abbreviations.
702
715
  """
716
+ args = ()
717
+ kwargs: dict[str, Any] = {}
718
+ choice, *input_args = shlex.split(raw_choices)
703
719
  is_preview, choice = self.parse_preview_command(choice)
704
720
  if is_preview and not choice and self.help_command:
705
721
  is_preview = False
706
722
  choice = "?"
707
723
  elif is_preview and not choice:
724
+ # No help command enabled
708
725
  if not from_validate:
709
726
  self.console.print(
710
727
  f"[{OneColors.DARK_RED}]❌ You must enter a command for preview mode."
711
728
  )
712
- return is_preview, None
729
+ return is_preview, None, args, kwargs
713
730
 
714
731
  choice = choice.upper()
715
732
  name_map = self._name_map
716
-
717
733
  if choice in name_map:
718
- return is_preview, name_map[choice]
734
+ if not from_validate:
735
+ logger.info("Command '%s' selected.", choice)
736
+ if input_args and name_map[choice].arg_parser:
737
+ try:
738
+ args, kwargs = name_map[choice].parse_args(input_args)
739
+ except CommandArgumentError as error:
740
+ if not from_validate:
741
+ if not name_map[choice].show_help():
742
+ self.console.print(
743
+ f"[{OneColors.DARK_RED}]❌ Invalid arguments for '{choice}': {error}"
744
+ )
745
+ else:
746
+ name_map[choice].show_help()
747
+ raise ValidationError(
748
+ message=str(error), cursor_position=len(raw_choices)
749
+ )
750
+ return is_preview, None, args, kwargs
751
+ return is_preview, name_map[choice], args, kwargs
719
752
 
720
753
  prefix_matches = [cmd for key, cmd in name_map.items() if key.startswith(choice)]
721
754
  if len(prefix_matches) == 1:
722
- return is_preview, prefix_matches[0]
755
+ return is_preview, prefix_matches[0], args, kwargs
723
756
 
724
757
  fuzzy_matches = get_close_matches(choice, list(name_map.keys()), n=3, cutoff=0.7)
725
758
  if fuzzy_matches:
@@ -736,7 +769,7 @@ class Falyx:
736
769
  self.console.print(
737
770
  f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command '{choice}'[/]"
738
771
  )
739
- return is_preview, None
772
+ return is_preview, None, args, kwargs
740
773
 
741
774
  def _create_context(self, selected_command: Command) -> ExecutionContext:
742
775
  """Creates a context dictionary for the selected command."""
@@ -759,8 +792,9 @@ class Falyx:
759
792
 
760
793
  async def process_command(self) -> bool:
761
794
  """Processes the action of the selected command."""
762
- choice = await self.prompt_session.prompt_async()
763
- is_preview, selected_command = self.get_command(choice)
795
+ with patch_stdout(raw=True):
796
+ choice = await self.prompt_session.prompt_async()
797
+ is_preview, selected_command, args, kwargs = self.get_command(choice)
764
798
  if not selected_command:
765
799
  logger.info("Invalid command '%s'.", choice)
766
800
  return True
@@ -789,8 +823,8 @@ class Falyx:
789
823
  context.start_timer()
790
824
  try:
791
825
  await self.hooks.trigger(HookType.BEFORE, context)
792
-
793
- result = await selected_command()
826
+ print(args, kwargs)
827
+ result = await selected_command(*args, **kwargs)
794
828
  context.result = result
795
829
  await self.hooks.trigger(HookType.ON_SUCCESS, context)
796
830
  except Exception as error:
@@ -803,10 +837,18 @@ class Falyx:
803
837
  await self.hooks.trigger(HookType.ON_TEARDOWN, context)
804
838
  return True
805
839
 
806
- async def run_key(self, command_key: str, return_context: bool = False) -> Any:
840
+ async def run_key(
841
+ self,
842
+ command_key: str,
843
+ return_context: bool = False,
844
+ args: tuple = (),
845
+ kwargs: dict[str, Any] | None = None,
846
+ ) -> Any:
807
847
  """Run a command by key without displaying the menu (non-interactive mode)."""
808
848
  self.debug_hooks()
809
- is_preview, selected_command = self.get_command(command_key)
849
+ is_preview, selected_command, _, __ = self.get_command(command_key)
850
+ kwargs = kwargs or {}
851
+
810
852
  self.last_run_command = selected_command
811
853
 
812
854
  if not selected_command:
@@ -827,7 +869,7 @@ class Falyx:
827
869
  context.start_timer()
828
870
  try:
829
871
  await self.hooks.trigger(HookType.BEFORE, context)
830
- result = await selected_command()
872
+ result = await selected_command(*args, **kwargs)
831
873
  context.result = result
832
874
 
833
875
  await self.hooks.trigger(HookType.ON_SUCCESS, context)
@@ -922,6 +964,8 @@ class Falyx:
922
964
  logger.info("BackSignal received.")
923
965
  except CancelSignal:
924
966
  logger.info("CancelSignal received.")
967
+ except HelpSignal:
968
+ logger.info("HelpSignal received.")
925
969
  finally:
926
970
  logger.info("Exiting menu: %s", self.get_title())
927
971
  if self.exit_message:
@@ -956,7 +1000,7 @@ class Falyx:
956
1000
 
957
1001
  if self.cli_args.command == "preview":
958
1002
  self.mode = FalyxMode.PREVIEW
959
- _, command = self.get_command(self.cli_args.name)
1003
+ _, command, args, kwargs = self.get_command(self.cli_args.name)
960
1004
  if not command:
961
1005
  self.console.print(
962
1006
  f"[{OneColors.DARK_RED}]❌ Command '{self.cli_args.name}' not found."
@@ -970,7 +1014,7 @@ class Falyx:
970
1014
 
971
1015
  if self.cli_args.command == "run":
972
1016
  self.mode = FalyxMode.RUN
973
- is_preview, command = self.get_command(self.cli_args.name)
1017
+ is_preview, command, _, __ = self.get_command(self.cli_args.name)
974
1018
  if is_preview:
975
1019
  if command is None:
976
1020
  sys.exit(1)
@@ -981,7 +1025,11 @@ class Falyx:
981
1025
  sys.exit(1)
982
1026
  self._set_retry_policy(command)
983
1027
  try:
984
- await self.run_key(self.cli_args.name)
1028
+ args, kwargs = command.parse_args(self.cli_args.command_args)
1029
+ except HelpSignal:
1030
+ sys.exit(0)
1031
+ try:
1032
+ await self.run_key(self.cli_args.name, args=args, kwargs=kwargs)
985
1033
  except FalyxError as error:
986
1034
  self.console.print(f"[{OneColors.DARK_RED}]❌ Error: {error}[/]")
987
1035
  sys.exit(1)
falyx/parsers.py CHANGED
@@ -2,7 +2,7 @@
2
2
  """parsers.py
3
3
  This module contains the argument parsers used for the Falyx CLI.
4
4
  """
5
- from argparse import ArgumentParser, Namespace, _SubParsersAction
5
+ from argparse import REMAINDER, ArgumentParser, Namespace, _SubParsersAction
6
6
  from dataclasses import asdict, dataclass
7
7
  from typing import Any, Sequence
8
8
 
@@ -114,6 +114,12 @@ def get_arg_parsers(
114
114
  help="Skip confirmation prompts",
115
115
  )
116
116
 
117
+ run_group.add_argument(
118
+ "command_args",
119
+ nargs=REMAINDER,
120
+ help="Arguments to pass to the command (if applicable)",
121
+ )
122
+
117
123
  run_all_parser = subparsers.add_parser(
118
124
  "run-all", help="Run all commands with a given tag"
119
125
  )
falyx/protocols.py CHANGED
@@ -2,10 +2,16 @@
2
2
  """protocols.py"""
3
3
  from __future__ import annotations
4
4
 
5
- from typing import Any, Awaitable, Protocol
5
+ from typing import Any, Awaitable, Protocol, runtime_checkable
6
6
 
7
7
  from falyx.action.action import BaseAction
8
8
 
9
9
 
10
+ @runtime_checkable
10
11
  class ActionFactoryProtocol(Protocol):
11
12
  async def __call__(self, *args: Any, **kwargs: Any) -> Awaitable[BaseAction]: ...
13
+
14
+
15
+ @runtime_checkable
16
+ class ArgParserProtocol(Protocol):
17
+ def __call__(self, args: list[str]) -> tuple[tuple, dict]: ...
falyx/signals.py CHANGED
@@ -29,3 +29,10 @@ class CancelSignal(FlowSignal):
29
29
 
30
30
  def __init__(self, message: str = "Cancel signal received."):
31
31
  super().__init__(message)
32
+
33
+
34
+ class HelpSignal(FlowSignal):
35
+ """Raised to display help information."""
36
+
37
+ def __init__(self, message: str = "Help signal received."):
38
+ super().__init__(message)
falyx/version.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.1.27"
1
+ __version__ = "0.1.28"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: falyx
3
- Version: 0.1.27
3
+ Version: 0.1.28
4
4
  Summary: Reliable and introspectable async CLI action framework.
5
5
  License: MIT
6
6
  Author: Roland Thomas Jr
@@ -12,36 +12,37 @@ falyx/action/selection_action.py,sha256=22rF7UqRrQAMjGIheDqAbUizVMBg9aCl9e4VOLLZ
12
12
  falyx/action/signal_action.py,sha256=5UMqvzy7fBnLANGwYUWoe1VRhrr7e-yOVeLdOnCBiJo,1350
13
13
  falyx/action/types.py,sha256=iVD-bHm1GRXOTIlHOeT_KcDBRZm4Hz5Xzl_BOalvEf4,961
14
14
  falyx/action/user_input_action.py,sha256=LSTzC_3TfsfXdz-qV3GlOIGpZWAOgO9J5DnNsHO7ee8,3398
15
+ falyx/argparse.py,sha256=kI_tLYD6KOiWvXEHEvOi8bprx5sQm9KTrgens2_nWKU,24498
15
16
  falyx/bottom_bar.py,sha256=iWxgOKWgn5YmREeZBuGA50FzqzEfz1-Vnqm0V_fhldc,7383
16
- falyx/command.py,sha256=CeleZJ039996d6qn895JXagLeh7gMZltx7jABecjSXY,12224
17
+ falyx/command.py,sha256=Vr5YHehZB6GavBbHe9diNbdeXNWfnWTkuDZ-NfymcFo,13437
17
18
  falyx/config.py,sha256=8dkQfL-Ro-vWw1AcO2fD1PGZ92Cyfnwl885ZlpLkp4Y,9636
18
19
  falyx/config_schema.py,sha256=j5GQuHVlaU-VLxLF9t8idZRjqOP9MIKp1hyd9NhpAGU,3124
19
20
  falyx/context.py,sha256=FNF-IS7RMDxel2l3kskEqQImZ0mLO6zvGw_xC9cIzgI,10338
20
21
  falyx/debug.py,sha256=oWWTLOF8elrx_RGZ1G4pbzfFr46FjB0woFXpVU2wmjU,1567
21
- falyx/exceptions.py,sha256=Qxp6UScZWEyno-6Lgksrv3s9iwjbr2U-d6hun-_xpc0,798
22
+ falyx/exceptions.py,sha256=kK9k1v7LVNjJSwYztRa9Krhr3ZOI-6Htq2ZjlYICPKg,922
22
23
  falyx/execution_registry.py,sha256=rctsz0mrIHPToLZqylblVjDdKWdq1x_JBc8GwMP5sJ8,4710
23
- falyx/falyx.py,sha256=ECL6nDgqxS0s8lzOlMnBOSqwZEsLN0kYzeBCs0lUsYI,40860
24
+ falyx/falyx.py,sha256=3OrmQ0p7ZOpjLaH5mqTYVEZ96KKTygSLYlPqg3eH5eM,43061
24
25
  falyx/hook_manager.py,sha256=GuGxVVz9FXrU39Tk220QcsLsMXeut7ZDkGW3hU9GcwQ,2952
25
26
  falyx/hooks.py,sha256=IV2nbj5FjY2m3_L7x4mYBnaRDG45E8tWQU90i4butlw,2940
26
27
  falyx/init.py,sha256=abcSlPmxVeByLIHdUkNjqtO_tEkO3ApC6f9WbxsSEWg,3393
27
28
  falyx/logger.py,sha256=1Mfb_vJFJ1tQwziuyU2p-cSMi2Js8N2byniFEnI6vOQ,132
28
29
  falyx/menu.py,sha256=faxGgocqQYY6HtzVbenHaFj8YqsmycBEyziC8Ahzqjo,2870
29
30
  falyx/options_manager.py,sha256=dFAnQw543tQ6Xupvh1PwBrhiSWlSACHw8K-sHP_lUh4,2842
30
- falyx/parsers.py,sha256=hxrBouQEqdgk6aWzNa7UwTg7u55vJffSEUUTiiQoI0U,5602
31
+ falyx/parsers.py,sha256=KsDFEmJLM86d2X4Kh4SHA9mBbUk351NjLhhFYzQkaPk,5762
31
32
  falyx/prompt_utils.py,sha256=qgk0bXs7mwzflqzWyFhEOTpKQ_ZtMIqGhKeg-ocwNnE,1542
32
- falyx/protocols.py,sha256=ejSz18D8Qg63ONdgwbvn2YanKK9bGF0e3Bjxh9y3Buc,334
33
+ falyx/protocols.py,sha256=mesdq5CjPF_5Kyu7Evwr6qMT71tUHlw0SjjtmnggTZw,495
33
34
  falyx/retry.py,sha256=UUzY6FlKobr84Afw7yJO9rj3AIQepDk2fcWs6_1gi6Q,3788
34
35
  falyx/retry_utils.py,sha256=EAzc-ECTu8AxKkmlw28ioOW9y-Y9tLQ0KasvSkBRYgs,694
35
36
  falyx/selection.py,sha256=l2LLISqgP8xfHdcTAEbTTqs_Bae4-LVUKMN7VQH7tM0,10731
36
- falyx/signals.py,sha256=PGlc0Cm8DfUB3lCf58u_kwTwm2XUEFQ2joFq0Qc_GXI,906
37
+ falyx/signals.py,sha256=Y_neFXpfHs7qY0syw9XcfR9WeAGRcRw1nG_2L1JJqKE,1083
37
38
  falyx/tagged_table.py,sha256=4SV-SdXFrAhy1JNToeBCvyxT-iWVf6cWY7XETTys4n8,1067
38
39
  falyx/themes/__init__.py,sha256=1CZhEUCin9cUk8IGYBUFkVvdHRNNJBEFXccHwpUKZCA,284
39
40
  falyx/themes/colors.py,sha256=4aaeAHJetmeNInI0Zytg4E3YqKfPFelpf04vtjSvsS8,19776
40
41
  falyx/utils.py,sha256=u3puR4Bh-unNBw9a0V9sw7PDTIzRaNLolap0oz5bVIk,6718
41
42
  falyx/validators.py,sha256=t5iyzVpY8tdC4rfhr4isEfWpD5gNTzjeX_Hbi_Uq6sA,1328
42
- falyx/version.py,sha256=vEF032D64gj-9WJp4kp0yS1eFIq4XHIqJr91sJJNwWg,23
43
- falyx-0.1.27.dist-info/LICENSE,sha256=B0yqgaHuSdhN7T3OBmgQSiDTy8HqT5Oe_dLypRe4Ra4,1073
44
- falyx-0.1.27.dist-info/METADATA,sha256=rs1IR_MPVQKge3QAZjwHUbh3sOfIutI5qckcMIaVR5w,5521
45
- falyx-0.1.27.dist-info/WHEEL,sha256=fGIA9gx4Qxk2KDKeNJCbOEwSrmLtjWCwzBz351GyrPQ,88
46
- falyx-0.1.27.dist-info/entry_points.txt,sha256=j8owOSl2j1Ss8DtGMnKfgehKaolqnIPhVFHaUBLUnMs,45
47
- falyx-0.1.27.dist-info/RECORD,,
43
+ falyx/version.py,sha256=MWZDdAHrdUZS0c3VlLqX4O1eaxPodI7irMtEvknKQ94,23
44
+ falyx-0.1.28.dist-info/LICENSE,sha256=B0yqgaHuSdhN7T3OBmgQSiDTy8HqT5Oe_dLypRe4Ra4,1073
45
+ falyx-0.1.28.dist-info/METADATA,sha256=n-VK44OFgjFxyRn1-LdysM1ciDF8RKxlKfAy_0Yz_Zc,5521
46
+ falyx-0.1.28.dist-info/WHEEL,sha256=fGIA9gx4Qxk2KDKeNJCbOEwSrmLtjWCwzBz351GyrPQ,88
47
+ falyx-0.1.28.dist-info/entry_points.txt,sha256=j8owOSl2j1Ss8DtGMnKfgehKaolqnIPhVFHaUBLUnMs,45
48
+ falyx-0.1.28.dist-info/RECORD,,
File without changes