├── src └── click_didyoumean │ ├── py.typed │ └── __init__.py ├── examples ├── asciicast.gif └── naval.py ├── .gitignore ├── pyproject.toml ├── LICENSE ├── .github └── workflows │ └── cicd.yml ├── tests └── test_core.py └── README.rst /src/click_didyoumean/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/asciicast.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/click-contrib/click-didyoumean/HEAD/examples/asciicast.gif -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # editors 2 | *.swp 3 | .vscode 4 | 5 | # Python bytecode files 6 | *.pyc 7 | __pycache__/ 8 | 9 | # Python packaging 10 | *.egg-info 11 | dist/ 12 | poetry.lock 13 | 14 | # python virtualenv 15 | .python-version -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "click-didyoumean" 3 | version = "0.3.1" 4 | description = "Enables git-like *did-you-mean* feature in click" 5 | authors = ["Timo Furrer "] 6 | license = "MIT" 7 | homepage = "https://github.com/click-contrib/click-didyoumean" 8 | repository = "https://github.com/click-contrib/click-didyoumean" 9 | readme = "README.rst" 10 | 11 | [tool.poetry.dependencies] 12 | python = ">=3.8" 13 | click = ">=7" 14 | 15 | [tool.poetry.dev-dependencies] 16 | pytest = "^6.2.5" 17 | mypy = "^0.910" 18 | ruff = "0.3.4" 19 | 20 | [tool.ruff] 21 | src = ["src"] 22 | target-version = "py38" 23 | 24 | [tool.ruff.lint] 25 | select = ["ALL"] 26 | ignore = ["ANN101", "COM812", "D", "FA", "ISC001"] 27 | 28 | [tool.ruff.lint.flake8-type-checking] 29 | strict = true 30 | 31 | [tool.ruff.lint.per-file-ignores] 32 | "examples/**" = ["ANN", "D", "INP001"] 33 | "tests/**" = ["ANN", "D", "INP001", "S101"] 34 | 35 | [build-system] 36 | requires = ["poetry-core>=1.0.0"] 37 | build-backend = "poetry.core.masonry.api" 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Timo Furrer 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /.github/workflows/cicd.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | ci: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | fail-fast: false 10 | max-parallel: 4 11 | matrix: 12 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Set up Python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | - run: pip install -U pip poetry 21 | - run: poetry install 22 | - run: poetry run ruff check src/ tests/ 23 | - run: poetry run ruff format --diff --check src/ tests/ 24 | - run: poetry run mypy src/ tests/ 25 | - run: poetry run pytest 26 | 27 | cd: 28 | needs: [ci] 29 | runs-on: ubuntu-latest 30 | 31 | steps: 32 | - uses: actions/checkout@v4 33 | - name: Set up Python 34 | if: startsWith(github.event.ref, 'refs/tags') 35 | uses: actions/setup-python@v5 36 | with: 37 | python-version: "3.12" 38 | - run: pip install -U pip poetry 39 | - name: Publish package 40 | if: startsWith(github.event.ref, 'refs/tags') 41 | run: | 42 | poetry publish --build --username __token__ --password ${{ secrets.PYPI_TOKEN }} 43 | -------------------------------------------------------------------------------- /examples/naval.py: -------------------------------------------------------------------------------- 1 | import click 2 | from click_didyoumean import DYMGroup 3 | 4 | 5 | @click.group(cls=DYMGroup) 6 | @click.version_option() 7 | def cli(): 8 | """Naval Fate. 9 | This is the docopt example adopted to Click but with some actual 10 | commands implemented and not just the empty parsing which really 11 | is not all that interesting. 12 | """ 13 | 14 | 15 | @cli.group(cls=DYMGroup) 16 | def ship(): 17 | """Manages ships.""" 18 | 19 | 20 | @ship.command("new") 21 | @click.argument("name") 22 | def ship_new(name): 23 | """Creates a new ship.""" 24 | click.echo("Created ship %s" % name) 25 | 26 | 27 | @ship.command("move") 28 | @click.argument("ship") 29 | @click.argument("x", type=float) 30 | @click.argument("y", type=float) 31 | @click.option("--speed", metavar="KN", default=10, help="Speed in knots.") 32 | def ship_move(ship, x, y, speed): 33 | """Moves SHIP to the new location X,Y.""" 34 | click.echo(f"Moving ship {ship} to {x},{y} with speed {speed}") 35 | 36 | 37 | @ship.command("shoot") 38 | @click.argument("ship") 39 | @click.argument("x", type=float) 40 | @click.argument("y", type=float) 41 | def ship_shoot(ship, x, y): 42 | """Makes SHIP fire to X,Y.""" 43 | click.echo(f"Ship {ship} fires to {x},{y}") 44 | 45 | 46 | @cli.group("mine", cls=DYMGroup) 47 | def mine(): 48 | """Manages mines.""" 49 | 50 | 51 | @mine.command("set") 52 | @click.argument("x", type=float) 53 | @click.argument("y", type=float) 54 | @click.option( 55 | "ty", 56 | "--moored", 57 | flag_value="moored", 58 | default=True, 59 | help="Moored (anchored) mine. Default.", 60 | ) 61 | @click.option("ty", "--drifting", flag_value="drifting", help="Drifting mine.") 62 | def mine_set(x, y, ty): 63 | """Sets a mine at a specific coordinate.""" 64 | click.echo(f"Set {ty} mine at {x},{y}") 65 | 66 | 67 | @mine.command("remove") 68 | @click.argument("x", type=float) 69 | @click.argument("y", type=float) 70 | def mine_remove(x, y): 71 | """Removes a mine at a specific coordinate.""" 72 | click.echo(f"Removed mine at {x},{y}") 73 | 74 | 75 | if __name__ == "__main__": 76 | cli() 77 | -------------------------------------------------------------------------------- /src/click_didyoumean/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Extension for ``click`` to provide a group 3 | with a git-like *did-you-mean* feature. 4 | """ 5 | 6 | import difflib 7 | import typing 8 | 9 | import click 10 | 11 | 12 | class DYMMixin: 13 | """ 14 | Mixin class for click MultiCommand inherited classes 15 | to provide git-like *did-you-mean* functionality when 16 | a certain command is not registered. 17 | """ 18 | 19 | def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None: # noqa: ANN401 20 | self.max_suggestions = kwargs.pop("max_suggestions", 3) 21 | self.cutoff = kwargs.pop("cutoff", 0.5) 22 | super().__init__(*args, **kwargs) # type: ignore[call-arg] 23 | 24 | def resolve_command( 25 | self, ctx: click.Context, args: typing.List[str] 26 | ) -> typing.Tuple[ 27 | typing.Optional[str], typing.Optional[click.Command], typing.List[str] 28 | ]: 29 | """ 30 | Overrides clicks ``resolve_command`` method 31 | and appends *Did you mean ...* suggestions 32 | to the raised exception message. 33 | """ 34 | try: 35 | return super().resolve_command(ctx, args) # type: ignore[misc] 36 | except click.exceptions.UsageError as error: 37 | error_msg = str(error) 38 | original_cmd_name = click.utils.make_str(args[0]) 39 | matches = difflib.get_close_matches( 40 | original_cmd_name, 41 | self.list_commands(ctx), # type: ignore[attr-defined] 42 | self.max_suggestions, 43 | self.cutoff, 44 | ) 45 | if matches: 46 | fmt_matches = "\n ".join(matches) 47 | error_msg += "\n\n" 48 | error_msg += f"Did you mean one of these?\n {fmt_matches}" 49 | 50 | raise click.exceptions.UsageError(error_msg, error.ctx) from error 51 | 52 | 53 | class DYMGroup(DYMMixin, click.Group): 54 | """ 55 | click Group to provide git-like 56 | *did-you-mean* functionality when a certain 57 | command is not found in the group. 58 | """ 59 | 60 | 61 | class DYMCommandCollection(DYMMixin, click.CommandCollection): 62 | """ 63 | click CommandCollection to provide git-like 64 | *did-you-mean* functionality when a certain 65 | command is not found in the group. 66 | """ 67 | -------------------------------------------------------------------------------- /tests/test_core.py: -------------------------------------------------------------------------------- 1 | import click 2 | import pytest 3 | from click.testing import CliRunner 4 | 5 | from click_didyoumean import DYMCommandCollection, DYMGroup 6 | 7 | 8 | @pytest.fixture() 9 | def runner(): 10 | return CliRunner() 11 | 12 | 13 | def test_basic_functionality_with_group(runner): 14 | @click.group(cls=DYMGroup) 15 | def cli(): 16 | pass 17 | 18 | @cli.command() 19 | def foo(): 20 | pass 21 | 22 | @cli.command() 23 | def bar(): 24 | pass 25 | 26 | @cli.command() 27 | def barrr(): 28 | pass 29 | 30 | result = runner.invoke(cli, ["barr"]) 31 | assert result.output == ( 32 | "Usage: cli [OPTIONS] COMMAND [ARGS]...\n" 33 | "Try 'cli --help' for help.\n" 34 | "\n" 35 | "Error: No such command 'barr'.\n\n" 36 | "Did you mean one of these?\n" 37 | " barrr\n" 38 | " bar\n" 39 | ) 40 | 41 | 42 | def test_basic_functionality_with_commandcollection(runner): 43 | @click.group() 44 | def cli1(): 45 | pass 46 | 47 | @cli1.command() 48 | def foo(): 49 | pass 50 | 51 | @cli1.command() 52 | def bar(): 53 | pass 54 | 55 | @click.group() 56 | def cli2(): 57 | pass 58 | 59 | @cli2.command() 60 | def barrr(): 61 | pass 62 | 63 | cli = DYMCommandCollection(sources=[cli1, cli2]) 64 | result = runner.invoke(cli, ["barr"]) 65 | assert result.output == ( 66 | "Usage: root [OPTIONS] COMMAND [ARGS]...\n" 67 | "Try 'root --help' for help.\n" 68 | "\n" 69 | "Error: No such command 'barr'.\n\n" 70 | "Did you mean one of these?\n" 71 | " barrr\n" 72 | " bar\n" 73 | ) 74 | 75 | 76 | def test_cutoff_factor(runner): 77 | @click.group(cls=DYMGroup, max_suggestions=3, cutoff=1.0) 78 | def cli(): 79 | pass 80 | 81 | @cli.command() 82 | def foo(): 83 | pass 84 | 85 | @cli.command() 86 | def bar(): 87 | pass 88 | 89 | @cli.command() 90 | def barrr(): 91 | pass 92 | 93 | # if cutoff factor is 1.0 the match must be perfect. 94 | result = runner.invoke(cli, ["barr"]) 95 | assert result.output == ( 96 | "Usage: cli [OPTIONS] COMMAND [ARGS]...\n" 97 | "Try 'cli --help' for help.\n" 98 | "\n" 99 | "Error: No such command 'barr'.\n" 100 | ) 101 | 102 | 103 | def test_max_suggetions(runner): 104 | @click.group(cls=DYMGroup, max_suggestions=2, cutoff=0.5) 105 | def cli(): 106 | pass 107 | 108 | @cli.command() 109 | def foo(): 110 | pass 111 | 112 | @cli.command() 113 | def bar(): 114 | pass 115 | 116 | @cli.command() 117 | def barrr(): 118 | pass 119 | 120 | @cli.command() 121 | def baarr(): 122 | pass 123 | 124 | # if cutoff factor is 1.0 the match must be perfect. 125 | result = runner.invoke(cli, ["barr"]) 126 | assert result.output == ( 127 | "Usage: cli [OPTIONS] COMMAND [ARGS]...\n" 128 | "Try 'cli --help' for help.\n" 129 | "\n" 130 | "Error: No such command 'barr'.\n\n" 131 | "Did you mean one of these?\n" 132 | " barrr\n" 133 | " baarr\n" 134 | ) 135 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | click-didyoumean 2 | ================ 3 | |pypi| |build| |license| 4 | 5 | Enable git-like *did-you-mean* feature in click. 6 | 7 | It's as simple as this: 8 | 9 | .. code:: python 10 | 11 | import click 12 | from click_didyoumean import DYMGroup 13 | 14 | @click.group(cls=DYMGroup) 15 | def cli(): 16 | ... 17 | 18 | |demo| 19 | 20 | Usage 21 | ----- 22 | 23 | Install this extension with pip: 24 | 25 | .. code:: 26 | 27 | pip install click-didyoumean 28 | 29 | 30 | Use specific *did-you-mean* `group` class for your cli: 31 | 32 | 33 | .. code:: python 34 | 35 | import click 36 | from click_didyoumean import DYMGroup 37 | 38 | @click.group(cls=DYMGroup) 39 | def cli(): 40 | pass 41 | 42 | @cli.command() 43 | def foo(): 44 | pass 45 | 46 | @cli.command() 47 | def bar(): 48 | pass 49 | 50 | @cli.command() 51 | def barrr(): 52 | pass 53 | 54 | if __name__ == "__main__": 55 | cli() 56 | 57 | 58 | Or you it in a `CommandCollection`: 59 | 60 | .. code:: python 61 | 62 | import click 63 | from click_didyoumean import DYMCommandCollection 64 | 65 | @click.group() 66 | def cli1(): 67 | pass 68 | 69 | @cli1.command() 70 | def foo(): 71 | pass 72 | 73 | @cli1.command() 74 | def bar(): 75 | pass 76 | 77 | @click.group() 78 | def cli2(): 79 | pass 80 | 81 | @cli2.command() 82 | def barrr(): 83 | pass 84 | 85 | cli = DYMCommandCollection(sources=[cli1, cli2]) 86 | 87 | if __name__ == "__main__": 88 | cli() 89 | 90 | 91 | Change configuration 92 | -------------------- 93 | 94 | There are two configuration for the ``DYMGroup`` and ``DYMCommandCollection``: 95 | 96 | +-----------------+-------+---------+---------------------------------------------------------------------------+ 97 | | Parameter | Type | Default | Description | 98 | +=================+=======+=========+===========================================================================+ 99 | | max_suggestions | int | 3 | Maximal number of *did-you-mean* suggestions | 100 | +-----------------+-------+---------+---------------------------------------------------------------------------+ 101 | | cutoff | float | 0.5 | Possibilities that don’t score at least that similar to word are ignored. | 102 | +-----------------+-------+---------+---------------------------------------------------------------------------+ 103 | 104 | Examples 105 | ~~~~~~~~ 106 | 107 | .. code:: python 108 | 109 | @cli.group(cls=DYMGroup, max_suggestions=2, cutoff=0.7) 110 | def cli(): 111 | pass 112 | 113 | ... or ... 114 | 115 | cli = DYMCommandCollection(sources=[cli1, cli2], max_suggestions=2, cutoff=0.7) 116 | 117 | 118 | .. |pypi| image:: https://img.shields.io/pypi/v/click-didyoumean.svg?style=flat&label=version 119 | :target: https://pypi.python.org/pypi/click-didyoumean 120 | :alt: Latest version released on PyPi 121 | 122 | .. |build| image:: https://img.shields.io/travis/click-contrib/click-didyoumean/master.svg?style=flat 123 | :target: http://travis-ci.org/click-contrib/click-didyoumean 124 | :alt: Build status of the master branch 125 | 126 | .. |demo| image:: https://raw.githubusercontent.com/click-contrib/click-didyoumean/master/examples/asciicast.gif 127 | :alt: Demo 128 | 129 | .. |license| image:: https://img.shields.io/badge/license-MIT-blue.svg?style=flat 130 | :target: https://raw.githubusercontent.com/click-contrib/click-didyoumean/master/LICENSE 131 | :alt: Package license 132 | --------------------------------------------------------------------------------