diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml index 4fcc0b5..c46d756 100644 --- a/.github/workflows/actions.yml +++ b/.github/workflows/actions.yml @@ -5,7 +5,6 @@ on: [push, pull_request] jobs: pre-commit: runs-on: ubuntu-latest - steps: - name: Checkout code uses: actions/checkout@v3 @@ -26,45 +25,67 @@ jobs: - name: Install dependencies run: | + rye pin cpython@3.12.2 rye sync --all-features - name: Run pre-commit run: rye run pre-commit run --all-files - test: + test-python-3_8: runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v3 + - name: Checkout code + uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.12' + - name: Set up Python for Rye + uses: actions/setup-python@v4 + with: + python-version: '3.12' - - name: Install Rye - run: | - curl -sSf https://rye.astral.sh/get | bash - echo "$HOME/.rye/shims" >> $GITHUB_PATH - env: - RYE_TOOLCHAIN_VERSION: '3.12' - RYE_VERSION: '0.44.0' - RYE_INSTALL_OPTION: '--yes' + - name: Install Rye + run: | + curl -sSf https://rye.astral.sh/get | bash + echo "$HOME/.rye/shims" >> $GITHUB_PATH + env: + RYE_INSTALL_OPTION: '--yes' - - name: Install dependencies - run: | - rye sync --all-features + - name: Install dependencies + run: | + rye pin cpython@3.8.2 + rye sync --all-features - - name: Run unit tests - run: rye run coverage run -m pytest + - name: Run unit tests + run: rye run fire tests - - name: Generate coverage report - run: rye run coverage xml + test-python-3_12: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 - - name: Upload coverage to Coveralls - env: - COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} - run: | - pip install coveralls - coveralls + - name: Set up Python for Rye + uses: actions/setup-python@v4 + with: + python-version: '3.12' + + - name: Install Rye + run: | + curl -sSf https://rye.astral.sh/get | bash + echo "$HOME/.rye/shims" >> $GITHUB_PATH + env: + RYE_INSTALL_OPTION: '--yes' + + - name: Install dependencies + run: | + rye pin cpython@3.12.2 + rye sync --all-features + + - name: Run unit tests with coverage + run: rye run fire coverage + + - name: Upload coverage to Coveralls + env: + COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} + run: | + pip install coveralls + coveralls diff --git a/.gitignore b/.gitignore index 3120337..71adf55 100644 --- a/.gitignore +++ b/.gitignore @@ -17,7 +17,6 @@ __pycache__/ .Python build/ dist/ -poetry.lock # Coverage htmlcov/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d9fb3f6..d4adc69 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -50,7 +50,7 @@ repos: hooks: - id: pyupgrade name: Automatically upgrade syntax for newer versions of the language - args: ["--py310-plus", "--keep-runtime-typing", "--keep-percent-format"] + args: ["--py38-plus", "--keep-runtime-typing", "--keep-percent-format"] - repo: local hooks: diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..56bb660 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12.7 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4950f81..ad00067 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [0.1.8] - 2025-07-07 ### Added +- Handle program interruption with `KeyboardInterrupt` to allow canceling the execution of a command. +- Added an `icon` argument to the `critical`, `error`, `warning`, and `success` methods in `out` to display an icon. +- Added the same `icon` argument to the `error`, `warning`, and `success` methods of `live`. +- Added a Dockerfile to test the library with Python 3.8. ### Changed -- Minimum Python version is now 3.10. +- Minimum Python version is now 3.6. +- Set the Python version for development to 3.12.7. +- Removed package versions in `pyproject.toml` to use the latest versions. ### Fixed -- Change poetry to rye in the scripts for building and testing the package. +- Changed poetry to rye in the scripts for building and testing the package. ## [0.1.7] - 2025-07-06 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8877546 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM ubuntu:latest +ARG PYTHON_VERSION=3.8.2 + +RUN apt-get update && apt-get install -y \ + curl \ + && rm -rf /var/lib/apt/lists/* + +ENV RYE_HOME="/root/.rye" +ENV RYE_INSTALL_OPTION="--yes" +ENV RYE_NO_AUTO_INSTALL="true" +RUN curl -sSf https://rye.astral.sh/get | bash +ENV PATH="/root/.rye/shims:${PATH}" + +WORKDIR /app +COPY pyproject.toml README.md ./ +COPY src ./src +COPY tests ./tests +COPY fire ./fire + +RUN rye pin cpython@${PYTHON_VERSION} && \ + rye sync --all-features + +CMD ["rye", "run", "fire", "tests"] diff --git a/docs/docs/en/contributing.md b/docs/docs/en/contributing.md index 67e3763..0abf543 100644 --- a/docs/docs/en/contributing.md +++ b/docs/docs/en/contributing.md @@ -40,14 +40,14 @@ There are several ways you can help: #### **Run the Tests:** Make sure all tests pass using: ```bash - poetry run pytest + rye run pytest ``` Check the coverage with: ```bash - poetry run coverage run -m pytest && poetry run coverage html + rye run coverage run -m pytest && rye run coverage html ``` - You can also use `poetry run fire coverage` to run the tests and generate the coverage report. + You can also use `rye run fire coverage` to run the tests and generate the coverage report. #### **Submit a Pull Request:** Once you are satisfied with your changes, submit a *pull request* to the main branch of the repository. Describe in detail what you have changed and the motivation behind it. diff --git a/docs/docs/en/quick-start.md b/docs/docs/en/quick-start.md index 9c3b26e..8ae791d 100644 --- a/docs/docs/en/quick-start.md +++ b/docs/docs/en/quick-start.md @@ -18,6 +18,12 @@ pip install clifire poetry add clifire ``` +### Using Rye + +```bash +rye add clifire +``` + ## Basic Usage CliFire allows you to define commands using decorators or classes. Here’s an example using a decorator to greet the user: diff --git a/docs/docs/en/user-guide/output.md b/docs/docs/en/user-guide/output.md index 671a6b8..b964be6 100644 --- a/docs/docs/en/user-guide/output.md +++ b/docs/docs/en/user-guide/output.md @@ -49,10 +49,10 @@ Below are the most commonly used functions: out.var_dump(sample_dict) ``` -- **`out.LiveText`** - This is a class that allows you to update text in real time in the terminal. It is useful for displaying progress bars or counters that update dynamically. +- **`out.live`** + This is a method that allows you to update text in real time in the terminal. It is useful for displaying progress bars or counters that update dynamically. ```python - live_text = out.LiveText("Starting...") + live_text = out.live("Starting...") live_text.info("Process running") live_text.warn("Retrying operation") live_text.success("Operation completed", end=False) @@ -100,7 +100,7 @@ class OutCommand(command.Command): print('') print('Live text') print('-' * 80) - live_text = out.LiveText("Starting...") + live_text = out.live("Starting...") time.sleep(1) live_text.info("Process running") time.sleep(1) diff --git a/docs/docs/es/contributing.md b/docs/docs/es/contributing.md index a2c3c42..ffa60ca 100644 --- a/docs/docs/es/contributing.md +++ b/docs/docs/es/contributing.md @@ -40,14 +40,14 @@ Existen varias maneras de ayudar: #### **Ejecuta los tests:** Asegúrate de que todos los tests pasan con: ```bash - poetry run pytest + rye run pytest ``` Comprueba la cobertura: ```bash - poetry run coverage run -m pytest && poetry run coverage html + rye run coverage run -m pytest && rye run coverage html ``` - También puedes usar `poetry run fire coverage` para ejecutar los tests y generar el informe de cobertura. + También puedes usar `rye run fire coverage` para ejecutar los tests y generar el informe de cobertura. #### **Realiza un Pull Request:** Una vez que estés satisfecho con tus cambios, realiza un *pull request* a la rama principal del repositorio. Describe detalladamente lo que has cambiado y la motivación detrás de ello. diff --git a/docs/docs/es/quick-start.md b/docs/docs/es/quick-start.md index 7d28c22..1d66016 100644 --- a/docs/docs/es/quick-start.md +++ b/docs/docs/es/quick-start.md @@ -18,6 +18,12 @@ pip install clifire poetry add clifire ``` +### Usando Rye + +```bash +rye add clifire +``` + ## Uso Básico CliFire te permite definir comandos mediante decoradores o clases. Aquí tienes un ejemplo utilizando un decorador para saludar al usuario: diff --git a/fire/main.py b/fire/main.py index f30e078..5a55b2e 100644 --- a/fire/main.py +++ b/fire/main.py @@ -60,6 +60,20 @@ def build(cmd): cmd.app.fire('doc build') +@command.fire +def publish(cmd): + ''' + Publish then package + ''' + live = out.LiveText('Puiblising ...') + res = cmd.app.shell('rye publish -y', capture_output=False) + if res: + live.success('Published') + else: + live.error('Error on publish package') + return 1 + + @command.fire def coverage(cmd): ''' @@ -75,3 +89,12 @@ def precommit(cmd): Launch pre-commit ''' cmd.app.shell('pre-commit run --all-files', capture_output=False) + + +@command.fire +def tests(cmd): + ''' + Launch tests + ''' + path = cmd.app.path(os.path.dirname(__file__), '..') + cmd.app.shell('rye run python -m pytest', capture_output=False, path=path) diff --git a/fire/test.py b/fire/test.py new file mode 100644 index 0000000..e9ce8cc --- /dev/null +++ b/fire/test.py @@ -0,0 +1,32 @@ +from clifire import command + + +@command.fire +def test_legacy(cmd, _build: bool = False, python_version: str = '3.8.2'): + ''' + Launch tests in docker image with specified Python version + + Args: + python_version: Python version to use for the tests. (default: '3.8.2') + _build: Force build the Docker image before running tests. + ''' + + image_name = f'clifire-py{python_version}' + + def docker_build(): + cmd.app.shell( + f'docker build --build-arg PYTHON_VERSION={python_version} ' + f'-t {image_name} .', + capture_output=False, + ) + + if _build: + docker_build() + elif image_name not in cmd.app.shell('docker images').stdout: + docker_build() + + volumen_str = '-v ./src:/app/src -v ./tests:/app/tests' + cmd.app.shell( + f'docker run --rm {volumen_str} {image_name}', + capture_output=False, + ) diff --git a/pyproject.toml b/pyproject.toml index e3c165b..9b63a0d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,11 +7,11 @@ authors = [ ] packages = ["src"] readme = "README.md" -requires-python = ">= 3.0" +requires-python = ">= 3.6" dependencies = [ - "jinja2>=3.1.6", - "pyyaml>=6.0.2", - "rich>=14.0.0", + "jinja2", + "pyyaml", + "rich", ] [build-system] @@ -21,13 +21,13 @@ build-backend = "hatchling.build" [tool.rye] managed = true dev-dependencies = [ - "coverage>=7.9.1", - "mkdocs-material>=9.6.14", - "mkdocs-static-i18n>=1.3.0", - "mkdocs>=1.6.1", - "pre-commit>=4.2.0", - "pytest>=8.4.0", - "black-with-tabs>=22.10.0", + "coverage", + "mkdocs-material", + "mkdocs-static-i18n", + "mkdocs", + "pre-commit", + "pytest", + "black-with-tabs", ] [tool.hatch.metadata] diff --git a/src/clifire/application.py b/src/clifire/application.py index 3e7ff2b..2b180a6 100644 --- a/src/clifire/application.py +++ b/src/clifire/application.py @@ -2,6 +2,7 @@ import shlex import subprocess import sys +from typing import Any, Dict, List, Type from clifire import command, commands, config, out, result, template @@ -13,10 +14,10 @@ def __init__( self, name: str = '', version: str = '0.0.1 alpha', - context: dict = None, + context: Dict[str, Any] = None, option_verbose: bool = True, option_ansi: bool = True, - config_files: list = None, + config_files: List[str] = None, config_create: bool = False, command_help=commands.help.CommandHelp, command_version=commands.version.CommandVersion, @@ -103,14 +104,14 @@ def get_option(self, name: str, default=None): return default return self.options[name][1] - def add_command(self, cls: command.Command): + def add_command(self, cls: Type[command.Command]): if not cls._name: raise command.CommandException( f'The command {cls} has no name, please set _name var in class' ) self.commands[cls._name] = cls - def add_commands(self, commands: list): + def add_commands(self, commands: List[Type[command.Command]]): for cmd in commands: self.add_command(cmd) @@ -186,6 +187,9 @@ def fire(self, command_line: str = None): out.critical(e, code=30) except command.FieldException as e: out.critical(e, code=40) + except KeyboardInterrupt: + out.error('Keyboard interrupt!') + raise @classmethod def shell( @@ -220,7 +224,7 @@ def shell( os.chdir(pwd) @classmethod - def path(cls, *args: list[str]) -> str: + def path(cls, *args: List[str]) -> str: if len(args) == 0: args = (os.getcwd(),) exapnd_path = os.path.join( diff --git a/src/clifire/command.py b/src/clifire/command.py index 739f04f..db4a6f8 100644 --- a/src/clifire/command.py +++ b/src/clifire/command.py @@ -1,6 +1,7 @@ import inspect import re import shlex +from typing import List, Optional, Type, Union from clifire import out @@ -68,8 +69,8 @@ def __init__( pos: int = False, help: str = '', default: str = None, - alias: str | list[str] | None = None, - force_type: type = None, + alias: Union[str, List[str], None] = None, + force_type: Optional[Type] = None, ): self.name = 'unknow' self.pos = pos diff --git a/src/clifire/config.py b/src/clifire/config.py index 9889186..c2a47b8 100644 --- a/src/clifire/config.py +++ b/src/clifire/config.py @@ -1,4 +1,5 @@ import os +from typing import Any, Dict, List import yaml from clifire import out @@ -6,7 +7,7 @@ class Config: @classmethod - def get_config(cls, files: list, create: bool = False, **kwargs): + def get_config(cls, files: List[str], create: bool = False, **kwargs): def path(*args) -> str: expand_path = os.path.join( *(a.replace('~', os.path.expanduser('~')) for a in args) @@ -24,8 +25,7 @@ def path(*args) -> str: config.read() return config out.debug2('Not exist') - file = path(files[0]) - config = Config(config_file=config_file, **kwargs) + config = Config(config_file=path(files[0]), **kwargs) if create: config.write() return config @@ -85,7 +85,7 @@ def __str__(self) -> str: def __repr__(self) -> str: return self.__str__() - def _safe_dict(self, data_dict: dict): + def _safe_dict(self, data_dict: Dict[str, Any]): return {k: v for k, v in data_dict.items() if not k.startswith('_')} def read(self): diff --git a/src/clifire/out.py b/src/clifire/out.py index ed62383..18ec577 100644 --- a/src/clifire/out.py +++ b/src/clifire/out.py @@ -3,7 +3,7 @@ import sys import threading import time -from typing import Any +from typing import Any, Dict, List, Optional, Tuple, Union from rich import traceback from rich.console import Console @@ -29,6 +29,10 @@ COLOR_WARN = 'yellow' COLOR_ERROR = 'red' +ICON_SUCCESS = '✓' +ICON_ERROR = '✗' +ICON_WARN = '▲' + def setup(ansi: bool = True): if ansi: @@ -80,9 +84,9 @@ def _start(self): self._live.update(self._text) def start(self): + self._running = True if self._thread and self._thread.is_alive(): return - self._running = True self._thread = threading.Thread(target=self._start, daemon=True) self._thread.start() @@ -91,32 +95,41 @@ def cancel(self): self.stop() def stop(self): - self._running = False if self._text != '': CONSOLE.print(self._text) + self._running = False if self._thread and self._thread.is_alive(): - self._live.transient = True + if self._live: + self._live.transient = True self._text = text_color('') self._thread.join(timeout=1.0) - self._live.update(self._text) + if self._live: + self._live.update(self._text) + self._live.stop() def info(self, text: str, end=False): self._text = text_color(text, color=COLOR_INFO) if end: self.stop() - def warn(self, text: str, end=False): - self._text = text_color(text, color=COLOR_WARN) + def warn(self, text: str, end=False, icon: bool = False): + self._text = text_color( + text, color=COLOR_WARN, icon=ICON_WARN, force_icon=icon + ) if end: self.stop() - def success(self, text: str, end=True): - self._text = text_color(text, color=COLOR_SUCCESS) + def success(self, text: str, end=True, icon: bool = False): + self._text = text_color( + text, color=COLOR_SUCCESS, icon=ICON_SUCCESS, force_icon=icon + ) if end: self.stop() - def error(self, text: str, end=True): - self._text = text_color(text, color=COLOR_ERROR) + def error(self, text: str, end=True, icon: bool = False): + self._text = text_color( + text, color=COLOR_ERROR, icon=ICON_ERROR, force_icon=icon + ) if end: self.stop() @@ -127,19 +140,23 @@ def error(self, text: str, end=True): def live(text: str = '', refresh_per_second: int = 10): global _current_live if _current_live: + _current_live.info(text) + _current_live.refresh_per_second = refresh_per_second + if not _current_live.is_alive: + _current_live.start() return _current_live _current_live = LiveText(text, refresh_per_second=refresh_per_second) return _current_live def table( - data: list[dict[str, Any]], + data: List[Dict[str, Any]], title: str = '', border: bool = True, show_header: bool = True, - style_cols: dict[str, str] = None, - padding: tuple = None, - style: str = None, + style_cols: Optional[Union[Dict[str, str], str]] = None, + padding: Optional[Tuple] = None, + style: Optional[str] = None, ): if not data: return @@ -171,28 +188,43 @@ def ansi_clean(text: str) -> str: return re.sub(r'\x1B\[[0-?]*[ -/]*[@-~]', '', text) -def text_color(text: str, color: str = COLOR_NORMAL) -> str: +def text_color( + text: str, + color: str = COLOR_NORMAL, + icon: str = None, + force_icon: bool = False, +) -> str: + text = str(text) + if icon and not text.startswith(icon): + if CONSOLE.no_color is True or force_icon: + text = f'{icon} {text}' return f'[{color}]{text}[/{color}]' -def _print(text: str, color: str = COLOR_NORMAL) -> None: - CONSOLE.print(text_color(text, color=color)) +def _print( + text: str, + color: str = COLOR_NORMAL, + icon: str = None, + force_icon: bool = False, +) -> None: + text = text_color(text, color=color, icon=icon, force_icon=force_icon) + CONSOLE.print(text) def info(text: str) -> None: _print(text, COLOR_INFO) -def success(text: str) -> None: - _print(text, COLOR_SUCCESS) +def success(text: str, icon: bool = False) -> None: + _print(text, color=COLOR_SUCCESS, icon=ICON_SUCCESS, force_icon=icon) -def warn(text: str) -> None: - _print(text, COLOR_WARN) +def warn(text: str, icon: bool = False) -> None: + _print(text, color=COLOR_WARN, icon=ICON_WARN, force_icon=icon) -def error(text: str) -> None: - _print(text, COLOR_ERROR) +def error(text: str, icon: bool = False) -> None: + _print(text, color=COLOR_ERROR, icon=ICON_ERROR, force_icon=icon) def critical(text: str, code: int = 1) -> None: @@ -220,7 +252,16 @@ def var_dump(var) -> None: CONSOLE.print(var, highlight=True) -def ask(text: str, choices: list[str] = ['y', 'n']): # noqa: B006 +def ask(text: str, choices: List[str] = False): + if choices is False: + choices = ['y', 'n'] return Prompt.ask( text, choices=choices, default=choices[0] if choices else None ) + + +def rule(text: str) -> None: + global _current_live + if _current_live: + _current_live.info(text) + CONSOLE.rule(f'[bold blue]{text}', align='left', style='blue') diff --git a/src/clifire/template.py b/src/clifire/template.py index 025c291..b5a9c06 100644 --- a/src/clifire/template.py +++ b/src/clifire/template.py @@ -1,5 +1,6 @@ import os import re +from typing import List import jinja2 from clifire import application @@ -12,7 +13,7 @@ def __init__(self, template_folder: str): loader=jinja2.FileSystemLoader(template_folder) ) - def path(self, *args: list[str]) -> str: + def path(self, *args: List[str]) -> str: return application.App.current_app.path(self.template_folder, *args) def render(self, template, **args): diff --git a/tests/test_app.py b/tests/test_app.py index c4314ae..428d21d 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,10 +1,11 @@ import os import subprocess import sys +import time from unittest.mock import patch import pytest -from clifire import application, out +from clifire import application, command, out def output(capsys): @@ -12,6 +13,30 @@ def output(capsys): return out.ansi_clean(captured.out) +def test_app_keyboard_interrupt(capsys): + class CommandWait(command.Command): + _name = 'wait' + + def fire(self): + try: + time.sleep(1) + except KeyboardInterrupt: + out.info('KeyboardInterrupt received') + raise + + app = application.App() + app.add_command(CommandWait) + + with patch('time.sleep', side_effect=KeyboardInterrupt): + with pytest.raises(KeyboardInterrupt): + app.fire('wait') + + printed = output(capsys) + assert 'KeyboardInterrupt received' in printed + assert 'End command' not in printed + assert 'Keyboard interrupt!' in printed + + def test_app_global_option_no_ansi(capsys): app = application.App(name='sample', version='1.0.99') assert app.get_option('no_ansi') is False diff --git a/tests/test_output.py b/tests/test_output.py index 32a4b82..a9ab96c 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -195,9 +195,9 @@ def test_var_dump(capsys): } ) printed = output(capsys) - assert "\n 'number': 1," in printed - assert "\n 'list': ['a', 'b', 'c']," in printed - assert "\n 'dict': {'key a': 'value a'," in printed + assert "'number': 1," in printed + assert "'list': ['a', 'b', 'c']," in printed + assert "'dict': {'key a': 'value a'," in printed def test_ask(capsys, monkeypatch): @@ -221,3 +221,61 @@ def test_ask(capsys, monkeypatch): printed = output(capsys) assert 'Continue?' in printed assert '[yes/no]' in printed + + +def test_rule(capsys): + out.rule('My rule') + printed = output(capsys) + assert 'My rule' in printed + assert '─' in printed + + out.rule('My rule') + printed = output(capsys) + assert 'My rule' in printed + assert '─' in printed + + live = out.live('Message in live') + assert 'Message in live' in live._text + out.rule('My rule') + assert 'My rule' in live._text + printed = output(capsys) + assert 'My rule' in printed + live.cancel() + printed = output(capsys) + assert 'My rule' not in printed + + +def test_no_ansi(capsys): + out.setup(ansi=False) + assert out.CONSOLE.no_color is True + out.success('Success') + assert '✓ Success' in output(capsys) + out.warn('Warn') + assert '▲ Warn' in output(capsys) + out.error('Error') + assert '✗ Error' in output(capsys) + + live = out.live('Message in live') + assert 'Message in live' in live._text + live.success('Success', end=False) + time.sleep(1) + assert '✓ Success' in live._text + live.error('Error', end=False) + assert '✗ Error' in live._text + live.cancel() + + output(capsys) + out.setup(ansi=True) + assert out.CONSOLE.no_color is False + out.success('Success') + assert '✓' not in output(capsys) + out.warn('Warn') + assert '▲' not in output(capsys) + out.error('Error') + assert '✗' not in output(capsys) + out.success('Success', icon=True) + assert '✓ Success' in output(capsys) + out.warn('Warn', icon=True) + assert '▲ Warn' in output(capsys) + out.error('Error', icon=True) + assert '✗ Error' in output(capsys) diff --git a/tests/test_sample_fire.py b/tests/test_sample_fire.py index 9343658..a3734a4 100644 --- a/tests/test_sample_fire.py +++ b/tests/test_sample_fire.py @@ -127,7 +127,8 @@ def test_fire_group_help(capsys): app.fire('help ab ef') printed = output(capsys) - assert 'ab ef' not in printed + assert 'ab [options]' in printed + assert 'ab ef [options]' not in printed assert 'doc_ab_ef_gh' in printed app.fire('help zz')