├── .github └── workflows │ ├── ci.yml │ ├── docs.yml │ └── release.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── LICENSE ├── Makefile ├── README.md ├── clypi ├── __init__.py ├── _cli │ ├── __init__.py │ ├── arg_config.py │ ├── arg_parser.py │ ├── autocomplete.py │ ├── context.py │ ├── deferred.py │ ├── distance.py │ ├── formatter.py │ └── main.py ├── _colors.py ├── _components │ ├── align.py │ ├── boxed.py │ ├── indented.py │ ├── separator.py │ ├── spinners.py │ ├── stack.py │ └── wraps.py ├── _configuration.py ├── _data │ ├── boxes.py │ ├── dunders.py │ └── spinners.py ├── _exceptions.py ├── _prompts.py ├── _type_util.py ├── _util.py ├── parsers.py └── py.typed ├── docs ├── about │ ├── planned_work.md │ └── why.md ├── api │ ├── cli.md │ ├── colors.md │ ├── components.md │ ├── config.md │ ├── parsers.md │ └── prompts.md ├── assets │ ├── icon.png │ └── logo.png ├── hooks │ └── helpers.py ├── index.md ├── javascripts │ └── spinner.js ├── learn │ ├── advanced_arguments.md │ ├── beautiful_uis.md │ ├── configuration.md │ ├── getting_started.md │ └── install.md ├── packaging.md └── stylesheets │ ├── extra.css │ └── termynal.css ├── examples ├── __init__.py ├── boxed.py ├── cli.py ├── cli_basic.py ├── cli_custom_parser.py ├── cli_deferred.py ├── cli_inherited.py ├── cli_negative_flags.py ├── colors.py ├── prompts.py ├── spinner.py └── uv │ ├── __init__.py │ ├── __main__.py │ ├── add.py │ ├── init.py │ ├── pip.py │ └── remove.py ├── mdtest ├── README.md └── __main__.py ├── mkdocs.yml ├── pyproject.toml ├── scripts ├── gen_readme └── tag ├── tests ├── __init__.py ├── boxed_test.py ├── cli_arg_config_test.py ├── cli_defer_test.py ├── cli_env_test.py ├── cli_hooks_test.py ├── cli_inherited_test.py ├── cli_parse_test.py ├── cli_test.py ├── distance_test.py ├── exceptions_test.py ├── formatter_test.py ├── parsers_test.py ├── prompt_test.py ├── separator_test.py ├── stack_test.py ├── type_util_test.py └── wrap_test.py ├── type_tests ├── cli_test.py └── prompt_test.py └── uv.lock /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 👀 2 | on: 3 | push: 4 | branches: [master] 5 | pull_request: 6 | workflow_dispatch: 7 | 8 | jobs: 9 | all-checks: 10 | name: All checks 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Install uv 16 | uses: astral-sh/setup-uv@v5 17 | 18 | - name: Run tests 19 | run: uv run --all-extras pytest tests 20 | 21 | - name: "Python format" 22 | run: uvx ruff format --diff . 23 | 24 | - name: "Python lint" 25 | run: uvx ruff check . 26 | 27 | - name: "Python type checking -- Pyright" 28 | run: uv run --all-extras pyright clypi/ type_tests/ examples/ 29 | 30 | - name: "Codespell" 31 | run: uv run --all-extras codespell 32 | 33 | - name: "Markdown tests" 34 | run: uv run --all-extras mdtest --parallel 20 --timeout 20 35 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: docs 2 | on: 3 | push: 4 | branches: 5 | - master 6 | permissions: 7 | contents: write 8 | jobs: 9 | deploy-docs: 10 | name: Deploying docs 📚 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Install uv 16 | uses: astral-sh/setup-uv@v5 17 | 18 | - name: Configure Git Credentials 19 | run: | 20 | git config user.name github-actions[bot] 21 | git config user.email 41898282+github-actions[bot]@users.noreply.github.com 22 | - uses: actions/setup-python@v5 23 | with: 24 | python-version: 3.x 25 | 26 | - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV 27 | 28 | - uses: actions/cache@v4 29 | with: 30 | key: mkdocs-material-${{ env.cache_id }} 31 | path: .cache 32 | restore-keys: | 33 | mkdocs-material- 34 | 35 | - run: uv run --all-extras mkdocs gh-deploy --force 36 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 🚀 2 | on: 3 | push: 4 | tags: 5 | - '*' 6 | jobs: 7 | build: 8 | name: Build distribution 📦 9 | runs-on: ubuntu-latest 10 | environment: 11 | name: build 12 | steps: 13 | - uses: actions/checkout@v4 14 | with: 15 | persist-credentials: false 16 | - name: Install UV 17 | run: curl -LsSf https://astral.sh/uv/install.sh | sh 18 | - name: Build wheels 19 | run: uv build -o dist/ 20 | - name: Store the distribution packages 21 | uses: actions/upload-artifact@v4 22 | with: 23 | name: python-package-distributions 24 | path: dist/ 25 | 26 | pypi-publish: 27 | name: Publish to PyPI 🐍 28 | runs-on: ubuntu-latest 29 | needs: 30 | - build 31 | environment: 32 | name: pypi 33 | url: https://pypi.org/p/ilc 34 | permissions: 35 | id-token: write 36 | steps: 37 | - name: Download all the dists 38 | uses: actions/download-artifact@v4 39 | with: 40 | name: python-package-distributions 41 | path: dist/ 42 | - name: Publish to PyPi 43 | uses: pypa/gh-action-pypi-publish@release/v1 44 | with: 45 | skip-existing: true 46 | 47 | github-release: 48 | name: GitHub Release 49 | needs: 50 | - pypi-publish 51 | runs-on: ubuntu-latest 52 | permissions: 53 | contents: write # IMPORTANT: mandatory for making GitHub Releases 54 | id-token: write # IMPORTANT: mandatory for sigstore 55 | steps: 56 | - name: Download all the dists 57 | uses: actions/download-artifact@v4 58 | with: 59 | name: python-package-distributions 60 | path: dist/ 61 | - name: Create GitHub Release 62 | uses: actions/create-release@v1 63 | env: 64 | GITHUB_TOKEN: ${{ github.token }} 65 | with: 66 | tag_name: ${{ github.ref }} 67 | release_name: Release ${{ github.ref }} 68 | draft: false 69 | prerelease: false 70 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | mdtest_autogen/ 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 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 | share/python-wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .nox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | *.py,cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | cover/ 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 | .pybuilder/ 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pyenv 88 | # For a library or package, you might want to ignore these files since the code is 89 | # intended to run in multiple environments; otherwise, check them in: 90 | # .python-version 91 | 92 | # pipenv 93 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 94 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 95 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 96 | # install all needed dependencies. 97 | #Pipfile.lock 98 | 99 | # UV 100 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 101 | # This is especially recommended for binary packages to ensure reproducibility, and is more 102 | # commonly ignored for libraries. 103 | #uv.lock 104 | 105 | # poetry 106 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 107 | # This is especially recommended for binary packages to ensure reproducibility, and is more 108 | # commonly ignored for libraries. 109 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 110 | #poetry.lock 111 | 112 | # pdm 113 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 114 | #pdm.lock 115 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 116 | # in version control. 117 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 118 | .pdm.toml 119 | .pdm-python 120 | .pdm-build/ 121 | 122 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 123 | __pypackages__/ 124 | 125 | # Celery stuff 126 | celerybeat-schedule 127 | celerybeat.pid 128 | 129 | # SageMath parsed files 130 | *.sage.py 131 | 132 | # Environments 133 | .env 134 | .venv 135 | env/ 136 | venv/ 137 | ENV/ 138 | env.bak/ 139 | venv.bak/ 140 | 141 | # Spyder project settings 142 | .spyderproject 143 | .spyproject 144 | 145 | # Rope project settings 146 | .ropeproject 147 | 148 | # mkdocs documentation 149 | /site 150 | 151 | # mypy 152 | .mypy_cache/ 153 | .dmypy.json 154 | dmypy.json 155 | 156 | # Pyre type checker 157 | .pyre/ 158 | 159 | # pytype static type analyzer 160 | .pytype/ 161 | 162 | # Cython debug symbols 163 | cython_debug/ 164 | 165 | # PyCharm 166 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 167 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 168 | # and can be added to the global gitignore or merged into this file. For a more nuclear 169 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 170 | #.idea/ 171 | 172 | # Ruff stuff: 173 | .ruff_cache/ 174 | 175 | # PyPI configuration file 176 | .pypirc 177 | 178 | .site/ 179 | 180 | # MacOS 181 | .DS_Store 182 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_install_hook_types: 2 | - pre-commit 3 | - pre-push 4 | 5 | repos: 6 | - repo: https://github.com/astral-sh/uv-pre-commit 7 | # uv version. 8 | rev: 0.6.2 9 | hooks: 10 | - id: uv-lock 11 | stages: [pre-push] 12 | 13 | - repo: local 14 | hooks: 15 | # Type check 16 | - id: pyright 17 | name: pyright 18 | entry: uv run --all-extras pyright clypi/ type_tests/ examples/ 19 | language: python 20 | types: [python] 21 | exclude: ^tests/.* 22 | pass_filenames: false 23 | always_run: true 24 | stages: [pre-push] 25 | 26 | # Run tests 27 | - id: pytest 28 | name: pytest 29 | entry: uv run pytest tests 30 | language: python 31 | types: [python] 32 | files: ^tests/.* 33 | pass_filenames: false 34 | always_run: true 35 | additional_dependencies: ["pytest==8.3.4"] 36 | stages: [pre-push] 37 | 38 | # Ruff - Format all files 39 | - id: ruff-format 40 | name: Run 'ruff format' 41 | description: "Run 'ruff format' for extremely fast Python formatting" 42 | entry: uvx ruff format --force-exclude 43 | language: python 44 | types_or: [python, pyi] 45 | exclude: ^tests/.* 46 | require_serial: true 47 | additional_dependencies: ["ruff==0.8.1"] 48 | 49 | # Ruff - Lint and autofix changes 50 | - id: ruff 51 | name: Run 'ruff' for extremely fast Python linting 52 | description: "Run 'ruff' for extremely fast Python linting" 53 | entry: uvx ruff check --force-exclude 54 | language: python 55 | types_or: [python, pyi] 56 | args: [--fix] 57 | exclude: ^tests/.* 58 | require_serial: true 59 | additional_dependencies: ["ruff==0.8.1"] 60 | 61 | # Codespell 62 | - id: codespell 63 | name: codespell 64 | entry: uv run codespell 65 | language: python 66 | pass_filenames: false 67 | always_run: true 68 | stages: [pre-push] 69 | 70 | # Markdown tests 71 | - id: mdtest 72 | name: mdtest 73 | entry: uv run mdtest 74 | language: python 75 | pass_filenames: false 76 | stages: [pre-push] 77 | types: [markdown] 78 | always_run: true 79 | 80 | # Docs 81 | - id: docs 82 | name: docs 83 | entry: ./scripts/gen_readme 84 | language: python 85 | pass_filenames: false 86 | stages: [pre-push] 87 | always_run: true 88 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | # Required 5 | version: 2 6 | 7 | # Set the OS, Python version, and other tools you might need 8 | build: 9 | os: ubuntu-24.04 10 | tools: 11 | python: "3.11" 12 | 13 | # Build documentation in the "docs/" directory with Sphinx 14 | sphinx: 15 | configuration: docs/conf.py 16 | 17 | # Optionally, but recommended, 18 | # declare the Python requirements required to build your documentation 19 | # See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html 20 | # python: 21 | # install: 22 | # - requirements: docs/requirements.txt 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2025 Daniel Melchor 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: tag local-docs 2 | 3 | tag: 4 | ./scripts/tag 5 | 6 | local-docs: 7 | uv run --extra docs mkdocs serve 8 | -------------------------------------------------------------------------------- /clypi/__init__.py: -------------------------------------------------------------------------------- 1 | from clypi import parsers 2 | from clypi._cli.arg_config import Positional, arg 3 | from clypi._cli.distance import closest, distance 4 | from clypi._cli.formatter import ClypiFormatter, Formatter 5 | from clypi._cli.main import Command 6 | from clypi._colors import ALL_COLORS, ColorType, Styler, cprint, style 7 | from clypi._components.align import AlignType, align 8 | from clypi._components.boxed import Boxes, boxed 9 | from clypi._components.indented import indented 10 | from clypi._components.separator import separator 11 | from clypi._components.spinners import Spin, Spinner, spinner 12 | from clypi._components.stack import stack 13 | from clypi._components.wraps import OverflowStyle, wrap 14 | from clypi._configuration import ClypiConfig, Theme, configure, get_config 15 | from clypi._exceptions import ( 16 | AbortException, 17 | ClypiException, 18 | MaxAttemptsException, 19 | format_traceback, 20 | print_traceback, 21 | ) 22 | from clypi._prompts import ( 23 | confirm, 24 | prompt, 25 | ) 26 | from clypi.parsers import Parser 27 | 28 | __all__ = ( 29 | "ALL_COLORS", 30 | "AbortException", 31 | "AlignType", 32 | "Boxes", 33 | "ClypiConfig", 34 | "ClypiException", 35 | "ClypiFormatter", 36 | "ColorType", 37 | "Command", 38 | "Formatter", 39 | "MaxAttemptsException", 40 | "OverflowStyle", 41 | "Parser", 42 | "Positional", 43 | "Spin", 44 | "Spinner", 45 | "Styler", 46 | "Theme", 47 | "align", 48 | "arg", 49 | "boxed", 50 | "closest", 51 | "configure", 52 | "confirm", 53 | "cprint", 54 | "distance", 55 | "format_traceback", 56 | "get_config", 57 | "indented", 58 | "parsers", 59 | "print_traceback", 60 | "prompt", 61 | "separator", 62 | "spinner", 63 | "stack", 64 | "style", 65 | "wrap", 66 | ) 67 | -------------------------------------------------------------------------------- /clypi/_cli/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danimelchor/clypi/3cb07e5ae03610302c079169654083fd9305ab99/clypi/_cli/__init__.py -------------------------------------------------------------------------------- /clypi/_cli/arg_config.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | from dataclasses import asdict, dataclass 3 | 4 | from clypi import _type_util 5 | from clypi._cli import arg_parser 6 | from clypi._exceptions import ClypiException 7 | from clypi._prompts import MAX_ATTEMPTS 8 | from clypi._util import UNSET, Unset 9 | from clypi.parsers import Parser 10 | 11 | T = t.TypeVar("T") 12 | 13 | Nargs: t.TypeAlias = t.Literal["*"] | float 14 | 15 | 16 | def _get_nargs(_type: t.Any) -> Nargs: 17 | if _type is bool: 18 | return 0 19 | 20 | if _type_util.is_list(_type): 21 | return "*" 22 | 23 | if _type_util.is_union(_type): 24 | nargs = [_get_nargs(t) for t in _type_util.union_inner(_type)] 25 | if "*" in nargs: 26 | return "*" 27 | return max(t.cast(list[int], nargs)) 28 | 29 | return 1 30 | 31 | 32 | @dataclass 33 | class PartialConfig(t.Generic[T]): 34 | parser: Parser[T] | None = None 35 | default: T | Unset = UNSET 36 | default_factory: t.Callable[[], T] | Unset = UNSET 37 | help: str | None = None 38 | short: str | None = None 39 | prompt: str | None = None 40 | hide_input: bool = False 41 | max_attempts: int = MAX_ATTEMPTS 42 | inherited: bool = False 43 | hidden: bool = False 44 | group: str | None = None 45 | negative: str | None = None 46 | defer: bool = False 47 | env: str | None = None 48 | 49 | 50 | @dataclass 51 | class Config(t.Generic[T]): 52 | name: str 53 | parser: Parser[T] 54 | arg_type: t.Any 55 | default: T | Unset = UNSET 56 | default_factory: t.Callable[[], T] | Unset = UNSET 57 | help: str | None = None 58 | short: str | None = None 59 | prompt: str | None = None 60 | hide_input: bool = False 61 | max_attempts: int = MAX_ATTEMPTS 62 | inherited: bool = False 63 | hidden: bool = False 64 | group: str | None = None 65 | negative: str | None = None 66 | defer: bool = False 67 | env: str | None = None 68 | 69 | def __post_init__(self): 70 | if self.is_positional and self.short: 71 | raise ClypiException("Positional arguments cannot have short names") 72 | if self.is_positional and self.group: 73 | raise ClypiException("Positional arguments cannot belong to groups") 74 | 75 | def has_default(self) -> bool: 76 | return not isinstance(self.default, Unset) or not isinstance( 77 | self.default_factory, Unset 78 | ) 79 | 80 | def get_default(self) -> T: 81 | val = self.get_default_or_missing() 82 | if isinstance(val, Unset): 83 | raise ValueError(f"Field {self} has no default value!") 84 | return val 85 | 86 | def get_default_or_missing(self) -> T | Unset: 87 | if not isinstance(self.default, Unset): 88 | return self.default 89 | if not isinstance(self.default_factory, Unset): 90 | return self.default_factory() 91 | return UNSET 92 | 93 | @classmethod 94 | def from_partial( 95 | cls, 96 | partial: PartialConfig[T], 97 | name: str, 98 | parser: Parser[T] | None, 99 | arg_type: t.Any, 100 | ): 101 | kwargs = asdict(partial) 102 | kwargs.update(name=name, parser=parser, arg_type=arg_type) 103 | return cls(**kwargs) 104 | 105 | @property 106 | def display_name(self): 107 | name = arg_parser.snake_to_dash(self.name) 108 | if self.is_opt: 109 | return f"--{name}" 110 | return name 111 | 112 | @property 113 | def negative_name(self): 114 | assert self.is_opt, "negative_name can only be used for options" 115 | assert self.negative, "negative is not set" 116 | negative_name = arg_parser.snake_to_dash(self.negative) 117 | return f"--{negative_name}" 118 | 119 | @property 120 | def short_display_name(self): 121 | assert self.short, f"Expected short to be set in {self}" 122 | name = arg_parser.snake_to_dash(self.short) 123 | return f"-{name}" 124 | 125 | @property 126 | def is_positional(self) -> bool: 127 | if t.get_origin(self.arg_type) != t.Annotated: 128 | return False 129 | 130 | metadata = self.arg_type.__metadata__ 131 | for m in metadata: 132 | if isinstance(m, _Positional): 133 | return True 134 | 135 | return False 136 | 137 | @property 138 | def is_opt(self) -> bool: 139 | return not self.is_positional 140 | 141 | @property 142 | def nargs(self) -> Nargs: 143 | return _get_nargs(self.arg_type) 144 | 145 | @property 146 | def modifier(self) -> str: 147 | nargs = self.nargs 148 | if nargs in ("+", "*"): 149 | return "…" 150 | elif isinstance(nargs, int) and nargs > 1: 151 | return "…" 152 | return "" 153 | 154 | 155 | def arg( 156 | default: T | Unset = UNSET, 157 | parser: Parser[T] | None = None, 158 | default_factory: t.Callable[[], T] | Unset = UNSET, 159 | help: str | None = None, 160 | short: str | None = None, 161 | prompt: str | None = None, 162 | hide_input: bool = False, 163 | max_attempts: int = MAX_ATTEMPTS, 164 | inherited: bool = False, 165 | hidden: bool = False, 166 | group: str | None = None, 167 | negative: str | None = None, 168 | defer: bool = False, 169 | env: str | None = None, 170 | ) -> T: 171 | return PartialConfig( 172 | default=default, 173 | parser=parser, 174 | default_factory=default_factory, 175 | help=help, 176 | short=short, 177 | prompt=prompt, 178 | hide_input=hide_input, 179 | max_attempts=max_attempts, 180 | inherited=inherited, 181 | hidden=hidden, 182 | group=group, 183 | negative=negative, 184 | defer=defer, 185 | env=env, 186 | ) # type: ignore 187 | 188 | 189 | @dataclass 190 | class _Positional: 191 | pass 192 | 193 | 194 | P = t.TypeVar("P") 195 | Positional: t.TypeAlias = t.Annotated[P, _Positional()] 196 | -------------------------------------------------------------------------------- /clypi/_cli/arg_parser.py: -------------------------------------------------------------------------------- 1 | import re 2 | import typing as t 3 | from dataclasses import dataclass 4 | 5 | _COMPRESSED_ARG = re.compile(r"^-[a-zA-Z]{2,}$") 6 | _SHORT_ARG = re.compile(r"^-[a-zA-Z]$") 7 | _LONG_ARG = re.compile(r"^--[a-zA-Z][a-zA-Z0-9\-\_]+$") 8 | 9 | 10 | def dash_to_snake(s: str) -> str: 11 | return re.sub(r"^-+", "", s).replace("-", "_") 12 | 13 | 14 | def snake_to_dash(s: str) -> str: 15 | return s.replace("_", "-") 16 | 17 | 18 | def normalize_args(args: t.Sequence[str]) -> list[str]: 19 | new_args: list[str] = [] 20 | for a in args: 21 | # Expand -a=1 or --a=1 into --a 1 22 | if a.startswith("-") and "=" in a: 23 | new_args.extend(a.split("=", 1)) 24 | 25 | # Expand -abc into -a -b -c 26 | elif _COMPRESSED_ARG.match(a): 27 | new_args.extend(f"-{arg}" for arg in a[1:]) 28 | 29 | # Leave as is 30 | else: 31 | new_args.append(a) 32 | return new_args 33 | 34 | 35 | @dataclass 36 | class Arg: 37 | value: str 38 | orig: str 39 | arg_type: t.Literal["long-opt", "short-opt", "pos"] 40 | 41 | def is_pos(self): 42 | return self.arg_type == "pos" 43 | 44 | def is_long_opt(self): 45 | return self.arg_type == "long-opt" 46 | 47 | def is_short_opt(self): 48 | return self.arg_type == "short-opt" 49 | 50 | def is_opt(self): 51 | return self.is_long_opt() or self.is_short_opt() 52 | 53 | 54 | def parse_as_attr(arg: str) -> Arg: 55 | if _LONG_ARG.match(arg): 56 | return Arg(value=dash_to_snake(arg), orig=arg, arg_type="long-opt") 57 | 58 | if _SHORT_ARG.match(arg): 59 | return Arg(value=dash_to_snake(arg), orig=arg, arg_type="short-opt") 60 | 61 | return Arg(value=arg, orig=arg, arg_type="pos") 62 | -------------------------------------------------------------------------------- /clypi/_cli/autocomplete.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import shlex 5 | import sys 6 | import typing as t 7 | from abc import ABC, abstractmethod 8 | from pathlib import Path 9 | from textwrap import dedent 10 | 11 | from typing_extensions import override 12 | 13 | from clypi import _colors 14 | from clypi._cli import arg_parser 15 | 16 | if t.TYPE_CHECKING: 17 | from clypi._cli.main import Command 18 | 19 | _CLYPI_CURRENT_ARGS = "_CLYPI_CURRENT_ARGS" 20 | 21 | 22 | class AutocompleteInstaller(ABC): 23 | """ 24 | The basic idea for autocomplete with clypi is that we'll configure 25 | the shell to call the users' CLI with whatever the user has typed so far. Then, 26 | we will try to parse as much as we can from what the user gave us, and when we can't 27 | anymore we just list the options the user has. At that point, we will have traversed 28 | enough into the right subcommand the user wants to autocomplete 29 | """ 30 | 31 | def __init__(self, command: type[Command]) -> None: 32 | self.name = command.prog() 33 | self._options = list(command.options().values()) 34 | self._subcommands = list(command.subcommands().values()) 35 | 36 | @abstractmethod 37 | def path(self) -> Path: ... 38 | 39 | @abstractmethod 40 | def script(self) -> str: ... 41 | 42 | def list_arguments(self) -> None: 43 | sys.stdout.write( 44 | "\n".join( 45 | [ 46 | *[s.prog() for s in self._subcommands if s], 47 | *[p.display_name for p in self._options], 48 | ] 49 | ), 50 | ) 51 | sys.stdout.flush() 52 | sys.exit(0) 53 | 54 | @property 55 | def gen_args(self) -> str: 56 | # We use get_current_args to pass in what the user has typed so far 57 | get_current_args = f"{_CLYPI_CURRENT_ARGS}=(commandline -cp)" 58 | return f"env {get_current_args} {self.name}" 59 | 60 | def install(self) -> None: 61 | p = self.path() 62 | p.parent.mkdir(parents=True, exist_ok=True) 63 | with open(p, "w") as f: 64 | f.write(self.script()) 65 | self.post_install(p) 66 | _colors.cprint( 67 | "Successfully installed autocomplete for fish", fg="green", bold=True 68 | ) 69 | _colors.cprint(f" 󰘍 {self.path()}") 70 | sys.exit(0) 71 | 72 | def post_install(self, path: Path): 73 | return None 74 | 75 | 76 | class FishInstaller(AutocompleteInstaller): 77 | @override 78 | def path(self) -> Path: 79 | return Path.home() / ".config" / "fish" / "completions" / f"{self.name}.fish" 80 | 81 | @override 82 | def script(self) -> str: 83 | return f'complete -c {self.name} --no-files -a "({self.gen_args})" -n "{self.gen_args}"' 84 | 85 | 86 | class BashInstaller(AutocompleteInstaller): 87 | @override 88 | def path(self) -> Path: 89 | base = Path("/etc/bash_completion.d/") 90 | if Path("/usr/local/etc/bash_completion.d").exists(): 91 | base = Path("/usr/local/etc/bash_completion.d") 92 | return base / self.name 93 | 94 | @override 95 | def post_install(self, path: Path): 96 | bashrc = Path.home() / ".bashrc" 97 | with open(bashrc, "a+") as f: 98 | for line in f.readline(): 99 | if str(path) in line: 100 | return 101 | 102 | f.write(f"source '{path}'") 103 | 104 | @override 105 | def script(self) -> str: 106 | return dedent( 107 | """ 108 | _complete_%(name)s() { 109 | _script_commands=$(env %(env_var)s="${COMP_WORDS[*]}" $1) 110 | local cur="${COMP_WORDS[COMP_CWORD]}" 111 | COMPREPLY=( $(compgen -W "${_script_commands}" -- ${cur}) ) 112 | } 113 | 114 | complete -o default -F _complete_%(name)s %(name)s 115 | """ 116 | % dict(name=self.name, env_var=_CLYPI_CURRENT_ARGS) 117 | ).strip() 118 | 119 | 120 | class ZshInstaller(AutocompleteInstaller): 121 | @override 122 | def path(self) -> Path: 123 | return Path.home() / ".zfunc" / f"_{self.name}" 124 | 125 | @override 126 | def post_install(self, path: Path): 127 | autoload_comp = "fpath+=~/.zfunc; autoload -Uz compinit; compinit" 128 | zshrc = Path.home() / ".zshrc" 129 | with open(zshrc, "a+") as f: 130 | for line in f.readline(): 131 | if autoload_comp in line: 132 | return 133 | 134 | f.write(autoload_comp) 135 | 136 | @override 137 | def script(self) -> str: 138 | return dedent( 139 | """ 140 | #compdef %(name)s 141 | 142 | _complete_%(name)s() { 143 | IFS=$'\\n' completions=( $(env %(env_var)s="${words[1,$CURRENT]}" %(name)s) ) 144 | 145 | local -a filtered 146 | for item in "${completions[@]}"; do 147 | if [[ $item == ${words[$CURRENT]}* ]]; then 148 | filtered+=("$item") 149 | fi 150 | done 151 | compadd -U -V unsorted -a filtered 152 | } 153 | 154 | compdef _complete_%(name)s %(name)s 155 | """ 156 | % dict(name=self.name, env_var=_CLYPI_CURRENT_ARGS) 157 | ).strip() 158 | 159 | 160 | def get_installer(command: type[Command]) -> AutocompleteInstaller: 161 | shell = Path(os.environ["SHELL"]).name 162 | if shell == "fish": 163 | return FishInstaller(command) 164 | if shell == "bash": 165 | return BashInstaller(command) 166 | if shell == "zsh": 167 | return ZshInstaller(command) 168 | raise ValueError(f"Autocomplete is not supported for shell '{shell}'") 169 | 170 | 171 | def get_autocomplete_args() -> list[str] | None: 172 | if args := os.environ.get(_CLYPI_CURRENT_ARGS): 173 | return shlex.split(args)[1:] 174 | return None 175 | 176 | 177 | def list_arguments(command: type[Command]): 178 | get_installer(command).list_arguments() 179 | 180 | 181 | def requested_autocomplete_install(args: t.Sequence[str]) -> bool: 182 | if not args: 183 | return False 184 | parsed = arg_parser.parse_as_attr(args[-1]) 185 | return parsed.is_long_opt() and parsed.value == "install_autocomplete" 186 | 187 | 188 | def install_autocomplete(command: type[Command]): 189 | get_installer(command).install() 190 | -------------------------------------------------------------------------------- /clypi/_cli/context.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | 3 | from clypi._cli.arg_config import Nargs 4 | 5 | 6 | @dataclass 7 | class CurrentCtx: 8 | name: str = "" 9 | nargs: Nargs = 0 10 | max_nargs: Nargs = 0 11 | 12 | _collected: list[str] = field(init=False, default_factory=list) 13 | 14 | def has_more(self) -> bool: 15 | if isinstance(self.nargs, float | int): 16 | return self.nargs > 0 17 | return True 18 | 19 | def needs_more(self) -> bool: 20 | if isinstance(self.nargs, float | int): 21 | return self.nargs > 0 22 | return False 23 | 24 | def collect(self, item: str) -> None: 25 | if isinstance(self.nargs, float | int): 26 | self.nargs -= 1 27 | 28 | self._collected.append(item) 29 | 30 | @property 31 | def collected(self) -> str | list[str]: 32 | if self.max_nargs == 1: 33 | return self._collected[0] 34 | return self._collected 35 | 36 | def __bool__(self) -> bool: 37 | return bool(self.name) 38 | -------------------------------------------------------------------------------- /clypi/_cli/deferred.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | from dataclasses import dataclass, field 3 | 4 | from clypi._data.dunders import ALL_DUNDERS 5 | from clypi._prompts import MAX_ATTEMPTS, prompt 6 | from clypi._util import UNSET, Unset 7 | from clypi.parsers import Parser 8 | 9 | T = t.TypeVar("T") 10 | 11 | 12 | def gen_impl(__f: str) -> t.Callable[..., t.Any]: 13 | def _impl(self: "DeferredValue[t.Any]", *args: t.Any, **kwargs: t.Any) -> t.Any: 14 | return getattr(self.__get__(None), __f)(*args, **kwargs) 15 | 16 | return _impl 17 | 18 | 19 | @dataclass 20 | class DeferredValue(t.Generic[T]): 21 | parser: Parser[T] 22 | prompt: str 23 | default: T | Unset = UNSET 24 | default_factory: t.Callable[[], T] | Unset = UNSET 25 | hide_input: bool = False 26 | max_attempts: int = MAX_ATTEMPTS 27 | 28 | _value: T | Unset = field(init=False, default=UNSET) 29 | 30 | def __set_name__(self, owner: t.Any, name: str): 31 | self.__name__ = name 32 | 33 | def __get__(self, obj: t.Any, objtype: t.Any = None) -> T: 34 | if self._value is UNSET: 35 | self._value = prompt( 36 | self.prompt, 37 | default=self.default, 38 | default_factory=self.default_factory, 39 | hide_input=self.hide_input, 40 | max_attempts=self.max_attempts, 41 | parser=self.parser, 42 | ) 43 | return self._value 44 | 45 | # Autogen all dunder methods to trigger __get__ 46 | # NOTE: I hate having to do this but I did not find how to trigger 47 | # the evaluation of a descriptor when a dunder method is called on it 48 | for dunder in ALL_DUNDERS: 49 | locals()[dunder] = gen_impl(dunder) 50 | -------------------------------------------------------------------------------- /clypi/_cli/distance.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable 2 | 3 | 4 | def distance(this: str, other: str) -> float: 5 | """ 6 | Modified version of the Levenshtein distance to consider the case 7 | of the letters being compared so that dist(a, A) < dist(a, b) 8 | """ 9 | if not this or not other: 10 | return max(len(this), len(other)) 11 | 12 | n, m = len(this), len(other) 13 | dist: list[list[float]] = [[0 for _ in range(m + 1)] for _ in range(n + 1)] 14 | 15 | # Prepopulate first X and Y axis 16 | for t in range(0, n + 1): 17 | dist[t][0] = t 18 | for o in range(0, m + 1): 19 | dist[0][o] = o 20 | 21 | def _subst_dist(t: str, o: str) -> float: 22 | if t == o: 23 | return 0 24 | elif t.lower() == o.lower(): 25 | return 0.5 26 | return 1 27 | 28 | # Compute actions 29 | for t in range(n): 30 | for o in range(m): 31 | insertion = dist[t][o + 1] + 1 32 | deletion = dist[t + 1][o] + 1 33 | substitution = dist[t][o] + _subst_dist(this[t], other[o]) 34 | dist[t + 1][o + 1] = min(insertion, deletion, substitution) 35 | 36 | # Get bottom right of computed matrix 37 | return dist[n][m] 38 | 39 | 40 | def closest(word: str, options: Iterable[str]) -> tuple[str, float]: 41 | """ 42 | Given a word and a list of options, it returns the closest 43 | option to that word and it's distance 44 | """ 45 | dists = [distance(word, o) for o in options] 46 | if not dists: 47 | return "", float("inf") 48 | min_opt = min(zip(options, dists), key=lambda x: x[1]) 49 | return min_opt 50 | -------------------------------------------------------------------------------- /clypi/_cli/formatter.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | from collections import defaultdict 5 | from dataclasses import dataclass 6 | from functools import cached_property 7 | 8 | from clypi import _type_util 9 | from clypi._cli.arg_parser import dash_to_snake 10 | from clypi._colors import ColorType, style 11 | from clypi._components.boxed import boxed 12 | from clypi._components.indented import indented 13 | from clypi._components.stack import stack 14 | from clypi._exceptions import format_traceback 15 | 16 | if t.TYPE_CHECKING: 17 | from clypi import Command 18 | from clypi._cli.arg_config import Config 19 | 20 | 21 | class Formatter(t.Protocol): 22 | def format_help( 23 | self, 24 | full_command: list[str], 25 | description: str | None, 26 | epilog: str | None, 27 | options: list[Config[t.Any]], 28 | positionals: list[Config[t.Any]], 29 | subcommands: list[type[Command]], 30 | exception: Exception | None, 31 | ) -> str: ... 32 | 33 | 34 | @dataclass 35 | class ClypiFormatter: 36 | boxed: bool = True 37 | show_option_types: bool = False 38 | show_inherited_options: bool = True 39 | normalize_dots: t.Literal[".", ""] | None = "" 40 | 41 | @cached_property 42 | def theme(self): 43 | from clypi._configuration import get_config 44 | 45 | return get_config().theme 46 | 47 | def _maybe_norm_help(self, message: str) -> str: 48 | """ 49 | Utility function to add or remove dots from the end of all option/arg 50 | descriptions to have a more consistent formatting experience. 51 | """ 52 | message = message.rstrip() 53 | if message and self.normalize_dots == "." and message[-1].isalnum(): 54 | return message + "." 55 | if message and self.normalize_dots == "" and message[-1] == ".": 56 | return message[:-1] 57 | return message 58 | 59 | def _maybe_boxed( 60 | self, *columns: list[str], title: str, color: ColorType | None = None 61 | ) -> str: 62 | first_col, *rest = columns 63 | 64 | # Filter out empty columns 65 | rest = list(filter(any, rest)) 66 | 67 | if not self.boxed: 68 | section_title = self.theme.section_title(title) 69 | 70 | # For non-boxed design, we just indent the first col a bit so that it looks 71 | # like it's inside the section 72 | stacked = stack(indented(first_col), *rest) 73 | return f"{section_title}\n{stacked}" 74 | 75 | stacked = stack(first_col, *rest, lines=True, width=-4) 76 | return "\n".join(boxed(stacked, width="max", title=title, color=color)) 77 | 78 | def _format_option_value(self, option: Config[t.Any]): 79 | if option.nargs == 0: 80 | return "" 81 | placeholder = dash_to_snake(option.name).upper() 82 | return self.theme.placeholder(f"<{placeholder}>") 83 | 84 | def _format_option(self, option: Config[t.Any]) -> tuple[str, ...]: 85 | help = self._maybe_norm_help(option.help or "") 86 | 87 | # E.g.: -r, --requirements 88 | usage = self.theme.long_option(option.display_name) 89 | if short_usage := ( 90 | self.theme.short_option(option.short_display_name) if option.short else "" 91 | ): 92 | usage = short_usage + ", " + usage 93 | 94 | # E.g.: --flag/--no-flag 95 | if option.negative: 96 | usage += "/" + self.theme.long_option(option.negative_name) 97 | 98 | if not self.show_option_types: 99 | usage += " " + self._format_option_value(option) 100 | 101 | # E.g.: TEXT 102 | type_str = "" 103 | type_upper = str(option.parser).upper() 104 | if self.show_option_types: 105 | type_str = self.theme.type_str(type_upper) 106 | elif _type_util.has_metavar(option.arg_type): 107 | help = help + " " + type_upper if help else type_upper 108 | 109 | return usage, type_str, help 110 | 111 | def _format_option_group( 112 | self, title: str, options: list[Config[t.Any]] 113 | ) -> str | None: 114 | usage: list[str] = [] 115 | type_str: list[str] = [] 116 | help: list[str] = [] 117 | for o in options: 118 | # Hidden options do not get displayed for the user 119 | if o.hidden: 120 | continue 121 | 122 | u, ts, hp = self._format_option(o) 123 | usage.append(u) 124 | type_str.append(ts) 125 | help.append(hp) 126 | 127 | if len(usage) == 0: 128 | return None 129 | 130 | return self._maybe_boxed(usage, type_str, help, title=title) 131 | 132 | def _format_options(self, options: list[Config[t.Any]]) -> str | None: 133 | if not options: 134 | return None 135 | 136 | groups: dict[str | None, list[Config[t.Any]]] = defaultdict(list) 137 | 138 | # We set an empty group first so that non-group options always render first 139 | groups[None] = [] 140 | 141 | # Group by option group 142 | for o in options: 143 | if o.inherited and not self.show_inherited_options: 144 | continue 145 | groups[o.group].append(o) 146 | 147 | # Render all groups 148 | rendered: list[str | None] = [] 149 | for group_name, options in groups.items(): 150 | if not options: 151 | continue 152 | name = f"{group_name or ''} Options".lstrip().capitalize() 153 | rendered.append(self._format_option_group(name, options)) 154 | 155 | return "\n\n".join(group for group in rendered if group) 156 | 157 | def _format_positional_with_mod(self, positional: Config[t.Any]) -> str: 158 | # E.g.: [FILES]... 159 | pos_name = positional.name.upper() 160 | name = f"[{pos_name}]{positional.modifier}" 161 | return name 162 | 163 | def _format_positional(self, positional: Config[t.Any]) -> tuple[str, ...]: 164 | # E.g.: [FILES]... or FILES 165 | name = ( 166 | self.theme.positional(self._format_positional_with_mod(positional)) 167 | if not self.show_option_types 168 | else self.theme.positional(positional.name.upper()) 169 | ) 170 | 171 | help = positional.help or "" 172 | type_str = ( 173 | self.theme.type_str(str(positional.parser).upper()) 174 | if self.show_option_types 175 | else "" 176 | ) 177 | return name, type_str, self._maybe_norm_help(help) 178 | 179 | def _format_positionals(self, positionals: list[Config[t.Any]]) -> str | None: 180 | name: list[str] = [] 181 | type_str: list[str] = [] 182 | help: list[str] = [] 183 | for p in positionals: 184 | n, ts, hp = self._format_positional(p) 185 | name.append(n) 186 | type_str.append(ts) 187 | help.append(hp) 188 | 189 | if len(name) == 0: 190 | return None 191 | 192 | return self._maybe_boxed(name, type_str, help, title="Arguments") 193 | 194 | def _format_subcommand(self, subcmd: type[Command]) -> tuple[str, str]: 195 | name = self.theme.subcommand(subcmd.prog()) 196 | help = subcmd.help() or "" 197 | return name, self._maybe_norm_help(help) 198 | 199 | def _format_subcommands(self, subcommands: list[type[Command]]) -> str | None: 200 | name: list[str] = [] 201 | help: list[str] = [] 202 | for p in subcommands: 203 | n, hp = self._format_subcommand(p) 204 | name.append(n) 205 | help.append(hp) 206 | 207 | if len(name) == 0: 208 | return None 209 | 210 | return self._maybe_boxed(name, help, title="Subcommands") 211 | 212 | def _format_header( 213 | self, 214 | full_command: list[str], 215 | options: list[Config[t.Any]], 216 | positionals: list[Config[t.Any]], 217 | subcommands: list[type[Command]], 218 | ) -> str: 219 | prefix = self.theme.usage("Usage:") 220 | command_str = self.theme.usage_command(" ".join(full_command)) 221 | 222 | positionals_str: list[str] = [] 223 | for pos in positionals: 224 | name = self._format_positional_with_mod(pos) 225 | positionals_str.append(self.theme.usage_args(name)) 226 | positional = " " + " ".join(positionals_str) if positionals else "" 227 | 228 | option = self.theme.usage_args(" [OPTIONS]") if options else "" 229 | command = self.theme.usage_args(" COMMAND") if subcommands else "" 230 | 231 | return f"{prefix} {command_str}{positional}{option}{command}" 232 | 233 | def _format_description(self, description: str | None) -> str | None: 234 | if not description: 235 | return None 236 | return self._maybe_norm_help(description) 237 | 238 | def _format_epilog(self, epilog: str | None) -> str | None: 239 | if not epilog: 240 | return None 241 | return self._maybe_norm_help(epilog) 242 | 243 | def _format_exception(self, exception: Exception | None) -> str | None: 244 | if not exception: 245 | return None 246 | 247 | if self.boxed: 248 | return self._maybe_boxed( 249 | format_traceback(exception), title="Error", color="red" 250 | ) 251 | 252 | # Special section title since it's an error 253 | section_title = style("Error:", fg="red", bold=True) 254 | stacked = "\n".join(indented(format_traceback(exception, color=None))) 255 | return f"{section_title}\n{stacked}" 256 | 257 | def format_help( 258 | self, 259 | full_command: list[str], 260 | description: str | None, 261 | epilog: str | None, 262 | options: list[Config[t.Any]], 263 | positionals: list[Config[t.Any]], 264 | subcommands: list[type[Command]], 265 | exception: Exception | None, 266 | ) -> str: 267 | lines: list[str | None] = [] 268 | 269 | # Description 270 | lines.append(self._format_description(description)) 271 | 272 | # Header 273 | lines.append( 274 | self._format_header(full_command, options, positionals, subcommands) 275 | ) 276 | 277 | # Subcommands 278 | lines.append(self._format_subcommands(subcommands)) 279 | 280 | # Positionals 281 | lines.append(self._format_positionals(positionals)) 282 | 283 | # Options 284 | lines.append(self._format_options(options)) 285 | 286 | # Epilog 287 | lines.append(self._format_epilog(epilog)) 288 | 289 | # Exceptions 290 | lines.append(self._format_exception(exception)) 291 | 292 | joined = "\n\n".join(line for line in lines if line) 293 | return joined + "\n" 294 | -------------------------------------------------------------------------------- /clypi/_colors.py: -------------------------------------------------------------------------------- 1 | import builtins 2 | import re 3 | import typing as t 4 | from dataclasses import dataclass 5 | from enum import Enum 6 | 7 | ESC = "\033[" 8 | ANSI_ESCAPE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") 9 | END = "m" 10 | 11 | FG_OFFSET = 30 12 | BG_OFFSET = 40 13 | BRIGHT_OFFSET = 60 14 | 15 | STYLE_ON_OFFSET = 0 16 | STYLE_OFF_OFFSET = 20 17 | 18 | ColorType: t.TypeAlias = t.Literal[ 19 | "black", 20 | "red", 21 | "green", 22 | "yellow", 23 | "blue", 24 | "magenta", 25 | "cyan", 26 | "white", 27 | "default", 28 | "bright_black", 29 | "bright_red", 30 | "bright_green", 31 | "bright_yellow", 32 | "bright_blue", 33 | "bright_magenta", 34 | "bright_cyan", 35 | "bright_white", 36 | "bright_default", 37 | ] 38 | ALL_COLORS = tuple(t.get_args(ColorType)) 39 | 40 | _color_codes = { 41 | "black": 0, 42 | "red": 1, 43 | "green": 2, 44 | "yellow": 3, 45 | "blue": 4, 46 | "magenta": 5, 47 | "cyan": 6, 48 | "white": 7, 49 | "default": 9, 50 | } 51 | 52 | 53 | def _code(code: int) -> str: 54 | return f"{ESC}{code}{END}" 55 | 56 | 57 | def _color_code(color: ColorType, offset: int) -> int: 58 | """ 59 | Given a color name and an offset (e.g.: fg, bright bg, etc.) 60 | it returns the actual color code that will need to be used 61 | 62 | Example: 63 | _color_code("bright_green", FG_OFFSET) -> 42 64 | Since: 2(green) + 10(bright) + 30(fg offset) 65 | """ 66 | 67 | key = str(color) 68 | if color.startswith("bright_"): 69 | key = color.removeprefix("bright_") 70 | offset += BRIGHT_OFFSET 71 | return _color_codes[key] + offset 72 | 73 | 74 | def _apply_color(s: str, color: ColorType, offset: int) -> str: 75 | start = _color_code(color, offset) 76 | end = _color_code("default", offset) 77 | return f"{_code(start)}{s}{_code(end)}" 78 | 79 | 80 | def _apply_fg(text: str, fg: ColorType): 81 | return _apply_color(text, fg, FG_OFFSET) 82 | 83 | 84 | def _apply_bg(text: str, bg: ColorType): 85 | return _apply_color(text, bg, BG_OFFSET) 86 | 87 | 88 | class StyleCode(Enum): 89 | BOLD = 1 90 | DIM = 2 91 | ITALIC = 3 92 | UNDERLINE = 4 93 | BLINK = 5 94 | REVERSE = 7 95 | STRIKETHROUGH = 9 96 | 97 | 98 | def _apply_style(s: str, style: StyleCode) -> str: 99 | start = style.value + STYLE_ON_OFFSET 100 | return f"{_code(start)}{s}{_code(0)}" 101 | 102 | 103 | def _reset(s: str) -> str: 104 | return f"{_code(0)}{s}" 105 | 106 | 107 | def remove_style(s: str): 108 | return ANSI_ESCAPE.sub("", s) 109 | 110 | 111 | def _should_disable_colors() -> bool: 112 | # Dynamic import to avoid cycles 113 | from clypi._configuration import get_config 114 | 115 | return get_config().disable_colors 116 | 117 | 118 | @dataclass 119 | class Styler: 120 | fg: ColorType | None = None 121 | bg: ColorType | None = None 122 | bold: bool = False 123 | italic: bool = False 124 | dim: bool = False 125 | underline: bool = False 126 | blink: bool = False 127 | reverse: bool = False 128 | strikethrough: bool = False 129 | reset: bool = False 130 | hide: bool = False 131 | 132 | def __call__(self, *messages: t.Any) -> str: 133 | # Utility so that strings can be dynamically removed 134 | if self.hide: 135 | return "" 136 | 137 | text = " ".join(str(m) for m in messages) 138 | 139 | # If the user wants to disable colors, never format 140 | if _should_disable_colors(): 141 | return text 142 | 143 | text = _apply_fg(text, self.fg) if self.fg else text 144 | text = _apply_bg(text, self.bg) if self.bg else text 145 | text = _apply_style(text, StyleCode.BOLD) if self.bold else text 146 | text = _apply_style(text, StyleCode.ITALIC) if self.italic else text 147 | text = _apply_style(text, StyleCode.DIM) if self.dim else text 148 | text = _apply_style(text, StyleCode.UNDERLINE) if self.underline else text 149 | text = _apply_style(text, StyleCode.BLINK) if self.blink else text 150 | text = _apply_style(text, StyleCode.REVERSE) if self.reverse else text 151 | text = ( 152 | _apply_style(text, StyleCode.STRIKETHROUGH) if self.strikethrough else text 153 | ) 154 | text = _reset(text) if self.reset else text 155 | return text 156 | 157 | 158 | def style( 159 | *messages: t.Any, 160 | fg: ColorType | None = None, 161 | bg: ColorType | None = None, 162 | bold: bool = False, 163 | italic: bool = False, 164 | dim: bool = False, 165 | underline: bool = False, 166 | blink: bool = False, 167 | reverse: bool = False, 168 | strikethrough: bool = False, 169 | reset: bool = False, 170 | hide: bool = False, 171 | ) -> str: 172 | return Styler( 173 | fg=fg, 174 | bg=bg, 175 | bold=bold, 176 | italic=italic, 177 | dim=dim, 178 | underline=underline, 179 | blink=blink, 180 | reverse=reverse, 181 | strikethrough=strikethrough, 182 | reset=reset, 183 | hide=hide, 184 | )(*messages) 185 | 186 | 187 | class SupportsWrite(t.Protocol): 188 | def write(self, s: t.Any, /) -> object: ... 189 | 190 | 191 | def cprint( 192 | *messages: t.Any, 193 | fg: ColorType | None = None, 194 | bg: ColorType | None = None, 195 | bold: bool = False, 196 | italic: bool = False, 197 | dim: bool = False, 198 | underline: bool = False, 199 | blink: bool = False, 200 | reverse: bool = False, 201 | strikethrough: bool = False, 202 | reset: bool = False, 203 | hide: bool = False, 204 | file: SupportsWrite | None = None, 205 | end: str | None = "\n", 206 | ): 207 | text = style( 208 | *messages, 209 | fg=fg, 210 | bg=bg, 211 | bold=bold, 212 | italic=italic, 213 | dim=dim, 214 | underline=underline, 215 | blink=blink, 216 | reverse=reverse, 217 | strikethrough=strikethrough, 218 | reset=reset, 219 | hide=hide, 220 | ) 221 | builtins.print(text, end=end, file=file) 222 | -------------------------------------------------------------------------------- /clypi/_components/align.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | from clypi._data.boxes import Boxes as _Boxes 4 | from clypi._util import visible_width 5 | 6 | Boxes = _Boxes 7 | 8 | 9 | def _ljust(s: str, width: int): 10 | len = visible_width(s) 11 | diff = max(0, width - len) 12 | return s + " " * diff 13 | 14 | 15 | def _rjust(s: str, width: int): 16 | len = visible_width(s) 17 | diff = max(0, width - len) 18 | return " " * diff + s 19 | 20 | 21 | def _center(s: str, width: int): 22 | len = visible_width(s) 23 | diff = max(0, width - len) 24 | right = diff // 2 25 | left = diff - right 26 | return " " * left + s + " " * right 27 | 28 | 29 | AlignType: t.TypeAlias = t.Literal["left", "center", "right"] 30 | 31 | 32 | def align(s: str, alignment: AlignType, width: int) -> str: 33 | if alignment == "left": 34 | return _ljust(s, width) 35 | if alignment == "right": 36 | return _rjust(s, width) 37 | return _center(s, width) 38 | -------------------------------------------------------------------------------- /clypi/_components/boxed.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | from clypi._colors import ColorType, Styler 4 | from clypi._components.align import AlignType 5 | from clypi._components.align import align as _align 6 | from clypi._components.wraps import wrap 7 | from clypi._data.boxes import Boxes as _Boxes 8 | from clypi._util import get_term_width, visible_width 9 | 10 | Boxes = _Boxes 11 | 12 | 13 | T = t.TypeVar("T", bound=list[str] | str) 14 | 15 | 16 | def boxed( 17 | lines: T, 18 | width: t.Literal["auto", "max"] | int = "auto", 19 | style: Boxes = Boxes.HEAVY, 20 | align: AlignType = "left", 21 | title: str | None = None, 22 | color: ColorType | None = None, 23 | ) -> T: 24 | box = style.value 25 | c = Styler(fg=color) 26 | 27 | def _iter_box( 28 | lines: t.Iterable[str], 29 | width: int, 30 | ): 31 | # Top bar 32 | nonlocal title 33 | top_bar_width = width - 3 34 | if title: 35 | top_bar_width = width - 5 - visible_width(title) 36 | title = f" {title} " 37 | else: 38 | title = "" 39 | yield c(box.tl + box.x + title + box.x * top_bar_width + box.tr) 40 | 41 | # Body 42 | for line in lines: 43 | # Remove two on each side due to the box edge and padding 44 | max_text_width = -2 + width - 2 45 | 46 | # Wrap it in case each line is longer than expected 47 | wrapped = wrap(line, max_text_width) 48 | for sub_line in wrapped: 49 | aligned = _align(sub_line, align, max_text_width) 50 | yield c(box.y) + " " + aligned + " " + c(box.y) 51 | 52 | # Footer 53 | yield c(box.bl + box.x * (width - 2) + box.br) 54 | 55 | def _get_width(lines: list[str]): 56 | if isinstance(width, int) and width >= 0: 57 | return width 58 | if isinstance(width, int) and width < 0: 59 | return get_term_width() + width 60 | 61 | if width == "max": 62 | return get_term_width() 63 | 64 | # Width is auto 65 | max_visible_width = max(visible_width(line) for line in lines) 66 | # Add two on each side for the box edge and padding 67 | return 2 + max_visible_width + 2 68 | 69 | if isinstance(lines, list): 70 | computed_width = _get_width(lines) 71 | return t.cast(T, list(_iter_box(lines, width=computed_width))) 72 | 73 | act_lines = lines.split("\n") 74 | computed_width = _get_width(act_lines) 75 | return t.cast(T, "\n".join(_iter_box(act_lines, width=computed_width))) 76 | -------------------------------------------------------------------------------- /clypi/_components/indented.py: -------------------------------------------------------------------------------- 1 | def indented(lines: list[str], prefix: str = " ") -> list[str]: 2 | return [prefix + s for s in lines] 3 | -------------------------------------------------------------------------------- /clypi/_components/separator.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | from clypi._colors import ColorType, Styler 4 | from clypi._util import get_term_width, visible_width 5 | 6 | 7 | def separator( 8 | separator: str = "━", 9 | width: t.Literal["max"] | int = "max", 10 | title: str | None = None, 11 | color: ColorType | None = None, 12 | ) -> str: 13 | if width == "max": 14 | width = get_term_width() 15 | 16 | c = Styler(fg=color) 17 | 18 | if not title: 19 | return c(separator * width) 20 | 21 | num_chars = width - 2 - visible_width(title) 22 | left_chars = num_chars // 2 23 | right_chars = num_chars - left_chars 24 | return c(separator * left_chars + " " + title + " " + separator * right_chars) 25 | -------------------------------------------------------------------------------- /clypi/_components/spinners.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import io 3 | import sys 4 | import typing as t 5 | from contextlib import AbstractAsyncContextManager, AbstractContextManager, suppress 6 | from types import TracebackType 7 | 8 | from typing_extensions import override 9 | 10 | import clypi 11 | from clypi._colors import ESC, ColorType 12 | from clypi._data.spinners import Spin as _Spin 13 | 14 | MOVE_START = f"{ESC}1G" 15 | DEL_LINE = f"{ESC}0K" 16 | 17 | Spin = _Spin 18 | 19 | 20 | class _PerLineIO(io.TextIOBase): 21 | def __init__(self, new_line_cb: t.Callable[[str], None]) -> None: 22 | """ 23 | A string buffer that captures text and calls the callback `new_line_cb` 24 | on every line written to the buffer. Useful to redirect stdout and stderr 25 | but only print them nicely on every new line. 26 | """ 27 | super().__init__() 28 | self._new_line_cb = new_line_cb 29 | self._closed = False 30 | self.buffer: list[str] = [] 31 | 32 | @override 33 | def write(self, s: str, /) -> int: 34 | """ 35 | When we get a string, split it by new lines, submit every line we've 36 | collected and keep the remainder for future writes 37 | """ 38 | parts = s.split("\n") 39 | 40 | # If there's a buffer, there's a half-way sentence there, so we merge it 41 | if self.buffer: 42 | self.buffer[0] += parts[0] 43 | self.buffer.extend(parts[1:]) 44 | else: 45 | self.buffer = parts 46 | 47 | while len(self.buffer) > 1: 48 | self._new_line_cb(self.buffer[0]) 49 | self.buffer = self.buffer[1:] 50 | 51 | return 0 52 | 53 | @override 54 | def flush(self) -> None: 55 | """ 56 | If flush is called, print whatever we have even if there's no new line 57 | """ 58 | if self.buffer and not self._closed: 59 | self._new_line_cb(self.buffer[0]) 60 | self.buffer = [] 61 | 62 | @override 63 | def close(self) -> None: 64 | self._closed = True 65 | 66 | 67 | class RedirectStdPipe(AbstractContextManager[None]): 68 | def __init__( 69 | self, 70 | pipe: t.Literal["stdout", "stderr"], 71 | target: t.Callable[[str], t.Any], 72 | ) -> None: 73 | """ 74 | Given a pipe (stdout or stderr) and a callback function, it redirects 75 | each line from the pipe into the callback. Useful to redirect users' 76 | outputs to a custom function without them needing to directly call it. 77 | """ 78 | self._pipe = pipe 79 | self._original = getattr(sys, pipe) 80 | self._new = _PerLineIO(new_line_cb=target) 81 | 82 | @override 83 | def __enter__(self) -> None: 84 | self.start() 85 | 86 | def start(self) -> None: 87 | setattr(sys, self._pipe, self._new) 88 | 89 | @override 90 | def __exit__( 91 | self, 92 | exc_type: type[BaseException] | None, 93 | exc_value: BaseException | None, 94 | traceback: TracebackType | None, 95 | /, 96 | ) -> bool | None: 97 | self.stop() 98 | return None 99 | 100 | def stop(self) -> None: 101 | self._new.close() 102 | setattr(sys, self._pipe, self._original) 103 | 104 | def write(self, s: str): 105 | self._original.write(s) 106 | 107 | def flush(self): 108 | self._original.flush() 109 | 110 | 111 | @t.final 112 | class Spinner(AbstractAsyncContextManager["Spinner"]): 113 | def __init__( 114 | self, 115 | title: str, 116 | animation: Spin | list[str] = Spin.DOTS, 117 | prefix: str = "", 118 | suffix: str = "…", 119 | speed: float = 1, 120 | capture: bool = False, 121 | output: t.Literal["stdout", "stderr"] = "stderr", 122 | ) -> None: 123 | """ 124 | A context manager that lets you run async code while nicely 125 | displaying a spinning animation. Using `capture=True` will 126 | capture all the stdout and stderr written during the spinner 127 | and display it nicely. 128 | """ 129 | 130 | self.animation = animation 131 | self.prefix = prefix 132 | self.suffix = suffix 133 | self.title = title 134 | 135 | self._task: asyncio.Task[None] | None = None 136 | self._manual_exit: bool = False 137 | self._frame_idx: int = 0 138 | self._refresh_rate = 0.7 / speed / len(self._frames) 139 | 140 | # For capturing stdout, stderr 141 | self._capture = capture 142 | self._output = output 143 | self._stdout = RedirectStdPipe("stdout", self.log) 144 | self._stderr = RedirectStdPipe("stderr", self.log) 145 | 146 | @override 147 | async def __aenter__(self): 148 | if self._capture: 149 | self._stdout.start() 150 | self._stderr.start() 151 | 152 | self._task = asyncio.create_task(self._spin()) 153 | return self 154 | 155 | @override 156 | async def __aexit__( 157 | self, 158 | exc_type: type[BaseException] | None, 159 | exc_value: BaseException | None, 160 | traceback: TracebackType | None, 161 | /, 162 | ) -> bool | None: 163 | # If a user already called `.done()`, leaving the closure 164 | # should not re-trigger a re-render 165 | if self._manual_exit: 166 | return None 167 | 168 | if any([exc_type, exc_value, traceback]): 169 | await self.fail() 170 | else: 171 | await self.done() 172 | 173 | return None 174 | 175 | def _print( 176 | self, 177 | msg: str, 178 | icon: str | None = None, 179 | color: ColorType | None = None, 180 | end: str = "", 181 | ): 182 | # Build the line being printed 183 | icon = clypi.style(icon + " ", fg=color) if icon else "" 184 | msg = f"{self.prefix}{icon}{msg}{end}" 185 | 186 | output_pipe = self._stderr if self._output == "stderr" else self._stdout 187 | 188 | # Wipe the line for next render 189 | output_pipe.write(MOVE_START) 190 | output_pipe.write(DEL_LINE) 191 | 192 | # Write msg and flush 193 | output_pipe.write(msg) 194 | output_pipe.flush() 195 | 196 | def _render_frame(self): 197 | self._print( 198 | self.title + self.suffix, 199 | icon=self._frames[self._frame_idx], 200 | color="blue", 201 | ) 202 | 203 | @property 204 | def _frames(self) -> list[str]: 205 | return ( 206 | self.animation.value if isinstance(self.animation, Spin) else self.animation 207 | ) 208 | 209 | async def _spin(self) -> None: 210 | while True: 211 | self._frame_idx = (self._frame_idx + 1) % len(self._frames) 212 | self._render_frame() 213 | await asyncio.sleep(self._refresh_rate) 214 | 215 | async def _exit(self, msg: str | None = None, success: bool = True): 216 | if t := self._task: 217 | t.cancel() 218 | with suppress(asyncio.CancelledError): 219 | await t 220 | 221 | # Stop capturing stdout/stderrr 222 | if self._capture: 223 | self._stdout.stop() 224 | self._stderr.stop() 225 | 226 | color: ColorType = "green" if success else "red" 227 | icon = "✔" if success else "×" 228 | self._print(msg or self.title, icon=icon, color=color, end="\n") 229 | 230 | async def done(self, msg: str | None = None): 231 | self._manual_exit = True 232 | await self._exit(msg) 233 | 234 | async def fail(self, msg: str | None = None): 235 | self._manual_exit = True 236 | await self._exit(msg, success=False) 237 | 238 | def log( 239 | self, 240 | msg: str, 241 | icon: str = " ┃", 242 | color: ColorType | None = None, 243 | end: str = "\n", 244 | ): 245 | """ 246 | Log a message nicely from inside a spinner. If `capture=True`, you can 247 | simply use `print("foo")`. 248 | """ 249 | self._print(msg.rstrip(), icon=icon, color=color, end=end) 250 | self._render_frame() 251 | 252 | async def pipe( 253 | self, 254 | pipe: asyncio.StreamReader | None, 255 | color: ColorType = "blue", 256 | prefix: str = "", 257 | ) -> None: 258 | """ 259 | Pass in an async pipe for the spinner to display 260 | """ 261 | if not pipe: 262 | return 263 | 264 | while True: 265 | line = await pipe.readline() 266 | if not line: 267 | break 268 | 269 | msg = f"{prefix} {line.decode()}" if prefix else line.decode() 270 | self.log(msg, color=color) 271 | 272 | 273 | P = t.ParamSpec("P") 274 | R = t.TypeVar("R") 275 | Func = t.Callable[P, t.Coroutine[t.Any, t.Any, R]] 276 | 277 | 278 | def spinner( 279 | title: str, 280 | animation: Spin | list[str] = Spin.DOTS, 281 | prefix: str = " ", 282 | suffix: str = "…", 283 | speed: float = 1, 284 | capture: bool = False, 285 | output: t.Literal["stdout", "stderr"] = "stderr", 286 | ) -> t.Callable[[Func[P, R]], Func[P, R]]: 287 | """ 288 | Utility decorator to wrap a function and display a Spinner while it's running. 289 | """ 290 | 291 | def wrapper(fn: Func[P, R]) -> Func[P, R]: 292 | async def inner(*args: P.args, **kwargs: P.kwargs) -> R: 293 | async with Spinner( 294 | title=title, 295 | animation=animation, 296 | prefix=prefix, 297 | suffix=suffix, 298 | speed=speed, 299 | capture=capture, 300 | output=output, 301 | ): 302 | return await fn(*args, **kwargs) 303 | 304 | return inner 305 | 306 | return wrapper 307 | -------------------------------------------------------------------------------- /clypi/_components/stack.py: -------------------------------------------------------------------------------- 1 | from typing import overload 2 | 3 | from clypi._components.wraps import wrap 4 | from clypi._util import get_term_width, visible_width 5 | 6 | 7 | def _safe_get(ls: list[str], idx: int) -> str: 8 | if idx >= len(ls): 9 | return "" 10 | return ls[idx] 11 | 12 | 13 | @overload 14 | def stack( 15 | *blocks: list[str], 16 | width: int | None = None, 17 | padding: int = 2, 18 | lines: bool, 19 | ) -> list[str]: ... 20 | 21 | 22 | @overload 23 | def stack( 24 | *blocks: list[str], 25 | width: int | None = None, 26 | padding: int = 2, 27 | ) -> str: ... 28 | 29 | 30 | def stack( 31 | *blocks: list[str], 32 | width: int | None = None, 33 | padding: int = 2, 34 | lines: bool = False, 35 | ) -> str | list[str]: 36 | # Figure out width 37 | if isinstance(width, int) and width < 0: 38 | width = get_term_width() + width 39 | elif width is None: 40 | width = get_term_width() 41 | 42 | padding_str = " " * padding 43 | 44 | new_lines: list[str] = [] 45 | height = max(len(b) for b in blocks) 46 | width_per_block = [max(visible_width(line) for line in block) for block in blocks] 47 | 48 | # Process line until all blocks are done 49 | for idx in range(height): 50 | more = False 51 | tmp: list[str] = [] 52 | 53 | # Add the line from each block into combined line 54 | for block, block_width in zip(blocks, width_per_block): 55 | # If there was a line, next iter will happen 56 | block_line = _safe_get(block, idx) 57 | if block_line: 58 | more = True 59 | 60 | # How much do we need to reach the actual visible length 61 | actual_width = (block_width - visible_width(block_line)) + len(block_line) 62 | 63 | # Align and append line 64 | tmp.append(block_line.ljust(actual_width)) 65 | 66 | # Check if combined line would overflow and wrap if needed 67 | combined_line = padding_str.join(tmp).rstrip() 68 | if visible_width(combined_line) <= width: 69 | new_lines.append(combined_line) 70 | else: 71 | # We need to wrap the last block and the remainder needs to be aligned 72 | # with the start of the second block 73 | width_without_last = visible_width(padding_str.join(tmp[:-1]) + padding_str) 74 | max_last_width = width - width_without_last 75 | wrapped_last = wrap(tmp[-1].strip(), max_last_width) 76 | 77 | # Add the combined line 78 | combined_line = padding_str.join(tmp[:-1] + [wrapped_last[0]]).rstrip() 79 | new_lines.append(combined_line) 80 | 81 | # Add the remainder aligned to the start of second block 82 | padding_left = " " * width_without_last 83 | for remaining_line in wrapped_last[1:]: 84 | new_lines.append(padding_left + remaining_line) 85 | 86 | # Exit if no more lines in any iter 87 | if not more: 88 | break 89 | 90 | return new_lines if lines else "\n".join(new_lines) 91 | -------------------------------------------------------------------------------- /clypi/_components/wraps.py: -------------------------------------------------------------------------------- 1 | import textwrap 2 | import typing as t 3 | 4 | from clypi._util import UNSET, Unset, visible_width 5 | 6 | OverflowStyle = t.Literal["ellipsis", "wrap"] 7 | 8 | 9 | def wrap( 10 | s: str, width: int, overflow_style: OverflowStyle | Unset = UNSET 11 | ) -> list[str]: 12 | """ 13 | If a string is larger than width, it either wraps the string into new 14 | lines or appends an ellipsis 15 | """ 16 | if visible_width(s) <= width: 17 | return [s] 18 | 19 | if overflow_style is UNSET: 20 | from clypi._configuration import get_config 21 | 22 | overflow_style = get_config().overflow_style 23 | 24 | if overflow_style == "ellipsis": 25 | return [s[: width - 1] + "…"] 26 | 27 | return textwrap.wrap(s, width=width) 28 | -------------------------------------------------------------------------------- /clypi/_configuration.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | 3 | from clypi._cli.formatter import ClypiFormatter, Formatter 4 | from clypi._colors import Styler 5 | from clypi._components.wraps import OverflowStyle 6 | from clypi._exceptions import ( 7 | ClypiException, 8 | ClypiExceptionGroup, 9 | ) 10 | 11 | 12 | @dataclass 13 | class Theme: 14 | usage: Styler = field(default_factory=lambda: Styler(fg="yellow")) 15 | usage_command: Styler = field(default_factory=lambda: Styler(bold=True)) 16 | usage_args: Styler = field(default_factory=lambda: Styler()) 17 | section_title: Styler = field(default_factory=lambda: Styler()) 18 | 19 | # Subcommands 20 | subcommand: Styler = field(default_factory=lambda: Styler(fg="blue", bold=True)) 21 | 22 | # Options 23 | long_option: Styler = field(default_factory=lambda: Styler(fg="blue", bold=True)) 24 | short_option: Styler = field(default_factory=lambda: Styler(fg="green", bold=True)) 25 | 26 | # Positionals 27 | positional: Styler = field(default_factory=lambda: Styler(fg="blue", bold=True)) 28 | 29 | placeholder: Styler = field(default_factory=lambda: Styler(fg="blue")) 30 | type_str: Styler = field(default_factory=lambda: Styler(fg="yellow", bold=True)) 31 | prompts: Styler = field(default_factory=lambda: Styler(fg="blue", bold=True)) 32 | 33 | 34 | @dataclass 35 | class ClypiConfig: 36 | # The theme sets Clypi's colors 37 | theme: Theme = field(default_factory=Theme) 38 | 39 | # What formatting class should we use? 40 | help_formatter: Formatter = field(default_factory=ClypiFormatter) 41 | 42 | # Should we display the help page if we are missing required args? 43 | help_on_fail: bool = True 44 | 45 | # What errors should we catch and neatly display? 46 | nice_errors: tuple[type[Exception], ...] = field( 47 | default_factory=lambda: (ClypiException, ClypiExceptionGroup) 48 | ) 49 | 50 | # How should sentences overwrap if they're too long? 51 | overflow_style: OverflowStyle = "wrap" 52 | 53 | # Should we disable all color printing? 54 | disable_colors: bool = False 55 | 56 | # If we cannot get the terminal size, what should be the fallback? 57 | fallback_term_width: int = 100 58 | 59 | 60 | _config = ClypiConfig() 61 | 62 | 63 | def configure(config: ClypiConfig): 64 | global _config 65 | _config = config 66 | 67 | 68 | def get_config() -> ClypiConfig: 69 | return _config 70 | -------------------------------------------------------------------------------- /clypi/_data/boxes.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from enum import Enum 3 | 4 | 5 | @dataclass 6 | class Box: 7 | """ 8 | tl x myt x tr 9 | y y 10 | mxl x mm x mxr 11 | y y 12 | bl x myb x br 13 | """ 14 | 15 | tl: str 16 | tr: str 17 | bl: str 18 | br: str 19 | x: str 20 | y: str 21 | 22 | 23 | _ROUNDED = Box( 24 | tl="╭", 25 | tr="╮", 26 | bl="╰", 27 | br="╯", 28 | x="─", 29 | y="│", 30 | ) 31 | 32 | _THIN = Box( 33 | tl="┌", 34 | tr="┐", 35 | bl="└", 36 | br="┘", 37 | x="─", 38 | y="│", 39 | ) 40 | 41 | _HEAVY = Box( 42 | tl="┏", 43 | tr="┓", 44 | bl="┗", 45 | br="┛", 46 | x="━", 47 | y="┃", 48 | ) 49 | 50 | 51 | class Boxes(Enum): 52 | ROUNDED = _ROUNDED 53 | THIN = _THIN 54 | HEAVY = _HEAVY 55 | 56 | def human_name(self): 57 | name = self.name 58 | return " ".join(p.capitalize() for p in name.split("_")) 59 | -------------------------------------------------------------------------------- /clypi/_data/dunders.py: -------------------------------------------------------------------------------- 1 | ALL_DUNDERS = ( 2 | "__abs__", 3 | "__add__", 4 | "__and__", 5 | "__bool__", 6 | "__bytes__", 7 | "__ceil__", 8 | "__complex__", 9 | "__contains__", 10 | "__delitem__", 11 | "__divmod__", 12 | "__eq__", 13 | "__float__", 14 | "__floor__", 15 | "__floordiv__", 16 | "__format__", 17 | "__getitem__", 18 | "__gt__", 19 | "__gte__", 20 | "__hash__", 21 | "__index__", 22 | "__int__", 23 | "__invert__", 24 | "__iter__", 25 | "__len__", 26 | "__length_hint__", 27 | "__lshift__", 28 | "__lt__", 29 | "__lte__", 30 | "__matmul__", 31 | "__missing__", 32 | "__mod__", 33 | "__mul__", 34 | "__ne__", 35 | "__neg__", 36 | "__next__", 37 | "__or__", 38 | "__pos__", 39 | "__pow__", 40 | "__radd__", 41 | "__rand__", 42 | "__rdivmod__", 43 | "__repr__", 44 | "__reversed__", 45 | "__rfloordiv__", 46 | "__rlshift__", 47 | "__rmatmul__", 48 | "__rmod__", 49 | "__rmul__", 50 | "__ror__", 51 | "__round__", 52 | "__rpow__", 53 | "__rrshift__", 54 | "__rshift__", 55 | "__rsub__", 56 | "__rtruediv__", 57 | "__rxor__", 58 | "__set__", 59 | "__setitem__", 60 | "__str__", 61 | "__sub__", 62 | "__sub__", 63 | "__truediv__", 64 | "__trunc__", 65 | "__xor__", 66 | ) 67 | -------------------------------------------------------------------------------- /clypi/_exceptions.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | from dataclasses import dataclass, field 3 | 4 | from clypi._colors import ColorType, style 5 | 6 | 7 | class ClypiException(Exception): 8 | pass 9 | 10 | 11 | class ClypiExceptionGroup(ExceptionGroup): 12 | pass 13 | 14 | 15 | class MaxAttemptsException(ClypiException): 16 | pass 17 | 18 | 19 | class AbortException(ClypiException): 20 | pass 21 | 22 | 23 | @dataclass 24 | class TreeExcNode: 25 | exc: BaseException 26 | children: list[t.Self] = field(default_factory=list) 27 | 28 | 29 | def _build_exc_tree(err: BaseException) -> TreeExcNode: 30 | root = TreeExcNode(err) 31 | 32 | # Add __cause__ levels 33 | if err.__cause__ is not None: 34 | root.children.append(_build_exc_tree(err.__cause__)) 35 | 36 | # Add exception group levels 37 | if isinstance(err, ExceptionGroup): 38 | for sub_exc in err.exceptions: 39 | root.children.append(_build_exc_tree(sub_exc)) 40 | 41 | return root 42 | 43 | 44 | def format_traceback(err: BaseException, color: ColorType | None = "red") -> list[str]: 45 | def _format_exc(e: BaseException, indent: int): 46 | msg = e.args[0] if e.args else str(err.__class__.__name__) 47 | icon = " " * (indent - 1) + " ↳ " if indent != 0 else "" 48 | return style(f"{icon}{str(msg)}", fg=color) 49 | 50 | def _print_level(tree: TreeExcNode, indent: int) -> list[str]: 51 | ret: list[str] = [] 52 | for child_node in tree.children: 53 | ret.append(_format_exc(child_node.exc, indent)) 54 | ret.extend(_print_level(child_node, indent=indent + 1)) 55 | return ret 56 | 57 | tree = _build_exc_tree(err) 58 | lines = [_format_exc(tree.exc, indent=0)] 59 | lines.extend(_print_level(tree, indent=1)) 60 | return lines 61 | 62 | 63 | def print_traceback(err: BaseException) -> None: 64 | for line in format_traceback(err): 65 | print(line) 66 | 67 | 68 | def flatten_exc(err: Exception) -> list[Exception]: 69 | if not isinstance(err, ExceptionGroup): 70 | return [err] 71 | 72 | result: list[Exception] = [] 73 | for sub_err in err.exceptions: 74 | result.extend(flatten_exc(sub_err)) 75 | return result 76 | -------------------------------------------------------------------------------- /clypi/_prompts.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | from enum import Enum 5 | from getpass import getpass 6 | 7 | import clypi 8 | from clypi import parsers 9 | from clypi._configuration import get_config 10 | from clypi._exceptions import AbortException, MaxAttemptsException 11 | from clypi._util import UNSET, Unset 12 | 13 | MAX_ATTEMPTS: int = 20 14 | 15 | 16 | def _error(msg: str): 17 | clypi.cprint(msg, fg="red") 18 | 19 | 20 | def _input(prompt: str, hide_input: bool = False) -> str: 21 | """ 22 | Prompts the user for a value or uses the default and returns the 23 | value and if we're using the default 24 | """ 25 | fun = getpass if hide_input else input 26 | styled_prompt = get_config().theme.prompts(prompt) 27 | return fun(styled_prompt) 28 | 29 | 30 | def _display_default(default: t.Any) -> str: 31 | if isinstance(default, bool): 32 | return "Y/n" if default else "y/N" 33 | if isinstance(default, Enum): 34 | return default.name.lower() 35 | return f"{default}" 36 | 37 | 38 | def _build_prompt(text: str, default: t.Any | Unset) -> str: 39 | prompt = text 40 | if default is not UNSET: 41 | prompt += f" [{_display_default(default)}]" 42 | prompt += ": " 43 | return prompt 44 | 45 | 46 | def confirm( 47 | text: str, 48 | *, 49 | default: bool | Unset = UNSET, 50 | default_factory: t.Callable[[], bool] | Unset = UNSET, 51 | max_attempts: int = MAX_ATTEMPTS, 52 | abort: bool = False, 53 | ) -> bool: 54 | """ 55 | Prompt the user for a yes/no value 56 | 57 | :param text: The prompt text. 58 | :param default: The default value. 59 | :param max_attempts: The maximum number of attempts to get a valid value. 60 | :return: The parsed value. 61 | """ 62 | parsed_inp = prompt( 63 | text=text, 64 | default=default, 65 | default_factory=default_factory, 66 | max_attempts=max_attempts, 67 | parser=parsers.from_type(bool), 68 | ) 69 | if abort and not parsed_inp: 70 | raise AbortException() 71 | return parsed_inp 72 | 73 | 74 | T = t.TypeVar("T") 75 | 76 | 77 | @t.overload 78 | def prompt( 79 | text: str, 80 | *, 81 | default: str | Unset = UNSET, 82 | default_factory: t.Callable[[], str] | Unset = UNSET, 83 | hide_input: bool = False, 84 | max_attempts: int = MAX_ATTEMPTS, 85 | ) -> str: ... 86 | 87 | 88 | @t.overload 89 | def prompt( 90 | text: str, 91 | *, 92 | parser: parsers.Parser[T] | type[T], 93 | default: T | Unset = UNSET, 94 | default_factory: t.Callable[[], T] | Unset = UNSET, 95 | hide_input: bool = False, 96 | max_attempts: int = MAX_ATTEMPTS, 97 | ) -> T: ... 98 | 99 | 100 | def prompt( 101 | text: str, 102 | *, 103 | parser: parsers.Parser[T] | type[T] | type[str] = str, 104 | default: T | Unset = UNSET, 105 | default_factory: t.Callable[[], T] | Unset = UNSET, 106 | hide_input: bool = False, 107 | max_attempts: int = MAX_ATTEMPTS, 108 | ) -> T: 109 | """ 110 | Prompt the user for a value. 111 | 112 | :param text: The prompt text. 113 | :param default: The default value. 114 | :param parser: The parser function parse the input with. 115 | :param max_attempts: The maximum number of attempts to get a valid value. 116 | :return: The parsed value. 117 | """ 118 | if default_factory is not UNSET: 119 | default = default_factory() 120 | 121 | # Build the prompt 122 | prompt = _build_prompt(text, default) 123 | 124 | # Loop until we get a valid value 125 | for _ in range(max_attempts): 126 | inp = _input(prompt, hide_input=hide_input) 127 | if not inp and default is UNSET: 128 | _error("A value is required.") 129 | continue 130 | 131 | # User answered the prompt -- Parse 132 | try: 133 | if t.TYPE_CHECKING: 134 | parser = t.cast(parsers.Parser[T], parser) 135 | 136 | # If no input, use the default without parsing 137 | if not inp and default is not UNSET: 138 | parsed_inp = default 139 | 140 | # Otherwise try parsing the string 141 | else: 142 | parsed_inp = parser(inp) 143 | except parsers.CATCH_ERRORS as e: 144 | _error(f"Unable to parse {inp!r}, please provide a valid value.\n ↳ {e}") 145 | continue 146 | 147 | return parsed_inp 148 | 149 | raise MaxAttemptsException( 150 | f"Failed to get a valid value after {max_attempts} attempts." 151 | ) 152 | -------------------------------------------------------------------------------- /clypi/_type_util.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import typing as t 3 | from enum import Enum 4 | from types import NoneType, UnionType 5 | 6 | P = t.ParamSpec("P") 7 | R = t.TypeVar("R") 8 | 9 | 10 | def ignore_annotated(fun: t.Callable[P, R]) -> t.Callable[P, R]: 11 | def inner(*args: P.args, **kwargs: P.kwargs) -> R: 12 | if t.get_origin(args[0]) == t.Annotated: 13 | args_ls = list(args) 14 | args_ls[0] = t.get_args(args[0])[0] 15 | args = tuple(args_ls) # type: ignore 16 | return fun(*args, **kwargs) 17 | 18 | return inner 19 | 20 | 21 | @ignore_annotated 22 | def is_list(_type: t.Any) -> t.TypeGuard[list[t.Any]]: 23 | return t.get_origin(_type) in (list, t.Sequence) 24 | 25 | 26 | @ignore_annotated 27 | def list_inner(_type: t.Any) -> t.Any: 28 | return t.get_args(_type)[0] 29 | 30 | 31 | @ignore_annotated 32 | def is_tuple(_type: t.Any) -> t.TypeGuard[tuple[t.Any]]: 33 | return t.get_origin(_type) is tuple 34 | 35 | 36 | @ignore_annotated 37 | def tuple_inner(_type: t.Any) -> tuple[list[t.Any], int | None]: 38 | """ 39 | Returns the list of types for the tuple and how many items 40 | it accepts 41 | """ 42 | # TODO: can be made more efficient 43 | inner_types = list(t.get_args(_type)) 44 | if inner_types[-1] is Ellipsis: 45 | return [inner_types[0]], None 46 | return inner_types, len(inner_types) 47 | 48 | 49 | @ignore_annotated 50 | def is_union(_type: t.Any) -> t.TypeGuard[UnionType]: 51 | return t.get_origin(_type) in (UnionType, t.Union) 52 | 53 | 54 | @ignore_annotated 55 | def union_inner(_type: t.Any) -> list[t.Any]: 56 | return list(t.get_args(_type)) 57 | 58 | 59 | @ignore_annotated 60 | def is_literal(_type: t.Any) -> bool: 61 | return t.get_origin(_type) == t.Literal 62 | 63 | 64 | @ignore_annotated 65 | def is_optional(_type: t.Any) -> bool: 66 | """Check type for |None""" 67 | if not is_union(_type): 68 | return False 69 | inner = union_inner(_type) 70 | if len(inner) != 2: 71 | return False 72 | if NoneType not in inner: 73 | return False 74 | return True 75 | 76 | 77 | @ignore_annotated 78 | def literal_inner(_type: t.Any) -> list[t.Any]: 79 | return list(t.get_args(_type)) 80 | 81 | 82 | @ignore_annotated 83 | def tuple_size(_type: t.Any) -> float: 84 | args = _type.__args__ 85 | if args[-1] is Ellipsis: 86 | return float("inf") 87 | return len(args) 88 | 89 | 90 | @ignore_annotated 91 | def is_none(_type: t.Any) -> t.TypeGuard[type[None]]: 92 | return _type is NoneType 93 | 94 | 95 | @ignore_annotated 96 | def is_enum(_type: t.Any) -> t.TypeGuard[type[Enum]]: 97 | return inspect.isclass(_type) and issubclass(_type, Enum) 98 | 99 | 100 | @ignore_annotated 101 | def has_metavar(_type: t.Any) -> bool: 102 | return is_enum(_type) or is_literal(_type) 103 | -------------------------------------------------------------------------------- /clypi/_util.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | from enum import Enum, auto 5 | 6 | from clypi._colors import remove_style 7 | 8 | 9 | class Unset(Enum): 10 | TOKEN = auto() 11 | 12 | 13 | UNSET = Unset.TOKEN 14 | 15 | 16 | def visible_width(s: str) -> int: 17 | s = remove_style(s) 18 | return len(s) 19 | 20 | 21 | def get_term_width(): 22 | if width := os.getenv("CLYPI_TERM_WIDTH"): 23 | return int(width) 24 | 25 | try: 26 | return os.get_terminal_size().columns 27 | except OSError: 28 | from clypi._configuration import get_config 29 | 30 | return get_config().fallback_term_width 31 | 32 | 33 | def trim_split_collection(s: str): 34 | if ", " in s: 35 | return s.split(", ") 36 | return s.split(",") 37 | -------------------------------------------------------------------------------- /clypi/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danimelchor/clypi/3cb07e5ae03610302c079169654083fd9305ab99/clypi/py.typed -------------------------------------------------------------------------------- /docs/about/planned_work.md: -------------------------------------------------------------------------------- 1 | # Planned work 2 | 3 | - [ ] Support for better prompts (select, multi-select, etc.) 4 | - [ ] Other simple UI components (e.g.: simple file tree) 5 | - [x] Testing tools -- Can we provide users with a good easy framework to test their CLI? 6 | - [x] Themes support -- How can users extend Formatter and/or select a theme? 7 | - [x] Strict Pyright -- Can this repo be entirely in strict mode? 8 | - [x] Better spinner interactions (capture and redirect stdout automatically) 9 | - [x] Better testing coverage (every parsing combination tested) 10 | -------------------------------------------------------------------------------- /docs/about/why.md: -------------------------------------------------------------------------------- 1 | I've been working with Python-based CLIs for several years with many users and strict quality requirements and always run into the sames problems with the go-to packages: 2 | 3 | - [Argparse](https://docs.python.org/3/library/argparse.html) is the builtin solution for CLIs, but, as expected, it's functionality is very restrictive. It is not very extensible, it's UI is not pretty and very hard to change, lacks type checking and type parsers, and does not offer any modern UI components that we all love. 4 | 5 | - [Rich](https://rich.readthedocs.io/en/stable/) is too complex and threaded. The vast catalog of UI components they offer is amazing, but it is both easy to get wrong and break the UI, and too complicated/verbose to onboard coworkers to. It's prompting functionality is also quite limited and it does not offer command-line arguments parsing. 6 | 7 | - [Click](https://click.palletsprojects.com/en/stable/) is too restrictive. It enforces you to use decorators, which is great for locality of behavior but not so much if you're trying to reuse arguments across your application. It is also painful to deal with the way arguments are injected into functions and very easy to miss one, misspell, or get the wrong type. Click is also fully untyped for the core CLI functionality and hard to test. 8 | 9 | - [Typer](https://github.com/fastapi/typer) seems great! I haven't personally tried it, but I have spent time looking through their docs and code. I think the overall experience is a step up from click's but, at the end of the day, it's built on top of it. Hence, many of the issues are the same: testing is hard, shared contexts are untyped, their built-in type parsing is quite limited, and it does not offer modern features like suggestions on typos. Using `Annotated` types is also very verbose inside function definitions. 10 | 11 | > [!WARNING] 12 | > This section is my ([danimelchor](https://github.com/danimelchor)'s) personal opinion I've gathered during my time 13 | > working with Python CLIs. If you do not agree, please feel free to reach out and I'm 14 | > open to discussing / trying out new tools. 15 | -------------------------------------------------------------------------------- /docs/api/colors.md: -------------------------------------------------------------------------------- 1 | !!! tip 2 | Follow the [Beautiful UIs guide](../learn/beautiful_uis.md) for examples.! 3 | 4 | ### `ColorType` 5 | 6 | ```python 7 | ColorType: t.TypeAlias = t.Literal[ 8 | "black", 9 | "red", 10 | "green", 11 | "yellow", 12 | "blue", 13 | "magenta", 14 | "cyan", 15 | "white", 16 | "default", 17 | "bright_black", 18 | "bright_red", 19 | "bright_green", 20 | "bright_yellow", 21 | "bright_blue", 22 | "bright_magenta", 23 | "bright_cyan", 24 | "bright_white", 25 | "bright_default", 26 | ] 27 | ``` 28 | 29 | ### `Styler` 30 | ```python 31 | class Styler( 32 | fg: ColorType | None = None, 33 | bg: ColorType | None = None, 34 | bold: bool = False, 35 | italic: bool = False, 36 | dim: bool = False, 37 | underline: bool = False, 38 | blink: bool = False, 39 | reverse: bool = False, 40 | strikethrough: bool = False, 41 | reset: bool = False, 42 | hide: bool = False, 43 | ) 44 | ``` 45 | Returns a reusable function to style text. 46 | 47 | ### `style` 48 | ```python 49 | def style( 50 | *messages: t.Any, 51 | fg: ColorType | None = None, 52 | bg: ColorType | None = None, 53 | bold: bool = False, 54 | italic: bool = False, 55 | dim: bool = False, 56 | underline: bool = False, 57 | blink: bool = False, 58 | reverse: bool = False, 59 | strikethrough: bool = False, 60 | reset: bool = False, 61 | hide: bool = False, 62 | ) -> str 63 | ``` 64 | Styles text and returns the styled string. 65 | 66 | 67 | ### `print` 68 | 69 | ```python 70 | def cprint( 71 | *messages: t.Any, 72 | fg: ColorType | None = None, 73 | bg: ColorType | None = None, 74 | bold: bool = False, 75 | italic: bool = False, 76 | dim: bool = False, 77 | underline: bool = False, 78 | blink: bool = False, 79 | reverse: bool = False, 80 | strikethrough: bool = False, 81 | reset: bool = False, 82 | hide: bool = False, 83 | file: SupportsWrite | None = None, 84 | end: str | None = "\n", 85 | ) -> None 86 | ``` 87 | Styles and prints colored and styled text directly. 88 | -------------------------------------------------------------------------------- /docs/api/components.md: -------------------------------------------------------------------------------- 1 | !!! tip 2 | Follow the [Beautiful UIs guide](../learn/beautiful_uis.md) for examples.! 3 | 4 | ### Spinners 5 | 6 | #### `Spin` 7 | 8 | ```python 9 | class Spin(Enum): ... 10 | ``` 11 | 12 | The spinning animation you'd like to use. The spinners are sourced from the NPM [cli-spinners](https://www.npmjs.com/package/cli-spinners) package. 13 | 14 | You can see all the spinners in action by running `uv run -m examples.spinner`. The full list can be found in the code [here](https://github.com/danimelchor/clypi/blob/master/clypi/_data/spinners.py). 15 | 16 | #### `Spinner` 17 | 18 | A spinner indicating that something is happening behind the scenes. It can be used as a context manager or [like a decorator](#spinner-decorator). The context manager usage is like so: 19 | 20 | 21 | ```python hl_lines="5" 22 | import asyncio 23 | from clypi import Spinner 24 | 25 | async def main(): 26 | async with Spinner("Doing something", capture=True) as s: 27 | await asyncio.sleep(1) 28 | s.title = "Slept for a bit" 29 | print("I slept for a bit, will sleep a bit more") 30 | await asyncio.sleep(1) 31 | 32 | asyncio.run(main()) 33 | ``` 34 | 35 | ##### `Spinner.__init__()` 36 | 37 | ```python 38 | def __init__( 39 | self, 40 | title: str, 41 | animation: Spin | list[str] = Spin.DOTS, 42 | prefix: str = " ", 43 | suffix: str = "…", 44 | speed: float = 1, 45 | capture: bool = False, 46 | output: t.Literal["stdout", "stderr"] = "stderr", 47 | ) 48 | ``` 49 | Parameters: 50 | 51 | - `title`: the initial text to display as the spinner spins 52 | - `animation`: a provided [`Spin`](#spin) animation or a list of frames to display 53 | - `prefix`: text or padding displayed before the icon 54 | - `suffix`: text or padding displayed after the icon 55 | - `speed`: a multiplier to speed or slow down the frame rate of the animation 56 | - `capture`: if enabled, the Spinner will capture all stdout and stderr and display it nicely 57 | - `output`: the pipe to write the spinner animation to 58 | 59 | ##### `done` 60 | 61 | ```python 62 | async def done(self, msg: str | None = None) 63 | ``` 64 | Mark the spinner as done early and optionally display a message. 65 | 66 | ##### `fail` 67 | 68 | ```python 69 | async def fail(self, msg: str | None = None) 70 | ``` 71 | Mark the spinner as failed early and optionally display an error message. 72 | 73 | ##### `log` 74 | 75 | ```python 76 | async def log(self, msg: str | None = None) 77 | ``` 78 | Display extra log messages to the user as the spinner spins and your work progresses. 79 | 80 | ##### `pipe` 81 | 82 | ```python 83 | async def pipe( 84 | self, 85 | pipe: asyncio.StreamReader | None, 86 | color: ColorType = "blue", 87 | prefix: str = "", 88 | ) 89 | ``` 90 | Pipe the output of an async subprocess into the spinner and display the stdout or stderr 91 | with a particular color and prefix. 92 | 93 | Examples: 94 | 95 | ```python 96 | import asyncio 97 | 98 | async def main(): 99 | async with Spinner("Doing something") as s: 100 | proc = await asyncio.create_subprocess_shell( 101 | "for i in $(seq 1 10); do date && sleep 0.4; done;", 102 | stdout=asyncio.subprocess.PIPE, 103 | stderr=asyncio.subprocess.PIPE, 104 | ) 105 | await asyncio.gather( 106 | s.pipe(proc.stdout, color="blue", prefix="(stdout)"), 107 | s.pipe(proc.stderr, color="red", prefix="(stdout)"), 108 | ) 109 | ``` 110 | 111 | #### `spinner` (decorator) 112 | 113 | This is just a utility decorator that let's you wrap functions so that a spinner 114 | displays while they run. `spinner` accepts the same arguments as the context manager [`Spinner`](#spinner). 115 | 116 | 117 | ```python hl_lines="4" 118 | import asyncio 119 | from clypi import spinner 120 | 121 | @spinner("Doing work", capture=True) 122 | async def do_some_work(): 123 | await asyncio.sleep(2) 124 | 125 | asyncio.run(do_some_work()) 126 | ``` 127 | 128 | ### Boxed 129 | 130 | #### `Boxes` 131 | 132 | ```python 133 | class Boxes(Enum): ... 134 | ``` 135 | 136 | The border style you'd like to use. To see all the box styles in action run `uv run -m examples.boxed`. 137 | 138 | The full list can be found in the code [here](https://github.com/danimelchor/clypi/blob/master/clypi/_data/boxes.py). 139 | 140 | 141 | #### `boxed` 142 | 143 | ```python 144 | def boxed( 145 | lines: T, 146 | width: t.Literal["auto", "max"] | int = "auto", 147 | style: Boxes = Boxes.HEAVY, 148 | alignment: AlignType = "left", 149 | title: str | None = None, 150 | color: ColorType = "bright_white", 151 | ) -> T: 152 | ``` 153 | Wraps text neatly in a box with the selected style, padding, and alignment. 154 | 155 | Parameters: 156 | 157 | - `lines`: the type of lines will determine it's output type. It can be one of `str`, `list[str]` or `Iterable[str]` 158 | - `width`: the desired width of the box: 159 | - If `"max"`, it will be set to the max width of the terminal. 160 | - If `"auto"`, it will be set to the max width of the content. 161 | - If `width < 0`, it will be set to the max width of the terminal - the number. 162 | - If `width > 0`, it will be set to that exact width. 163 | - `style`: the desired style (see [`Boxes`](#boxes)) 164 | - `alignment`: the style of alignment (see [`align`](#align)) 165 | - `title`: optionally define a title for the box, it's length must be < width 166 | - `color`: a color for the box border and title (see [`colors`](./colors.md)) 167 | 168 | ### Stack 169 | 170 | ```python 171 | def stack(*blocks: list[str], width: int | None = None, padding: int = 1) -> str: 172 | def stack(*blocks: list[str], width: int | None = None, padding: int = 1, lines: bool) -> list[str]: 173 | ``` 174 | 175 | Horizontally aligns blocks of text to display a nice layout where each block is displayed 176 | side by side. 177 | 178 | Parameters: 179 | 180 | - `blocks`: a series of blocks of lines of strings to display side by side 181 | - `width`: the desired width of the box. If None, it will be set to the max width of the terminal. If negative, it will be set to the max width of the terminal - the number. 182 | - `padding`: the space between each block 183 | - `lines`: if the output should be returned as lines or as a string 184 | 185 | ### Separator 186 | 187 | #### `separator` 188 | ```python 189 | def separator( 190 | separator: str = "━", 191 | width: t.Literal["max"] | int = "max", 192 | title: str | None = None, 193 | color: ColorType | None = None, 194 | ) -> str: 195 | ``` 196 | Prints a line made of the given separator character. 197 | 198 | Parameters: 199 | 200 | - `separator`: the character used to build the separator line 201 | - `width`: if `max` it will use the max size of the terminal. Otherwise you can provide a fixed width. 202 | - `title`: optionally provide a title to display in the middle of the separator 203 | - `color`: the color for the characters 204 | 205 | 206 | ### Indented 207 | 208 | #### `indented` 209 | ```python 210 | def indented(lines: list[str], prefix: str = " ") -> list[str] 211 | ``` 212 | Indents a set of lines with the given prefix 213 | 214 | ### Align 215 | 216 | #### `align` 217 | 218 | ```python 219 | def align(s: str, alignment: AlignType, width: int) -> str 220 | ``` 221 | Aligns text according to `alignment` and `width`. In contrast with the built-in 222 | methods `rjust`, `ljust`, and `center`, `clypi.align(...)` aligns text according 223 | to it's true visible width (the built-in methods count color codes as width chars). 224 | 225 | Parameters: 226 | 227 | - `s`: the string being aligned 228 | - `alignment`: one of `left`, `right`, or `center` 229 | - `width`: the wished final visible width of the string 230 | 231 | Examples: 232 | 233 | ```python 234 | import clypi 235 | 236 | clypi.align("foo", "left", 10) # -> "foo " 237 | clypi.align("foo", "right", 10) # -> " foo" 238 | clypi.align("foo", "center", 10) # -> " foo " 239 | ``` 240 | -------------------------------------------------------------------------------- /docs/api/config.md: -------------------------------------------------------------------------------- 1 | ### Accessing and changing the configuration 2 | 3 | ```python 4 | from clypi import ClypiConfig, configure, get_config 5 | 6 | # Gets the current config (or a default) 7 | conf = get_config() 8 | 9 | # Change the configuration 10 | config = ClypiConfig(help_on_fail=False) 11 | configure(config) 12 | ``` 13 | 14 | ### Default config 15 | 16 | 17 | ```python 18 | ClypiConfig( 19 | help_formatter=ClypiFormatter( 20 | boxed=True, 21 | show_option_types=True, 22 | ), 23 | help_on_fail=True, 24 | nice_errors=(ClypiException,), 25 | theme=Theme( 26 | usage=Styler(fg="yellow"), 27 | usage_command=Styler(bold=True), 28 | usage_args=Styler(), 29 | section_title=Styler(), 30 | subcommand=Styler(fg="blue", bold=True), 31 | long_option=Styler(fg="blue", bold=True), 32 | short_option=Styler(fg="green", bold=True), 33 | positional=Styler(fg="blue", bold=True), 34 | placeholder=Styler(fg="blue"), 35 | type_str=Styler(fg="yellow", bold=True), 36 | prompts=Styler(fg="blue", bold=True), 37 | ), 38 | overflow_style="wrap", 39 | disable_colors=False, 40 | fallback_term_width=100, 41 | ) 42 | ``` 43 | 44 | Parameters: 45 | 46 | - `help_formatter`: the formatter class to use to display the help pages (see [Formatter](./cli.md#formatter)) 47 | - `help_on_fail`: whether the help page should be displayed if a user doesn't pass the right params 48 | - `nice_errors`: a list of errors clypi will catch and display neatly 49 | - `theme`: a `Theme` object used to format different styles and colors for help pages, prompts, tracebacks, etc. 50 | - `overflow_style`: either `wrap` or `ellipsis`. If wrap, text that is too long will get wrapped into the next line. If ellipsis, the text will be truncated with an `…` at the end 51 | - `disable_colors`: whether we should disable all colors and text styles 52 | - `fallback_term_width`: if we cannot get the current terminal width (e.g.: subprocesses, non-tty devices, etc.), what should the fallback terminal width be (mostly used for displaying errors) 53 | -------------------------------------------------------------------------------- /docs/api/prompts.md: -------------------------------------------------------------------------------- 1 | ### `Parser[T]` 2 | 3 | ```python 4 | Parser: TypeAlias = Callable[[Any], T] | type[T] 5 | ``` 6 | A function taking in any value and returns a value of type `T`. This parser 7 | can be a user defined function, a built-in type like `str`, `int`, etc., or a parser 8 | from a library. 9 | 10 | ### `confirm` 11 | 12 | ```python 13 | def confirm( 14 | text: str, 15 | *, 16 | default: bool | Unset = UNSET, 17 | max_attempts: int = MAX_ATTEMPTS, 18 | abort: bool = False, 19 | ) -> bool: 20 | ``` 21 | Prompts the user for a yes/no value. 22 | 23 | Parameters: 24 | 25 | - `text`: the text to display to the user when asking for input 26 | - `default`: optionally set a default value that the user can immediately accept 27 | - `max_attempts`: how many times to ask the user before giving up and raising 28 | - `abort`: if a user answers "no", it will raise a `AbortException` 29 | 30 | 31 | ### `prompt` 32 | 33 | ```python 34 | def prompt( 35 | text: str, 36 | default: T | Unset = UNSET, 37 | parser: Parser[T] = str, 38 | hide_input: bool = False, 39 | max_attempts: int = MAX_ATTEMPTS, 40 | ) -> T: 41 | ``` 42 | Prompts the user for a value and uses the provided parser to validate and parse the input 43 | 44 | Parameters: 45 | 46 | - `text`: the text to display to the user when asking for input 47 | - `default`: optionally set a default value that the user can immediately accept 48 | - `parser`: a function that parses in the user input as a string and returns the parsed value or raises 49 | - `hide_input`: whether the input shouldn't be displayed as the user types (for passwords, API keys, etc.) 50 | - `max_attempts`: how many times to ask the user before giving up and raising 51 | -------------------------------------------------------------------------------- /docs/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danimelchor/clypi/3cb07e5ae03610302c079169654083fd9305ab99/docs/assets/icon.png -------------------------------------------------------------------------------- /docs/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danimelchor/clypi/3cb07e5ae03610302c079169654083fd9305ab99/docs/assets/logo.png -------------------------------------------------------------------------------- /docs/hooks/helpers.py: -------------------------------------------------------------------------------- 1 | # Based on squidfunk/mkdocs-material badges 2 | # https://github.com/squidfunk/mkdocs-material/blob/master/material/overrides/hooks/shortcodes.py 3 | 4 | 5 | from __future__ import annotations 6 | 7 | import re 8 | import typing as t 9 | from re import Match 10 | 11 | VERSION_ICON = "material-tag-outline" 12 | 13 | 14 | def on_page_markdown(markdown: str, **kwargs: t.Any): 15 | return re.sub(r"", _gen_badge, markdown, flags=re.I | re.M) 16 | 17 | 18 | def _gen_badge(match: Match[t.Any]): 19 | name, arg_ls = match.groups() 20 | args: list[str] = [a for a in arg_ls.strip().split(" ")] 21 | if name == "version": 22 | return _version_badge(args[0]) 23 | 24 | raise Exception(f"Unknown name for helper badge: {name}") 25 | 26 | 27 | def _badge(icon: str, text: str = "", icon_tooltip: str | None = None): 28 | tooltip = f'{{ title="{icon_tooltip}" }}' if icon_tooltip else "" 29 | return "".join( 30 | [ 31 | '', 32 | *( 33 | [f':{icon}:{tooltip}'] 34 | if icon 35 | else [] 36 | ), 37 | *([f'{text}'] if text else []), 38 | "", 39 | ] 40 | ) 41 | 42 | 43 | def _version_badge(version: str): 44 | assert re.match(r"^\d+\.\d+\.\d+$", version), f"Unexpected version {version}" 45 | return _badge(icon=VERSION_ICON, text=f"{version}", icon_tooltip="Minimum version") 46 | -------------------------------------------------------------------------------- /docs/javascripts/spinner.js: -------------------------------------------------------------------------------- 1 | const spinnerFrames = "⣾⣽⣻⢿⡿⣟⣯⣷".split(""); 2 | const duration = 800; 3 | 4 | var elements; 5 | 6 | const step = (timestamp) => { 7 | let index = Math.floor((timestamp * spinnerFrames.length) / duration); 8 | let frameIdx = index % spinnerFrames.length; 9 | if (!elements) { 10 | elements = window.document.getElementsByClassName("clypi-spinner"); 11 | } 12 | 13 | for (const element of elements) { 14 | element.innerHTML = spinnerFrames[frameIdx]; 15 | } 16 | 17 | return window.requestAnimationFrame(step); 18 | }; 19 | 20 | window.requestAnimationFrame(step); 21 | -------------------------------------------------------------------------------- /docs/learn/advanced_arguments.md: -------------------------------------------------------------------------------- 1 | ## Argument inheritance 2 | 3 | Say you have arguments that you want every command to be able to use but you want to avoid 4 | having to copy paste their definition over and over on every command. Clypi provides an intuitive 5 | solution for this issue: argument inheritance. 6 | 7 | The idea is easy: define the arguments in a parent command and all children will be able to use them 8 | without having to redefine them. 9 | 10 | 11 | ```python title="cli.py" hl_lines="7 18-20" 12 | from clypi import Command, Positional, arg 13 | from typing_extensions import override 14 | 15 | class Wave(Command): 16 | """Wave at someone""" 17 | name: Positional[str] 18 | verbose: bool = arg(inherited=True) 19 | 20 | @override 21 | async def run(self) -> None: 22 | print(f"👋 Hey {self.name}") 23 | if self.verbose: 24 | print(f"👋👋👋 HEYYY {self.name}") 25 | 26 | class Cli(Command): 27 | """A very simple CLI""" 28 | subcommand: Wave | None 29 | verbose: bool = arg( 30 | False, short="v", help="Whether to show verbose output", group="global" 31 | ) 32 | 33 | if __name__ == "__main__": 34 | cmd = Cli.parse() 35 | cmd.start() 36 | ``` 37 | 38 | You will see even though the help message for `verbose` is defined in the parent command, 39 | the subcommand `Wave` gets the entire argument definition for free: 40 | 41 | 42 | ``` 43 | $ python cli.py wave --help 44 | Wave at someone 45 | 46 | Usage: cli wave [NAME] [OPTIONS] 47 | 48 | ┏━ Arguments ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ 49 | ┃ [NAME] ┃ 50 | ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ 51 | 52 | ┏━ Global options ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ 53 | ┃ -v, --verbose Whether to show verbose output ┃ 54 | ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ 55 | 56 | 57 | $ python cli.py Daniel 58 | 👋 Hey Daniel 59 | 60 | 61 | $ python cli.py Daniel -v 62 | 👋 Hey Daniel 63 | 👋👋👋 HEYYY Daniel 64 | ``` 65 | 66 | 67 | ## Deferring arguments 68 | 69 | CLIs can get very complex. Sometimes we want to build a complex graph of dependencies between the arguments and it is hard to do that. For example, we can have an application that does not use `--num-cores` if `--single-threaded` was provided already. For that, clypi offers `arg(defer=True)`. 70 | 71 | The internals are complex but the user experience is quite simple: clypi will not prompt or require this value being passed up until when it's executed. 72 | 73 | 74 | ```python hl_lines="7 19" 75 | from clypi import Command, arg 76 | from typing_extensions import override 77 | 78 | class Cli(Command): 79 | single_threaded: bool = arg(False) 80 | num_cores: int = arg( 81 | defer=True, 82 | prompt="How many CPU cores do you want to use?" 83 | ) 84 | 85 | @override 86 | async def run(self): 87 | print(f"Running single theaded:", self.single_threaded) # << will not prompt yet... 88 | if self.single_threaded: 89 | # if we never access num_cores in this if condition, we will 90 | # never prompt! 91 | print("Running single threaded...") 92 | else: 93 | threads = self.num_cores // 4 # << we prompt here! 94 | print("Running with threads:", threads) 95 | 96 | if __name__ == "__main__": 97 | cmd = Cli.parse() # << will not prompt yet... 98 | cmd.start() # << will not prompt yet... 99 | ``` 100 | 101 | As you can see, we are prompted only if we do not specify `--single-threaded` and only 102 | after we've printed the `"Running single threaded: False"` message: 103 | 104 | 105 | ``` 106 | $ python cli.py --single-threaded 107 | Running single theaded: True 108 | Running single threaded... 109 | 110 | 111 | $ python cli.py 112 | Running single theaded: False 113 | How many CPU cores do you want to use?: 16 114 | Running with threads: 4 115 | ``` 116 | 117 | ## Custom parsers 118 | 119 | If the type you want to parse from the user is too complex, you can define your own parser 120 | using `config` as well: 121 | 122 | 123 | ```python hl_lines="4-7 10" 124 | import typing as t 125 | from clypi import Command, arg 126 | 127 | def parse_slack(value: t.Any) -> str: 128 | if not value.startswith('#'): 129 | raise ValueError("Invalid Slack channel. It must start with a '#'.") 130 | return value 131 | 132 | class MyCommand(Command): 133 | slack: str = arg(parser=parse_slack) 134 | ``` 135 | -------------------------------------------------------------------------------- /docs/learn/beautiful_uis.md: -------------------------------------------------------------------------------- 1 | ## Colorful outputs 2 | 3 | You can easily print colorful text using clypi's `cprint` (for "Colored Print") function. 4 | 5 | 6 | ```python title="colors.py" 7 | from clypi import cprint 8 | 9 | cprint("Some colorful text", fg="green", bold=True) 10 | cprint("Some more colorful text", fg="red", strikethrough=True) 11 | ``` 12 | 13 |
14 | python colors.py 15 | Some colorful text 16 | Some more colorful text 17 |
18 | 19 | You can also style individual pieces of text: 20 | 21 | 22 | ```python title="colors.py" 23 | import clypi 24 | 25 | print(clypi.style("This is blue", fg="blue"), "and", clypi.style("this is red", fg="red")) 26 | ``` 27 | 28 |
29 | python colors.py 30 | This is blue and this is red 31 |
32 | 33 | And also create a reusable styler: 34 | 35 | 36 | ```python title="colors.py" 37 | import clypi 38 | 39 | wrong = clypi.Styler(fg="red", strikethrough=True) 40 | print("The old version said", wrong("Pluto was a planet")) 41 | print("The old version said", wrong("the Earth was flat")) 42 | ``` 43 | 44 |
45 | python colors.py 46 | The old version said Pluto was a planet 47 | The old version said the Earth was flat 48 |
49 | 50 | ## Boxed outputs 51 | 52 | 53 | ```python title="boxed.py" 54 | import clypi 55 | 56 | print(clypi.boxed("Some boxed text", width=30, align="center")) 57 | ``` 58 | 59 |
60 | python boxed.py 61 | ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ 62 | ┃ Some boxed text ┃ 63 | ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ 64 |
65 | 66 | ## Stacks 67 | 68 | 69 | 70 | ```python title="stacks.py" 71 | import clypi 72 | 73 | names = clypi.boxed(["Daniel", "Pedro", "Paul"], title="Names", width=15) 74 | colors = clypi.boxed(["Blue", "Red", "Green"], title="Colors", width=15) 75 | print(clypi.stack(names, colors)) 76 | ``` 77 | 78 |
79 | python stacks.py 80 | ┏━ Names ━━━━━┓ ┏━ Colors ━━━━┓ 81 | ┃ Daniel ┃ ┃ Blue ┃ 82 | ┃ Pedro ┃ ┃ Red ┃ 83 | ┃ Paul ┃ ┃ Green ┃ 84 | ┗━━━━━━━━━━━━━┛ ┗━━━━━━━━━━━━━┛ 85 |
86 | 87 | ## Separators 88 | 89 | 90 | ```python title="separator.py" 91 | import clypi 92 | 93 | print(clypi.separator(title="Some title", color="red", width=30)) 94 | ``` 95 | 96 |
97 | python separator.py 98 | ━━━━━━━━━ Some title ━━━━━━━━━ 99 |
100 | 101 | 102 | ## Spinners 103 | 104 | !!! tip 105 | Read the [Spinner API docs](../api/components.md#spinner) for more detail into how to use 106 | this component. 107 | 108 | 109 | ```python title="spinner.py" hl_lines="4" 110 | import asyncio 111 | from clypi import spinner 112 | 113 | @spinner("Doing work") 114 | async def do_some_work(): 115 | await asyncio.sleep(2) 116 | 117 | asyncio.run(do_some_work()) 118 | ``` 119 | 120 |
121 | python spinner.py 122 | Doing work 123 |
124 | 125 | 126 | You can also use it as a context manager: 127 | 128 | ```python title="spinner.py" hl_lines="5" 129 | import asyncio 130 | from clypi import Spinner 131 | 132 | async def main(): 133 | async with Spinner("Doing something", capture=True): 134 | await asyncio.sleep(2) 135 | 136 | asyncio.run(main()) 137 | ``` 138 |
139 | python spinner.py 140 | Doing something 141 |
142 | -------------------------------------------------------------------------------- /docs/learn/configuration.md: -------------------------------------------------------------------------------- 1 | Clypi lets you configure the app globally. This means that all the styling will be easy, 2 | uniform across your entire app, and incredibly maintainable. 3 | 4 | For example, this is how you'd achieve a UI like `uv`'s CLI: 5 | 6 | 7 | ```python 8 | from clypi import ClypiConfig, ClypiFormatter, Styler, Theme, configure 9 | 10 | theme: Theme = Theme( 11 | usage=Styler(fg="green", bold=True), 12 | usage_command=Styler(fg="cyan", bold=True), 13 | usage_args=Styler(fg="cyan"), 14 | section_title=Styler(fg="green", bold=True), 15 | subcommand=Styler(fg="cyan", bold=True), 16 | long_option=Styler(fg="cyan", bold=True), 17 | short_option=Styler(fg="cyan", bold=True), 18 | positional=Styler(fg="cyan"), 19 | placeholder=Styler(fg="cyan"), 20 | prompts=Styler(fg="green", bold=True), 21 | ) 22 | 23 | config = ClypiConfig( 24 | theme=theme, 25 | help_formatter=ClypiFormatter( 26 | boxed=False, 27 | show_option_types=False, 28 | ), 29 | ) 30 | 31 | configure(config) 32 | ``` 33 | 34 | `uv run -m examples.uv add -c` 35 | 36 | image 37 | 38 | !!! tip 39 | Read the [Configuration API reference](../api/config.md) docs for more information into the available options. 40 | -------------------------------------------------------------------------------- /docs/learn/install.md: -------------------------------------------------------------------------------- 1 | You can install clypi with your favorite package manager (`pip`, `uv`, `pipx,` etc.). To get 2 | started, simply run: 3 | 4 | === "uv" 5 | 6 | ``` 7 | $ uv add clypi 8 | ---> 100% 9 | Successfully installed clypi 10 | ``` 11 | 12 | === "pip" 13 | 14 | ``` 15 | $ pip install clypi 16 | ---> 100% 17 | Successfully installed clypi 18 | ``` 19 | 20 | By default, clypi only installs two dependencies: 21 | 22 | - `python-dateutil`: to parse almost any date format in the world 23 | - `typing-extensions`: to provide better typing backwards compatibility for older Python versions 24 | -------------------------------------------------------------------------------- /docs/packaging.md: -------------------------------------------------------------------------------- 1 | # Building and distributing your CLIs 2 | 3 | To build and distribute you own CLI I recommend you use [uv](https://docs.astral.sh/uv/).. 4 | 5 | In this quick walkthrough we'll be creating a CLI called `zit`, a basic clone of git. 6 | 7 | 8 | ## Creating a new CLI 9 | 10 | First, you'll want to create a project. For that, follow uv's most up to date documentation 11 | about [creating a new project](https://docs.astral.sh/uv/guides/projects/#project-structure). 12 | 13 | A quick summary at the time of writing is: 14 | 15 | 1. Create a project directory: 16 | 17 | 18 | ``` 19 | $ mkdir zit 20 | $ cd zit 21 | ``` 22 | 23 | 2. Initialize a project: 24 | 25 | 26 | ``` 27 | $ uv init 28 | ``` 29 | 3. Install clypi: 30 | 31 | 32 | ``` 33 | $ uv add clypi 34 | ``` 35 | 36 | 4. Code your CLI. `uv` created a `main.py` file but you should create your own python package inside a subdirectory called `zit`. Inside that subdirectory create an empty file called `__init__.py` and a file called `main.py` with the following content: 37 | 38 | 39 | ``` 40 | $ tree 41 | . 42 | ├── README.md 43 | ├── pyproject.toml 44 | ├── uv.lock 45 | └── zit 46 | ├── __init__.py 47 | └── main.py 48 | ``` 49 | 50 | ```python 51 | # zit/main.py 52 | import clypi 53 | from clypi import Command, arg 54 | 55 | class Zit(Command): 56 | """ 57 | A git clone, but much slower ;) 58 | """ 59 | verbose: bool = arg(False, short="v") 60 | 61 | async def run(self): 62 | clypi.cprint("Sorry I don't know how to use git, it's too hard!", fg="yellow") 63 | if self.verbose: 64 | clypi.cprint("asdkjnbsvaeusbvkajhfnuehfvousadhvuashfqei" * 100) 65 | 66 | def main(): 67 | """ 68 | This will be the entrypoint for our CLI 69 | """ 70 | zit = Zit.parse() 71 | zit.start() 72 | 73 | if __name__ == "__main__": 74 | main() 75 | ``` 76 | 77 | 5. Test out your new CLI. You can run it locally with: 78 | 79 | 80 | ``` 81 | $ uv run ./zit/main.py 82 | ``` 83 | 84 | 6. You'll need to add a build system so that `uv` understands this is a package you want to distribute and people to install. Add the following to your `pyproject.toml` 85 | 86 | ```diff 87 | + [build-system] 88 | + requires = ["hatchling"] 89 | + build-backend = "hatchling.build" 90 | ``` 91 | 92 | 7. Add an entrypoint to your Python package. This tells Python how to execute your program. Add the following lines to your `pyproject.toml` 93 | 94 | ```diff 95 | + [project.scripts] 96 | + zit = "zit.main:main" 97 | ``` 98 | 99 | 8. Install your package locally and run it 100 | 101 | 102 | ``` 103 | $ uv pip install -e . 104 | $ zit --verbose 105 | Sorry I don't know how to use git, it's too hard! ... 106 | ``` 107 | 108 | ## Building and distributing your CLI 109 | 110 | I highly recommend you follow uv's guide on [building an publishing packages](https://docs.astral.sh/uv/guides/package/#publishing-your-package). 111 | 112 | The TLDR is `uv build` then `uv publish`, but you'll want to set up your project with the right metadata. The [official packaging Python guide](https://packaging.python.org/en/latest/tutorials/packaging-projects/) is really good to configure all you'll need for distributing a quality package. 113 | 114 | 115 | ## [Advanced] Pre-built binaries (Shivs 🔪) 116 | 117 | [Shiv](https://shiv.readthedocs.io/en/latest/)'s provide an easy way to bundle Python code into an executable file. Shiv's are, essentially, an executable zip file with Python files inside. 118 | 119 | To build a shiv with uv and clypi given the above `zit` example, run: 120 | 121 | 122 | ``` 123 | $ uvx shiv -c zit -o zit-bin . 124 | 125 | $ ./zit-bin --verbose 126 | Sorry I don't know how to use git, it's too hard! ... 127 | ``` 128 | 129 | You now have a binary (`zit-bin`) that you can distribute and run like any other binary. You'll have to manually add it to a `$PATH` location though ([What is $PATH?](https://askubuntu.com/a/551993)). 130 | -------------------------------------------------------------------------------- /docs/stylesheets/extra.css: -------------------------------------------------------------------------------- 1 | :root > * { 2 | --color-bg: var(--md-code-bg-color); 3 | } 4 | 5 | .termy { 6 | padding: 2.5rem 1rem 1rem !important; 7 | font-family: monospace !important; 8 | } 9 | 10 | [data-ty] { 11 | line-height: 1.3 !important; 12 | } 13 | 14 | /* 15 | * Based on squidfunk/mkdocs-material badges 16 | * https://github.com/squidfunk/mkdocs-material/blob/master/src/overrides/assets/stylesheets/custom/_typeset.scss 17 | */ 18 | 19 | .clypi-badge { 20 | font-size: 0.85em; 21 | } 22 | 23 | .clypi-badge-icon { 24 | background: var(--md-accent-fg-color--transparent); 25 | border-radius: 1rem 0 0 1rem; 26 | padding: 0.1rem 0.1rem 0.1rem 0.3rem; 27 | } 28 | 29 | .clypi-badge-text { 30 | padding: 0.1rem 0.3rem; 31 | border-radius: 0 1rem 1rem 0; 32 | box-shadow: 0 0 0 1px inset var(--md-accent-fg-color--transparent); 33 | } 34 | -------------------------------------------------------------------------------- /docs/stylesheets/termynal.css: -------------------------------------------------------------------------------- 1 | :root > * { 2 | --color-bg: var(--md-code-bg-color); 3 | --color-text: var(--md-code-hl-name-color); 4 | } 5 | 6 | .termy { 7 | padding: 2.5rem 1rem 1rem !important; 8 | font-family: monospace !important; 9 | } 10 | 11 | [data-ty] { 12 | line-height: 1.3 !important; 13 | } 14 | -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danimelchor/clypi/3cb07e5ae03610302c079169654083fd9305ab99/examples/__init__.py -------------------------------------------------------------------------------- /examples/boxed.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import random 4 | 5 | import clypi 6 | 7 | 8 | def main() -> None: 9 | for box in clypi.Boxes: 10 | color = random.choice(clypi.ALL_COLORS) 11 | content = f"This is a {box.human_name()!r} {color} box!" 12 | print(clypi.boxed(content, style=box, color=color)) 13 | 14 | 15 | if __name__ == "__main__": 16 | main() 17 | -------------------------------------------------------------------------------- /examples/cli.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import typing as t 3 | from enum import Enum 4 | from pathlib import Path 5 | 6 | from typing_extensions import override 7 | 8 | import clypi.parsers as cp 9 | from clypi import Command, Positional, Spinner, arg, boxed, cprint, style 10 | 11 | 12 | # ---- START DEMO UTILS ---- 13 | def debug(command: Command) -> None: 14 | """ 15 | Just a utility function to display the commands being passed in a somewhat 16 | nice way 17 | """ 18 | box = boxed(style(command, bold=True), title="Debug", color="magenta") 19 | print(box, end="\n\n") 20 | 21 | 22 | # ---- END DEMO UTILS ---- 23 | 24 | 25 | class Env(Enum): 26 | QA = 1 27 | PROD = 2 28 | 29 | 30 | class RunParallel(Command): 31 | """ 32 | Runs all of the files in parallel 33 | """ 34 | 35 | files: Positional[list[str]] 36 | exceptions_with_reasons: Path | None = arg(None, parser=cp.Path(exists=True)) 37 | env: Env = arg(inherited=True) 38 | 39 | @override 40 | async def run(self): 41 | debug(self) 42 | cprint(f"{self.env.name} - Running all files", fg="blue", bold=True) 43 | 44 | async with Spinner(f"Running {', '.join(self.files)} in parallel"): 45 | await asyncio.sleep(2) 46 | 47 | async with Spinner(f"Linting {', '.join(self.files)} in parallel"): 48 | await asyncio.sleep(2) 49 | 50 | cprint("\nDone!", fg="green", bold=True) 51 | 52 | 53 | class RunSerial(Command): 54 | """ 55 | Runs all of the files one by one 56 | """ 57 | 58 | files: Positional[list[Path]] = arg(parser=cp.List(cp.Path(exists=True))) 59 | env: Env = arg(inherited=True) 60 | 61 | @override 62 | async def run(self): 63 | debug(self) 64 | cprint(f"{self.env.name} - Running all files", fg="blue", bold=True) 65 | for f in self.files: 66 | async with Spinner(f"Running {f.as_posix()} in parallel"): 67 | await asyncio.sleep(2) 68 | cprint("\nDone!", fg="green", bold=True) 69 | 70 | 71 | class Run(Command): 72 | """ 73 | Allows running files with different options 74 | """ 75 | 76 | subcommand: RunParallel | RunSerial 77 | quiet: bool = arg( 78 | False, 79 | short="q", 80 | help="If the runner should omit all stdout messages", 81 | group="Global", 82 | ) 83 | env: Env = arg(Env.PROD, help="The environment to run in") 84 | format: t.Literal["json", "pretty"] = arg( 85 | "pretty", help="The format with which to display results" 86 | ) 87 | 88 | 89 | class Lint(Command): 90 | """ 91 | Lints all of the files in a given directory using the latest 92 | termuff rules. 93 | """ 94 | 95 | files: Positional[list[str]] = arg(help="The list of files to lint") 96 | quiet: bool = arg( 97 | False, 98 | short="q", 99 | help="If the linter should omit all stdout messages", 100 | ) 101 | timeout: int = arg(help="Disable the termuff cache") 102 | index: str = arg( 103 | "http://pypi.org", 104 | help="The index to download termuff from", 105 | prompt="What index do you want to download termuff from?", 106 | ) 107 | 108 | @override 109 | async def run(self) -> None: 110 | debug(self) 111 | async with Spinner(f"Linting {', '.join(self.files)}"): 112 | await asyncio.sleep(self.timeout) 113 | cprint("\nDone!", fg="green", bold=True) 114 | 115 | 116 | class Main(Command): 117 | """ 118 | Termuff is a powerful command line interface to lint and 119 | run arbitrary files. 120 | """ 121 | 122 | subcommand: Run | Lint | None = None 123 | verbose: bool = arg(False, short="v", help="Whether to show more output") 124 | 125 | @override 126 | @classmethod 127 | def prog(cls): 128 | return "termuff" 129 | 130 | @override 131 | @classmethod 132 | def epilog(cls): 133 | return "Learn more at http://termuff.org" 134 | 135 | 136 | if __name__ == "__main__": 137 | main: Main = Main.parse() 138 | main.start() 139 | -------------------------------------------------------------------------------- /examples/cli_basic.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from typing_extensions import override 4 | 5 | import clypi.parsers as cp 6 | from clypi import Command, Positional, arg 7 | 8 | 9 | class Lint(Command): 10 | files: Positional[tuple[Path, ...]] 11 | verbose: bool = arg(inherited=True) # Comes from MyCli but I want to use it too 12 | 13 | @override 14 | async def run(self): 15 | print(f"Linting {self.files=} and {self.verbose=}") 16 | 17 | 18 | class MyCli(Command): 19 | """ 20 | my-cli is a very nifty demo CLI tool 21 | """ 22 | 23 | subcommand: Lint | None = None 24 | threads: int = arg( 25 | default=4, 26 | # Built-in parsers for useful validations 27 | parser=cp.Int(min=1, max=10), 28 | ) 29 | verbose: bool = arg( 30 | False, 31 | help="Whether to show extra logs", 32 | prompt="Do you want to see extra logs?", 33 | short="v", # User can pass in --verbose or -v 34 | ) 35 | 36 | @override 37 | async def run(self): 38 | print(f"Running the main command with {self.verbose}") 39 | 40 | 41 | if __name__ == "__main__": 42 | cli: MyCli = MyCli.parse() 43 | cli.start() 44 | -------------------------------------------------------------------------------- /examples/cli_custom_parser.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from typing_extensions import override 4 | 5 | import clypi.parsers as cp 6 | from clypi import Command, arg, cprint 7 | 8 | 9 | class SlackChannel(cp.Str): 10 | @override 11 | def __call__(self, raw: str | list[str], /) -> str: 12 | parsed = super().__call__(raw) 13 | if not re.match(r"#[a-z0-9-]+", parsed): 14 | raise ValueError("Invalid slack channel") 15 | return parsed 16 | 17 | 18 | class SlackChannelId(cp.Int): 19 | @override 20 | def __call__(self, raw: str | list[str], /) -> int: 21 | parsed = super().__call__(raw) 22 | if parsed < 1_000_000 or parsed > 9_999_999: 23 | raise ValueError(f"Invalid Slack channel {parsed}, it must be 8 digits") 24 | return parsed 25 | 26 | 27 | class Main(Command): 28 | """An example of how useful custom parsers can be""" 29 | 30 | slack: str | int | None = arg( 31 | help="The Slack channel to send notifications to", 32 | prompt="What Slack channel should we send notifications to?", 33 | parser=SlackChannel() | SlackChannelId() | cp.NoneParser(), 34 | ) 35 | 36 | @override 37 | async def run(self): 38 | cprint(f"Slack: {self.slack} ({type(self.slack)})", fg="blue") 39 | cprint("Try using a valid or invalid slack channel", fg="cyan") 40 | 41 | 42 | if __name__ == "__main__": 43 | main: Main = Main.parse() 44 | main.start() 45 | -------------------------------------------------------------------------------- /examples/cli_deferred.py: -------------------------------------------------------------------------------- 1 | from typing_extensions import override 2 | 3 | import clypi.parsers as cp 4 | from clypi import Command, arg, cprint 5 | 6 | 7 | class VerboseIntParser(cp.Int): 8 | """Helper parser to show the user when the parsing is being done""" 9 | 10 | @override 11 | def __call__(self, raw: str | list[str], /) -> int: 12 | cprint(f"⚠️ The call to to parse {raw} as an int was executed!", fg="yellow") 13 | return super().__call__(raw) 14 | 15 | 16 | class Main(Command): 17 | runner: bool = arg( 18 | False, 19 | help="Whether you run", 20 | prompt="Do you run?", 21 | ) 22 | often: int = arg( 23 | help="The frequency you run with in days", 24 | prompt="How many days a week do you run?", 25 | defer=True, 26 | parser=VerboseIntParser(), 27 | ) 28 | 29 | @override 30 | async def run(self): 31 | print("Command execution started...") 32 | 33 | if not self.runner: 34 | cprint("You are not a runner!", fg="green", bold=True) 35 | cprint("Try answering yes on the next try :)", bold=True) 36 | else: 37 | cprint( 38 | # This line will trigger the evaluation of `often` and prompt 39 | # the user if it was not provided as a CLI arg 40 | f"You are a runner and run every {self.often} days!", 41 | fg="green", 42 | bold=True, 43 | ) 44 | cprint("Try answering no on the next try :)", bold=True) 45 | 46 | 47 | if __name__ == "__main__": 48 | main: Main = Main.parse() 49 | main.start() 50 | -------------------------------------------------------------------------------- /examples/cli_inherited.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shlex 3 | import sys 4 | import typing as t 5 | 6 | from typing_extensions import override 7 | 8 | from clypi import ClypiFormatter, Command, Positional, arg, cprint, get_config, style 9 | 10 | 11 | class Run(Command): 12 | """ 13 | Runs all files 14 | """ 15 | 16 | files: Positional[list[str]] = arg(help="The files to run") 17 | verbose: bool = arg(inherited=True, group="global") 18 | env: str = arg(inherited=True, group="global") 19 | 20 | @override 21 | async def run(self): 22 | cprint("Running with:", fg="blue", bold=True) 23 | cprint(f" - Files: {self.files}", fg="blue") 24 | cprint(f" - Verbose: {self.verbose}", fg="blue") 25 | cprint(f" - Env: {self.env}", fg="blue") 26 | cprint("Done!", fg="green", bold=True) 27 | 28 | 29 | class Main(Command): 30 | """ 31 | 4ward is an example of how we can reuse args across commands using Clypi. 32 | """ 33 | 34 | subcommand: Run | None = None 35 | verbose: bool = arg(False, short="v", help="Whether to show more output") 36 | env: t.Literal["qa", "prod"] = arg(help="Whether to show more output") 37 | 38 | @override 39 | @classmethod 40 | def prog(cls): 41 | return "4ward" 42 | 43 | @override 44 | @classmethod 45 | def epilog(cls): 46 | return "Learn more at http://4ward.org" 47 | 48 | @override 49 | async def run(self): 50 | cprint("Running with:", fg="blue", bold=True) 51 | cprint(f" - Verbose: {self.verbose}", fg="blue") 52 | cprint(f" - Env: {self.env}", fg="blue") 53 | cprint("Done!", fg="green", bold=True) 54 | 55 | 56 | if __name__ == "__main__": 57 | show_inherited = True 58 | if os.getenv("SHOW_INHERITED") != "1": 59 | cprint( 60 | "Not showing inherited args. Try using: " 61 | + style( 62 | "SHOW_INHERITED=1 uv run -m examples.cli_inherited " 63 | + shlex.join(sys.argv[1:]), 64 | bold=True, 65 | ) 66 | + "\n\n", 67 | fg="yellow", 68 | ) 69 | show_inherited = False 70 | 71 | get_config().help_formatter = ClypiFormatter(show_inherited_options=show_inherited) 72 | 73 | main: Main = Main.parse() 74 | main.start() 75 | -------------------------------------------------------------------------------- /examples/cli_negative_flags.py: -------------------------------------------------------------------------------- 1 | from typing_extensions import override 2 | 3 | from clypi import Command, arg, cprint, style 4 | 5 | 6 | class Main(Command): 7 | """An example of how enabling negative flags looks like""" 8 | 9 | verbose: bool = arg( 10 | True, 11 | short="v", 12 | negative="quiet", 13 | help="Whether to show more output", 14 | ) 15 | 16 | @override 17 | async def run(self): 18 | cprint(f"Verbose: {self.verbose}", fg="blue") 19 | print( 20 | style("Try using ", fg="cyan") 21 | + style("--quiet", fg="yellow", bold=True) 22 | + style(" or ", fg="cyan") 23 | + style("--help", fg="yellow", bold=True) 24 | ) 25 | 26 | 27 | if __name__ == "__main__": 28 | main: Main = Main.parse() 29 | main.start() 30 | -------------------------------------------------------------------------------- /examples/colors.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Generator 4 | 5 | from clypi import ALL_COLORS, ColorType, boxed, stack, style 6 | 7 | 8 | # --- DEMO UTILS --- 9 | def _all_colors() -> Generator[tuple[ColorType, ...], None, None]: 10 | mid = len(ALL_COLORS) // 2 11 | normal, bright = ALL_COLORS[:mid], ALL_COLORS[mid:] 12 | for color in zip(normal, bright): 13 | yield color 14 | 15 | 16 | # --- DEMO START --- 17 | def main() -> None: 18 | fg_block: list[str] = [] 19 | for color, bright_color in _all_colors(): 20 | fg_block.append( 21 | style("██ " + color.ljust(9), fg=color) 22 | + style("██ " + bright_color.ljust(16), fg=bright_color) 23 | ) 24 | 25 | bg_block: list[str] = [] 26 | for color, bright_color in _all_colors(): 27 | bg_block.append( 28 | style(color.ljust(9), bg=color) 29 | + " " 30 | + style(bright_color.ljust(16), bg=bright_color) 31 | ) 32 | 33 | style_block: list[str] = [] 34 | style_block.append(style("I am bold", bold=True)) 35 | style_block.append(style("I am dim", dim=True)) 36 | style_block.append(style("I am underline", underline=True)) 37 | style_block.append(style("I am blink", blink=True)) 38 | style_block.append(style("I am reverse", reverse=True)) 39 | style_block.append(style("I am strikethrough", strikethrough=True)) 40 | 41 | stacked_colors = stack( 42 | boxed(fg_block, title="Foregrounds"), 43 | boxed(bg_block, title="Backgrounds"), 44 | boxed(style_block, title="Styles"), 45 | ) 46 | print(stacked_colors) 47 | 48 | 49 | if __name__ == "__main__": 50 | main() 51 | -------------------------------------------------------------------------------- /examples/prompts.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import clypi 4 | import clypi.parsers as cp 5 | 6 | 7 | def _validate_earth_age(x: str | list[str]) -> int: 8 | if isinstance(x, str): 9 | x_int = int(x) 10 | if x_int == 4_543_000_000: 11 | return x_int 12 | raise ValueError("The Earth is 4.543 billion years old. Try 4543000000.") 13 | 14 | 15 | def main() -> None: 16 | # Basic prompting 17 | name = clypi.prompt("What's your name?") 18 | 19 | # Default values 20 | is_cool = clypi.confirm("Is clypi cool?", default=True) 21 | 22 | # Custom types with parsing 23 | age = clypi.prompt( 24 | "How old are you?", 25 | parser=cp.Int(gte=18), 26 | hide_input=True, 27 | ) 28 | hours = clypi.prompt( 29 | "How many hours are there in a day?", 30 | parser=cp.Union(cp.TimeDelta(), cp.Int()), 31 | ) 32 | 33 | # Custom validations 34 | earth = clypi.prompt( 35 | "How old is The Earth?", 36 | parser=_validate_earth_age, 37 | ) 38 | 39 | # ----------- 40 | print() 41 | clypi.cprint("🚀 Summary", bold=True, fg="green") 42 | answer = clypi.Styler(fg="magenta", bold=True) 43 | print(" ↳ Name:", answer(name)) 44 | print(" ↳ Clypi is cool:", answer(is_cool)) 45 | print(" ↳ Age:", answer(age)) 46 | print(" ↳ Hours in a day:", answer(hours), f"({type(hours).__name__})") 47 | print(" ↳ Earth age:", answer(earth)) 48 | 49 | 50 | if __name__ == "__main__": 51 | main() 52 | -------------------------------------------------------------------------------- /examples/spinner.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import sys 3 | 4 | from clypi import Spin, Spinner, cprint, spinner 5 | 6 | 7 | async def all_spinners(): 8 | cprint( 9 | "Displaying all spinner animations." + "\n ↳ Press ctrl+c to skip all examples", 10 | fg="blue", 11 | bold=True, 12 | ) 13 | 14 | for i, anim in enumerate(Spin, 1): 15 | async with Spinner( 16 | f"{anim.human_name()} spinning animation [{i}/{len(Spin)}]", 17 | animation=anim, 18 | ): 19 | await asyncio.sleep(1.2) 20 | 21 | 22 | async def subprocess(): 23 | # Example with subprocess 24 | title = "Example with subprocess" 25 | async with Spinner(title) as s: 26 | # Fist subprocess 27 | proc = await asyncio.create_subprocess_shell( 28 | "for i in $(seq 1 10); do date && sleep 0.2; done;", 29 | stdout=asyncio.subprocess.PIPE, 30 | ) 31 | 32 | # Second subprocess 33 | proc2 = await asyncio.create_subprocess_shell( 34 | "for i in $(seq 1 20); do echo $RANDOM && sleep 0.1; done;", 35 | stdout=asyncio.subprocess.PIPE, 36 | ) 37 | 38 | coros = ( 39 | s.pipe(proc.stdout, color="red"), 40 | s.pipe(proc2.stdout, prefix="(rand)"), 41 | ) 42 | await asyncio.gather(*coros) 43 | 44 | 45 | @spinner("Example that captures stdout/stderr", capture=True) 46 | async def captured_with_decorator(): 47 | # Example with subprocess 48 | for i in range(10): 49 | if i % 2 == 0: 50 | cprint("Stdout output", fg="blue") 51 | else: 52 | cprint("Stderr output", fg="red", file=sys.stderr) 53 | await asyncio.sleep(0.3) 54 | 55 | 56 | async def main(): 57 | # Run all of the spinner animations 58 | try: 59 | await all_spinners() 60 | except asyncio.CancelledError: 61 | pass 62 | 63 | # Display a subprocess example 64 | print() 65 | try: 66 | await subprocess() 67 | except asyncio.CancelledError: 68 | pass 69 | 70 | # Show an example decorator usage with stdout capture 71 | print() 72 | await captured_with_decorator() 73 | 74 | 75 | if __name__ == "__main__": 76 | asyncio.run(main()) 77 | -------------------------------------------------------------------------------- /examples/uv/__init__.py: -------------------------------------------------------------------------------- 1 | # Nothing to see here, start in __main__.py 2 | -------------------------------------------------------------------------------- /examples/uv/__main__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from typing_extensions import override 4 | 5 | import clypi 6 | from clypi import ClypiConfig, ClypiFormatter, Command, Styler, Theme, arg, configure 7 | from examples.uv.add import Add 8 | from examples.uv.init import Init 9 | from examples.uv.pip import Pip 10 | from examples.uv.remove import Remove 11 | 12 | 13 | class Uv(Command): 14 | """ 15 | A clone of an extremely fast Python package manager. 16 | """ 17 | 18 | subcommand: Add | Init | Pip | Remove | None 19 | quiet: bool = arg(False, short="q", help="Do not print any output", group="global") 20 | version: bool = arg(False, short="V", help="Display the uv version", group="global") 21 | no_cache: bool = arg( 22 | False, 23 | help="Avoid reading from or writing to the cache, instead using a temporary directory for the duration of the operation", 24 | hidden=True, 25 | group="global", 26 | ) 27 | 28 | @override 29 | async def run(self) -> None: 30 | # If the version was requested, print it 31 | if self.version: 32 | clypi.cprint("clypi's UV 0.0.1", fg="green") 33 | sys.exit(0) 34 | 35 | if not self.quiet: 36 | self.print_help() 37 | 38 | 39 | if __name__ == "__main__": 40 | # Configure the CLI to look like uv's 41 | configure( 42 | ClypiConfig( 43 | theme=Theme( 44 | usage=Styler(fg="green", bold=True), 45 | usage_command=Styler(fg="cyan", bold=True), 46 | usage_args=Styler(fg="cyan"), 47 | section_title=Styler(fg="green", bold=True), 48 | subcommand=Styler(fg="cyan", bold=True), 49 | long_option=Styler(fg="cyan", bold=True), 50 | short_option=Styler(fg="cyan", bold=True), 51 | positional=Styler(fg="cyan"), 52 | placeholder=Styler(fg="cyan"), 53 | prompts=Styler(fg="green", bold=True), 54 | ), 55 | help_formatter=ClypiFormatter( 56 | boxed=False, 57 | show_option_types=False, 58 | ), 59 | ) 60 | ) 61 | 62 | # Parse and run the commands 63 | uv = Uv.parse() 64 | uv.start() 65 | -------------------------------------------------------------------------------- /examples/uv/add.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import re 3 | from pathlib import Path 4 | 5 | from typing_extensions import override 6 | 7 | import clypi 8 | from clypi import ClypiException, Command, Positional, Spinner, arg 9 | 10 | 11 | async def from_requirements(file: Path): 12 | """ 13 | Given a file, it will load it, try to parse the packages and versions 14 | with regex, and "install" them. 15 | """ 16 | 17 | packages_with_versions: dict[str, str] = {} 18 | for line in file.read_text().split(): 19 | package = re.search(r"(\w+)[>=<]+([0-9\.]+)", line) 20 | if not package: 21 | continue 22 | packages_with_versions[package.group(1)] = package.group(2) 23 | 24 | await _install_packages(packages_with_versions) 25 | 26 | 27 | async def from_packages(packages: list[str]): 28 | """ 29 | Given a list of packages, it will try to parse the packages and versions 30 | with regex, and "install" them. 31 | """ 32 | 33 | packages_with_versions: dict[str, str] = {} 34 | 35 | clypi.cprint("\nAdded new packages", fg="blue", bold=True) 36 | for p in packages: 37 | package = re.search(r"(\w+)[>=<]+([0-9\.]+)", p) 38 | if not package: 39 | continue 40 | packages_with_versions[package.group(1)] = package.group(2) 41 | 42 | await _install_packages(packages_with_versions) 43 | 44 | 45 | async def _install_packages(packages: dict[str, str]): 46 | """ 47 | Mock install the packages with a nice colored spinner. 48 | """ 49 | 50 | async with Spinner("Installing packages", capture=True): 51 | for name, version in packages.items(): 52 | print("Installed", name) 53 | await asyncio.sleep(0.3) 54 | 55 | clypi.cprint("\nAdded new packages", fg="blue", bold=True) 56 | for name, version in packages.items(): 57 | icon = clypi.style("+", fg="green", bold=True) 58 | print(f"[{icon}] {name} {version}") 59 | 60 | 61 | class Add(Command): 62 | """Add dependencies to the project""" 63 | 64 | packages: Positional[list[str]] = arg( 65 | default_factory=list, 66 | help="The packages to add, as PEP 508 requirements (e.g., `ruff==0.5.0`)", 67 | ) 68 | requirements: Path | None = arg( 69 | None, 70 | short="r", 71 | help="Add all packages listed in the given `requirements.txt` files", 72 | ) 73 | dev: bool = arg( 74 | False, help="Add the requirements to the development dependency group" 75 | ) 76 | 77 | # Inherited opts 78 | quiet: bool = arg(inherited=True) 79 | version: bool = arg(inherited=True) 80 | no_cache: bool = arg(inherited=True) 81 | 82 | @override 83 | async def run(self) -> None: 84 | clypi.cprint("Running `uv add` command...\n", fg="blue", bold=True) 85 | 86 | # Download from requirements.txt file 87 | if self.requirements: 88 | await from_requirements(self.requirements) 89 | 90 | # Download positional args 91 | elif self.packages: 92 | await from_packages(self.packages) 93 | 94 | else: 95 | raise ClypiException("One of requirements or packages is required!") 96 | -------------------------------------------------------------------------------- /examples/uv/init.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from typing_extensions import override 4 | 5 | import clypi 6 | from clypi import Command, Positional, arg 7 | 8 | 9 | class Init(Command): 10 | """Create a new project""" 11 | 12 | path: Positional[Path] = arg(help="The path to use for the project/script") 13 | name: str = arg( 14 | help="The name of the project", 15 | prompt="What's the name of your project/script?", 16 | ) 17 | description: str = arg( 18 | help="Set the project description", 19 | prompt="What's your project/script's description?", 20 | ) 21 | 22 | @override 23 | async def run(self) -> None: 24 | clypi.cprint("Running `uv init` command...", fg="blue") 25 | -------------------------------------------------------------------------------- /examples/uv/pip.py: -------------------------------------------------------------------------------- 1 | from typing_extensions import override 2 | 3 | import clypi 4 | from clypi import Command, arg 5 | 6 | 7 | class Install(Command): 8 | """Install packages into an environment""" 9 | 10 | # Inherited opts 11 | quiet: bool = arg(inherited=True) 12 | version: bool = arg(inherited=True) 13 | no_cache: bool = arg(inherited=True) 14 | 15 | @override 16 | async def run(self) -> None: 17 | clypi.cprint("Running `uv pip install` command...", fg="blue") 18 | 19 | 20 | class Uninstall(Command): 21 | """Uninstall packages from an environment""" 22 | 23 | # Inherited opts 24 | quiet: bool = arg(inherited=True) 25 | version: bool = arg(inherited=True) 26 | no_cache: bool = arg(inherited=True) 27 | 28 | @override 29 | async def run(self) -> None: 30 | clypi.cprint("Running `uv pip uninstall` command...", fg="blue") 31 | 32 | 33 | class Freeze(Command): 34 | """List, in requirements format, packages installed in an environment""" 35 | 36 | # Inherited opts 37 | quiet: bool = arg(inherited=True) 38 | version: bool = arg(inherited=True) 39 | no_cache: bool = arg(inherited=True) 40 | 41 | @override 42 | async def run(self) -> None: 43 | clypi.cprint("Running `uv pip freeze` command...", fg="blue") 44 | 45 | 46 | class List(Command): 47 | """List, in tabular format, packages installed in an environment""" 48 | 49 | # Inherited opts 50 | quiet: bool = arg(inherited=True) 51 | version: bool = arg(inherited=True) 52 | no_cache: bool = arg(inherited=True) 53 | 54 | @override 55 | async def run(self) -> None: 56 | clypi.cprint("Running `uv pip list` command...", fg="blue") 57 | 58 | 59 | class Pip(Command): 60 | """Manage Python packages with a pip-compatible interface""" 61 | 62 | subcommand: Install | Uninstall | Freeze | List 63 | 64 | # Inherited opts 65 | quiet: bool = arg(inherited=True) 66 | version: bool = arg(inherited=True) 67 | no_cache: bool = arg(inherited=True) 68 | -------------------------------------------------------------------------------- /examples/uv/remove.py: -------------------------------------------------------------------------------- 1 | from typing_extensions import override 2 | 3 | import clypi 4 | from clypi import Command, Positional, arg 5 | 6 | 7 | class Remove(Command): 8 | """Remove dependencies from the project""" 9 | 10 | packages: Positional[list[str]] = arg( 11 | help="The names of the dependencies to remove (e.g., `ruff`)" 12 | ) 13 | dev: bool = arg( 14 | False, help="Remove the packages from the development dependency group" 15 | ) 16 | 17 | # Inherited opts 18 | quiet: bool = arg(inherited=True) 19 | version: bool = arg(inherited=True) 20 | no_cache: bool = arg(inherited=True) 21 | 22 | @override 23 | async def run(self) -> None: 24 | clypi.cprint("Running `uv remove` command...", fg="blue") 25 | 26 | # Remove the packages passed as args 27 | clypi.cprint("\nRemoved packages", fg="blue", bold=True) 28 | for p in self.packages: 29 | icon = clypi.style("-", fg="red", bold=True) 30 | print(f"[{icon}] {p} 0.1.0") 31 | -------------------------------------------------------------------------------- /mdtest/README.md: -------------------------------------------------------------------------------- 1 | # mdtest 2 | 3 | I want to make sure almost every code block in the clypi repository is runnable and has no typos. For that, 4 | I've created a tiny CLI I called `md-test` (using clypi obviously). The idea is simple, any Python code block annotated as an `mdtest` (see below) can be run to ensure it's correctly defined. 5 | 6 | image 7 | 8 | 9 | ## Creating Markdown Tests 10 | 11 | 12 | ### Non-input tests 13 | ```` 14 | 15 | ```python 16 | assert 1 + 1 == 2, f"Expected 1 + 1 to equal 2" 17 | ``` 18 | ```` 19 | 20 | ### Command-line tests 21 | ```` 22 | 23 | ```python 24 | import sys 25 | assert sys.argv[1] == '--foo', f"Expected the first arg to be 'foo'" 26 | ``` 27 | ```` 28 | 29 | ### User input tests 30 | ```` 31 | 32 | ```python 33 | import sys 34 | assert input() == 'hello world', f"Expected the stdin to be 'hello world'" 35 | ``` 36 | ```` 37 | 38 | ## Running Markdown Tests 39 | 40 | ``` 41 | uv run mdtest 42 | ``` 43 | 44 | -------------------------------------------------------------------------------- /mdtest/__main__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import re 3 | import shutil 4 | import time 5 | import tomllib 6 | from contextlib import suppress 7 | from dataclasses import dataclass 8 | from pathlib import Path 9 | from textwrap import dedent 10 | 11 | import anyio 12 | from typing_extensions import override 13 | 14 | import clypi.parsers as cp 15 | from clypi import Command, Positional, Spinner, arg, boxed, cprint, style 16 | 17 | MDTEST_DIR = Path.cwd() / "mdtest_autogen" 18 | PREAMBLE = """\ 19 | from pathlib import Path # pyright: ignore 20 | from typing import reveal_type, Any # pyright: ignore 21 | from clypi import * # pyright: ignore 22 | from enum import Enum # pyright: ignore 23 | from datetime import datetime, timedelta # pyright: ignore 24 | 25 | def assert_raises(func: Any) -> Any: 26 | exc = None 27 | try: 28 | func() 29 | except Exception as e: 30 | exc = e 31 | assert exc is not None 32 | """ 33 | 34 | 35 | class TestFailed(Exception): 36 | pass 37 | 38 | 39 | @dataclass 40 | class Test: 41 | name: str 42 | orig: str 43 | code: str 44 | args: str = "" 45 | stdin: str = "" 46 | 47 | @property 48 | def command(self) -> str: 49 | return "" 50 | 51 | 52 | @dataclass 53 | class RunTest(Test): 54 | @property 55 | @override 56 | def command(self) -> str: 57 | cmd = f"uv run --all-extras {MDTEST_DIR / self.name}.py" 58 | if self.args: 59 | cmd += f" {self.args}" 60 | return cmd 61 | 62 | 63 | @dataclass 64 | class RunPyright(Test): 65 | @property 66 | @override 67 | def command(self) -> str: 68 | return f"uv run --all-extras pyright {MDTEST_DIR / self.name}.py" 69 | 70 | 71 | async def parse_file(sm: asyncio.Semaphore, file: Path) -> list[Test]: 72 | tests: list[Test] = [] 73 | base_name = file.as_posix().replace("/", "-").replace(".md", "").lower() 74 | 75 | # Wait for turn 76 | await sm.acquire() 77 | 78 | async with await anyio.open_file(file, "r") as f: 79 | current_test: list[str] = [] 80 | in_test, args, stdin = False, "", "" 81 | async for line in f: 82 | # End of a code block 83 | if "```" in line and current_test: 84 | code = "\n".join(current_test[1:]) 85 | tests.extend( 86 | [ 87 | RunTest( 88 | name=f"{base_name}-{len(tests)}-run", 89 | orig=dedent(code), 90 | code=PREAMBLE + dedent(code), 91 | args=args, 92 | stdin=stdin + "\n", 93 | ), 94 | RunPyright( 95 | name=f"{base_name}-{len(tests)}-pyright", 96 | orig=dedent(code), 97 | code=PREAMBLE + dedent(code), 98 | ), 99 | ] 100 | ) 101 | in_test, current_test, args, stdin = False, [], "", "" 102 | 103 | # We're in a test, accumulate all lines 104 | elif in_test: 105 | current_test.append(line.removeprefix("> ").removeprefix(">").rstrip()) 106 | 107 | # Mdtest arg definition 108 | elif g := re.search("", line): 109 | args = g.group(1) 110 | in_test = True 111 | 112 | # Mdtest stdin definition 113 | elif g := re.search("", line): 114 | stdin = g.group(1) 115 | in_test = True 116 | 117 | # Mdtest generic definition 118 | elif g := re.search("", line): 119 | in_test = True 120 | 121 | elif "mdtest" in line: 122 | raise ValueError(f"Invalid mdtest config line: {line}") 123 | 124 | sm.release() 125 | cprint(style("✔", fg="green") + f" Collected {len(tests)} tests for {file}") 126 | return tests 127 | 128 | 129 | def error_msg(test: Test, stdout: str | None = None, stderr: str | None = None) -> str: 130 | error: list[str] = [] 131 | error.append(style(f"\n\nError running test {test.name!r}\n", fg="red", bold=True)) 132 | error.append(boxed(test.orig, title="Code", width="max")) 133 | 134 | if stdout: 135 | error.append("") 136 | error.append(boxed(stdout.strip(), title="Stdout", width="max")) 137 | 138 | if stderr: 139 | error.append("") 140 | error.append(boxed(stderr.strip(), title="Stderr", width="max")) 141 | 142 | return "\n".join(error) 143 | 144 | 145 | class Runner: 146 | def __init__(self, parallel: int, timeout: int, verbose: bool) -> None: 147 | self.sm = asyncio.Semaphore(parallel) 148 | self.timeout = timeout 149 | self.verbose = verbose 150 | 151 | async def run_test(self, test: Test) -> tuple[str, str | None]: 152 | # Save test to temp file 153 | test_file = MDTEST_DIR / f"{test.name}.py" 154 | test_file.write_text(test.code) 155 | 156 | # Await the subprocess to run it 157 | proc = await asyncio.create_subprocess_shell( 158 | test.command, 159 | stdout=asyncio.subprocess.PIPE, 160 | stdin=asyncio.subprocess.PIPE, 161 | stderr=asyncio.subprocess.PIPE, 162 | ) 163 | try: 164 | stdout, stderr = await proc.communicate(test.stdin.encode()) 165 | except: 166 | proc.terminate() 167 | raise 168 | 169 | # If no errors, return 170 | if proc.returncode == 0: 171 | if self.verbose: 172 | cprint( 173 | style("✔", fg="green") 174 | + f" Test {test.name} passed with command: {test.command}" 175 | ) 176 | return test.name, None 177 | 178 | # If there was an error, pretty print it 179 | error = error_msg(test, stdout.decode(), stderr.decode()) 180 | return test.name, error 181 | 182 | async def run_test_with_timeout(self, test: Test) -> tuple[str, str | None]: 183 | await self.sm.acquire() 184 | start = time.perf_counter() 185 | try: 186 | async with asyncio.timeout(self.timeout): 187 | return await self.run_test(test) 188 | except TimeoutError: 189 | error = error_msg( 190 | test, 191 | stderr=f"Test timed out after {time.perf_counter() - start:.3f}s", 192 | ) 193 | return test.name, error 194 | finally: 195 | self.sm.release() 196 | 197 | async def run_mdtests(self, tests: list[Test]) -> int: 198 | errors: list[str] = [] 199 | async with Spinner("Running Markdown Tests", capture=True) as s: 200 | coros = [self.run_test_with_timeout(test) for test in tests] 201 | for task in asyncio.as_completed(coros): 202 | idx, err = await task 203 | if err is None: 204 | cprint(style("✔", fg="green") + f" Finished test {idx}") 205 | else: 206 | errors.append(err) 207 | cprint(style("×", fg="red") + f" Finished test {idx}") 208 | 209 | if errors: 210 | await s.fail() 211 | 212 | for err in errors: 213 | cprint(err) 214 | 215 | return 1 if errors else 0 216 | 217 | 218 | class Mdtest(Command): 219 | """ 220 | Run python code embedded in markdown files to ensure it's 221 | runnable. 222 | """ 223 | 224 | files: Positional[list[Path] | None] = arg( 225 | help="The list of markdown files to test", 226 | default=None, 227 | ) 228 | parallel: int | None = arg(None, parser=cp.Int(positive=True)) 229 | timeout: int = arg(6, parser=cp.Int(positive=True)) 230 | config: Path = Path("./pyproject.toml") 231 | verbose: bool = arg(False, help="Enable verbose output", short="v") 232 | 233 | def load_config(self): 234 | if not self.config.exists(): 235 | return Mdtest() 236 | 237 | with open(self.config, "rb") as f: 238 | conf = tomllib.load(f) 239 | 240 | with suppress(KeyError): 241 | data = conf["tool"]["mdtest"] 242 | parallel = int(data["parallel"]) if "parallel" in data else None 243 | files = [p for f in data["include"] for p in Path().glob(f)] 244 | return Mdtest(files, parallel) 245 | 246 | return Mdtest() 247 | 248 | @override 249 | async def run(self) -> None: 250 | conf = self.load_config() 251 | files = self.files or conf.files 252 | parallel = self.parallel or conf.parallel or 1 253 | if files is None: 254 | cprint("No files to run!", fg="yellow") 255 | return 256 | 257 | # Setup test dir 258 | MDTEST_DIR.mkdir(exist_ok=True) 259 | 260 | # Assert each file exists 261 | for file in files: 262 | assert file.exists(), f"File {file} does not exist!" 263 | 264 | try: 265 | # Collect tests 266 | async with Spinner("Collecting Markdown Tests", capture=True): 267 | sm = asyncio.Semaphore(parallel) 268 | per_file = await asyncio.gather( 269 | *( 270 | parse_file(sm, file) 271 | for file in files 272 | if not file.parents[-1].name.startswith(".") 273 | ) 274 | ) 275 | all_tests = [test for file in per_file for test in file] 276 | 277 | # Run each file 278 | print() 279 | code = await Runner(parallel, self.timeout, self.verbose).run_mdtests( 280 | all_tests 281 | ) 282 | finally: 283 | # Cleanup 284 | shutil.rmtree(MDTEST_DIR) 285 | 286 | raise SystemExit(code) 287 | 288 | 289 | if __name__ == "__main__": 290 | mdtest = Mdtest.parse() 291 | mdtest.start() 292 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Clypi 2 | site_url: https://danimelchor.github.io/clypi/ 3 | repo_name: danimelchor/clypi 4 | repo_url: https://github.com/danimelchor/clypi 5 | 6 | theme: 7 | name: material 8 | logo: assets/icon.png 9 | favicon: assets/icon.png 10 | font: 11 | text: Roboto 12 | code: JetBrains Mono 13 | palette: 14 | - scheme: slate 15 | primary: deep purple 16 | accent: purple 17 | toggle: 18 | icon: material/weather-sunny 19 | name: Switch to light mode 20 | 21 | - scheme: default 22 | primary: deep purple 23 | accent: purple 24 | toggle: 25 | icon: material/weather-night 26 | name: Switch to dark mode 27 | features: 28 | - content.code.annotate 29 | - content.code.copy 30 | - content.code.select 31 | - content.tooltips 32 | - navigation.indexes 33 | - navigation.instant 34 | - navigation.path 35 | - navigation.tabs 36 | - navigation.top 37 | - navigation.tracking 38 | - search.highlight 39 | - search.share 40 | - toc.integrate 41 | - toc.follow 42 | 43 | nav: 44 | - Clypi: index.md 45 | - Learn: 46 | - Install: learn/install.md 47 | - Getting Started: learn/getting_started.md 48 | - Advanced Arguments: learn/advanced_arguments.md 49 | - Beautiful UIs: learn/beautiful_uis.md 50 | - Configuring clypi: learn/configuration.md 51 | - API: 52 | - Configuration: api/config.md 53 | - CLI: api/cli.md 54 | - UI Components: api/components.md 55 | - Colors: api/colors.md 56 | - Prompts: api/prompts.md 57 | - Parsers: api/parsers.md 58 | - Packaging: packaging.md 59 | - About: 60 | - Why Clypi?: about/why.md 61 | - Planned work: about/planned_work.md 62 | 63 | hooks: 64 | - docs/hooks/helpers.py 65 | 66 | extra_css: 67 | - stylesheets/extra.css 68 | - stylesheets/termynal.css 69 | 70 | extra_javascript: 71 | - javascripts/spinner.js 72 | 73 | plugins: 74 | - search 75 | - termynal 76 | - glightbox 77 | 78 | markdown_extensions: 79 | - admonition 80 | - attr_list 81 | - github-callouts 82 | - md_in_html 83 | - pymdownx.details 84 | - pymdownx.highlight: 85 | anchor_linenums: true 86 | line_spans: __span 87 | pygments_lang_class: true 88 | - pymdownx.inlinehilite 89 | - pymdownx.emoji: 90 | emoji_generator: !!python/name:material.extensions.emoji.to_svg 91 | emoji_index: !!python/name:material.extensions.emoji.twemoji 92 | - pymdownx.snippets 93 | - pymdownx.superfences 94 | - pymdownx.tasklist: 95 | custom_checkbox: true 96 | - toc: 97 | permalink: true 98 | - pymdownx.tabbed: 99 | alternate_style: true 100 | 101 | extra: 102 | social: 103 | - icon: fontawesome/brands/github-alt 104 | link: https://github.com/dmelchor/clypi 105 | - icon: fontawesome/brands/twitter 106 | link: https://x.com/dmelchor672 107 | - icon: fontawesome/brands/linkedin 108 | link: https://www.linkedin.com/in/danimelchor 109 | - icon: fontawesome/solid/globe 110 | link: https://dmelchor.com 111 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling==1.27.0"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "clypi" 7 | description = "Your all-in-one for beautiful, lightweight, prod-ready CLIs" 8 | readme = "README.md" 9 | version = "1.8.1" 10 | license = "MIT" 11 | license-files = ["LICEN[CS]E*"] 12 | requires-python = ">=3.11" 13 | authors = [ 14 | {name = "Daniel Melchor", email = "dmelchor@pm.me"}, 15 | ] 16 | keywords = ["cli", "terminal", "ui"] 17 | dependencies = [ 18 | "python-dateutil>=2.9.0.post0", 19 | "typing-extensions>=4.4.0", 20 | ] 21 | 22 | 23 | [project.urls] 24 | Documentation = "https://danimelchor.github.io/clypi/" 25 | Homepage = "https://danimelchor.github.io/clypi/" 26 | Repository = "https://github.com/danimelchor/clypi" 27 | Issues = "https://github.com/danimelchor/clypi/issues" 28 | 29 | [project.optional-dependencies] 30 | dev = [ 31 | "ruff>=0.9.7", 32 | "pyright[nodejs]>=1.1.396", 33 | "pytest>=8.3.5", 34 | "codespell>=2.4.1", 35 | "anyio>=4.8.0", 36 | "types-python-dateutil>=2.9.0.20241206", 37 | ] 38 | docs = [ 39 | "markdown-callouts>=0.4.0", 40 | "mkdocs-glightbox>=0.4.0", 41 | "mkdocs-material>=9.6.8", 42 | "pygments>=2.19.1", 43 | "termynal>=0.13.0", 44 | ] 45 | 46 | [tool.ruff] 47 | target-version = "py311" 48 | 49 | [tool.pytest.ini_options] 50 | minversion = "8.0" 51 | addopts = "-ra -q" 52 | testpaths = ["tests"] 53 | 54 | [tool.pyright] 55 | include = [ 56 | "examples", 57 | "tests", 58 | "type_tests", 59 | "clypi", 60 | "mdtest_autogen", 61 | ] 62 | typeCheckingMode = "strict" 63 | reportImplicitOverride = "error" 64 | reportUnknownArgumentType = "warning" 65 | reportUnknownParameterType = "warning" 66 | reportUnknownMemberType = "warning" 67 | reportUnknownVariableType = "warning" 68 | 69 | [tool.mdtest] 70 | include = [ 71 | "docs/**/*.md", 72 | "examples/**/*.md", 73 | "README.md", 74 | ] 75 | parallel = 15 76 | -------------------------------------------------------------------------------- /scripts/gen_readme: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from pathlib import Path 4 | 5 | REPLACEMENTS = { 6 | "./assets/logo.png": "http://danimelchor.github.io/clypi/assets/logo.png", 7 | } 8 | 9 | index = Path("docs/index.md") 10 | content = index.read_text() 11 | for orig, repl in REPLACEMENTS.items(): 12 | content = content.replace(orig, repl) 13 | 14 | output = Path("README.md") 15 | output.write_text(content) 16 | -------------------------------------------------------------------------------- /scripts/tag: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | branch=$(git rev-parse --abbrev-ref HEAD) 6 | if [[ $branch != "master" ]]; then 7 | echo "This script can only be ran in master. You're in '$branch'" 8 | exit 1 9 | fi 10 | 11 | git pull 12 | version=$(cat pyproject.toml | sed -En 's/^version = "([0-9\.]+)"$/\1/p') 13 | echo "Creating tag for version $version" 14 | 15 | git tag -a "$version" -m "$version Release" 16 | git push origin "$version" 17 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danimelchor/clypi/3cb07e5ae03610302c079169654083fd9305ab99/tests/__init__.py -------------------------------------------------------------------------------- /tests/boxed_test.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | 3 | from clypi import boxed 4 | 5 | 6 | def test_boxed(): 7 | result = boxed("a" * 16, width=20) 8 | expected = dedent( 9 | """ 10 | ┏━━━━━━━━━━━━━━━━━━┓ 11 | ┃ aaaaaaaaaaaaaaaa ┃ 12 | ┗━━━━━━━━━━━━━━━━━━┛ 13 | """ 14 | ) 15 | assert result == expected.strip() 16 | 17 | 18 | def test_boxed_with_wrapping(): 19 | result = boxed("a" * 40, width=20) 20 | expected = dedent( 21 | """ 22 | ┏━━━━━━━━━━━━━━━━━━┓ 23 | ┃ aaaaaaaaaaaaaaaa ┃ 24 | ┃ aaaaaaaaaaaaaaaa ┃ 25 | ┃ aaaaaaaa ┃ 26 | ┗━━━━━━━━━━━━━━━━━━┛ 27 | """ 28 | ) 29 | assert result == expected.strip() 30 | -------------------------------------------------------------------------------- /tests/cli_arg_config_test.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | import pytest 4 | 5 | from clypi._cli.arg_config import Nargs, _get_nargs # type: ignore 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "_type,expected", 10 | [ 11 | (bool, 0), 12 | (t.Literal["a"], 1), 13 | (t.Optional[bool], 1), 14 | (list[bool], "*"), 15 | (list[bool] | None, "*"), 16 | (list[bool] | None, "*"), 17 | (bool | int, 1), 18 | ], 19 | ) 20 | def test_get_nargs(_type: t.Any, expected: Nargs): 21 | assert _get_nargs(_type) == expected 22 | -------------------------------------------------------------------------------- /tests/cli_defer_test.py: -------------------------------------------------------------------------------- 1 | from typing_extensions import override 2 | 3 | from clypi import Command, arg 4 | from clypi.parsers import Str 5 | from tests.prompt_test import replace_stdin 6 | 7 | 8 | class DeferredError(Exception): 9 | pass 10 | 11 | 12 | class Signal: 13 | called: bool = False 14 | 15 | def __bool__(self): 16 | return self.called 17 | 18 | 19 | class SignalParser(Str): 20 | """Utility parser that just raises to test when it is evaluated""" 21 | 22 | def __init__(self, signal: Signal) -> None: 23 | self._signal = signal 24 | 25 | @override 26 | def __call__(self, raw: str | list[str], /) -> str: 27 | self._signal.called = True 28 | return super().__call__(raw) 29 | 30 | 31 | def test_never_evaluates(): 32 | called = Signal() 33 | 34 | class Main(Command): 35 | verbose: bool = False 36 | some_arg: str = arg( 37 | prompt="What's the value?", 38 | defer=True, 39 | parser=SignalParser(called), 40 | ) 41 | 42 | cmd = Main.parse([]) 43 | assert cmd.verbose is False 44 | assert not called 45 | 46 | 47 | def test_defer_with_cli_args(): 48 | called = Signal() 49 | 50 | class Main(Command): 51 | verbose: bool = False 52 | some_arg: str = arg( 53 | prompt="What's the value?", 54 | defer=True, 55 | parser=SignalParser(called), 56 | ) 57 | 58 | # Happens during CLI arg parse 59 | assert not called 60 | Main.parse(["--some-arg", "foo"]) 61 | assert called 62 | 63 | 64 | def test_defer_not_provided(): 65 | called = Signal() 66 | 67 | class Main(Command): 68 | verbose: bool = False 69 | some_arg: str = arg( 70 | prompt="What's the value?", 71 | defer=True, 72 | parser=SignalParser(called), 73 | ) 74 | 75 | cmd = Main.parse([]) 76 | 77 | # Happens during attribute access 78 | assert not called 79 | with replace_stdin("foo"): 80 | assert cmd.some_arg == "foo" 81 | assert called 82 | -------------------------------------------------------------------------------- /tests/cli_env_test.py: -------------------------------------------------------------------------------- 1 | import os 2 | import typing as t 3 | 4 | from clypi import Command, arg 5 | 6 | SOME_ENV_VAR = "SOME_ENV_VAR" 7 | SOME_ENV_VAR2 = "SOME_ENV_VAR2" 8 | 9 | 10 | class Main(Command): 11 | foo: float | None = arg(None, env=SOME_ENV_VAR) 12 | bar: list[int] = arg(env=SOME_ENV_VAR2) 13 | 14 | 15 | def test_env_var_works(monkeypatch: t.Any): 16 | monkeypatch.setenv(SOME_ENV_VAR, "-0.1") 17 | monkeypatch.setenv(SOME_ENV_VAR2, "1,2,3") 18 | 19 | # Just to make sure 20 | assert os.getenv(SOME_ENV_VAR) == "-0.1" 21 | assert os.getenv(SOME_ENV_VAR2) == "1,2,3" 22 | 23 | cmd = Main.parse([]) 24 | assert cmd.foo == -0.1 25 | assert cmd.bar == [1, 2, 3] 26 | -------------------------------------------------------------------------------- /tests/cli_hooks_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from typing_extensions import override 3 | 4 | from clypi import Command 5 | 6 | # A counter to check the order of events and their results 7 | _counter: list[Exception | int] = [] 8 | 9 | # An exception we will raise and catch 10 | _EXC: Exception = Exception("foo") 11 | 12 | 13 | class ExampleSubCommand(Command): 14 | """Some sample docs""" 15 | 16 | should_raise: bool = False 17 | 18 | @override 19 | async def pre_run_hook(self) -> None: 20 | _counter.append(1) 21 | 22 | @override 23 | async def post_run_hook(self, exception: Exception | None) -> None: 24 | _counter.append(exception or 3) 25 | 26 | @override 27 | async def run(self): 28 | _counter.append(2) 29 | 30 | if self.should_raise: 31 | raise _EXC 32 | 33 | 34 | def test_cli_hooks_run_in_order(): 35 | global _counter 36 | _counter = [] 37 | 38 | ExampleSubCommand().parse([]).start() 39 | 40 | # Pre-run, run, post-run no exception 41 | assert _counter == [1, 2, 3] 42 | 43 | 44 | def test_cli_hooks_catch_exception(): 45 | global _counter 46 | _counter = [] 47 | 48 | with pytest.raises(Exception): 49 | ExampleSubCommand().parse(["--should-raise"]).start() 50 | 51 | # Pre-run, run, post-run with catch 52 | assert _counter == [1, 2, _EXC] 53 | -------------------------------------------------------------------------------- /tests/cli_inherited_test.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | from dataclasses import dataclass 3 | 4 | import pytest 5 | 6 | from clypi import Command, Positional, arg 7 | from tests.cli_parse_test import parametrize 8 | from tests.prompt_test import replace_stdin 9 | 10 | 11 | @dataclass 12 | class CustomType: 13 | foo: str = "bar" 14 | 15 | 16 | def parse_custom(raw: str | list[str]) -> CustomType: 17 | return CustomType(foo=str(raw)) 18 | 19 | 20 | class Run(Command): 21 | """ 22 | Runs all files 23 | """ 24 | 25 | pos: Positional[str] = arg(inherited=True) 26 | verbose: bool = arg(inherited=True) 27 | env: str = arg(inherited=True) 28 | env_prompt: str = arg(inherited=True) 29 | custom: CustomType = arg(inherited=True) 30 | 31 | 32 | class Main(Command): 33 | subcommand: Run | None = None 34 | pos: Positional[int] = arg(help="Some positional arg") 35 | verbose: bool = arg(False, short="v", help="Whether to show more output") 36 | env: t.Literal["qa", "prod"] = arg(help="The environment to use") 37 | env_prompt: t.Literal["qa", "prod"] = arg( 38 | help="The environment to use", 39 | prompt="What environment should we use?", 40 | ) 41 | custom: CustomType = arg(default=CustomType(), parser=parse_custom) 42 | 43 | 44 | @parametrize( 45 | "args,expected,fails,stdin", 46 | [ 47 | ([], {}, True, ""), 48 | (["-v"], {}, True, ""), 49 | (["-v", "--env", "qa"], {}, True, ""), 50 | (["-v", "--env", "qa", "--env-prompt", "qa"], {}, True, ""), 51 | ( 52 | ["1", "-v", "--env", "qa"], 53 | { 54 | "pos": 1, 55 | "verbose": True, 56 | "env": "qa", 57 | "env_prompt": "qa", 58 | }, 59 | False, 60 | "qa\n", 61 | ), 62 | ( 63 | ["1", "-v", "--env", "qa", "--env-prompt", "qa"], 64 | { 65 | "pos": 1, 66 | "verbose": True, 67 | "env": "qa", 68 | "env_prompt": "qa", 69 | }, 70 | False, 71 | "", 72 | ), 73 | ( 74 | ["1", "--env", "qa", "-v", "run"], 75 | { 76 | "pos": 1, 77 | "verbose": True, 78 | "env": "qa", 79 | "env_prompt": "qa", 80 | "run": { 81 | "pos": 1, 82 | "verbose": True, 83 | "env": "qa", 84 | "env_prompt": "qa", 85 | }, 86 | }, 87 | False, 88 | "qa\n", 89 | ), 90 | ( 91 | ["1", "--custom", "baz", "run", "--env", "qa", "-v"], 92 | { 93 | "pos": 1, 94 | "verbose": True, 95 | "env": "qa", 96 | "env_prompt": "qa", 97 | "run": { 98 | "pos": 1, 99 | "verbose": True, 100 | "env": "qa", 101 | "env_prompt": "qa", 102 | "custom": CustomType("baz"), 103 | }, 104 | "custom": CustomType("baz"), 105 | }, 106 | False, 107 | "qa\n", 108 | ), 109 | ( 110 | ["--env", "qa", "run", "1", "-v", "--env-prompt", "qa"], 111 | { 112 | "pos": 1, 113 | "verbose": True, 114 | "env": "qa", 115 | "env_prompt": "qa", 116 | "run": { 117 | "pos": 1, 118 | "verbose": True, 119 | "env": "qa", 120 | "env_prompt": "qa", 121 | }, 122 | }, 123 | False, 124 | "", 125 | ), 126 | ( 127 | ["--env", "qa", "--env-prompt", "qa", "run", "1", "-v"], 128 | { 129 | "pos": 1, 130 | "verbose": True, 131 | "env": "qa", 132 | "env_prompt": "qa", 133 | "run": { 134 | "pos": 1, 135 | "verbose": True, 136 | "env": "qa", 137 | "env_prompt": "qa", 138 | }, 139 | }, 140 | False, 141 | "", 142 | ), 143 | ( 144 | ["--env", "qa", "run", "1", "-v"], 145 | { 146 | "pos": 1, 147 | "verbose": True, 148 | "env": "qa", 149 | "env_prompt": "qa", 150 | "run": { 151 | "pos": 1, 152 | "verbose": True, 153 | "env": "qa", 154 | "env_prompt": "qa", 155 | }, 156 | }, 157 | False, 158 | "qa\n", 159 | ), 160 | ( 161 | ["run", "--env", "qa", "-v", "1"], 162 | { 163 | "pos": 1, 164 | "verbose": True, 165 | "env": "qa", 166 | "env_prompt": "qa", 167 | "run": { 168 | "pos": 1, 169 | "verbose": True, 170 | "env": "qa", 171 | "env_prompt": "qa", 172 | }, 173 | }, 174 | False, 175 | "qa\n", 176 | ), 177 | (["run", "-v"], {}, True, ""), 178 | ( 179 | ["run", "--env", "qa", "-v", "1", "--custom", "baz"], 180 | { 181 | "pos": 1, 182 | "verbose": True, 183 | "env": "qa", 184 | "env_prompt": "qa", 185 | "run": { 186 | "pos": 1, 187 | "verbose": True, 188 | "env": "qa", 189 | "env_prompt": "qa", 190 | "custom": CustomType("baz"), 191 | }, 192 | "custom": CustomType("baz"), 193 | }, 194 | False, 195 | "qa\n", 196 | ), 197 | ], 198 | ) 199 | def test_parse_inherited( 200 | args: list[str], 201 | expected: dict[str, t.Any], 202 | fails: bool, 203 | stdin: str | list[str], 204 | ): 205 | if fails: 206 | with pytest.raises(BaseException): 207 | _ = Main.parse(args) 208 | return 209 | 210 | # Check command 211 | with replace_stdin(stdin): 212 | main = Main.parse(args) 213 | 214 | assert main is not None 215 | for k, v in expected.items(): 216 | if k == "run": 217 | continue 218 | lc_v = getattr(main, k) 219 | assert lc_v == v, f"{k} should be {v} but got {lc_v}" 220 | 221 | # Check subcommand 222 | if "run" in expected: 223 | assert main.subcommand is not None 224 | assert isinstance(main.subcommand, Run) 225 | for k, v in expected["run"].items(): 226 | lc_v = getattr(main, k) 227 | assert lc_v == v, f"run.{k} should be {v} but got {lc_v}" 228 | 229 | 230 | def test_inherited_fails_on_load(): 231 | class Subcmd(Command): 232 | pos: Positional[str] = arg(inherited=True) 233 | verbose: bool = arg(inherited=True) 234 | 235 | with pytest.raises(TypeError) as exc_info: 236 | 237 | class Main(Command): 238 | subcommand: Subcmd | None = None 239 | 240 | assert ( 241 | str(exc_info.value) 242 | == "Fields ['verbose', 'pos'] in Subcmd cannot be inherited from Main since they don't exist!" 243 | ) 244 | -------------------------------------------------------------------------------- /tests/cli_test.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Optional 3 | 4 | import pytest 5 | from typing_extensions import override 6 | 7 | from clypi import Command, Positional, arg 8 | from clypi._cli.arg_parser import Arg 9 | 10 | 11 | def _raise_error() -> str: 12 | raise ValueError("Whoops! This should never be called") 13 | 14 | 15 | class ExampleSubCommand(Command): 16 | """Some sample docs""" 17 | 18 | positional: Positional[tuple[str | Path, ...]] 19 | 20 | @override 21 | async def run(self): 22 | print("subcommand") 23 | 24 | 25 | class ExampleCommand(Command): 26 | """ 27 | Some sample documentation for the main command 28 | """ 29 | 30 | flag: bool = False 31 | subcommand: Optional[ExampleSubCommand] = None 32 | option: list[str] = arg( 33 | short="o", help="A list of strings please", default_factory=list 34 | ) 35 | default_test: str = arg(default_factory=_raise_error) 36 | 37 | @override 38 | @classmethod 39 | def prog(cls): 40 | return "example" 41 | 42 | @override 43 | @classmethod 44 | def epilog(cls): 45 | return "Some text to display after..." 46 | 47 | @override 48 | async def run(self): 49 | print("main") 50 | 51 | 52 | def test_expected_base(): 53 | assert ExampleCommand.help() == "Some sample documentation for the main command" 54 | assert ExampleCommand.prog() == "example" 55 | assert ExampleCommand.full_command() == ["example"] 56 | assert ExampleCommand.epilog() == "Some text to display after..." 57 | 58 | 59 | def test_expected_options(): 60 | opts = ExampleCommand.options() 61 | assert len(opts) == 3 62 | 63 | assert opts["flag"].name == "flag" 64 | assert opts["flag"].arg_type is bool 65 | assert opts["flag"].nargs == 0 66 | 67 | assert opts["option"].name == "option" 68 | assert opts["option"].arg_type == list[str] 69 | assert opts["option"].nargs == "*" 70 | 71 | 72 | def test_expected_positional(): 73 | pos = ExampleSubCommand.positionals() 74 | assert len(pos) == 1 75 | 76 | assert pos["positional"].name == "positional" 77 | assert pos["positional"].arg_type == Positional[tuple[str | Path, ...]] 78 | assert pos["positional"].nargs == 1 79 | 80 | 81 | def test_expected_subcommands(): 82 | ec = ExampleCommand.subcommands() 83 | assert len(ec) == 2 84 | 85 | assert ec[None] is None 86 | 87 | sub = ec["example-sub-command"] 88 | assert sub is ExampleSubCommand 89 | assert sub.prog() == "example-sub-command" 90 | assert sub.help() == "Some sample docs" 91 | 92 | 93 | def test_expected_cls_introspection(): 94 | assert ExampleCommand.flag is False 95 | 96 | 97 | def test_expected_init(): 98 | cmd = ExampleCommand(default_test="") 99 | assert cmd.flag is False 100 | assert cmd.option == [] 101 | assert cmd.subcommand is None 102 | 103 | 104 | def test_expected_init_with_kwargs(): 105 | cmd = ExampleCommand( 106 | flag=True, 107 | option=["f"], 108 | subcommand=ExampleSubCommand(positional=tuple("g")), 109 | default_test="", 110 | ) 111 | assert cmd.flag is True 112 | assert cmd.option == ["f"] 113 | assert cmd.subcommand is not None 114 | assert cmd.subcommand.positional == tuple("g") 115 | 116 | 117 | def test_expected_init_with_args(): 118 | cmd = ExampleCommand(True, ExampleSubCommand(tuple("g")), ["f"], "") 119 | assert cmd.flag is True 120 | assert cmd.option == ["f"] 121 | assert cmd.subcommand is not None 122 | assert cmd.subcommand.positional == tuple("g") 123 | 124 | 125 | def test_expected_init_with_mixed_args_kwargs(): 126 | cmd = ExampleCommand( 127 | True, ExampleSubCommand(tuple("g")), option=["f"], default_test="" 128 | ) 129 | assert cmd.flag is True 130 | assert cmd.option == ["f"] 131 | assert cmd.subcommand is not None 132 | assert cmd.subcommand.positional == tuple("g") 133 | 134 | 135 | def test_expected_repr(): 136 | cmd = ExampleCommand( 137 | flag=True, 138 | option=["f"], 139 | subcommand=ExampleSubCommand(positional=tuple("g")), 140 | default_test="foo", 141 | ) 142 | assert ( 143 | str(cmd) 144 | == "ExampleCommand(flag=True, option=['f'], subcommand=ExampleSubCommand(positional=('g',)), default_test=foo)" 145 | ) 146 | 147 | 148 | def test_get_similar_opt_error(): 149 | with pytest.raises(ValueError) as exc_info: 150 | raise ExampleCommand.get_similar_arg_error( 151 | Arg( 152 | "falg", # codespell:ignore 153 | "--falg", 154 | "long-opt", 155 | ) 156 | ) 157 | 158 | assert exc_info.value.args[0] == "Unknown option '--falg'. Did you mean '--flag'?" 159 | 160 | 161 | def test_get_similar_opt_short_error(): 162 | with pytest.raises(ValueError) as exc_info: 163 | raise ExampleCommand.get_similar_arg_error( 164 | Arg( 165 | "c", # codespell:ignore 166 | "-c", 167 | "short-opt", 168 | ) 169 | ) 170 | 171 | assert exc_info.value.args[0] == "Unknown option '-c'. Did you mean '-o'?" 172 | 173 | 174 | def test_get_similar_subcmd_error(): 175 | with pytest.raises(ValueError) as exc_info: 176 | raise ExampleCommand.get_similar_arg_error( 177 | Arg( 178 | "example-suv-command", 179 | "example-suv-command", 180 | "pos", 181 | ) 182 | ) 183 | 184 | assert ( 185 | exc_info.value.args[0] 186 | == "Unknown argument 'example-suv-command'. Did you mean 'example-sub-command'?" 187 | ) 188 | 189 | 190 | def test_get_similar_non_similar(): 191 | with pytest.raises(ValueError) as exc_info: 192 | raise ExampleCommand.get_similar_arg_error( 193 | Arg( 194 | "foo", 195 | "foo", 196 | "pos", 197 | ) 198 | ) 199 | 200 | assert exc_info.value.args[0] == "Unknown argument 'foo'" 201 | 202 | 203 | def test_repeated_subcommands(): 204 | class Example1(Command): 205 | @override 206 | @classmethod 207 | def prog(cls): 208 | return "example" 209 | 210 | class Example2(Command): 211 | @override 212 | @classmethod 213 | def prog(cls): 214 | return "example" 215 | 216 | with pytest.raises(TypeError) as exc_info: 217 | 218 | class Main(Command): 219 | subcommand: Example1 | Example2 220 | 221 | assert exc_info.value.args[0] == "Found duplicate subcommand 'example' in Main" 222 | -------------------------------------------------------------------------------- /tests/distance_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from clypi import closest, distance 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "this,other,expected", 8 | [ 9 | ("a", "a", 0), 10 | ("a", "A", 0.5), 11 | ("a", "b", 1), 12 | ("aa", "bb", 2), 13 | ("ab", "bb", 1), 14 | ("", "bb", 2), 15 | ("aa", "", 2), 16 | ("wrapped", "tapped", 2), 17 | ("that", "this", 2), 18 | ], 19 | ) 20 | def test_distance(this: str, other: str, expected: str): 21 | assert distance(this, other) == expected 22 | assert distance(other, this) == expected 23 | 24 | 25 | @pytest.mark.parametrize( 26 | "this,others,expected", 27 | [ 28 | ("v", ["v", "version", "foo"], ("v", 0)), 29 | ("v", ["V", "version", "foo"], ("V", 0.5)), 30 | ("a", ["b", "version", "foo"], ("b", 1)), 31 | ("that", ["this", "foo"], ("this", 2)), 32 | ], 33 | ) 34 | def test_closest(this: str, others: list[str], expected: tuple[str, int]): 35 | assert closest(this, others) == expected 36 | -------------------------------------------------------------------------------- /tests/exceptions_test.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | 3 | from clypi import format_traceback 4 | from clypi._exceptions import ClypiExceptionGroup 5 | 6 | 7 | def test_single_exception(): 8 | err = RuntimeError("Actual root cause") 9 | result = format_traceback(err, color=None) 10 | result_str = "\n".join(result) 11 | assert result_str == "Actual root cause".strip() 12 | 13 | 14 | def test_single_exception_no_args(): 15 | err = RuntimeError() 16 | result = format_traceback(err, color=None) 17 | result_str = "\n".join(result) 18 | assert result_str == "RuntimeError".strip() 19 | 20 | 21 | def test_basic_traceback(): 22 | root_cause = RuntimeError("Actual root cause") 23 | 24 | context = ValueError("Some context") 25 | context.__cause__ = root_cause 26 | 27 | err = ValueError("User facing error") 28 | err.__cause__ = context 29 | 30 | result = format_traceback(err, color=None) 31 | result_str = "\n".join(result) 32 | assert ( 33 | result_str 34 | == dedent( 35 | """ 36 | User facing error 37 | ↳ Some context 38 | ↳ Actual root cause 39 | """ 40 | ).strip() 41 | ) 42 | 43 | 44 | def test_traceback_exc_group(): 45 | context1 = ValueError("Failed to parse 'foo' as int()") 46 | context2 = ValueError("Failed to parse 'foo' as None") 47 | err = ClypiExceptionGroup("Failed to parse as int|none", [context1, context2]) 48 | 49 | result = format_traceback(err, color=None) 50 | result_str = "\n".join(result) 51 | assert ( 52 | result_str 53 | == dedent( 54 | """ 55 | Failed to parse as int|none 56 | ↳ Failed to parse 'foo' as int() 57 | ↳ Failed to parse 'foo' as None 58 | """ 59 | ).strip() 60 | ) 61 | 62 | 63 | def test_traceback_complex(): 64 | root1 = ValueError("Invalid int literal for base 10 'foo'") 65 | context1 = ValueError("Failed to parse 'foo' as int()") 66 | context1.__cause__ = root1 67 | 68 | root2_1 = ValueError("Text 'foo' does not match 'none'") 69 | root2_2 = ValueError("Text 'foo' is not an empty list") 70 | context2 = ClypiExceptionGroup("Failed to parse 'foo' as None", [root2_1, root2_2]) 71 | 72 | err = ClypiExceptionGroup("Failed to parse as int|none", [context1, context2]) 73 | 74 | result = format_traceback(err, color=None) 75 | result_str = "\n".join(result) 76 | assert ( 77 | result_str 78 | == dedent( 79 | """ 80 | Failed to parse as int|none 81 | ↳ Failed to parse 'foo' as int() 82 | ↳ Invalid int literal for base 10 'foo' 83 | ↳ Failed to parse 'foo' as None 84 | ↳ Text 'foo' does not match 'none' 85 | ↳ Text 'foo' is not an empty list 86 | """ 87 | ).strip() 88 | ) 89 | -------------------------------------------------------------------------------- /tests/formatter_test.py: -------------------------------------------------------------------------------- 1 | from contextlib import suppress 2 | from io import StringIO 3 | from textwrap import dedent 4 | 5 | from clypi import Command, Positional, arg, get_config 6 | from tests.prompt_test import replace_stdout 7 | 8 | 9 | def _assert_stdout_matches(stdout: StringIO, expected: str): 10 | __tracebackhide__ = True 11 | stdout_str = stdout.getvalue() 12 | assert stdout_str.strip() == expected.strip() 13 | 14 | 15 | def _get_help( 16 | cmd: type[Command], subcmds: list[str] = [], error: bool = False 17 | ) -> StringIO: 18 | with replace_stdout() as stdout: 19 | with suppress(SystemExit): 20 | cmd.parse( 21 | ["--sdasdasjkdasd"] 22 | if error 23 | else [ 24 | *subcmds, 25 | "--help", 26 | ] 27 | ) 28 | 29 | return stdout 30 | 31 | 32 | class TestCase: 33 | def setup_method(self): 34 | conf = get_config() 35 | conf.disable_colors = True 36 | conf.fallback_term_width = 50 37 | 38 | def test_basic_example(self): 39 | class Main(Command): 40 | pass 41 | 42 | stdout = _get_help(Main) 43 | _assert_stdout_matches( 44 | stdout, 45 | dedent( 46 | """ 47 | Usage: main 48 | """ 49 | ), 50 | ) 51 | 52 | def test_basic_example_with_error(self): 53 | class Main(Command): 54 | pass 55 | 56 | stdout = _get_help(Main, error=True) 57 | _assert_stdout_matches( 58 | stdout, 59 | dedent( 60 | """ 61 | Usage: main 62 | 63 | ┏━ Error ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ 64 | ┃ Unknown option '--sdasdasjkdasd' ┃ 65 | ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ 66 | """ 67 | ), 68 | ) 69 | 70 | def test_basic_example_with_all_args(self): 71 | class Subcmd(Command): 72 | pass 73 | 74 | class Main(Command): 75 | subcommand: Subcmd 76 | positional: Positional[str] 77 | flag: bool = False 78 | option: int = 5 79 | 80 | stdout = _get_help(Main) 81 | _assert_stdout_matches( 82 | stdout, 83 | dedent( 84 | """ 85 | Usage: main [POSITIONAL] [OPTIONS] COMMAND 86 | 87 | ┏━ Subcommands ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ 88 | ┃ subcmd ┃ 89 | ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ 90 | 91 | ┏━ Arguments ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ 92 | ┃ [POSITIONAL] ┃ 93 | ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ 94 | 95 | ┏━ Options ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ 96 | ┃ --flag ┃ 97 | ┃ --option