├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── examples ├── README.md ├── argparse_example.py ├── click_example.py └── setup.py ├── pdm.lock ├── pycomplete ├── __init__.py ├── __main__.py ├── getters.py └── templates │ ├── __init__.py │ ├── bash.tpl │ ├── fish.tpl │ ├── powershell.tpl │ └── zsh.tpl ├── pyproject.toml └── test_pycomplete.py /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | Testing: 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | matrix: 14 | python-version: ['3.7', '3.8', '3.9', '3.10'] 15 | os: [ubuntu-latest, macOS-latest, windows-latest] 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Set up PDM with ${{ matrix.python-version }} 20 | uses: pdm-project/setup-pdm@v3 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | - name: Run Tests 24 | run: | 25 | pdm install 26 | pdm run pytest -vv 27 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | jobs: 9 | release-pypi: 10 | name: release-pypi 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: actions/setup-python@v4 16 | with: 17 | python-version: '3.11' 18 | architecture: "x64" 19 | - name: Build artifacts 20 | run: | 21 | pip install build 22 | python -m build 23 | 24 | - name: Test build 25 | run: | 26 | pip install dist/*.whl 27 | pycomplete --help 28 | - name: Upload to Pypi 29 | run: | 30 | pip install twine 31 | twine upload --username __token__ --password ${{ secrets.PYPI_TOKEN }} dist/* 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | .pdm.toml 6 | .vscode/ 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | pip-wheel-metadata/ 26 | share/python-wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | *.py,cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | 56 | # Translations 57 | *.mo 58 | *.pot 59 | 60 | # Django stuff: 61 | *.log 62 | local_settings.py 63 | db.sqlite3 64 | db.sqlite3-journal 65 | 66 | # Flask stuff: 67 | instance/ 68 | .webassets-cache 69 | 70 | # Scrapy stuff: 71 | .scrapy 72 | 73 | # Sphinx documentation 74 | docs/_build/ 75 | 76 | # PyBuilder 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | .python-version 88 | 89 | # pipenv 90 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 91 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 92 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 93 | # install all needed dependencies. 94 | #Pipfile.lock 95 | 96 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 97 | __pypackages__/ 98 | 99 | # Celery stuff 100 | celerybeat-schedule 101 | celerybeat.pid 102 | 103 | # SageMath parsed files 104 | *.sage.py 105 | 106 | # Environments 107 | .env 108 | .venv 109 | env/ 110 | venv/ 111 | ENV/ 112 | env.bak/ 113 | venv.bak/ 114 | 115 | # Spyder project settings 116 | .spyderproject 117 | .spyproject 118 | 119 | # Rope project settings 120 | .ropeproject 121 | 122 | # mkdocs documentation 123 | /site 124 | 125 | # mypy 126 | .mypy_cache/ 127 | .dmypy.json 128 | dmypy.json 129 | 130 | # Pyre type checker 131 | .pyre/ 132 | .pdm-python 133 | .pdm-build/ 134 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | Everyone interacting in the pycomplete project's codebases, issue trackers, chat rooms, and mailing lists is expected to follow the [PSF Code of Conduct](https://www.python.org/psf/conduct/). 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to pycomplete 2 | 3 | ## Package management 4 | 5 | `pycomplete` uses [PDM](https://pdm.fming.dev/) as the project's package manager, 6 | this means you may have to install it first following the installation guide. Then execute: 7 | 8 | ```bash 9 | $ pdm install -d 10 | ``` 11 | 12 | to initialize the development environment. 13 | 14 | But since `pycomplete` doesn't depend on any packages, you can still use the old way: 15 | 16 | ```bash 17 | # Create a fresh venv for this project and activate it 18 | $ python -m venv venv && source venv/bin/activate 19 | # Install development dependencies, which usually include pytest and click for testing. 20 | (venv) pip install pytest click 21 | ``` 22 | 23 | ## Code styles 24 | 25 | The codebase of `pycomplete` adopts the style of [black](https://github.com/psf/black) with 26 | a maximum line length of 88. Type annotations are mandatory for all exposed functions, methods and members 27 | whose names don't start with a underscore(`_`). 28 | 29 | *To enforce the code style, inting process may be added to CI in the future.* 30 | 31 | ## Add a new CLI framework 32 | 33 | It is easy to add support for a new CLI framework. Create a new getter class inheriting from `BaseGetter` in `pycomplete/getters.py` 34 | and implement all abstract methods and properties. The constructor method accepts the CLI object as the only parameter. 35 | Then add the new getter class to the tail of `GETTERS` colletion. 36 | 37 | ## Add a new shell type 38 | 39 | We keep all completion script templates in `pycomplete/templates` folder, where you can add support for other shell types. 40 | The templates use the built-in `string.Template` with `%` as the delimiter. That is to say, template placeholders like `%{foo}` expect 41 | a key named `foo` in the template replacement map. For now 3rd party template engines are not considered to keep zero-dependencies. 42 | Remember to add the new shell to `SUPPORTED_SHELLS` collection in `pycomplete/templates/__init__.py`. 43 | 44 | You should also implement a `Completer.render_` method in `pycomplete/__init__.py`. 45 | 46 | ## Send a PR 47 | 48 | After all these are done, you are ready to submit your changes. Create a Pull Request by clicking the green button on the home page 49 | or [this link](https://github.com/frostming/pycomplete/compare) in case it isn't shown there. Describe what you have changed in clean 50 | and intuitive words and submit it. Waiting for review and CI success and then it will be merged. 51 | 52 | ## Report issues 53 | 54 | If you encounter bugs or have feature requests for `pycomplete`, feel free to file a new issue. Reproducing steps together with actual and expected 55 | results are required if neccessary. Try to write in English for more people to understand. Note that you should follow [Code of Conduct](/CODE_OF_CONDUCT.md) 56 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2020, Frost Ming 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pycomplete 2 | 3 | A Python library to generate static completion scripts for your CLI app 4 | 5 | ![Tests](https://github.com/frostming/pycomplete/workflows/Tests/badge.svg) 6 | [![PyPI](https://img.shields.io/pypi/v/pycomplete)](https://pypi.org/project/pycomplete) 7 | [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pycomplete)](https://pypi.org/project/pycomplete) 8 | ![Supported Shells - bash|zsh|fish|powershell](https://img.shields.io/badge/shell-bash%7Czsh%7Cfish%7Cpowershell-yellow) 9 | 10 | ## Installation 11 | 12 | `pycomplete` requires Python 3.6 or higher, you can install it via PyPI: 13 | 14 | ```bash 15 | $ pip install pycomplete 16 | ``` 17 | 18 | ## Usage 19 | 20 | With `pycomplete`, one can generate a completion script for CLI application that is compatible with a given shell. 21 | The script outputs the result onto `stdout`, allowing one to re-direct the output to the file of their choosing. 22 | 23 | `pycomplete` accepts different types of objects depending on which CLI framework you are using. 24 | For `argparse`, `argparse.ArgumentParser` is expected while for `click`, either `click.Command` or `click.Context` is OK. 25 | `pycomplete` knows what to do smartly. 26 | 27 | Where you place the file will depend on which shell, and which operating system you are using. 28 | Your particular configuration may also determine where these scripts need to be placed. 29 | 30 | Note that `pycomplete` needs to be installed in the same environment as the target CLI app to work properly. 31 | 32 | Here are some common set ups for the three supported shells under Unix and similar operating systems (such as GNU/Linux). 33 | 34 | ### BASH 35 | 36 | Completion files are commonly stored in `/etc/bash_completion.d/`. Run command: 37 | 38 | ```bash 39 | $ pycomplete "myscript:parser" bash > /etc/bash_completion.d/_myscript 40 | ``` 41 | 42 | You may have to log out and log back in to your shell session for the changes to take effect. 43 | 44 | ### FISH 45 | 46 | Fish completion files are commonly stored in`$HOME/.config/fish/completions/`. Run command: 47 | 48 | ```bash 49 | $ pycomplete "myscript:parser" fish > $HOME/.config/fish/completions/myscript.fish 50 | ``` 51 | 52 | You may have to log out and log back in to your shell session for the changes to take effect. 53 | 54 | ### ZSH 55 | 56 | ZSH completions are commonly stored in any directory listed in your `$fpath` variable. To use these completions, you 57 | must either add the generated script to one of those directories, or add your own to this list. 58 | 59 | Adding a custom directory is often the safest best if you're unsure of which directory to use. First create the directory, for this 60 | example we'll create a hidden directory inside our `$HOME` directory 61 | 62 | ```bash 63 | $ mkdir ~/.zfunc 64 | ``` 65 | 66 | Then add the following lines to your `.zshrc` just before `compinit` 67 | 68 | ```bash 69 | $ fpath+=~/.zfunc 70 | ``` 71 | 72 | Run command: 73 | 74 | ```bash 75 | $ pycomplete "myscript:parser" zsh > ~/.zfunc/_myscript 76 | ``` 77 | 78 | You must then either log out and log back in, or simply run 79 | 80 | ```bash 81 | $ exec zsh 82 | ``` 83 | 84 | For the new completions to take affect. 85 | 86 | ### Powershell 87 | 88 | There is no default location for completion scripts on Powershell. One may need to execute the scripts in their profile: 89 | 90 | ```powershell 91 | PS > mkdir $PROFILE\..\Completions 92 | PS > echo @' 93 | Get-ChildItem "$PROFILE\..\Completions\" | ForEach-Object { 94 | . $_.FullName 95 | } 96 | '@ | Out-File -Append -Encoding utf8 $PROFILE 97 | ``` 98 | 99 | Make sure you set the proper [Execution Policy](https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.security/set-executionpolicy): 100 | 101 | ```powershell 102 | PS > Set-ExecutionPolicy Unrestricted -Scope CurrentUser 103 | ``` 104 | 105 | Run command to generate script: 106 | 107 | ```powershell 108 | PS > pycomplete "myscript:parser" powershell | Out-File -Encoding utf8 $PROFILE\..\Completions\myscript_completion.ps1 109 | ``` 110 | 111 | You may have to log out and log back in to your shell session for the changes to take effect. 112 | 113 | ### CUSTOM LOCATIONS 114 | 115 | Alternatively, you could save these files to the place of your choosing, such as a custom directory inside your \$HOME. Doing so will 116 | require you to add the proper directives, such as `source`ing inside your login script. Consult your shells documentation for how to 117 | add such directives. 118 | 119 | ### Integrate with existing CLI apps 120 | 121 | `pycomplete` can be also used as a Python library, allowing one to integrate with existing CLI apps. 122 | 123 | ```python 124 | from pycomplete import Completer 125 | from mypackage.cli import parser 126 | 127 | completer = Completer(parser) 128 | print(completer.render()) 129 | ``` 130 | 131 | See `examples/` folder for full examples of working apps. 132 | 133 | ## How does it differ from `argcomplete`? 134 | 135 | `argcomplete`, together with `click-completion`, can also generate scripts for shell completion. However, they work in a different way 136 | that commands and options are retrieved on the fly when they are requested by a matching token. This brings a performance shrinkage 137 | when it is expensive to import the CLI app. In the other side, `pycomplete` produces **static and fixed** scripts which contain all required information 138 | within themselves. Plus, `argcomplete` and `click-completion` both work for specific framework. One may notice the disadvantage of static completion 139 | is also obvious -- users must regenerate the script when the commands and/or options are updated. Fortunately, it shouldn't be a problem 140 | in most package managers like `homebrew`, where completion scripts are part of the package and are bundled with it. 141 | 142 | ## Limitations 143 | 144 | Only options and subcommands are autocompleted, positional arguments are not completed since user usually expects the path sugguestion to work 145 | in this case. 146 | 147 | ## Supported CLI Frameworks 148 | 149 | - [x] `argparse.ArgumentParser` 150 | - [x] `click.Command`, `click.Context` 151 | - [ ] More to be added 152 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Example of pycomplete integration 2 | 3 | ## Example CLIs 4 | 5 | - `argparse_example` 6 | - `click_example` 7 | 8 | ## Usage 9 | 10 | Install this example in an activated venv: 11 | 12 | ```bash 13 | $ pip install -e . 14 | ``` 15 | 16 | Run the CLI executable to print the completion script onto screen: 17 | 18 | ```bash 19 | $ completion 20 | ``` 21 | -------------------------------------------------------------------------------- /examples/argparse_example.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | import pycomplete 4 | 5 | 6 | parser = argparse.ArgumentParser(__name__) 7 | parser.add_argument("-V", "--version", action="version", version="0.1.0") 8 | subparsers = parser.add_subparsers() 9 | subparser = subparsers.add_parser( 10 | "completion", description="Show completion script for given shell" 11 | ) 12 | subparser.add_argument("shell", nargs="?", help="The shell to generate script for") 13 | 14 | 15 | def cli(): 16 | args = parser.parse_args() 17 | if "shell" in args: 18 | print(pycomplete.Completer(parser).render(args.shell)) 19 | -------------------------------------------------------------------------------- /examples/click_example.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import click 3 | import pycomplete 4 | 5 | 6 | @click.group() 7 | @click.version_option() 8 | def cli(): 9 | pass 10 | 11 | 12 | @cli.command() 13 | @click.argument( 14 | "shell", default=None, required=False, help="The shell to generate script for" 15 | ) 16 | @click.pass_context 17 | def completion(ctx, shell=None): 18 | """Show completion script for given shell""" 19 | completer = pycomplete.Completer(ctx) 20 | print(completer.render(shell)) 21 | -------------------------------------------------------------------------------- /examples/setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | 4 | setup( 5 | name="pycomplete-example", 6 | version="0.1.0", 7 | py_modules=["click_example", "argparse_example"], 8 | entry_points={ 9 | "console_scripts": [ 10 | "click_example=click_example:cli", 11 | "argparse_example=argparse_example:cli", 12 | ] 13 | }, 14 | ) 15 | -------------------------------------------------------------------------------- /pdm.lock: -------------------------------------------------------------------------------- 1 | # This file is @generated by PDM. 2 | # It is not intended for manual editing. 3 | 4 | [[package]] 5 | name = "click" 6 | version = "8.1.3" 7 | requires_python = ">=3.7" 8 | summary = "Composable command line interface toolkit" 9 | dependencies = [ 10 | "colorama; platform_system == \"Windows\"", 11 | "importlib-metadata; python_version < \"3.8\"", 12 | ] 13 | 14 | [[package]] 15 | name = "colorama" 16 | version = "0.4.6" 17 | requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 18 | summary = "Cross-platform colored terminal text." 19 | 20 | [[package]] 21 | name = "exceptiongroup" 22 | version = "1.1.1" 23 | requires_python = ">=3.7" 24 | summary = "Backport of PEP 654 (exception groups)" 25 | 26 | [[package]] 27 | name = "importlib-metadata" 28 | version = "6.7.0" 29 | requires_python = ">=3.7" 30 | summary = "Read metadata from Python packages" 31 | dependencies = [ 32 | "typing-extensions>=3.6.4; python_version < \"3.8\"", 33 | "zipp>=0.5", 34 | ] 35 | 36 | [[package]] 37 | name = "iniconfig" 38 | version = "2.0.0" 39 | requires_python = ">=3.7" 40 | summary = "brain-dead simple config-ini parsing" 41 | 42 | [[package]] 43 | name = "packaging" 44 | version = "23.1" 45 | requires_python = ">=3.7" 46 | summary = "Core utilities for Python packages" 47 | 48 | [[package]] 49 | name = "pluggy" 50 | version = "1.2.0" 51 | requires_python = ">=3.7" 52 | summary = "plugin and hook calling mechanisms for python" 53 | dependencies = [ 54 | "importlib-metadata>=0.12; python_version < \"3.8\"", 55 | ] 56 | 57 | [[package]] 58 | name = "pytest" 59 | version = "7.4.0" 60 | requires_python = ">=3.7" 61 | summary = "pytest: simple powerful testing with Python" 62 | dependencies = [ 63 | "colorama; sys_platform == \"win32\"", 64 | "exceptiongroup>=1.0.0rc8; python_version < \"3.11\"", 65 | "importlib-metadata>=0.12; python_version < \"3.8\"", 66 | "iniconfig", 67 | "packaging", 68 | "pluggy<2.0,>=0.12", 69 | "tomli>=1.0.0; python_version < \"3.11\"", 70 | ] 71 | 72 | [[package]] 73 | name = "tomli" 74 | version = "2.0.1" 75 | requires_python = ">=3.7" 76 | summary = "A lil' TOML parser" 77 | 78 | [[package]] 79 | name = "typing-extensions" 80 | version = "4.6.3" 81 | requires_python = ">=3.7" 82 | summary = "Backported and Experimental Type Hints for Python 3.7+" 83 | 84 | [[package]] 85 | name = "zipp" 86 | version = "3.15.0" 87 | requires_python = ">=3.7" 88 | summary = "Backport of pathlib-compatible object wrapper for zip files" 89 | 90 | [metadata] 91 | lock_version = "4.2" 92 | cross_platform = true 93 | groups = ["default", "dev"] 94 | content_hash = "sha256:da2e01cd532bf836988f5a261e713461bb9801aaac6884076c9785050cfff771" 95 | 96 | [metadata.files] 97 | "click 8.1.3" = [ 98 | {url = "https://files.pythonhosted.org/packages/59/87/84326af34517fca8c58418d148f2403df25303e02736832403587318e9e8/click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, 99 | {url = "https://files.pythonhosted.org/packages/c2/f1/df59e28c642d583f7dacffb1e0965d0e00b218e0186d7858ac5233dce840/click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, 100 | ] 101 | "colorama 0.4.6" = [ 102 | {url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 103 | {url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 104 | ] 105 | "exceptiongroup 1.1.1" = [ 106 | {url = "https://files.pythonhosted.org/packages/61/97/17ed81b7a8d24d8f69b62c0db37abbd8c0042d4b3fc429c73dab986e7483/exceptiongroup-1.1.1-py3-none-any.whl", hash = "sha256:232c37c63e4f682982c8b6459f33a8981039e5fb8756b2074364e5055c498c9e"}, 107 | {url = "https://files.pythonhosted.org/packages/cc/38/57f14ddc8e8baeddd8993a36fe57ce7b4ba174c35048b9a6d270bb01e833/exceptiongroup-1.1.1.tar.gz", hash = "sha256:d484c3090ba2889ae2928419117447a14daf3c1231d5e30d0aae34f354f01785"}, 108 | ] 109 | "importlib-metadata 6.7.0" = [ 110 | {url = "https://files.pythonhosted.org/packages/a3/82/f6e29c8d5c098b6be61460371c2c5591f4a335923639edec43b3830650a4/importlib_metadata-6.7.0.tar.gz", hash = "sha256:1aaf550d4f73e5d6783e7acb77aec43d49da8017410afae93822cc9cca98c4d4"}, 111 | {url = "https://files.pythonhosted.org/packages/ff/94/64287b38c7de4c90683630338cf28f129decbba0a44f0c6db35a873c73c4/importlib_metadata-6.7.0-py3-none-any.whl", hash = "sha256:cb52082e659e97afc5dac71e79de97d8681de3aa07ff18578330904a9d18e5b5"}, 112 | ] 113 | "iniconfig 2.0.0" = [ 114 | {url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, 115 | {url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, 116 | ] 117 | "packaging 23.1" = [ 118 | {url = "https://files.pythonhosted.org/packages/ab/c3/57f0601a2d4fe15de7a553c00adbc901425661bf048f2a22dfc500caf121/packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"}, 119 | {url = "https://files.pythonhosted.org/packages/b9/6c/7c6658d258d7971c5eb0d9b69fa9265879ec9a9158031206d47800ae2213/packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"}, 120 | ] 121 | "pluggy 1.2.0" = [ 122 | {url = "https://files.pythonhosted.org/packages/51/32/4a79112b8b87b21450b066e102d6608907f4c885ed7b04c3fdb085d4d6ae/pluggy-1.2.0-py3-none-any.whl", hash = "sha256:c2fd55a7d7a3863cba1a013e4e2414658b1d07b6bc57b3919e0c63c9abb99849"}, 123 | {url = "https://files.pythonhosted.org/packages/8a/42/8f2833655a29c4e9cb52ee8a2be04ceac61bcff4a680fb338cbd3d1e322d/pluggy-1.2.0.tar.gz", hash = "sha256:d12f0c4b579b15f5e054301bb226ee85eeeba08ffec228092f8defbaa3a4c4b3"}, 124 | ] 125 | "pytest 7.4.0" = [ 126 | {url = "https://files.pythonhosted.org/packages/33/b2/741130cbcf2bbfa852ed95a60dc311c9e232c7ed25bac3d9b8880a8df4ae/pytest-7.4.0-py3-none-any.whl", hash = "sha256:78bf16451a2eb8c7a2ea98e32dc119fd2aa758f1d5d66dbf0a59d69a3969df32"}, 127 | {url = "https://files.pythonhosted.org/packages/a7/f3/dadfbdbf6b6c8b5bd02adb1e08bc9fbb45ba51c68b0893fa536378cdf485/pytest-7.4.0.tar.gz", hash = "sha256:b4bf8c45bd59934ed84001ad51e11b4ee40d40a1229d2c79f9c592b0a3f6bd8a"}, 128 | ] 129 | "tomli 2.0.1" = [ 130 | {url = "https://files.pythonhosted.org/packages/97/75/10a9ebee3fd790d20926a90a2547f0bf78f371b2f13aa822c759680ca7b9/tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 131 | {url = "https://files.pythonhosted.org/packages/c0/3f/d7af728f075fb08564c5949a9c95e44352e23dee646869fa104a3b2060a3/tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 132 | ] 133 | "typing-extensions 4.6.3" = [ 134 | {url = "https://files.pythonhosted.org/packages/42/56/cfaa7a5281734dadc842f3a22e50447c675a1c5a5b9f6ad8a07b467bffe7/typing_extensions-4.6.3.tar.gz", hash = "sha256:d91d5919357fe7f681a9f2b5b4cb2a5f1ef0a1e9f59c4d8ff0d3491e05c0ffd5"}, 135 | {url = "https://files.pythonhosted.org/packages/5f/86/d9b1518d8e75b346a33eb59fa31bdbbee11459a7e2cc5be502fa779e96c5/typing_extensions-4.6.3-py3-none-any.whl", hash = "sha256:88a4153d8505aabbb4e13aacb7c486c2b4a33ca3b3f807914a9b4c844c471c26"}, 136 | ] 137 | "zipp 3.15.0" = [ 138 | {url = "https://files.pythonhosted.org/packages/00/27/f0ac6b846684cecce1ee93d32450c45ab607f65c2e0255f0092032d91f07/zipp-3.15.0.tar.gz", hash = "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b"}, 139 | {url = "https://files.pythonhosted.org/packages/5b/fa/c9e82bbe1af6266adf08afb563905eb87cab83fde00a0a08963510621047/zipp-3.15.0-py3-none-any.whl", hash = "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556"}, 140 | ] 141 | -------------------------------------------------------------------------------- /pycomplete/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import hashlib 4 | import os 5 | import posixpath 6 | import re 7 | import subprocess 8 | import sys 9 | from typing import Any 10 | 11 | from pycomplete.getters import GETTERS, BaseGetter, NotSupportedError 12 | from pycomplete.templates import SUPPORTED_SHELLS, TEMPLATES 13 | 14 | __version__ = "0.4.0" 15 | 16 | 17 | class Completer: 18 | """The completer to generate scripts for given shell. Currently only support 19 | (bash, zsh, fish). If the shell type is not given, the completer will try to guess 20 | from $SHELL environment variable. 21 | 22 | To use the completer:: 23 | 24 | from pycomplete import Completer 25 | from mypackage.cli import parser 26 | 27 | completer = Completer(parser) 28 | result = completer.render() 29 | 30 | Then save the result into a file that is read by the shell's autocomplete engine. 31 | """ 32 | 33 | def __init__(self, cli: Any, prog: list[str] | None = None) -> None: 34 | for getter in GETTERS: 35 | try: 36 | self.getter = getter(cli) 37 | break 38 | except NotSupportedError: 39 | pass 40 | else: 41 | raise NotSupportedError( 42 | f"CLI object type {type(cli)} is not supported yet. " 43 | "It must be one of (`argparse.ArgumentParser`, `click.Command`).\n" 44 | "It may be also because requirements are not met to detect a specified " 45 | "framework. Please make sure you install pycomplete in the same " 46 | "environment as the target CLI app." 47 | ) 48 | if prog is None: 49 | prog = [os.path.basename(posixpath.realpath(sys.argv[0]))] 50 | self.prog = prog 51 | 52 | def render(self, shell: str | None = None) -> str: 53 | if shell is None: 54 | shell = self.get_shell_type() 55 | if shell not in SUPPORTED_SHELLS: 56 | raise ValueError( 57 | "[shell] argument must be one of {}".format(", ".join(SUPPORTED_SHELLS)) 58 | ) 59 | return getattr(self, "render_{}".format(shell))() 60 | 61 | def render_bash(self) -> str: 62 | template = TEMPLATES["bash"] 63 | 64 | script_path = os.path.realpath(sys.argv[0]) 65 | script_name = self.prog[0] 66 | aliases = self.prog 67 | function = self._generate_function_name(script_name, script_path) 68 | 69 | commands = [] 70 | global_options = set() 71 | commands_options = {} 72 | for option, _ in self.getter.get_options(): 73 | global_options.add(option) 74 | 75 | for name, command in self.getter.get_commands().items(): 76 | command_options = [] 77 | commands.append(name) 78 | 79 | for option, _ in command.get_options(): 80 | command_options.append(option) 81 | 82 | commands_options[name] = command_options 83 | 84 | compdefs = "\n".join( 85 | [ 86 | "complete -o default -F {} {}".format(function, alias) 87 | for alias in aliases 88 | ] 89 | ) 90 | 91 | commands = sorted(commands) 92 | 93 | command_list = [] 94 | for i, command in enumerate(commands): 95 | options = sorted(commands_options[command]) 96 | options = [self._zsh_describe(opt, None).strip('"') for opt in options] 97 | 98 | desc = [ 99 | " ({})".format(command), 100 | ' opts="{}"'.format(" ".join(options)), 101 | " ;;", 102 | ] 103 | 104 | if i < len(commands) - 1: 105 | desc.append("") 106 | 107 | command_list.append("\n".join(desc)) 108 | 109 | output = template.safe_substitute( 110 | { 111 | "script_name": script_name, 112 | "function": function, 113 | "opts": " ".join(sorted(global_options)), 114 | "coms": " ".join(commands), 115 | "command_list": "\n".join(command_list), 116 | "compdefs": compdefs, 117 | "version": __version__, 118 | } 119 | ) 120 | 121 | return output 122 | 123 | def render_zsh(self) -> str: 124 | template = TEMPLATES["zsh"] 125 | 126 | script_path = posixpath.realpath(sys.argv[0]) 127 | script_name, *aliases = self.prog 128 | 129 | function = self._generate_function_name(script_name, script_path) 130 | 131 | global_options = set() 132 | commands_descriptions = [] 133 | options_descriptions = {} 134 | commands_options_descriptions = {} 135 | commands_options = {} 136 | for option_name, option_help in self.getter.get_options(): 137 | global_options.add(option_name) 138 | options_descriptions[option_name] = option_help 139 | 140 | for name, command in self.getter.get_commands().items(): 141 | command_options = [] 142 | commands_options_descriptions[name] = {} 143 | command_description = command.help 144 | commands_descriptions.append(self._zsh_describe(name, command_description)) 145 | 146 | for option_name, option_help in command.get_options(): 147 | command_options.append(option_name) 148 | options_descriptions[option_name] = option_help 149 | commands_options_descriptions[name][option_name] = option_help 150 | 151 | commands_options[name] = command_options 152 | 153 | compdefs = "\n".join( 154 | ["compdef {} {}".format(function, alias) for alias in aliases] 155 | ) 156 | 157 | commands = sorted(list(commands_options.keys())) 158 | command_list = [] 159 | for i, command in enumerate(commands): 160 | options = sorted(commands_options[command]) 161 | options = [ 162 | self._zsh_describe(opt, commands_options_descriptions[command][opt]) 163 | for opt in options 164 | ] 165 | 166 | desc = [ 167 | " ({})".format(command), 168 | " opts=({})".format(" ".join(options)), 169 | " ;;", 170 | ] 171 | 172 | if i < len(commands) - 1: 173 | desc.append("") 174 | 175 | command_list.append("\n".join(desc)) 176 | 177 | opts = [] 178 | for opt in global_options: 179 | opts.append(self._zsh_describe(opt, options_descriptions[opt])) 180 | 181 | output = template.safe_substitute( 182 | { 183 | "script_name": script_name, 184 | "function": function, 185 | "opts": " ".join(sorted(opts)), 186 | "coms": " ".join(sorted(commands_descriptions)), 187 | "command_list": "\n".join(command_list), 188 | "compdefs": compdefs, 189 | "version": __version__, 190 | } 191 | ) 192 | 193 | return output 194 | 195 | def render_fish(self) -> str: 196 | template = TEMPLATES["fish"] 197 | 198 | def cmd_completion( 199 | name: str, command: BaseGetter, parents: list[str] 200 | ) -> list[str]: 201 | result: list[str] = [] 202 | parents_join = "_".join(parents) 203 | result.append(f"# {' '.join(parents + [name])}") 204 | see_parent = "; and ".join( 205 | f"__fish_seen_subcommand_from {p}" for p in parents 206 | ) 207 | if not parents: 208 | result.append( 209 | "complete -c {} -f -n '__fish{}_no_subcommand' " 210 | "-a {} -d '{}'".format( 211 | script_name, function, name, command.help.replace("'", "\\'") 212 | ) 213 | ) 214 | else: 215 | no_subcommand = ( 216 | f"not __fish_seen_subcommand_from ${parents_join}_subcommands" 217 | ) 218 | result.append( 219 | "complete -c {} -f -n '{}; and {}' -a {} -d '{}'".format( 220 | script_name, 221 | see_parent, 222 | no_subcommand, 223 | name, 224 | command.help.replace("'", "\\'"), 225 | ) 226 | ) 227 | 228 | see_this = f"__fish_seen_subcommand_from {name}" 229 | # options 230 | for option_name, option_help in sorted(command.get_options()): 231 | condition = f"{see_parent}; and {see_this}" if parents else see_this 232 | result.append( 233 | "complete -c {} -A -n '{}' -l {} -d '{}'".format( 234 | script_name, 235 | condition, 236 | option_name[2:], 237 | option_help.replace("'", "\\'"), 238 | ) 239 | ) 240 | subcommands = command.get_commands() 241 | if not subcommands: 242 | return result 243 | result.append(f"# {name} subcommands") 244 | subcommands_var = ( 245 | f"{parents_join}_{name}_subcommands" 246 | if parents 247 | else f"{name}_subcommands" 248 | ) 249 | result.append(f"set -l {subcommands_var} {' '.join(sorted(subcommands))}") 250 | for i, (subcommand_name, subcommand) in enumerate( 251 | sorted(subcommands.items()) 252 | ): 253 | result.extend( 254 | cmd_completion(subcommand_name, subcommand, parents + [name]) 255 | ) 256 | if i < len(subcommands) - 1: 257 | result.append("") 258 | return result 259 | 260 | script_path = posixpath.realpath(sys.argv[0]) 261 | script_name = self.prog[0] 262 | 263 | function = self._generate_function_name(script_name, script_path) 264 | 265 | opts: list[str] = [] 266 | cmd_names: set[str] = set() 267 | cmds: list[str] = [] 268 | for option_name, option_help in sorted(self.getter.get_options()): 269 | opts.append( 270 | "complete -c {} -n '__fish{}_no_subcommand' " 271 | "-l {} -d '{}'".format( 272 | script_name, 273 | function, 274 | option_name[2:], 275 | option_help.replace("'", "\\'"), 276 | ) 277 | ) 278 | 279 | all_commands = sorted(self.getter.get_commands().items()) 280 | for i, (name, command) in enumerate(all_commands): 281 | cmd_names.add(name) 282 | cmds.extend(cmd_completion(name, command, [])) 283 | if i < len(all_commands) - 1: 284 | cmds.append("") 285 | 286 | output = template.safe_substitute( 287 | { 288 | "script_name": script_name, 289 | "function": function, 290 | "cmds_names": " ".join(sorted(cmd_names)), 291 | "opts": "\n".join(opts), 292 | "cmds": "\n".join(cmds), 293 | "version": __version__, 294 | } 295 | ) 296 | 297 | return output 298 | 299 | def render_powershell(self) -> str: 300 | template = TEMPLATES["powershell"] 301 | 302 | script_path = posixpath.realpath(sys.argv[0]) 303 | script_name = self.prog[0] 304 | aliases = self.prog 305 | 306 | function = self._generate_function_name(script_name, script_path) 307 | 308 | commands = [] 309 | global_options = set() 310 | commands_options = {} 311 | for option, _ in self.getter.get_options(): 312 | global_options.add(option) 313 | 314 | for name, command in self.getter.get_commands().items(): 315 | command_options = [] 316 | commands.append(name) 317 | 318 | for option, _ in command.get_options(): 319 | command_options.append(option) 320 | 321 | commands_options[name] = command_options 322 | 323 | opts = ", ".join(f'"{option}"' for option in sorted(global_options)) 324 | coms = ", ".join(f'"{cmd}"' for cmd in sorted(commands)) 325 | command_list = [] 326 | for name, options in commands_options.items(): 327 | cmd_opts = ", ".join(f'"{option}"' for option in sorted(options)) 328 | command_list.append(f' "{name}" {{ $opts = @({cmd_opts}) }}') 329 | 330 | return template.safe_substitute( 331 | { 332 | "script_name": script_name, 333 | "function": function, 334 | "aliases": ", ".join(f'"{name}"' for name in aliases), 335 | "opts": opts, 336 | "coms": coms, 337 | "command_list": "\n".join(command_list), 338 | "version": __version__, 339 | } 340 | ) 341 | 342 | def get_shell_type(self) -> str: 343 | """This is a simple but working implementation to find the current shell in use. 344 | However, cases vary where uses may have many different shell setup and this 345 | helper can't return the correct shell type. 346 | 347 | shellingam(https://pypi.org/project/shellingam) should be a more robust 348 | library to do this job but for now we just want to keep things simple here. 349 | """ 350 | shell = os.getenv("SHELL") 351 | if not shell: 352 | raise RuntimeError( 353 | "Could not read SHELL environment variable. " 354 | "Please specify your shell type by passing it as the first argument." 355 | ) 356 | 357 | return os.path.basename(shell) 358 | 359 | def _generate_function_name(self, script_name, script_path): 360 | return "_{}_{}_complete".format( 361 | self._sanitize_for_function_name(script_name), 362 | hashlib.md5(script_path.encode("utf-8")).hexdigest()[0:16], 363 | ) 364 | 365 | def _sanitize_for_function_name(self, name): 366 | name = name.replace("-", "_") 367 | 368 | return re.sub("[^A-Za-z0-9_]+", "", name) 369 | 370 | def _zsh_describe(self, value, description=None): 371 | value = '"' + value.replace(":", "\\:") 372 | if description: 373 | description = re.sub( 374 | r'(["\'#&;`|*?~<>^()\[\]{}$\\\x0A\xFF])', r"\\\1", description 375 | ) 376 | value += ":{}".format(subprocess.list2cmdline([description]).strip('"')) 377 | 378 | value += '"' 379 | 380 | return value 381 | -------------------------------------------------------------------------------- /pycomplete/__main__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import ast 4 | import importlib 5 | import os 6 | import sys 7 | from argparse import ArgumentParser 8 | from typing import Any 9 | 10 | from pycomplete import Completer, __version__ 11 | 12 | 13 | def create_parser() -> ArgumentParser: 14 | parser = ArgumentParser( 15 | "pycomplete", 16 | description="Command line tool to generate completion scripts for given shell", 17 | ) 18 | parser.add_argument("--version", action="version", version=__version__) 19 | parser.add_argument( 20 | "-p", "--prog", nargs="?", help="Specify the program name for completion" 21 | ) 22 | parser.add_argument( 23 | "cli", 24 | help="The import name of the CLI object. e.g. `package.module:parser`", 25 | ) 26 | parser.add_argument( 27 | "shell", nargs="?", help="The shell type of the completion script" 28 | ) 29 | return parser 30 | 31 | 32 | def load_cli(import_str: str) -> Any: 33 | """Load the cli object from a import name. Adapted from gunicorn. 34 | 35 | Examples: 36 | 37 | flask.cli:cli 38 | pipx.cli:create_parser() 39 | """ 40 | import_name, _, obj = import_str.partition(":") 41 | if not (import_name and obj): 42 | raise ValueError( 43 | "The cli import name is invalid, import name " 44 | "and attribute name must be supplied. Examples:\n" 45 | "\tflask.cli:cli" 46 | "\tpipx.cli:create_parser()" 47 | ) 48 | module = importlib.import_module(import_name) 49 | try: 50 | expression = ast.parse(obj, mode="eval").body 51 | except SyntaxError: 52 | raise ValueError( 53 | f"Failed to parse {obj} as an attribute name or function call." 54 | ) 55 | if isinstance(expression, ast.Name): 56 | name = expression.id 57 | args = kwargs = None 58 | elif isinstance(expression, ast.Call): 59 | if not isinstance(expression.func, ast.Name): 60 | raise ValueError("Function reference must be a simple name: %r" % obj) 61 | name = expression.func.id 62 | # Parse the positional and keyword arguments as literals. 63 | try: 64 | args = [ast.literal_eval(arg) for arg in expression.args] 65 | kwargs = {kw.arg: ast.literal_eval(kw.value) for kw in expression.keywords} 66 | except ValueError: 67 | # literal_eval gives cryptic error messages, show a generic 68 | # message with the full expression instead. 69 | raise ValueError("Failed to parse arguments as literal values: %r" % obj) 70 | else: 71 | raise ValueError( 72 | "Failed to parse %r as an attribute name or function call." % obj 73 | ) 74 | 75 | try: 76 | app = getattr(module, name) 77 | except AttributeError: 78 | raise ValueError("Failed to find attribute %r in %r." % (name, import_name)) 79 | 80 | if args is not None: 81 | app = app(*args, **kwargs) 82 | 83 | if app is None: 84 | raise ValueError(f"The function {import_str} must return a non-None value") 85 | return app 86 | 87 | 88 | def get_prog_name(module: str) -> list[str]: 89 | """Get the program name from the given module name.""" 90 | if not module: 91 | return [os.path.basename(os.path.realpath(sys.argv[0]))] 92 | 93 | try: 94 | import importlib.metadata as imp_metadata 95 | except ModuleNotFoundError: 96 | try: 97 | import importlib_metadata as imp_metadata 98 | except ModuleNotFoundError: 99 | imp_metadata = None 100 | try: 101 | import pkg_resources 102 | except ModuleNotFoundError: 103 | try: 104 | from pip._vendor import pkg_resources 105 | except ModuleNotFoundError: 106 | pkg_resources = None 107 | 108 | result = [] 109 | 110 | if imp_metadata: 111 | for dist in imp_metadata.distributions(): 112 | for entry_point in dist.entry_points: 113 | entry_module, _, _ = entry_point.value.partition(":") 114 | if entry_point.group == "console_scripts" and entry_module == module: 115 | result.append(entry_point.name) 116 | elif pkg_resources: 117 | for dist in pkg_resources.working_set: 118 | scripts = dist.get_entry_map().get("console_scripts") or {} 119 | for _, entry_point in scripts.items(): 120 | if entry_point.module_name == module: 121 | result.append(entry_point.name) 122 | # Fallback to sys.argv[0] 123 | return result or [os.path.basename(os.path.realpath(sys.argv[0]))] 124 | 125 | 126 | def main(argv=None): 127 | args = create_parser().parse_args(argv) 128 | cli = load_cli(args.cli) 129 | completer = Completer( 130 | cli, prog=[args.prog] if args.prog else get_prog_name(args.cli.split(":")[0]) 131 | ) 132 | print(completer.render(args.shell)) 133 | 134 | 135 | if __name__ == "__main__": 136 | main() 137 | -------------------------------------------------------------------------------- /pycomplete/getters.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import abc 4 | import argparse 5 | from typing import Iterable 6 | 7 | try: 8 | import click 9 | except ModuleNotFoundError: 10 | click = None 11 | 12 | 13 | class NotSupportedError(Exception): 14 | pass 15 | 16 | 17 | class BaseGetter(abc.ABC): 18 | """Abstract base class for getting metadata from a CLI main object, which may be 19 | 20 | - :class:`argparse.ArgumentParser` instance. 21 | - :class:`click.Command` instance. 22 | 23 | subclasses must inherit this and implement the abstract methods and properties. 24 | """ 25 | 26 | @abc.abstractmethod 27 | def get_options(self) -> Iterable[tuple[str, str]]: 28 | """Return a list of [option_name, option_help] pairs.""" 29 | pass 30 | 31 | @abc.abstractmethod 32 | def get_commands(self) -> dict[str, BaseGetter]: 33 | """Return a mapping of command_name -> command getter.""" 34 | pass 35 | 36 | @abc.abstractproperty 37 | def help(self) -> str: 38 | """Get the help string for the command.""" 39 | pass 40 | 41 | 42 | class ArgparseGetter(BaseGetter): 43 | """Helper class to fetch options/commands from a 44 | :class:`argparse.ArgumentParser` instance 45 | """ 46 | 47 | def __init__(self, parser: argparse.ArgumentParser) -> None: 48 | if not isinstance(parser, argparse.ArgumentParser): 49 | raise NotSupportedError("Not supported") 50 | self._parser = parser 51 | 52 | def get_options(self) -> Iterable[tuple[str, str]]: 53 | for action in self._parser._actions: 54 | if not action.option_strings: 55 | continue 56 | # Prefer the --long-option-name, just compare by the length of the string. 57 | name = max(action.option_strings, key=len) 58 | yield name, action.help 59 | 60 | def get_commands(self) -> dict[str, BaseGetter]: 61 | subparsers = next( 62 | ( 63 | action 64 | for action in self._parser._actions 65 | if action.nargs == argparse.PARSER 66 | ), 67 | None, 68 | ) 69 | if not subparsers: 70 | return {} 71 | return {k: ArgparseGetter(p) for k, p in subparsers.choices.items()} 72 | 73 | @property 74 | def help(self) -> str: 75 | return self._parser.description 76 | 77 | 78 | GETTERS = [ArgparseGetter] 79 | 80 | 81 | if click: 82 | 83 | class ClickGetter(BaseGetter): 84 | """Helper class to fetch options/commands from a 85 | :class:`click.Command` instance 86 | """ 87 | 88 | def __init__(self, cli: click.Command | click.Context) -> None: 89 | if not isinstance(cli, (click.Command, click.Context)): 90 | raise NotSupportedError("Not supported") 91 | self._cli = self._get_top_command(cli) 92 | 93 | @staticmethod 94 | def _get_top_command( 95 | cmd_or_ctx: click.Command | click.Context, 96 | ) -> click.Command: 97 | if isinstance(cmd_or_ctx, click.Command): 98 | return cmd_or_ctx 99 | while cmd_or_ctx.parent: 100 | cmd_or_ctx = cmd_or_ctx.parent 101 | return cmd_or_ctx.command 102 | 103 | def get_options(self) -> Iterable[tuple[str, str]]: 104 | ctx = click.Context(self._cli, info_name=self._cli.name) 105 | for param in self._cli.get_params(ctx): 106 | if param.get_help_record(ctx): 107 | yield max(param.opts, key=len), param.help 108 | if param.secondary_opts: 109 | yield max(param.secondary_opts, key=len), param.help 110 | 111 | def get_commands(self) -> dict[str, BaseGetter]: 112 | commands = getattr(self._cli, "commands", {}) 113 | return { 114 | name: ClickGetter(cmd) 115 | for name, cmd in commands.items() 116 | if not cmd.hidden 117 | } 118 | 119 | @property 120 | def help(self) -> str: 121 | return self._cli.help 122 | 123 | GETTERS.append(ClickGetter) 124 | -------------------------------------------------------------------------------- /pycomplete/templates/__init__.py: -------------------------------------------------------------------------------- 1 | import pkgutil 2 | from string import Template as _Template 3 | 4 | SUPPORTED_SHELLS = ("bash", "zsh", "fish", "powershell") 5 | 6 | 7 | class Template(_Template): 8 | # '$' and '#' are preserved delimiters in most shells. 9 | delimiter = "%" 10 | 11 | 12 | def make_template(template_name: str) -> Template: 13 | # Use pkgutil.get_data so that it also supports reading data from a zipped app. 14 | template_str = pkgutil.get_data(__name__, f"{template_name}.tpl").decode("utf-8") 15 | return Template(template_str) 16 | 17 | 18 | TEMPLATES = {name: make_template(name) for name in SUPPORTED_SHELLS} 19 | -------------------------------------------------------------------------------- /pycomplete/templates/bash.tpl: -------------------------------------------------------------------------------- 1 | # BASH completion script for %{script_name} 2 | # Generated by pycomplete %{version} 3 | 4 | %{function}() 5 | { 6 | local cur script coms opts com 7 | COMPREPLY=() 8 | _get_comp_words_by_ref -n : cur words 9 | 10 | # for an alias, get the real script behind it 11 | if [[ $(type -t ${words[0]}) == "alias" ]]; then 12 | script=$(alias ${words[0]} | sed -E "s/alias ${words[0]}='(.*)'/\\1/") 13 | else 14 | script=${words[0]} 15 | fi 16 | 17 | # lookup for command 18 | for word in ${words[@]:1}; do 19 | if [[ $word != -* ]]; then 20 | com=$word 21 | break 22 | fi 23 | done 24 | 25 | # completing for an option 26 | if [[ ${cur} == --* ]] ; then 27 | opts="%{opts}" 28 | 29 | case "$com" in 30 | 31 | %{command_list} 32 | 33 | esac 34 | 35 | COMPREPLY=($(compgen -W "${opts}" -- ${cur})) 36 | __ltrim_colon_completions "$cur" 37 | 38 | return 0; 39 | fi 40 | 41 | # completing for a command 42 | if [[ $cur == $com ]]; then 43 | coms="%{coms}" 44 | 45 | COMPREPLY=($(compgen -W "${coms}" -- ${cur})) 46 | __ltrim_colon_completions "$cur" 47 | 48 | return 0 49 | fi 50 | } 51 | 52 | %{compdefs} 53 | -------------------------------------------------------------------------------- /pycomplete/templates/fish.tpl: -------------------------------------------------------------------------------- 1 | # FISH completion script for %{script_name} 2 | # Generated by pycomplete %{version} 3 | 4 | function __fish%{function}_no_subcommand 5 | for i in (commandline -opc) 6 | if contains -- $i %{cmds_names} 7 | return 1 8 | end 9 | end 10 | return 0 11 | end 12 | 13 | # global options 14 | %{opts} 15 | 16 | # commands 17 | %{cmds} 18 | -------------------------------------------------------------------------------- /pycomplete/templates/powershell.tpl: -------------------------------------------------------------------------------- 1 | # Powershell completion script for %{script_name} 2 | # Generated by pycomplete %{version} 3 | 4 | if ((Test-Path Function:\TabExpansion) -and -not (Test-Path Function:\%{function}Backup)) { 5 | Rename-Item Function:\TabExpansion %{function}Backup 6 | } 7 | 8 | function TabExpansion($line, $lastWord) { 9 | $lastBlock = [regex]::Split($line, '[|;]')[-1].TrimStart() 10 | $aliases = @(%{aliases}) 11 | $aliasPattern = "($($aliases -join '|'))" 12 | if ($lastBlock -match "^$aliasPattern ") { 13 | $command = ($lastBlock.Split() | Where-Object { $_ -NotLike "-*" })[1] 14 | 15 | if ($lastWord.StartsWith("-")) { 16 | # Complete options 17 | $opts = @(%{opts}) 18 | Switch ($command) { 19 | 20 | %{command_list} 21 | 22 | default {} 23 | } 24 | $opts | Where-Object { $_ -Like "$lastWord*" } 25 | } elseif ($lastWord -eq $command) { 26 | # Complete commands 27 | $commands = @(%{coms}) 28 | 29 | $commands | Where-Object { $_ -Like "$lastWord*" } 30 | } 31 | 32 | 33 | } 34 | elseif (Test-Path Function:\%{function}Backup) { 35 | # Fall back on existing tab expansion 36 | %{function}Backup $line $lastWord 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /pycomplete/templates/zsh.tpl: -------------------------------------------------------------------------------- 1 | #compdef %{script_name} 2 | 3 | # ZSH completion script for %{script_name} 4 | # Generated by pycomplete %{version} 5 | 6 | %{function}() 7 | { 8 | local state com cur opts 9 | 10 | cur=${words[${#words[@]}]} 11 | 12 | # lookup for command 13 | for word in ${words[@]:1}; do 14 | if [[ $word != -* ]]; then 15 | com=$word 16 | break 17 | fi 18 | done 19 | 20 | if [[ ${cur} == --* ]]; then 21 | state="option" 22 | opts=(%{opts}) 23 | elif [[ $cur == $com ]]; then 24 | state="command" 25 | coms=(%{coms}) 26 | fi 27 | 28 | case $state in 29 | (command) 30 | _describe 'command' coms 31 | ;; 32 | (option) 33 | case "$com" in 34 | 35 | %{command_list} 36 | 37 | esac 38 | 39 | _describe 'option' opts 40 | ;; 41 | *) 42 | # fallback to file completion 43 | _arguments '*:file:_files' 44 | esac 45 | } 46 | 47 | %{function} "$@" 48 | %{compdefs} 49 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["pdm-backend"] 3 | build-backend = "pdm.backend" 4 | 5 | [project] 6 | # PEP 621 project metadata 7 | # See https://www.python.org/dev/peps/pep-0621/ 8 | name = "pycomplete" 9 | description = "A Python library to generate static completion scripts for your CLI app" 10 | authors = [ 11 | {name = "Frost Ming", email = "mianghong@gmail.com"}, 12 | ] 13 | requires-python = ">=3.7" 14 | license = {text = "BSD-3-Clause"} 15 | dependencies = [] 16 | readme = "README.md" 17 | keywords = ["cli", "shell"] 18 | classifiers = [ 19 | "Development Status :: 3 - Alpha", 20 | "Programming Language :: Python :: 3", 21 | ] 22 | dynamic = ["version"] 23 | 24 | [project.urls] 25 | Homepage = "https://github.com/frostming/pycomplete" 26 | 27 | [project.scripts] 28 | pycomplete = "pycomplete.__main__:main" 29 | 30 | [tool.pdm.version] 31 | source = "file" 32 | path = "pycomplete/__init__.py" 33 | 34 | [tool.pdm.dev-dependencies] 35 | dev = [ 36 | "pytest>=7", 37 | "click>=7", 38 | ] 39 | -------------------------------------------------------------------------------- /test_pycomplete.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | import click 4 | import click.testing 5 | import pytest 6 | 7 | from pycomplete import Completer, NotSupportedError 8 | from pycomplete.__main__ import get_prog_name, load_cli 9 | 10 | 11 | @pytest.fixture() 12 | def argument_parser(): 13 | parser = argparse.ArgumentParser( 14 | "pycomplete", description="Test argument parser", add_help=False 15 | ) 16 | parser.add_argument("-f", "--file", help="File to be write into") 17 | subparsers = parser.add_subparsers() 18 | list_command = subparsers.add_parser( 19 | "list", description="List files", add_help=False 20 | ) 21 | list_command.add_argument("-a", "--all", help="Include hidden files") 22 | list_command.add_argument("path", help="Path to list") 23 | return parser 24 | 25 | 26 | @pytest.fixture() 27 | def click_command(): 28 | @click.group(add_help_option=False) 29 | @click.option("-f", "--file", help="File to be write into") 30 | def _cli(file=None): 31 | """Test click group""" 32 | 33 | @_cli.command(name="list", add_help_option=False) 34 | @click.option("-a", "--all", help="Include hidden files") 35 | @click.argument("path") 36 | def list_command(all=False): 37 | """List files""" 38 | 39 | return _cli 40 | 41 | 42 | @pytest.fixture(params=["argparse", "click"]) 43 | def cli(request, argument_parser, click_command): 44 | mapping = {"argparse": argument_parser, "click": click_command} 45 | return mapping[request.param] 46 | 47 | 48 | def test_render_bash_completion(cli): 49 | completer = Completer(cli) 50 | output = completer.render_bash() 51 | assert 'opts="--file"' in output 52 | assert "(list)" in output 53 | assert 'opts="--all"' in output 54 | 55 | 56 | def test_render_zsh_completion(cli): 57 | completer = Completer(cli) 58 | output = completer.render_zsh() 59 | assert 'opts=("--file:File to be write into")' in output 60 | assert 'coms=("list:List files")' in output 61 | assert 'opts=("--all:Include hidden files")' in output 62 | 63 | 64 | def test_render_fish_completion(cli): 65 | completer = Completer(cli) 66 | output = completer.render_fish() 67 | assert "-l file -d 'File to be write into'" in output 68 | assert "-a list -d 'List files'" in output 69 | assert "-l all -d 'Include hidden files'" in output 70 | 71 | 72 | def test_render_powershell_completion(cli): 73 | completer = Completer(cli) 74 | output = completer.render_powershell() 75 | assert '$opts = @("--file")' in output 76 | assert '"list" { $opts = @("--all") }' in output 77 | 78 | 79 | def test_unsupported_shell_type(cli, monkeypatch): 80 | completer = Completer(cli) 81 | monkeypatch.delenv("SHELL", raising=False) 82 | with pytest.raises(RuntimeError): 83 | completer.get_shell_type() 84 | monkeypatch.setenv("SHELL", "tcsh") 85 | assert completer.get_shell_type() == "tcsh" 86 | with pytest.raises(ValueError): 87 | completer.render() 88 | 89 | 90 | def test_unsupported_framework(): 91 | with pytest.raises(NotSupportedError): 92 | Completer(object()) 93 | 94 | 95 | def test_click_integration(click_command, monkeypatch): 96 | monkeypatch.setenv("SHELL", "zsh") 97 | 98 | def show_completion(ctx, param, value): 99 | if value: 100 | completer = Completer(ctx) 101 | click.echo(completer.render()) 102 | ctx.exit() 103 | 104 | cli = click.option( 105 | "--completion", 106 | help="Print completion script", 107 | callback=show_completion, 108 | expose_value=False, 109 | is_flag=True, 110 | )(click_command) 111 | runner = click.testing.CliRunner() 112 | result = runner.invoke(cli, ["--completion"]) 113 | 114 | assert result.exit_code == 0 115 | output = result.output 116 | assert ( 117 | 'opts=("--completion:Print completion script" "--file:File to be write into")' 118 | in output 119 | ) 120 | assert 'coms=("list:List files")' in output 121 | assert 'opts=("--all:Include hidden files")' in output 122 | 123 | 124 | def test_click_subcommand_integration(click_command, monkeypatch): 125 | monkeypatch.setenv("SHELL", "zsh") 126 | 127 | @click.command() 128 | @click.argument("shell", required=False) 129 | @click.pass_context 130 | def completion(ctx, shell=None): 131 | """Print completion script""" 132 | click.echo(Completer(ctx).render(shell)) 133 | 134 | click_command.add_command(completion) 135 | runner = click.testing.CliRunner() 136 | result = runner.invoke(click_command, ["completion"]) 137 | 138 | assert result.exit_code == 0 139 | output = result.output 140 | assert 'opts=("--file:File to be write into")' in output 141 | assert 'coms=("completion:Print completion script" "list:List files")' in output 142 | assert 'opts=("--all:Include hidden files")' in output 143 | 144 | 145 | def test_guess_prog_name(): 146 | assert "pytest" in get_prog_name("pytest") 147 | assert "py.test" in get_prog_name("pytest") 148 | 149 | 150 | def test_load_cli_object(): 151 | from pytest import console_main 152 | 153 | assert load_cli("pytest:console_main") is console_main 154 | assert isinstance( 155 | load_cli("pycomplete.__main__:create_parser()"), argparse.ArgumentParser 156 | ) 157 | 158 | 159 | def test_load_illegal_cli_object(): 160 | with pytest.raises(ValueError): 161 | load_cli("pycomplete") 162 | 163 | with pytest.raises(ValueError): 164 | load_cli("pycomplete:=1") 165 | 166 | with pytest.raises(ValueError): 167 | load_cli("pycomplete:{'a': 1}") 168 | 169 | with pytest.raises(ValueError): 170 | load_cli("pycomplete:foo") 171 | --------------------------------------------------------------------------------