diff --git a/CHANGELOG.md b/CHANGELOG.md index 86e9bdb..4afa8fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.11] - 2025-09-10 + +### Added + +### Changed +- Simplified the management of options in both `Application` and `Commands` to use the same approach. +- Improved the extraction of arguments and options from the command line for easier and more robust parsing. + +### Fixed +- Fixed the issue where `extra_args` did not preserve the original order from the command line. + ## [0.1.10] - 2025-07-21 ### Added diff --git a/fire/main.py b/fire/main.py index 55cea28..94c2d60 100644 --- a/fire/main.py +++ b/fire/main.py @@ -73,11 +73,24 @@ def publish(cmd): @command.fire -def coverage(cmd): +def coverage(cmd, file: str = ''): ''' Launch tests with coverage + + Args: + file: Specific test file to run (e.g., "test_command.py") ''' - bash = 'rye run coverage run -m pytest && rye run coverage html' + if file: + test_path = f'tests/{file}' if not file.startswith('tests/') else file + if not os.path.exists(test_path): + out.critical(f'File {test_path} not exists') + bash = ( + f'rye run coverage run -m pytest {test_path} ' + '&& rye run coverage html' + ) + else: + bash = 'rye run coverage run -m pytest && rye run coverage html' + cmd.app.shell(bash, capture_output=False) diff --git a/pyproject.toml b/pyproject.toml index dcd4bdd..61f5557 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "CliFire" -version = "0.1.10" +version = "0.1.11" description = "Minimal CLI framework to build Python commands quickly and elegantly." authors = [ { name = "Roberto Lizana", email = "rober.lizana@gmail.com" } diff --git a/src/clifire/application.py b/src/clifire/application.py index 932d41f..284dce9 100644 --- a/src/clifire/application.py +++ b/src/clifire/application.py @@ -84,7 +84,7 @@ def _add_option_ansi(self): self.set_option('no_ansi', True) def add_option(self, name: str, field: command.Field): - self.options[name] = [field, field.default] + self.options[name] = field for alias in field.alias: if alias.startswith('-'): alias = alias[2:] if alias.startswith('--') else alias[1:] @@ -93,18 +93,18 @@ def add_option(self, name: str, field: command.Field): raise command.CommandException( f'Duplicate global option alias "{alias}"' ) - self.options[alias] = [name, None] + self.options[alias] = name def set_option(self, name: str, value): if name not in self.options: return False - self.options[name][1] = value + self.options[name].value = value return True def get_option(self, name: str, default=None): if name not in self.options: return default - return self.options[name][1] + return self.options[name].value def add_command(self, cls: Type[command.Command]): if not cls._name: diff --git a/src/clifire/command.py b/src/clifire/command.py index db4a6f8..ba89381 100644 --- a/src/clifire/command.py +++ b/src/clifire/command.py @@ -78,6 +78,7 @@ def __init__( alias = [] if alias is None else alias self.alias = [alias] if isinstance(alias, str) else alias self.default = default + self.value = default self.is_option = bool(pos is False or pos is None) self.is_required = default is None if force_type is None: @@ -159,60 +160,96 @@ def _fields_check(self): def _parse_command_line(self, command_line: str): out.debug(f'Parse command line: {command_line}') - arguments = [] - self.command_line = shlex.split(command_line) - parts = self.command_line.copy() - remove_parts = len(self._name.split('.')) - while parts: - part = parts.pop(0) - if not part.startswith('-'): - remove_parts -= 1 - if remove_parts < 0: - arguments.append(part) - continue - option = part[2:] if part.startswith('--') else part[1:] - name, value = ( - option.split('=', 1) if '=' in option else (option, None) - ) - name = name.replace('-', '_') - if name not in self._options: - if name not in self.app.options: - out.debug2(f'Extra option "{part}"') - self.extra_args.append(part) - continue - field, _value = self.app.options[name] - if isinstance(field, str): - name = field - field, _value = self.app.options[name] - if not value and field.type != bool: - value = parts.pop(0) - value = field.convert(value) - self.app.set_option(name, value) - out.debug2(f'Global option "{name}" = {value}') - continue - field = self._options[name] - if isinstance(field, str): - name = field - field = self._options[name] - if not value and field.type != bool: - value = parts.pop(0) - value = field.convert(value) - out.debug2(f'Option "{name}" = {value}') - setattr(self, name, value) - arg_names = self._argument_names.copy() - for index, argument in enumerate(arguments): - if not arg_names: - out.debug2(f'Extra argument "{argument}"') - self.extra_args.append(argument) - continue - name = arg_names.pop(0) - if self._fields[name].type == list: - out.debug2(f'Argument "{name}" = {arguments[index:]}') - setattr(self, name, arguments[index:]) - break - value = self._fields[name].convert(argument) - out.debug2(f'Argument "{name}" = {value}') - setattr(self, name, value) + tokens = shlex.split(command_line) + command_parts = self._name.split('.') + argument_index = 0 + index = 0 + while index < len(tokens): + token = tokens[index] + if token in command_parts: + command_parts.remove(token) + index += 1 + elif token.startswith('-'): + consumed = self._handle_option(tokens, index) + index += consumed + else: + consumed = self._handle_argument(tokens, index, argument_index) + if consumed > 0: + argument_index += 1 + index += consumed + else: + self.extra_args.append(token) + index += 1 + + def _handle_option(self, tokens: List[str], index: int) -> int: + token = tokens[index] + option_str = token[2:] if token.startswith('--') else token[1:] + name, value = ( + option_str.split('=', 1) + if '=' in option_str + else (option_str, None) + ) + name = name.replace('-', '_') + option_field = self._find_option(name) + if not option_field: + self.extra_args.append(token) + return 1 + consumed = 1 + if value is None and option_field['field'].type != bool: + next_token = tokens[index + 1] if index + 1 < len(tokens) else None + if not next_token.startswith('-'): + value = next_token + consumed = 2 + parsed_value = option_field['field'].convert(value) + option_name = option_field['name'] + if option_field['is_global']: + self.app.set_option(option_name, parsed_value) + out.debug2(f'Global option "{option_name}" = {parsed_value}') + else: + setattr(self, option_name, parsed_value) + out.debug2(f'Option "{option_name}" = {parsed_value}') + return consumed + + def _find_option(self, name: str) -> dict: + def _get_options(name: str) -> dict: + if name in self.app.options: + return self.app.options + if name in self._options: + return self._options + return {} + + options = _get_options(name) + if not options: + return None + field_name = options[name] if isinstance(options[name], str) else name + return { + 'field': options[field_name], + 'name': field_name, + 'is_global': name in self.app.options, + } + + def _handle_argument( + self, tokens: List[str], index: int, argument_index: int + ) -> int: + if argument_index >= len(self._argument_names): + return 0 + field_name = self._argument_names[argument_index] + field = self._fields[field_name] + if field.type == list: + list_values = [] + consumed = 0 + for i in range(index, len(tokens)): + token = tokens[i] + list_values.append(token) + consumed += 1 + setattr(self, field_name, list_values) + out.debug2(f'Argument "{field_name}" = {list_values}') + return consumed + token = tokens[index] + value = field.convert(token) + setattr(self, field_name, value) + out.debug2(f'Argument "{field_name}" = {value}') + return 1 def parse(self, command_line: str): self._parse_command_line(command_line) diff --git a/src/clifire/commands/help.py b/src/clifire/commands/help.py index 8e89cff..c30f029 100644 --- a/src/clifire/commands/help.py +++ b/src/clifire/commands/help.py @@ -79,9 +79,9 @@ def print_options(self, cmd): def print_options_global(self): options = {} for name in self.app.options: - field, _value = self.app.options[name] + field = self.app.options[name] if isinstance(field, str): - field, _value = self.app.options[field] + field = self.app.options[field] name = name.replace('_', '-') name = f'-{name}' if len(name) == 1 else f'--{name}' options.setdefault(field, []).append(name) diff --git a/src/clifire/main.py b/src/clifire/main.py index 0dc5f71..1d4c082 100644 --- a/src/clifire/main.py +++ b/src/clifire/main.py @@ -31,7 +31,7 @@ def load_file(filename): def main(command_line: str = None): - app = application.App(name='CliFire', version='0.1.10') + app = application.App(name='CliFire', version='0.1.11') current_dir = os.getcwd() out.debug(f'Search commands in {current_dir} folder and parents') loaded = False diff --git a/tests/test_command.py b/tests/test_command.py index de67a82..32fc542 100644 --- a/tests/test_command.py +++ b/tests/test_command.py @@ -405,6 +405,43 @@ def get_test_app(): assert 'Global option "verbose" = True' in output(capsys) +def test_command_extra_args(capsys): + app = application.App() + app.add_command(CommandContact) + + cmd = app.get_command('contact') + assert cmd.extra_args == [] + cmd.parse('contact Rob 21') + assert cmd.extra_args == [] + + cmd = app.get_command('contact') + cmd.parse('contact Rob 21 extra args') + assert cmd.extra_args == ['extra', 'args'] + + cmd = app.get_command('contact') + cmd.parse('contact Rob 21 --int 1 --extra args') + assert cmd.extra_args == ['--extra', 'args'] + + cmd = app.get_command('contact') + cmd.parse('contact Rob 21 --extra args --int 1') + assert cmd.extra_args == ['--extra', 'args'] + assert cmd.int_option == 1 + cmd.parse('contact Rob 21') + assert cmd.int_option == 1 + + cmd = app.get_command('contact') + cmd.parse('contact Rob 21 --extra e1 --int 1 e2 e3 --extra-option o1 o2') + assert cmd.extra_args == [ + '--extra', + 'e1', + 'e2', + 'e3', + '--extra-option', + 'o1', + 'o2', + ] + + def test_command_return_error_code(capsys): class CommandTest(command.Command): _name = 'test'