Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 15 additions & 2 deletions fire/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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" }
Expand Down
8 changes: 4 additions & 4 deletions src/clifire/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:]
Expand All @@ -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:
Expand Down
145 changes: 91 additions & 54 deletions src/clifire/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions src/clifire/commands/help.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/clifire/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions tests/test_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down