├── .github ├── dependabot.yml └── workflows │ ├── nightly-tests.yml │ └── tests.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .vscode ├── cspell.json ├── extensions.json ├── launch.json └── settings.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── pyproject.toml ├── requirements-dev.txt ├── rich_argparse ├── __init__.py ├── __main__.py ├── _argparse.py ├── _common.py ├── _contrib.py ├── _lazy_rich.py ├── _optparse.py ├── _patching.py ├── _patching.pyi ├── contrib.py ├── django.py ├── optparse.py └── py.typed ├── scripts ├── generate-preview ├── release └── run-tests └── tests ├── __init__.py ├── conftest.py ├── helpers.py ├── requirements.txt ├── test_argparse.py ├── test_contrib.py ├── test_django.py └── test_optparse.py /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "github-actions" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/nightly-tests.yml: -------------------------------------------------------------------------------- 1 | name: nightly-tests 2 | 3 | on: 4 | push: 5 | branches: ["main", "python-nightly"] 6 | paths-ignore: 7 | - ".vscode/**" 8 | - "scripts/**" 9 | - ".pre-commit-config.yaml" 10 | - "*.md" 11 | schedule: 12 | - cron: '0 8 * * *' 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | strategy: 18 | matrix: 19 | python-version: ["3.14-dev"] 20 | name: main 21 | steps: 22 | - uses: actions/checkout@v4 23 | - uses: deadsnakes/action@v3.2.0 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | - run: python --version --version && which python 27 | - name: Install uv 28 | uses: astral-sh/setup-uv@v6 29 | - name: Install dependencies 30 | run: | 31 | uv venv --python $(which python) 32 | uv pip install . -r tests/requirements.txt 33 | - name: Run the test suite 34 | run: uv run pytest -vv --color=yes --cov 35 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | pull_request: 5 | paths-ignore: 6 | - ".vscode/**" 7 | - "scripts/**" 8 | - ".pre-commit-config.yaml" 9 | - "*.md" 10 | push: 11 | branches: [main] 12 | paths-ignore: 13 | - ".vscode/**" 14 | - "scripts/**" 15 | - ".pre-commit-config.yaml" 16 | - "*.md" 17 | 18 | jobs: 19 | build: 20 | strategy: 21 | matrix: 22 | os: [ubuntu-latest] 23 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] 24 | include: 25 | - os: windows-latest 26 | python-version: "3.13" 27 | - os: macos-latest 28 | python-version: "3.13" 29 | runs-on: ${{ matrix.os }} 30 | steps: 31 | - uses: actions/checkout@v4 32 | - name: Set up Python ${{ matrix.python-version }} 33 | uses: actions/setup-python@v5 34 | with: 35 | python-version: ${{ matrix.python-version }} 36 | allow-prereleases: true 37 | - name: Install uv 38 | uses: astral-sh/setup-uv@v6 39 | - name: Install dependencies 40 | run: | 41 | uv venv --python ${{ matrix.python-version }} 42 | uv pip install . -r tests/requirements.txt 43 | - name: Run the test suite 44 | run: uv run pytest -vv --color=yes --cov 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.pyc 3 | *.swp 4 | /.coverage 5 | /.tox 6 | dist/ 7 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | autoupdate_schedule: "quarterly" 3 | 4 | repos: 5 | - repo: https://github.com/pre-commit/pre-commit-hooks 6 | rev: v5.0.0 7 | hooks: 8 | - id: check-added-large-files 9 | - id: check-case-conflict 10 | - id: check-merge-conflict 11 | - id: check-toml 12 | - id: check-yaml 13 | - id: end-of-file-fixer 14 | - id: mixed-line-ending 15 | - id: trailing-whitespace 16 | - repo: https://github.com/astral-sh/ruff-pre-commit 17 | rev: v0.11.4 18 | hooks: 19 | - id: ruff 20 | args: [--fix] 21 | - id: ruff-format 22 | - repo: https://github.com/pre-commit/mirrors-mypy 23 | rev: v1.15.0 24 | hooks: 25 | - id: mypy 26 | additional_dependencies: ["rich", "types-colorama", "django-stubs"] 27 | pass_filenames: false 28 | args: ["rich_argparse"] 29 | - repo: local 30 | hooks: 31 | - id: bad-gh-link 32 | name: bad-gh-link 33 | description: Detect PR/Issue GitHub links text that don't match their URL in CHANGELOG.md 34 | language: pygrep 35 | entry: '(?i)\[(?:PR|GH)-(\d+)\]\(https://github.com/hamdanal/rich-argparse/(?:pull|issues)/(?!\1/?\))\d+/?\)' 36 | files: CHANGELOG.md 37 | -------------------------------------------------------------------------------- /.vscode/cspell.json: -------------------------------------------------------------------------------- 1 | { 2 | // Version of the setting file. Always 0.2 3 | "version": "0.2", 4 | // language - current active spelling language 5 | "language": "en", 6 | // words - list of words to be always considered correct 7 | "words": [ 8 | "capsys", 9 | "devenv", 10 | "htmlcov", 11 | "isort", 12 | "kwargs", 13 | "kwds", 14 | "mbar", 15 | "mdescription", 16 | "mepilog", 17 | "mfile", 18 | "mfoo", 19 | "mmiddle", 20 | "moom", 21 | "moptional", 22 | "mparser", 23 | "mremainder", 24 | "mrequired", 25 | "mshow", 26 | "msuppress", 27 | "msyntax", 28 | "mypy", 29 | "mzom", 30 | "positionals", 31 | "pypi", 32 | "pyproject", 33 | "pytest", 34 | "pyupgrade", 35 | "sdist", 36 | "venv", 37 | "virtualenv", 38 | "yesqa" 39 | ], 40 | // flagWords - list of words to be always considered incorrect 41 | // This is useful for offensive words and common spelling errors. 42 | // For example "hte" should be "the" 43 | "flagWords": [ 44 | "hte", 45 | "tge" 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "charliermarsh.ruff", 4 | "ms-python.python", 5 | "ms-python.vscode-pylance", 6 | ], 7 | "unwantedRecommendations": [ 8 | "ms-python.black-formatter", 9 | "ms-python.flake8", 10 | "ms-python.isort", 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Python: Debug Tests", 9 | "type": "python", 10 | "request": "launch", 11 | "program": "${file}", 12 | "purpose": [ 13 | "debug-test", 14 | "debug-in-terminal", 15 | ], 16 | "console": "integratedTerminal", 17 | "justMyCode": false 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.analysis.diagnosticSeverityOverrides": { 3 | "reportInvalidStringEscapeSequence": "warning", 4 | "reportUnusedImport": "warning", 5 | }, 6 | "python.testing.pytestEnabled": true, 7 | "python.testing.unittestEnabled": false, 8 | "[python]": { 9 | "editor.tabSize": 4, 10 | "editor.defaultFormatter": "charliermarsh.ruff", 11 | "editor.formatOnSave": true, 12 | "editor.codeActionsOnSave": { 13 | "source.organizeImports.ruff": "explicit", 14 | }, 15 | }, 16 | "[yaml][toml]": { 17 | "editor.tabSize": 2 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## Unreleased 4 | 5 | ### Features 6 | - [GH-155](https://github.com/hamdanal/rich-argparse/issues/155), 7 | [PR-160](https://github.com/hamdanal/rich-argparse/pull/160) 8 | Python 3.8 is no longer supported (EOL since 7/10/2024) 9 | 10 | ### Fixes 11 | - [PR-162](https://github.com/hamdanal/rich-argparse/pull/162) 12 | Fix TypeError on nightly builds (Python 3.14.0a7+) due to new `HelpFormatter` arguments. 13 | The `console` parameter is now keyword-only. 14 | 15 | ## 1.7.0 - 2025-02-08 16 | 17 | ### Features 18 | - [PR-149](https://github.com/hamdanal/rich-argparse/pull/149) 19 | Add support for django commands in the new `rich_argparse.django` module. Read more at 20 | https://github.com/hamdanal/rich-argparse#django-support 21 | - [GH-140](https://github.com/hamdanal/rich-argparse/issues/140), 22 | [PR-147](https://github.com/hamdanal/rich-argparse/pull/147) 23 | Add `ParagraphRichHelpFormatter`, a formatter that preserves paragraph breaks, in the new 24 | `rich_argparse.contrib` module. Read more at 25 | https://github.com/hamdanal/rich-argparse#additional-formatters 26 | 27 | ### Fixes 28 | - [GH-152](https://github.com/hamdanal/rich-argparse/issues/152), 29 | [PR-153](https://github.com/hamdanal/rich-argparse/pull/153), 30 | [PR-154](https://github.com/hamdanal/rich-argparse/pull/154) 31 | Fix `ValueError` when using `%(default)s` inside square brackets and `help_markup` is enabled. 32 | - [GH-141](https://github.com/hamdanal/rich-argparse/issues/141), 33 | [PR-142](https://github.com/hamdanal/rich-argparse/pull/142) 34 | Do not highlight --options inside backticks. 35 | 36 | ## 1.6.0 - 2024-11-02 37 | 38 | ### Fixes 39 | - [GH-133](https://github.com/hamdanal/rich-argparse/issues/133), 40 | [PR-135](https://github.com/hamdanal/rich-argparse/pull/135) 41 | Fix help preview generation with newer releases of rich. 42 | - [GH-130](https://github.com/hamdanal/rich-argparse/issues/130), 43 | [PR-131](https://github.com/hamdanal/rich-argparse/pull/131) 44 | Fix a bug that caused long group titles to wrap. 45 | - [GH-125](https://github.com/hamdanal/rich-argparse/issues/125), 46 | [GH-127](https://github.com/hamdanal/rich-argparse/pull/127), 47 | [PR-128](https://github.com/hamdanal/rich-argparse/pull/128) 48 | Redesign metavar styling to fix broken colors of usage when some metavars are wrapped to multiple 49 | lines. The brackets and spaces of metavars are no longer colored. 50 | 51 | ## 1.5.2 - 2024-06-15 52 | 53 | ### Fixes 54 | - [PR-124](https://github.com/hamdanal/rich-argparse/pull/124) 55 | Fix a regression in `%(default)s` style that was introduced in #123. 56 | 57 | ## 1.5.1 - 2024-06-06 58 | 59 | ### Fixes 60 | - [GH-121](https://github.com/hamdanal/rich-argparse/issues/121), 61 | [PR-123](https://github.com/hamdanal/rich-argparse/pull/123) 62 | Fix `%(default)s` style when help markup is deactivated. 63 | 64 | ## 1.5.0 - 2024-06-01 65 | 66 | ### Features 67 | - [PR-103](https://github.com/hamdanal/rich-argparse/pull/103) 68 | Python 3.13 is now officially supported 69 | - [GH-95](https://github.com/hamdanal/rich-argparse/issues/95), 70 | [PR-103](https://github.com/hamdanal/rich-argparse/pull/103) 71 | Python 3.7 is no longer supported (EOL since 27/6/2023) 72 | - [GH-120](https://github.com/hamdanal/rich-argparse/issues/120), 73 | [GH-121](https://github.com/hamdanal/rich-argparse/issues/121), 74 | [PR-122](https://github.com/hamdanal/rich-argparse/pull/122) 75 | Add options `help_markup` and `text_markup` to disable console markup in the help text and the 76 | description text respectively. 77 | 78 | ### Fixes 79 | - [GH-115](https://github.com/hamdanal/rich-argparse/issues/115), 80 | [PR-116](https://github.com/hamdanal/rich-argparse/pull/116) 81 | Do not print group names suppressed with `argparse.SUPPRESS` 82 | 83 | ## 1.4.0 - 2023-10-21 84 | 85 | ### Features 86 | - [PR-90](https://github.com/hamdanal/rich-argparse/pull/90) 87 | Make `RichHelpFormatter` itself a rich renderable with rich console. 88 | - [GH-91](https://github.com/hamdanal/rich-argparse/issues/91), 89 | [PR-92](https://github.com/hamdanal/rich-argparse/pull/92) 90 | Allow passing custom console to `RichHelpFormatter`. 91 | - [GH-91](https://github.com/hamdanal/rich-argparse/issues/91), 92 | [PR-93](https://github.com/hamdanal/rich-argparse/pull/93) 93 | Add `HelpPreviewAction` to generate a preview of the help output in SVG, HTML, or TXT formats. 94 | - [PR-97](https://github.com/hamdanal/rich-argparse/pull/97) 95 | Avoid importing `typing` to improve startup time by about 35%. 96 | - [GH-84](https://github.com/hamdanal/rich-argparse/issues/84), 97 | [PR-98](https://github.com/hamdanal/rich-argparse/pull/98) 98 | Add a style for default values when using `%(default)s` in the help text. 99 | - [PR-99](https://github.com/hamdanal/rich-argparse/pull/99) 100 | Allow arbitrary renderables in the descriptions and epilog. 101 | 102 | ### Fixes 103 | - [GH-100](https://github.com/hamdanal/rich-argparse/issues/100), 104 | [PR-101](https://github.com/hamdanal/rich-argparse/pull/101) 105 | Fix color of brackets surrounding positional arguments in the usage. 106 | 107 | ## 1.3.0 - 2023-08-19 108 | 109 | ### Features 110 | - [PR-87](https://github.com/hamdanal/rich-argparse/pull/87) 111 | Add `optparse.GENERATE_USAGE` to auto generate a usage similar to argparse. 112 | - [PR-87](https://github.com/hamdanal/rich-argparse/pull/87) 113 | Add `rich_format_*` methods to optparse formatters. These return a `rich.text.Text` object. 114 | 115 | ### Fixes 116 | - [GH-79](https://github.com/hamdanal/rich-argparse/issues/79), 117 | [PR-80](https://github.com/hamdanal/rich-argparse/pull/80), 118 | [PR-85](https://github.com/hamdanal/rich-argparse/pull/85) 119 | Fix ansi escape codes on legacy Windows console 120 | 121 | ## 1.2.0 - 2023-07-02 122 | 123 | ### Features 124 | - [PR-73](https://github.com/hamdanal/rich-argparse/pull/73) 125 | Add experimental support for `optparse`. Import optparse formatters from `rich_argparse.optparse`. 126 | 127 | ### Changes 128 | - [PR-72](https://github.com/hamdanal/rich-argparse/pull/72) 129 | The project now uses `ruff` for linting and import sorting. 130 | - [PR-71](https://github.com/hamdanal/rich-argparse/pull/71) 131 | `rich_argparse` is now a package instead of a module. This should not affect users. 132 | 133 | ### Fixes 134 | - [PR-74](https://github.com/hamdanal/rich-argparse/pull/74) 135 | Fix crash when a metavar following a long option contains control codes. 136 | 137 | ## 1.1.1 - 2023-05-30 138 | 139 | ### Fixes 140 | - [GH-67](https://github.com/hamdanal/rich-argparse/issues/67), 141 | [PR-69](https://github.com/hamdanal/rich-argparse/pull/69) 142 | Fix `%` not being escaped properly 143 | - [PR-68](https://github.com/hamdanal/rich-argparse/pull/68) 144 | Restore lazy loading of `rich`. Delay its import until it is needed. 145 | 146 | ## 1.1.0 - 2023-03-11 147 | 148 | ### Features 149 | - [GH-55](https://github.com/hamdanal/rich-argparse/issues/55), 150 | [PR-56](https://github.com/hamdanal/rich-argparse/pull/56) 151 | Add a new style for `%(prog)s` in the usage. The style is applied in argparse-generated usage and 152 | in user defined usage whether the user usage is plain text or rich markup. 153 | 154 | ## 1.0.0 - 2023-01-07 155 | 156 | ### Fixes 157 | - [GH-49](https://github.com/hamdanal/rich-argparse/issues/49), 158 | [PR-50](https://github.com/hamdanal/rich-argparse/pull/50) 159 | `RichHelpFormatter` now respects format conversion types in help strings 160 | 161 | ## 0.7.0 - 2022-12-31 162 | 163 | ### Features 164 | - [GH-47](https://github.com/hamdanal/rich-argparse/issues/47), 165 | [PR-48](https://github.com/hamdanal/rich-argparse/pull/48) 166 | The default `group_name_formatter` has changed from `str.upper` to `str.title`. This renders 167 | better with long group names and follows the convention of popular CLI tools and programs. 168 | Please note that if you test the output of your CLI **verbatim** and rely on the default behavior 169 | of rich_argparse, you will have to either set the formatter explicitly or update the tests. 170 | 171 | 172 | ## 0.6.0 - 2022-12-18 173 | 174 | ### Features 175 | - [PR-43](https://github.com/hamdanal/rich-argparse/pull/43) 176 | Support type checking for users. Bundle type information in the wheel and sdist. 177 | 178 | ### Fixes 179 | - [PR-43](https://github.com/hamdanal/rich-argparse/pull/43) 180 | Fix annotations of class variables previously typed as instance variables. 181 | 182 | ## 0.5.0 - 2022-11-05 183 | 184 | ### Features 185 | - [PR-38](https://github.com/hamdanal/rich-argparse/pull/38) 186 | Support console markup in **custom** `usage` messages. Note that this feature is not activated by 187 | default. To enable it, set `RichHelpFormatter.usage_markup = True`. 188 | 189 | 190 | ### Fixes 191 | - [PR-35](https://github.com/hamdanal/rich-argparse/pull/35) 192 | Use `soft_wrap` in `console.print` instead of a large fixed console width for wrapping 193 | - [GH-36](https://github.com/hamdanal/rich-argparse/issues/36), 194 | [PR-37](https://github.com/hamdanal/rich-argparse/pull/37) 195 | Fix a regression in highlight regexes that caused the formatter to crash when using the same 196 | style multiple times. 197 | 198 | 199 | ## 0.4.0 - 2022-10-15 200 | 201 | ### Features 202 | - [PR-31](https://github.com/hamdanal/rich-argparse/pull/31) 203 | Add support for all help formatters of argparse. Now there are five formatter classes defined in 204 | `rich_argparse`: 205 | ``` 206 | RichHelpFormatter: the equivalent of argparse.HelpFormatter 207 | RawDescriptionRichHelpFormatter: the equivalent of argparse.RawDescriptionHelpFormatter 208 | RawTextRichHelpFormatter: the equivalent of argparse.RawTextHelpFormatter 209 | ArgumentDefaultsRichHelpFormatter: the equivalent of argparse.ArgumentDefaultsHelpFormatter 210 | MetavarTypeRichHelpFormatter: the equivalent of argparse.MetavarTypeHelpFormatter 211 | ``` 212 | Note that this changes the default behavior of `RichHelpFormatter` to no longer respect line 213 | breaks in the description and help text. It now behaves similarly to the original 214 | `HelpFormatter`. You have now to use the appropriate subclass for this to happen. 215 | 216 | ## 0.3.1 - 2022-10-08 217 | 218 | ### Fixes 219 | - [GH-28](https://github.com/hamdanal/rich-argparse/issues/28), 220 | [PR-30](https://github.com/hamdanal/rich-argparse/pull/30) 221 | Fix required options not coloured in the usage 222 | 223 | ## 0.3.0 - 2022-10-01 224 | 225 | ### Features 226 | - [GH-16](https://github.com/hamdanal/rich-argparse/issues/16), 227 | [PR-17](https://github.com/hamdanal/rich-argparse/pull/17) 228 | A new custom usage lexer that is consistent with the formatter styles 229 | 230 | ### Fixes 231 | - [GH-16](https://github.com/hamdanal/rich-argparse/issues/16), 232 | [PR-17](https://github.com/hamdanal/rich-argparse/pull/17) 233 | Fix inconsistent coloring of args in the top usage panel 234 | - [GH-12](https://github.com/hamdanal/rich-argparse/issues/12), 235 | [PR-20](https://github.com/hamdanal/rich-argparse/pull/20) 236 | Fix incorrect line breaks that put metavars on a alone on a new line 237 | - [GH-19](https://github.com/hamdanal/rich-argparse/issues/19), 238 | [PR-21](https://github.com/hamdanal/rich-argparse/pull/21) 239 | Do not print help output, return it instead 240 | 241 | ### Changes 242 | - [PR-17](https://github.com/hamdanal/rich-argparse/pull/17) 243 | The default styles have been changed to be more in line with the new usage coloring 244 | - [PR-20](https://github.com/hamdanal/rich-argparse/pull/20) 245 | The default `max_help_position` is now set to 24 (the default used in argparse) as line breaks 246 | are no longer an issue 247 | 248 | 249 | ### Removed 250 | - [PR-20](https://github.com/hamdanal/rich-argparse/pull/20) 251 | The `RichHelpFormatter.renderables` property has been removed, it was never documented 252 | 253 | ### Tests 254 | - [PR-22](https://github.com/hamdanal/rich-argparse/pull/22) 255 | Run windows tests in CI 256 | 257 | ## 0.2.1 - 2022-09-25 258 | 259 | ### Fixes 260 | - [GH-13](https://github.com/hamdanal/rich-argparse/issues/13), 261 | [PR-14](https://github.com/hamdanal/rich-argparse/pull/14) 262 | Fix compatibility with `argparse.ArgumentDefaultsHelpFormatter` 263 | 264 | ## 0.2.0 - 2022-09-17 265 | 266 | ### Features 267 | - [GH-4](https://github.com/hamdanal/rich-argparse/issues/4), 268 | [PR-9](https://github.com/hamdanal/rich-argparse/pull/9) 269 | Metavars now have their own style `argparse.metavar` which defaults to `'bold cyan'` 270 | 271 | ### Fixes 272 | - [GH-4](https://github.com/hamdanal/rich-argparse/issues/4), 273 | [PR-10](https://github.com/hamdanal/rich-argparse/pull/10) 274 | Add missing ":" after the group name similar to the default HelpFormatter 275 | - [PR-11](https://github.com/hamdanal/rich-argparse/pull/11) 276 | Fix padding of long options or metavars 277 | - [PR-11](https://github.com/hamdanal/rich-argparse/pull/11) 278 | Fix overflow of text in help that was truncated 279 | - [PR-11](https://github.com/hamdanal/rich-argparse/pull/11) 280 | Escape parameters that get substituted with % such as %(prog)s and %(default)s 281 | - [PR-11](https://github.com/hamdanal/rich-argparse/pull/11) 282 | Fix flaky wrapping of long lines 283 | 284 | ## 0.1.1 - 2022-09-10 285 | 286 | ### Fixes 287 | - [GH-5](https://github.com/hamdanal/rich-argparse/issues/5), 288 | [PR-6](https://github.com/hamdanal/rich-argparse/pull/6) 289 | Fix `RichHelpFormatter` does not replace `%(prog)s` in text 290 | - [GH-7](https://github.com/hamdanal/rich-argparse/issues/7), 291 | [PR-8](https://github.com/hamdanal/rich-argparse/pull/8) 292 | Fix extra newline at the end 293 | 294 | ## 0.1.0 - 2022-09-03 295 | 296 | Initial release 297 | 298 | ### Features 299 | - First upload to PyPI, `pip install rich-argparse` now supported 300 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to rich-argparse 2 | 3 | The best way to contribute to this project is by opening an issue in the issue tracker. Issues for 4 | reporting bugs or requesting new features are all welcome and appreciated. Also, [Discussions] are 5 | open for any discussion related to this project. There you can ask questions, discuss ideas, or 6 | simply show your clever snippets or hacks. 7 | 8 | Code contributions are also welcome in the form of Pull Requests. For these you need to open an 9 | issue prior to starting work to discuss it first (with the exception of very clear bug fixes and 10 | typo fixes where an issue may not be needed). 11 | 12 | ## Getting started 13 | 14 | *python* version 3.9 or higher is required for development. 15 | 16 | 1. Fork the repository on GitHub. 17 | 18 | 2. Clone the repository: 19 | 20 | ```sh 21 | git clone git@github.com:/rich-argparse.git rich-argparse 22 | cd rich-argparse 23 | ``` 24 | 3. Create and activate a virtual environment: 25 | 26 | Linux and macOS: 27 | ```sh 28 | python3 -m venv .venv 29 | . .venv/bin/activate 30 | ``` 31 | 32 | Windows: 33 | ```sh 34 | py -m venv .venv 35 | .venv\Scripts\activate 36 | ``` 37 | 38 | 4. Install the project and its dependencies: 39 | 40 | ```sh 41 | python -m pip install -r requirements-dev.txt 42 | ``` 43 | 44 | ## Testing 45 | 46 | Running all the tests can be done with `pytest --cov`. This also runs the test coverage to ensure 47 | 100% of the code is covered by tests. You can also run individual tests with 48 | `pytest -k the_name_of_your_test`. 49 | 50 | The helper script `scripts/run-tests` runs the tests with coverage on all supported python versions. 51 | 52 | ### Code quality 53 | 54 | After staging your work with `git add`, you can run `pre-commit run --all-files` to run all the 55 | code quality tools. These include [ruff] for formatting and linting, and [mypy] for 56 | type checking. You can also run each tool individually with `pre-commit run --all-files`. 57 | 58 | ## Creating a Pull Request 59 | 60 | Once you are happy with your change you can create a pull request. GitHub offers a guide on how to 61 | do this [here][PR]. Please ensure that you include a good description of what your change does in 62 | your pull request, and link it to any relevant issues or discussions. 63 | 64 | [Discussions]: https://github.com/hamdanal/rich-argparse/discussions 65 | [mypy]: https://mypy.readthedocs.io/en/stable/ 66 | [ruff]: https://docs.astral.sh/ruff/ 67 | [PR]: https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request-from-a-fork 68 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Ali Hamdan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rich-argparse 2 | 3 | ![python -m rich_argparse]( 4 | https://github.com/hamdanal/rich-argparse/assets/93259987/5eb719ce-9865-4654-a5c6-04950a86d40d) 5 | 6 | [![tests](https://github.com/hamdanal/rich-argparse/actions/workflows/tests.yml/badge.svg) 7 | ](https://github.com/hamdanal/rich-argparse/actions/workflows/tests.yml) 8 | [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/hamdanal/rich-argparse/main.svg) 9 | ](https://results.pre-commit.ci/latest/github/hamdanal/rich-argparse/main) 10 | [![Downloads](https://img.shields.io/pypi/dm/rich-argparse)](https://pypistats.org/packages/rich-argparse) 11 | [![Python Version](https://img.shields.io/pypi/pyversions/rich-argparse) 12 | ![Release](https://img.shields.io/pypi/v/rich-argparse) 13 | ](https://pypi.org/project/rich-argparse/) 14 | 15 | Format argparse and optparse help using [rich](https://pypi.org/project/rich). 16 | 17 | *rich-argparse* improves the look and readability of argparse's help while requiring minimal 18 | changes to the code. 19 | 20 | ## Table of contents 21 | 22 | * [Installation](#installation) 23 | * [Usage](#usage) 24 | * [Output styles](#output-styles) 25 | * [Customizing colors](#customize-the-colors) 26 | * [Group name formatting](#customize-the-group-name-format) 27 | * [Special text highlighting](#special-text-highlighting) 28 | * [Customizing `usage`](#colors-in-the-usage) 29 | * [Console markup](#disable-console-markup) 30 | * [Colors in `--version`](#colors-in---version) 31 | * [Rich renderables](#rich-descriptions-and-epilog) 32 | * [Working with subparsers](#working-with-subparsers) 33 | * [Documenting your CLI](#generate-help-preview) 34 | * [Additional formatters](#additional-formatters) 35 | * [Django support](#django-support) 36 | * [Optparse support](#optparse-support) (experimental) 37 | * [Legacy Windows](#legacy-windows-support) 38 | 39 | ## Installation 40 | 41 | Install from PyPI with pip or your favorite tool. 42 | 43 | ```sh 44 | pip install rich-argparse 45 | ``` 46 | 47 | ## Usage 48 | 49 | Simply pass `formatter_class` to the argument parser 50 | ```python 51 | import argparse 52 | from rich_argparse import RichHelpFormatter 53 | 54 | parser = argparse.ArgumentParser(..., formatter_class=RichHelpFormatter) 55 | ... 56 | ``` 57 | 58 | *rich-argparse* defines equivalents to all [argparse's standard formatters]( 59 | https://docs.python.org/3/library/argparse.html#formatter-class): 60 | 61 | | `rich_argparse` formatter | equivalent in `argparse` | 62 | |-------------------------------------|---------------------------------| 63 | | `RichHelpFormatter` | `HelpFormatter` | 64 | | `RawDescriptionRichHelpFormatter` | `RawDescriptionHelpFormatter` | 65 | | `RawTextRichHelpFormatter` | `RawTextHelpFormatter` | 66 | | `ArgumentDefaultsRichHelpFormatter` | `ArgumentDefaultsHelpFormatter` | 67 | | `MetavarTypeRichHelpFormatter` | `MetavarTypeHelpFormatter` | 68 | 69 | Additional formatters are available in the `rich_argparse.contrib` [module](#additional-formatters). 70 | 71 | ## Output styles 72 | 73 | The default styles used by *rich-argparse* are carefully chosen to work in different light and dark 74 | themes. 75 | 76 | ### Customize the colors 77 | 78 | You can customize the colors of the output by modifying the `styles` dictionary on the formatter 79 | class. You can use any rich style as defined [here](https://rich.readthedocs.io/en/latest/style.html). 80 | *rich-argparse* defines and uses the following styles: 81 | 82 | ```python 83 | { 84 | 'argparse.args': 'cyan', # for positional-arguments and --options (e.g "--help") 85 | 'argparse.groups': 'dark_orange', # for group names (e.g. "positional arguments") 86 | 'argparse.help': 'default', # for argument's help text (e.g. "show this help message and exit") 87 | 'argparse.metavar': 'dark_cyan', # for metavariables (e.g. "FILE" in "--file FILE") 88 | 'argparse.prog': 'grey50', # for %(prog)s in the usage (e.g. "foo" in "Usage: foo [options]") 89 | 'argparse.syntax': 'bold', # for highlights of back-tick quoted text (e.g. "`some text`") 90 | 'argparse.text': 'default', # for descriptions, epilog, and --version (e.g. "A program to foo") 91 | 'argparse.default': 'italic', # for %(default)s in the help (e.g. "Value" in "(default: Value)") 92 | } 93 | ``` 94 | 95 | For example, to make the description and epilog *italic*, change the `argparse.text` style: 96 | 97 | ```python 98 | RichHelpFormatter.styles["argparse.text"] = "italic" 99 | ``` 100 | 101 | ### Customize the group name format 102 | 103 | You can change how the names of the groups (like `'positional arguments'` and `'options'`) are 104 | formatted by setting the `RichHelpFormatter.group_name_formatter` which is set to `str.title` by 105 | default. Any callable that takes the group name as an input and returns a str works: 106 | 107 | ```python 108 | RichHelpFormatter.group_name_formatter = str.upper # Make group names UPPERCASE 109 | ``` 110 | 111 | ### Special text highlighting 112 | 113 | You can [highlight patterns](https://rich.readthedocs.io/en/stable/highlighting.html) in the 114 | arguments help and the description and epilog using regular expressions. By default, 115 | *rich-argparse* highlights patterns of `--options-with-hyphens` using the `argparse.args` style 116 | and patterns of `` `back tick quoted text` `` using the `argparse.syntax` style. You can control 117 | what patterns are highlighted by modifying the `RichHelpFormatter.highlights` list. To disable all 118 | highlights, you can clear this list using `RichHelpFormatter.highlights.clear()`. 119 | 120 | You can also add custom highlight patterns and styles. The following example highlights all 121 | occurrences of `pyproject.toml` in green: 122 | 123 | ```python 124 | # Add a style called `pyproject` which applies a green style (any rich style works) 125 | RichHelpFormatter.styles["argparse.pyproject"] = "green" 126 | # Add the highlight regex (the regex group name must match an existing style name) 127 | RichHelpFormatter.highlights.append(r"\b(?Ppyproject\.toml)\b") 128 | # Pass the formatter class to argparse 129 | parser = argparse.ArgumentParser(..., formatter_class=RichHelpFormatter) 130 | ... 131 | ``` 132 | 133 | ### Colors in the `usage` 134 | 135 | The usage **generated by the formatter** is colored using the `argparse.args` and `argparse.metavar` 136 | styles. If you use a custom `usage` message in the parser, it will be treated as "plain text" and 137 | will **not** be colored by default. You can enable colors in user defined usage message through 138 | [console markup](https://rich.readthedocs.io/en/stable/markup.html) by setting 139 | `RichHelpFormatter.usage_markup = True`. If you enable this option, make sure to [escape]( 140 | https://rich.readthedocs.io/en/stable/markup.html#escaping) any square brackets in the usage text. 141 | 142 | ### Disable console markup 143 | 144 | The text of the descriptions and epilog is interpreted as 145 | [console markup](https://rich.readthedocs.io/en/stable/markup.html) by default. If this conflicts 146 | with your usage of square brackets, make sure to [escape]( 147 | https://rich.readthedocs.io/en/stable/markup.html#escaping) the square brackets or to disable 148 | markup globally with `RichHelpFormatter.text_markup = False`. 149 | 150 | Similarly the help text of arguments is interpreted as markup by default. It can be disabled using 151 | `RichHelpFormatter.help_markup = False`. 152 | 153 | ### Colors in `--version` 154 | 155 | If you use the `"version"` action from argparse, you can use console markup in the `version` string: 156 | 157 | ```python 158 | parser.add_argument( 159 | "--version", action="version", version="[argparse.prog]%(prog)s[/] version [i]1.0.0[/]" 160 | ) 161 | ``` 162 | 163 | Note that the `argparse.text` style is applied to the `version` string similar to the description 164 | and epilog. 165 | 166 | ### Rich descriptions and epilog 167 | 168 | You can use any rich renderable in the descriptions and epilog. This includes all built-in rich 169 | renderables like `Table` and `Markdown` and any custom renderables defined using the 170 | [Console Protocol](https://rich.readthedocs.io/en/stable/protocol.html#console-protocol). 171 | 172 | ```python 173 | import argparse 174 | from rich.markdown import Markdown 175 | from rich_argparse import RichHelpFormatter 176 | 177 | description = """ 178 | # My program 179 | 180 | This is a markdown description of my program. 181 | 182 | * It has a list 183 | * And a table 184 | 185 | | Column 1 | Column 2 | 186 | | -------- | -------- | 187 | | Value 1 | Value 2 | 188 | """ 189 | parser = argparse.ArgumentParser( 190 | description=Markdown(description, style="argparse.text"), 191 | formatter_class=RichHelpFormatter, 192 | ) 193 | ... 194 | ``` 195 | Certain features are **disabled** for arbitrary renderables other than strings, including: 196 | 197 | * Syntax highlighting with `RichHelpFormatter.highlights` 198 | * Styling with the `"argparse.text"` style defined in `RichHelpFormatter.styles` 199 | * Replacement of `%(prog)s` with the program name 200 | 201 | ## Working with subparsers 202 | 203 | Subparsers do not inherit the formatter class from the parent parser by default. You have to pass 204 | the formatter class explicitly: 205 | 206 | ```python 207 | subparsers = parser.add_subparsers(...) 208 | p1 = subparsers.add_parser(..., formatter_class=parser.formatter_class) 209 | p2 = subparsers.add_parser(..., formatter_class=parser.formatter_class) 210 | ``` 211 | 212 | ## Generate help preview 213 | 214 | You can generate a preview of the help message for your CLI in SVG, HTML, or TXT formats using the 215 | `HelpPreviewAction` action. This is useful for including the help message in the documentation of 216 | your app. The action uses the 217 | [rich exporting API](https://rich.readthedocs.io/en/stable/console.html#exporting) internally. 218 | 219 | ```python 220 | import argparse 221 | from rich.terminal_theme import DIMMED_MONOKAI 222 | from rich_argparse import HelpPreviewAction, RichHelpFormatter 223 | 224 | parser = argparse.ArgumentParser(..., formatter_class=RichHelpFormatter) 225 | ... 226 | parser.add_argument( 227 | "--generate-help-preview", 228 | action=HelpPreviewAction, 229 | path="help-preview.svg", # (optional) or "help-preview.html" or "help-preview.txt" 230 | export_kwds={"theme": DIMMED_MONOKAI}, # (optional) keywords passed to console.save_... methods 231 | ) 232 | ``` 233 | This action is hidden, it won't show up in the help message or in the parsed arguments namespace. 234 | 235 | Use it like this: 236 | 237 | ```sh 238 | python my_cli.py --generate-help-preview # generates help-preview.svg (default path specified above) 239 | # or 240 | python my_cli.py --generate-help-preview my-help.svg # generates my-help.svg 241 | # or 242 | COLUMNS=120 python my_cli.py --generate-help-preview # force the width of the output to 120 columns 243 | ``` 244 | 245 | ## Additional formatters 246 | 247 | *rich-argparse* defines additional non-standard argparse formatters for some common use cases in 248 | the `rich_argparse.contrib` module. They can be imported with the `from rich_argparse.contrib import` 249 | syntax. The following formatters are available: 250 | 251 | * `ParagraphRichHelpFormatter`: A formatter similar to `RichHelpFormatter` that preserves paragraph 252 | breaks. A paragraph break is defined as two consecutive newlines (`\n\n`) in the help or 253 | description text. Leading and trailing trailing whitespace are stripped similar to 254 | `RichHelpFormatter`. 255 | 256 | _More formatters will be added in the future._ 257 | 258 | ## Django support 259 | 260 | *rich-argparse* provides support for django's custom help formatter. You can instruct django to use 261 | *rich-argparse* with all built-in, extension libraries, and user defined commands in a django 262 | project by adding these two lines to the `manage.py` file: 263 | 264 | ```diff 265 | diff --git a/manage.py b/manage.py 266 | def main(): 267 | """Run administrative tasks.""" 268 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'my_project.settings') 269 | try: 270 | from django.core.management import execute_from_command_line 271 | except ImportError as exc: 272 | raise ImportError( 273 | "Couldn't import Django. Are you sure it's installed and " 274 | "available on your PYTHONPATH environment variable? Did you " 275 | "forget to activate a virtual environment?" 276 | ) from exc 277 | + from rich_argparse.django import richify_command_line_help 278 | + richify_command_line_help() 279 | execute_from_command_line(sys.argv) 280 | ``` 281 | 282 | Alternatively, you can use the `DjangoRichHelpFormatter` class directly in your commands: 283 | 284 | ```diff 285 | diff --git a/my_app/management/commands/my_command.py b/my_app/management/commands/my_command.py 286 | from django.core.management.base import BaseCommand 287 | +from rich_argparse.django import DjangoRichHelpFormatter 288 | 289 | class Command(BaseCommand): 290 | def add_arguments(self, parser): 291 | + parser.formatter_class = DjangoRichHelpFormatter 292 | parser.add_argument("--option", action="store_true", help="An option") 293 | ... 294 | ``` 295 | 296 | ## Optparse support 297 | 298 | *rich-argparse* now ships with experimental support for [optparse]( 299 | https://docs.python.org/3/library/optparse.html). 300 | 301 | Import optparse help formatters from `rich_argparse.optparse`: 302 | 303 | ```python 304 | import optparse 305 | from rich_argparse.optparse import IndentedRichHelpFormatter # or TitledRichHelpFormatter 306 | 307 | parser = optparse.OptionParser(formatter=IndentedRichHelpFormatter()) 308 | ... 309 | ``` 310 | 311 | You can also generate a more helpful usage message by passing `usage=GENERATE_USAGE` to the 312 | parser. This is similar to the default behavior of `argparse`. 313 | 314 | ```python 315 | from rich_argparse.optparse import GENERATE_USAGE, IndentedRichHelpFormatter 316 | 317 | parser = optparse.OptionParser(usage=GENERATE_USAGE, formatter=IndentedRichHelpFormatter()) 318 | ``` 319 | 320 | Similar to `argparse`, you can customize the styles used by the formatter by modifying the 321 | `RichHelpFormatter.styles` dictionary. These are the same styles used by `argparse` but with 322 | the `optparse.` prefix instead: 323 | 324 | ```python 325 | RichHelpFormatter.styles["optparse.metavar"] = "bold magenta" 326 | ``` 327 | 328 | Syntax highlighting works the same as with `argparse`. 329 | 330 | Colors in the `usage` are only supported when using `GENERATE_USAGE`. 331 | 332 | ## Legacy Windows support 333 | 334 | When used on legacy Windows versions like *Windows 7*, colors are disabled unless 335 | [colorama](https://pypi.org/project/colorama/) is used: 336 | 337 | ```python 338 | import argparse 339 | import colorama 340 | from rich_argparse import RichHelpFormatter 341 | 342 | colorama.init() 343 | parser = argparse.ArgumentParser(..., formatter_class=RichHelpFormatter) 344 | ... 345 | ``` 346 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling>=1.11.0"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "rich-argparse" 7 | version = "1.7.0" 8 | description = "Rich help formatters for argparse and optparse" 9 | authors = [ 10 | {name="Ali Hamdan", email="ali.hamdan.dev@gmail.com"}, 11 | ] 12 | readme = "README.md" 13 | license = "MIT" 14 | classifiers = [ 15 | "Development Status :: 5 - Production/Stable", 16 | "Environment :: Console", 17 | "Intended Audience :: Developers", 18 | "License :: OSI Approved :: MIT License", 19 | "Operating System :: OS Independent", 20 | "Programming Language :: Python :: 3.9", 21 | "Programming Language :: Python :: 3.10", 22 | "Programming Language :: Python :: 3.11", 23 | "Programming Language :: Python :: 3.12", 24 | "Programming Language :: Python :: 3.13", 25 | "Programming Language :: Python :: 3.14", 26 | "Topic :: Software Development :: User Interfaces", 27 | ] 28 | keywords = ["argparse", "rich", "help-formatter", "optparse"] 29 | dependencies = [ 30 | "rich >= 11.0.0", 31 | ] 32 | requires-python = ">=3.9" 33 | 34 | [project.urls] 35 | Homepage = "https://github.com/hamdanal/rich-argparse" 36 | Documentation = "https://github.com/hamdanal/rich-argparse#rich-argparse" 37 | Issue-Tracker = "https://github.com/hamdanal/rich-argparse/issues" 38 | Changelog = "https://github.com/hamdanal/rich-argparse/blob/main/CHANGELOG.md" 39 | 40 | [tool.hatch.build.targets.sdist] 41 | include = [ 42 | "CHANGELOG.md", 43 | "CONTRIBUTING.md", 44 | "requirements-dev.txt", 45 | "rich_argparse", 46 | "tests", 47 | "LICENSE", 48 | "README.md", 49 | "pyproject.toml", 50 | ] 51 | 52 | [tool.hatch.build.targets.wheel] 53 | packages = ["rich_argparse"] 54 | 55 | [tool.ruff] 56 | line-length = 100 57 | 58 | [tool.ruff.lint] 59 | extend-select = ["C4", "B", "UP", "RUF100", "TID", "T10"] 60 | extend-ignore = ["E501"] 61 | unfixable = ["B"] 62 | isort.required-imports = ["from __future__ import annotations"] 63 | isort.extra-standard-library = ["typing_extensions"] 64 | flake8-tidy-imports.ban-relative-imports = "all" 65 | 66 | [tool.mypy] 67 | python_version = "3.9" 68 | strict = true 69 | local_partial_types = true 70 | 71 | [[tool.mypy.overrides]] 72 | module = ["tests.*"] 73 | check_untyped_defs = false 74 | disallow_untyped_defs = false 75 | disallow_incomplete_defs = false 76 | 77 | [tool.pytest.ini_options] 78 | testpaths = ["tests"] 79 | 80 | [tool.coverage.run] 81 | plugins = ["covdefaults"] 82 | source = ["rich_argparse", "tests"] 83 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -e . 2 | -r tests/requirements.txt 3 | pre-commit 4 | uv 5 | -------------------------------------------------------------------------------- /rich_argparse/__init__.py: -------------------------------------------------------------------------------- 1 | # Source code: https://github.com/hamdanal/rich-argparse 2 | # MIT license: Copyright (c) Ali Hamdan 3 | from __future__ import annotations 4 | 5 | from rich_argparse._argparse import ( 6 | ArgumentDefaultsRichHelpFormatter, 7 | HelpPreviewAction, 8 | MetavarTypeRichHelpFormatter, 9 | RawDescriptionRichHelpFormatter, 10 | RawTextRichHelpFormatter, 11 | RichHelpFormatter, 12 | ) 13 | 14 | __all__ = [ 15 | "RichHelpFormatter", 16 | "RawDescriptionRichHelpFormatter", 17 | "RawTextRichHelpFormatter", 18 | "ArgumentDefaultsRichHelpFormatter", 19 | "MetavarTypeRichHelpFormatter", 20 | "HelpPreviewAction", 21 | ] 22 | -------------------------------------------------------------------------------- /rich_argparse/__main__.py: -------------------------------------------------------------------------------- 1 | # Source code: https://github.com/hamdanal/rich-argparse 2 | # MIT license: Copyright (c) Ali Hamdan 3 | from __future__ import annotations 4 | 5 | if __name__ == "__main__": 6 | import argparse 7 | import sys 8 | 9 | from rich.terminal_theme import DIMMED_MONOKAI 10 | 11 | from rich_argparse import HelpPreviewAction, RichHelpFormatter 12 | 13 | parser = argparse.ArgumentParser( 14 | prog="python -m rich_argparse", 15 | formatter_class=RichHelpFormatter, 16 | description=( 17 | "This is a [link https://pypi.org/project/rich]rich[/]-based formatter for " 18 | "[link https://docs.python.org/3/library/argparse.html#formatter-class]" 19 | "argparse's help output[/].\n\n" 20 | "It enables you to use the powers of rich like markup and highlights in your CLI help. " 21 | ), 22 | epilog=":link: Read more at https://github.com/hamdanal/rich-argparse#usage.", 23 | ) 24 | parser.add_argument( 25 | "formatter-class", 26 | help=( 27 | "Simply pass `formatter_class=RichHelpFormatter` to the argument parser to get a " 28 | "colorful help like this." 29 | ), 30 | ) 31 | parser.add_argument( 32 | "styles", 33 | help="Customize your CLI's help with the `RichHelpFormatter.styles` dictionary.", 34 | ) 35 | parser.add_argument( 36 | "--highlights", 37 | metavar="REGEXES", 38 | help=( 39 | "Highlighting the help text is managed by the list of regular expressions " 40 | "`RichHelpFormatter.highlights`. Set to empty list to turn off highlighting.\n" 41 | "See the next two options for default values." 42 | ), 43 | ) 44 | parser.add_argument( 45 | "--syntax", 46 | default=RichHelpFormatter.styles["argparse.syntax"], 47 | help=( 48 | "Text inside backticks is highlighted using the `argparse.syntax` style " 49 | "(default: %(default)r)" 50 | ), 51 | ) 52 | parser.add_argument( 53 | "-o", 54 | "--option", 55 | metavar="METAVAR", 56 | help="Text that looks like an --option is highlighted using the `argparse.args` style.", 57 | ) 58 | group = parser.add_argument_group( 59 | "more arguments", 60 | description=( 61 | "This is a custom group. Group names are [italic]*Title Cased*[/] by default. Use the " 62 | "`RichHelpFormatter.group_name_formatter` function to change their format." 63 | ), 64 | ) 65 | group.add_argument( 66 | "--more", 67 | nargs="*", 68 | help="This formatter works with subparsers, mutually exclusive groups and hidden arguments.", 69 | ) 70 | mutex = group.add_mutually_exclusive_group() 71 | mutex.add_argument( 72 | "--rich", 73 | action="store_true", 74 | help="Rich and poor are mutually exclusive. Choose either one but not both.", 75 | ) 76 | mutex.add_argument( 77 | "--poor", action="store_false", dest="rich", help="Does poor mean --not-rich 😉?" 78 | ) 79 | mutex.add_argument("--not-rich", action="store_false", dest="rich", help=argparse.SUPPRESS) 80 | parser.add_argument( 81 | "--generate-rich-argparse-preview", 82 | action=HelpPreviewAction, 83 | path="rich-argparse.svg", 84 | export_kwds={"theme": DIMMED_MONOKAI}, 85 | ) 86 | # There is no program to run, always print help (except for the hidden --generate option) 87 | # You probably don't want to do this in your own code. 88 | if any(arg.startswith("--generate") for arg in sys.argv): 89 | parser.parse_args() 90 | else: 91 | parser.print_help() 92 | -------------------------------------------------------------------------------- /rich_argparse/_argparse.py: -------------------------------------------------------------------------------- 1 | # Source code: https://github.com/hamdanal/rich-argparse 2 | # MIT license: Copyright (c) Ali Hamdan 3 | 4 | # for internal use only 5 | from __future__ import annotations 6 | 7 | import argparse 8 | import re 9 | 10 | import rich_argparse._lazy_rich as r 11 | from rich_argparse._common import ( 12 | _HIGHLIGHTS, 13 | _fix_legacy_win_text, 14 | rich_fill, 15 | rich_strip, 16 | rich_wrap, 17 | ) 18 | 19 | TYPE_CHECKING = False 20 | if TYPE_CHECKING: 21 | from argparse import Action, ArgumentParser, Namespace, _MutuallyExclusiveGroup 22 | from collections.abc import Callable, Iterable, Iterator, MutableMapping, Sequence 23 | from typing import Any, ClassVar 24 | from typing_extensions import Self 25 | 26 | 27 | class RichHelpFormatter(argparse.HelpFormatter): 28 | """An argparse HelpFormatter class that renders using rich.""" 29 | 30 | group_name_formatter: ClassVar[Callable[[str], str]] = str.title 31 | """A function that formats group names. Defaults to ``str.title``.""" 32 | styles: ClassVar[dict[str, r.StyleType]] = { 33 | "argparse.args": "cyan", 34 | "argparse.groups": "dark_orange", 35 | "argparse.help": "default", 36 | "argparse.metavar": "dark_cyan", 37 | "argparse.syntax": "bold", 38 | "argparse.text": "default", 39 | "argparse.prog": "grey50", 40 | "argparse.default": "italic", 41 | } 42 | """A dict of rich styles to control the formatter styles. 43 | 44 | The following styles are used: 45 | 46 | - ``argparse.args``: for positional-arguments and --options (e.g "--help") 47 | - ``argparse.groups``: for group names (e.g. "positional arguments") 48 | - ``argparse.help``: for argument's help text (e.g. "show this help message and exit") 49 | - ``argparse.metavar``: for meta variables (e.g. "FILE" in "--file FILE") 50 | - ``argparse.prog``: for %(prog)s in the usage (e.g. "foo" in "Usage: foo [options]") 51 | - ``argparse.syntax``: for highlights of back-tick quoted text (e.g. "``` `some text` ```") 52 | - ``argparse.text``: for the descriptions and epilog (e.g. "A foo program") 53 | - ``argparse.default``: for %(default)s in the help (e.g. "Value" in "(default: Value)") 54 | """ 55 | highlights: ClassVar[list[str]] = _HIGHLIGHTS[:] 56 | """A list of regex patterns to highlight in the help text. 57 | 58 | It is used in the description, epilog, groups descriptions, and arguments' help. By default, 59 | it highlights ``--words-with-dashes`` with the `argparse.args` style and 60 | `` `text in backquotes` `` with the `argparse.syntax` style. 61 | 62 | To disable highlighting, clear this list (``RichHelpFormatter.highlights.clear()``). 63 | """ 64 | usage_markup: ClassVar[bool] = False 65 | """If True, render the usage string passed to ``ArgumentParser(usage=...)`` as markup. 66 | 67 | Defaults to ``False`` meaning the text of the usage will be printed verbatim. 68 | 69 | Note that the auto-generated usage string is always colored. 70 | """ 71 | help_markup: ClassVar[bool] = True 72 | """If True (default), render the help message of arguments as console markup.""" 73 | text_markup: ClassVar[bool] = True 74 | """If True (default), render the descriptions and epilog as console markup.""" 75 | 76 | _root_section: _Section 77 | _current_section: _Section 78 | 79 | def __init__( 80 | self, 81 | prog: str, 82 | indent_increment: int = 2, 83 | max_help_position: int = 24, 84 | width: int | None = None, 85 | *, 86 | console: r.Console | None = None, 87 | **kwargs: Any, 88 | ) -> None: 89 | super().__init__(prog, indent_increment, max_help_position, width, **kwargs) 90 | self._console = console 91 | 92 | # https://docs.python.org/3/library/stdtypes.html#printf-style-string-formatting 93 | self._printf_style_pattern = re.compile( 94 | r""" 95 | % # Percent character 96 | (?:\((?P[^)]*)\))? # Mapping key 97 | (?P[#0\-+ ])? # Conversion Flags 98 | (?P\*|\d+)? # Minimum field width 99 | (?P\.(?:\*?|\d*))? # Precision 100 | [hlL]? # Length modifier (ignored) 101 | (?P[diouxXeEfFgGcrsa%]) # Conversion type 102 | """, 103 | re.VERBOSE, 104 | ) 105 | 106 | @property 107 | def console(self) -> r.Console: 108 | if self._console is None: 109 | self._console = r.Console() 110 | return self._console 111 | 112 | @console.setter 113 | def console(self, console: r.Console) -> None: 114 | self._console = console 115 | 116 | class _Section(argparse.HelpFormatter._Section): 117 | def __init__( 118 | self, formatter: RichHelpFormatter, parent: Self | None, heading: str | None = None 119 | ) -> None: 120 | if heading is not argparse.SUPPRESS and heading is not None: 121 | heading = f"{type(formatter).group_name_formatter(heading)}:" 122 | super().__init__(formatter, parent, heading) 123 | self.formatter: RichHelpFormatter 124 | self.rich_items: list[r.RenderableType] = [] 125 | self.rich_actions: list[tuple[r.Text, r.Text | None]] = [] 126 | if parent is not None: 127 | parent.rich_items.append(self) 128 | 129 | def _render_items(self, console: r.Console, options: r.ConsoleOptions) -> r.RenderResult: 130 | if not self.rich_items: 131 | return 132 | generated_options = options.update(no_wrap=True, overflow="ignore") 133 | new_line = r.Segment.line() 134 | for item in self.rich_items: 135 | if isinstance(item, RichHelpFormatter._Section): 136 | yield from console.render(item, options) 137 | elif isinstance(item, r.Padding): # user added rich renderable 138 | yield from console.render(item, options) 139 | yield new_line 140 | else: # argparse generated rich renderable 141 | yield from console.render(item, generated_options) 142 | 143 | def _render_actions(self, console: r.Console, options: r.ConsoleOptions) -> r.RenderResult: 144 | if not self.rich_actions: 145 | return 146 | options = options.update(no_wrap=True, overflow="ignore") 147 | help_pos = min(self.formatter._action_max_length + 2, self.formatter._max_help_position) 148 | help_width = max(self.formatter._width - help_pos, 11) 149 | indent = r.Text(" " * help_pos) 150 | for action_header, action_help in self.rich_actions: 151 | if not action_help: 152 | # no help, yield the header and finish 153 | yield from console.render(action_header, options) 154 | continue 155 | action_help_lines = self.formatter._rich_split_lines(action_help, help_width) 156 | if len(action_header) > help_pos - 2: 157 | # the header is too long, put it on its own line 158 | yield from console.render(action_header, options) 159 | action_header = indent 160 | action_header.set_length(help_pos) 161 | action_help_lines[0].rstrip() 162 | yield from console.render(action_header + action_help_lines[0], options) 163 | for line in action_help_lines[1:]: 164 | line.rstrip() 165 | yield from console.render(indent + line, options) 166 | yield "" 167 | 168 | def __rich_console__(self, console: r.Console, options: r.ConsoleOptions) -> r.RenderResult: 169 | if not self.rich_items and not self.rich_actions: 170 | return # empty section 171 | if self.heading is not argparse.SUPPRESS and self.heading is not None: 172 | yield r.Text(self.heading, style="argparse.groups", overflow="ignore") 173 | yield from self._render_items(console, options) 174 | yield from self._render_actions(console, options) 175 | 176 | def __rich_console__(self, console: r.Console, options: r.ConsoleOptions) -> r.RenderResult: 177 | with console.use_theme(r.Theme(self.styles)): 178 | root = console.render(self._root_section, options.update_width(self._width)) 179 | new_line = r.Segment.line() 180 | add_empty_line = False 181 | for line_segments in r.Segment.split_lines(root): 182 | for i, segment in enumerate(reversed(line_segments), start=1): 183 | stripped = segment.text.rstrip() 184 | if stripped: 185 | if add_empty_line: 186 | yield new_line 187 | add_empty_line = False 188 | yield from line_segments[:-i] 189 | yield r.Segment(stripped, style=segment.style, control=segment.control) 190 | yield new_line 191 | break 192 | else: # empty line 193 | add_empty_line = True 194 | 195 | def add_text(self, text: r.RenderableType | None) -> None: 196 | if text is argparse.SUPPRESS or text is None: 197 | return 198 | elif isinstance(text, str): 199 | self._current_section.rich_items.append(self._rich_format_text(text)) 200 | else: 201 | self.add_renderable(text) 202 | 203 | def add_renderable(self, renderable: r.RenderableType) -> None: 204 | padded = r.Padding.indent(renderable, self._current_indent) 205 | self._current_section.rich_items.append(padded) 206 | 207 | def add_usage( 208 | self, 209 | usage: str | None, 210 | actions: Iterable[Action], 211 | groups: Iterable[_MutuallyExclusiveGroup], 212 | prefix: str | None = None, 213 | ) -> None: 214 | if usage is argparse.SUPPRESS: 215 | return 216 | if prefix is None: 217 | prefix = self._format_usage(usage="", actions=(), groups=(), prefix=None).rstrip("\n") 218 | prefix_end = ": " if prefix.endswith(": ") else "" 219 | prefix = prefix[: len(prefix) - len(prefix_end)] 220 | prefix = r.strip_control_codes(type(self).group_name_formatter(prefix)) + prefix_end 221 | 222 | usage_spans = [r.Span(0, len(prefix.rstrip()), "argparse.groups")] 223 | usage_text = r.strip_control_codes( 224 | self._format_usage(usage, actions, groups, prefix=prefix) 225 | ) 226 | if usage is None: # get colour spans for generated usage 227 | prog = r.strip_control_codes(f"{self._prog}") 228 | if actions: 229 | prog_start = usage_text.index(prog, len(prefix)) 230 | usage_spans.append(r.Span(prog_start, prog_start + len(prog), "argparse.prog")) 231 | actions_start = len(prefix) + len(prog) + 1 232 | try: 233 | spans = list(self._rich_usage_spans(usage_text, actions_start, actions=actions)) 234 | except ValueError: 235 | spans = [] 236 | usage_spans.extend(spans) 237 | rich_usage = r.Text(usage_text) 238 | elif self.usage_markup: # treat user provided usage as markup 239 | usage_spans.extend(self._rich_prog_spans(prefix + r.Text.from_markup(usage).plain)) 240 | rich_usage = r.Text.from_markup(usage_text) 241 | usage_spans.extend(rich_usage.spans) 242 | rich_usage.spans.clear() 243 | else: # treat user provided usage as plain text 244 | usage_spans.extend(self._rich_prog_spans(prefix + usage)) 245 | rich_usage = r.Text(usage_text) 246 | rich_usage.spans.extend(usage_spans) 247 | self._root_section.rich_items.append(rich_usage) 248 | 249 | def add_argument(self, action: Action) -> None: 250 | super().add_argument(action) 251 | if action.help is not argparse.SUPPRESS: 252 | self._current_section.rich_actions.extend(self._rich_format_action(action)) 253 | 254 | def format_help(self) -> str: 255 | with self.console.capture() as capture: 256 | self.console.print(self, crop=False) 257 | return _fix_legacy_win_text(self.console, capture.get()) 258 | 259 | # =============== 260 | # Utility methods 261 | # =============== 262 | def _rich_prog_spans(self, usage: str) -> Iterator[r.Span]: 263 | if "%(prog)" not in usage: 264 | return 265 | params = {"prog": self._prog} 266 | formatted_usage = "" 267 | last = 0 268 | for m in self._printf_style_pattern.finditer(usage): 269 | start, end = m.span() 270 | formatted_usage += usage[last:start] 271 | sub = usage[start:end] % params 272 | prog_start = len(formatted_usage) 273 | prog_end = prog_start + len(sub) 274 | formatted_usage += sub 275 | last = end 276 | yield r.Span(prog_start, prog_end, "argparse.prog") 277 | 278 | def _rich_usage_spans( 279 | self, text: str, start: int, actions: Iterable[Action] 280 | ) -> Iterator[r.Span]: 281 | options: list[Action] = [] 282 | positionals: list[Action] = [] 283 | for action in actions: 284 | if action.help is not argparse.SUPPRESS: 285 | options.append(action) if action.option_strings else positionals.append(action) 286 | pos = start 287 | 288 | def find_span(_string: str) -> tuple[int, int]: 289 | stripped = r.strip_control_codes(_string) 290 | _start = text.index(stripped, pos) 291 | _end = _start + len(stripped) 292 | return _start, _end 293 | 294 | for action in options: # start with the options 295 | usage = action.format_usage() 296 | if isinstance(action, argparse.BooleanOptionalAction): 297 | for option_string in action.option_strings: 298 | start, end = find_span(option_string) 299 | yield r.Span(start, end, "argparse.args") 300 | pos = end + 1 301 | continue 302 | start, end = find_span(usage) 303 | yield r.Span(start, end, "argparse.args") 304 | pos = end + 1 305 | if action.nargs != 0: 306 | default_metavar = self._get_default_metavar_for_optional(action) 307 | for metavar_part, colorize in self._rich_metavar_parts(action, default_metavar): 308 | start, end = find_span(metavar_part) 309 | if colorize: 310 | yield r.Span(start, end, "argparse.metavar") 311 | pos = end 312 | pos = end + 1 313 | for action in positionals: # positionals come at the end 314 | default_metavar = self._get_default_metavar_for_positional(action) 315 | for metavar_part, colorize in self._rich_metavar_parts(action, default_metavar): 316 | start, end = find_span(metavar_part) 317 | if colorize: 318 | yield r.Span(start, end, "argparse.args") 319 | pos = end 320 | pos = end + 1 321 | 322 | def _rich_metavar_parts( 323 | self, action: Action, default_metavar: str 324 | ) -> Iterator[tuple[str, bool]]: 325 | get_metavar = self._metavar_formatter(action, default_metavar) 326 | # similar to self._format_args but yields (part, colorize) of the metavar 327 | if action.nargs is None: 328 | # '%s' % get_metavar(1) 329 | yield "%s" % get_metavar(1), True # noqa: UP031 330 | elif action.nargs == argparse.OPTIONAL: 331 | # '[%s]' % get_metavar(1) 332 | yield from ( 333 | ("[", False), 334 | ("%s" % get_metavar(1), True), # noqa: UP031 335 | ("]", False), 336 | ) 337 | elif action.nargs == argparse.ZERO_OR_MORE: 338 | if len(get_metavar(1)) == 2: 339 | metavar = get_metavar(2) 340 | # '[%s [%s ...]]' % metavar 341 | yield from ( 342 | ("[", False), 343 | ("%s" % metavar[0], True), # noqa: UP031 344 | (" [", False), 345 | ("%s" % metavar[1], True), # noqa: UP031 346 | (" ", False), 347 | ("...", True), 348 | ("]]", False), 349 | ) 350 | else: 351 | # '[%s ...]' % metavar 352 | yield from ( 353 | ("[", False), 354 | ("%s" % get_metavar(1), True), # noqa: UP031 355 | (" ", False), 356 | ("...", True), 357 | ("]", False), 358 | ) 359 | elif action.nargs == argparse.ONE_OR_MORE: 360 | # '%s [%s ...]' % get_metavar(2) 361 | metavar = get_metavar(2) 362 | yield from ( 363 | ("%s" % metavar[0], True), # noqa: UP031 364 | (" [", False), 365 | ("%s" % metavar[1], True), # noqa: UP031 366 | (" ", False), 367 | ("...", True), 368 | ("]", False), 369 | ) 370 | elif action.nargs == argparse.REMAINDER: 371 | # '...' 372 | yield "...", True 373 | elif action.nargs == argparse.PARSER: 374 | # '%s ...' % get_metavar(1) 375 | yield from ( 376 | ("%s" % get_metavar(1), True), # noqa: UP031 377 | (" ", False), 378 | ("...", True), 379 | ) 380 | elif action.nargs == argparse.SUPPRESS: 381 | # '' 382 | yield "", False 383 | else: 384 | metavar = get_metavar(action.nargs) # type: ignore[arg-type] 385 | first = True 386 | for met in metavar: 387 | if first: 388 | first = False 389 | else: 390 | yield " ", False 391 | yield "%s" % met, True # noqa: UP031 392 | 393 | def _rich_whitespace_sub(self, text: r.Text) -> r.Text: 394 | # do this `self._whitespace_matcher.sub(' ', text).strip()` but text is Text 395 | spans = [m.span() for m in self._whitespace_matcher.finditer(text.plain)] 396 | for start, end in reversed(spans): 397 | if end - start > 1: # slow path 398 | space = text[start : start + 1] 399 | space.plain = " " 400 | text = text[:start] + space + text[end:] 401 | else: # performance shortcut 402 | text.plain = text.plain[:start] + " " + text.plain[end:] 403 | return rich_strip(text) 404 | 405 | # ===================================== 406 | # Rich version of HelpFormatter methods 407 | # ===================================== 408 | def _rich_expand_help(self, action: Action) -> r.Text: 409 | params = dict(vars(action), prog=self._prog) 410 | for name in list(params): 411 | if params[name] is argparse.SUPPRESS: 412 | del params[name] 413 | elif hasattr(params[name], "__name__"): 414 | params[name] = params[name].__name__ 415 | if params.get("choices") is not None: 416 | params["choices"] = ", ".join([str(c) for c in params["choices"]]) 417 | help_string = self._get_help_string(action) 418 | assert help_string is not None 419 | # raise ValueError if needed 420 | help_string % params # pyright: ignore[reportUnusedExpression] 421 | parts = [] 422 | defaults: list[str] = [] 423 | default_sub_template = "rich-argparse-f3ae8b55df34d5d83a8189d2e4766e68-{}-argparse-rich" 424 | default_n = 0 425 | last = 0 426 | for m in self._printf_style_pattern.finditer(help_string): 427 | start, end = m.span() 428 | parts.append(help_string[last:start]) 429 | sub = help_string[start:end] % params 430 | if m.group("mapping") == "default": 431 | defaults.append(sub) 432 | sub = default_sub_template.format(default_n) 433 | default_n += 1 434 | else: 435 | sub = r.escape(sub) 436 | parts.append(sub) 437 | last = end 438 | parts.append(help_string[last:]) 439 | rich_help = ( 440 | r.Text.from_markup("".join(parts), style="argparse.help") 441 | if self.help_markup 442 | else r.Text("".join(parts), style="argparse.help") 443 | ) 444 | for i, default in reversed(list(enumerate(defaults))): 445 | default_sub = default_sub_template.format(i) 446 | try: 447 | start = rich_help.plain.rindex(default_sub) 448 | except ValueError: 449 | # This could happen in cases like `[default: %(default)s]` with markup activated 450 | import warnings 451 | 452 | action_id = next(iter(action.option_strings), action.dest) 453 | printf_pat = self._printf_style_pattern.pattern 454 | repl = next( 455 | ( 456 | repr(m.group(1))[1:-1] 457 | for m in re.finditer(rf"\[([^\]]*{printf_pat}[^\]]*)\]", help_string, re.X) 458 | if m.group("mapping") == "default" 459 | ), 460 | "default: %(default)s", 461 | ) 462 | msg = ( 463 | f"Failed to process default value in help string of argument {action_id!r}." 464 | f"\nHint: try disabling rich markup: `RichHelpFormatter.help_markup = False`" 465 | f"\n or replace brackets by parenthesis: `[{repl}]` -> `({repl})`" 466 | ) 467 | warnings.warn(msg, UserWarning, stacklevel=4) 468 | continue 469 | end = start + len(default_sub) 470 | rich_help = ( 471 | rich_help[:start].append(default, style="argparse.default").append(rich_help[end:]) 472 | ) 473 | for highlight in self.highlights: 474 | rich_help.highlight_regex(highlight, style_prefix="argparse.") 475 | return rich_help 476 | 477 | def _rich_format_text(self, text: str) -> r.Text: 478 | if "%(prog)" in text: 479 | text = text % {"prog": r.escape(self._prog)} 480 | rich_text = ( 481 | r.Text.from_markup(text, style="argparse.text") 482 | if self.text_markup 483 | else r.Text(text, style="argparse.text") 484 | ) 485 | for highlight in self.highlights: 486 | rich_text.highlight_regex(highlight, style_prefix="argparse.") 487 | text_width = max(self._width - self._current_indent * 2, 11) 488 | indent = r.Text(" " * self._current_indent) 489 | return self._rich_fill_text(rich_text, text_width, indent) 490 | 491 | def _rich_format_action(self, action: Action) -> Iterator[tuple[r.Text, r.Text | None]]: 492 | header = self._rich_format_action_invocation(action) 493 | header.pad_left(self._current_indent) 494 | help = self._rich_expand_help(action) if action.help and action.help.strip() else None 495 | yield header, help 496 | for subaction in self._iter_indented_subactions(action): 497 | yield from self._rich_format_action(subaction) 498 | 499 | def _rich_format_action_invocation(self, action: Action) -> r.Text: 500 | if not action.option_strings: 501 | return r.Text().append(self._format_action_invocation(action), style="argparse.args") 502 | else: 503 | action_header = r.Text(", ").join( 504 | r.Text(o, "argparse.args") for o in action.option_strings 505 | ) 506 | if action.nargs != 0: 507 | default = self._get_default_metavar_for_optional(action) 508 | action_header.append(" ") 509 | for metavar_part, colorize in self._rich_metavar_parts(action, default): 510 | style = "argparse.metavar" if colorize else None 511 | action_header.append(metavar_part, style=style) 512 | return action_header 513 | 514 | def _rich_split_lines(self, text: r.Text, width: int) -> r.Lines: 515 | return rich_wrap(self.console, self._rich_whitespace_sub(text), width) 516 | 517 | def _rich_fill_text(self, text: r.Text, width: int, indent: r.Text) -> r.Text: 518 | return rich_fill(self.console, self._rich_whitespace_sub(text), width, indent) + "\n\n" 519 | 520 | 521 | class RawDescriptionRichHelpFormatter(RichHelpFormatter): 522 | """Rich help message formatter which retains any formatting in descriptions.""" 523 | 524 | def _rich_fill_text(self, text: r.Text, width: int, indent: r.Text) -> r.Text: 525 | return r.Text("\n").join(indent + line for line in text.split()) + "\n\n" 526 | 527 | 528 | class RawTextRichHelpFormatter(RawDescriptionRichHelpFormatter): 529 | """Rich help message formatter which retains formatting of all help text.""" 530 | 531 | def _rich_split_lines(self, text: r.Text, width: int) -> r.Lines: 532 | return text.split() 533 | 534 | 535 | class ArgumentDefaultsRichHelpFormatter(argparse.ArgumentDefaultsHelpFormatter, RichHelpFormatter): 536 | """Rich help message formatter which adds default values to argument help.""" 537 | 538 | 539 | class MetavarTypeRichHelpFormatter(argparse.MetavarTypeHelpFormatter, RichHelpFormatter): 540 | """Rich help message formatter which uses the argument 'type' as the default 541 | metavar value (instead of the argument 'dest'). 542 | """ 543 | 544 | 545 | class HelpPreviewAction(argparse.Action): 546 | """Action that renders the help to SVG, HTML, or text file and exits.""" 547 | 548 | def __init__( 549 | self, 550 | option_strings: Sequence[str], 551 | dest: str = argparse.SUPPRESS, 552 | default: str = argparse.SUPPRESS, 553 | help: str = argparse.SUPPRESS, 554 | *, 555 | path: str | None = None, 556 | export_kwds: MutableMapping[str, Any] | None = None, 557 | ) -> None: 558 | super().__init__(option_strings, dest, nargs="?", const=path, default=default, help=help) 559 | self.export_kwds = export_kwds or {} 560 | 561 | def __call__( 562 | self, 563 | parser: ArgumentParser, 564 | namespace: Namespace, 565 | values: str | Sequence[Any] | None, 566 | option_string: str | None = None, 567 | ) -> None: 568 | path = values 569 | if path is None: 570 | parser.exit(1, "error: help preview path is not provided\n") 571 | if not isinstance(path, str): 572 | parser.exit(1, "error: help preview path must be a string\n") 573 | if not path.endswith((".svg", ".html", ".txt")): 574 | parser.exit(1, "error: help preview path must end with .svg, .html, or .txt\n") 575 | import io 576 | 577 | text = r.Text.from_ansi(parser.format_help()) 578 | console = r.Console(file=io.StringIO(), record=True) 579 | console.print(text, crop=False) 580 | 581 | if path.endswith(".svg"): 582 | self.export_kwds.setdefault("title", "") 583 | console.save_svg(path, **self.export_kwds) 584 | elif path.endswith(".html"): 585 | console.save_html(path, **self.export_kwds) 586 | elif path.endswith(".txt"): 587 | console.save_text(path, **self.export_kwds) 588 | else: 589 | raise AssertionError("unreachable") 590 | parser.exit(0, f"Help preview saved to {path}\n") 591 | -------------------------------------------------------------------------------- /rich_argparse/_common.py: -------------------------------------------------------------------------------- 1 | # Source code: https://github.com/hamdanal/rich-argparse 2 | # MIT license: Copyright (c) Ali Hamdan 3 | 4 | # for internal use only 5 | from __future__ import annotations 6 | 7 | import sys 8 | 9 | import rich_argparse._lazy_rich as r 10 | 11 | # Default highlight patterns: 12 | # - highlight `text in backquotes` as "syntax" 13 | # - --words-with-dashes outside backticks as "args" 14 | _HIGHLIGHTS = [ 15 | r"`(?P[^`]*)`|(?:^|\s)(?P-{1,2}[\w]+[\w-]*)", 16 | ] 17 | 18 | _windows_console_fixed: bool | None = None 19 | 20 | 21 | def rich_strip(text: r.Text) -> r.Text: 22 | """Strip leading and trailing whitespace from `rich.text.Text`.""" 23 | lstrip_at = len(text.plain) - len(text.plain.lstrip()) 24 | if lstrip_at: # rich.Text.lstrip() is not available yet!! 25 | text = text[lstrip_at:] 26 | text.rstrip() 27 | return text 28 | 29 | 30 | def rich_wrap(console: r.Console, text: r.Text, width: int) -> r.Lines: 31 | """`textwrap.wrap()` equivalent for `rich.text.Text`.""" 32 | text = text.copy() 33 | text.expand_tabs(8) # textwrap expands tabs first 34 | whitespace_trans = dict.fromkeys(map(ord, "\t\n\x0b\x0c\r "), ord(" ")) 35 | text.plain = text.plain.translate(whitespace_trans) 36 | return text.wrap(console, width) 37 | 38 | 39 | def rich_fill(console: r.Console, text: r.Text, width: int, indent: r.Text) -> r.Text: 40 | """`textwrap.fill()` equivalent for `rich.text.Text`.""" 41 | lines = rich_wrap(console, text, width) 42 | return r.Text("\n").join(indent + line for line in lines) 43 | 44 | 45 | def _initialize_win_colors() -> bool: # pragma: no cover 46 | global _windows_console_fixed 47 | assert sys.platform == "win32" 48 | if _windows_console_fixed is None: 49 | winver = sys.getwindowsversion() # type: ignore[attr-defined] 50 | if winver.major < 10 or winver.build < 10586: 51 | try: 52 | import colorama 53 | 54 | _windows_console_fixed = isinstance(sys.stdout, colorama.ansitowin32.StreamWrapper) 55 | except Exception: 56 | _windows_console_fixed = False 57 | else: 58 | import ctypes 59 | 60 | kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined] 61 | ENABLE_PROCESSED_OUTPUT = 0x1 62 | ENABLE_WRAP_AT_EOL_OUTPUT = 0x2 63 | ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x4 64 | STD_OUTPUT_HANDLE = -11 65 | kernel32.SetConsoleMode( 66 | kernel32.GetStdHandle(STD_OUTPUT_HANDLE), 67 | ENABLE_PROCESSED_OUTPUT 68 | | ENABLE_WRAP_AT_EOL_OUTPUT 69 | | ENABLE_VIRTUAL_TERMINAL_PROCESSING, 70 | ) 71 | _windows_console_fixed = True 72 | return _windows_console_fixed 73 | 74 | 75 | def _fix_legacy_win_text(console: r.Console, text: str) -> str: 76 | # activate legacy Windows console colors if needed (and available) or strip ANSI escape codes 77 | if ( 78 | text 79 | and sys.platform == "win32" 80 | and console.legacy_windows 81 | and console.color_system is not None 82 | and not _initialize_win_colors() 83 | ): # pragma: win32 cover 84 | text = "\n".join(r.re_ansi.sub("", line) for line in text.split("\n")) 85 | return text 86 | -------------------------------------------------------------------------------- /rich_argparse/_contrib.py: -------------------------------------------------------------------------------- 1 | # Source code: https://github.com/hamdanal/rich-argparse 2 | # MIT license: Copyright (c) Ali Hamdan 3 | 4 | # for internal use only 5 | from __future__ import annotations 6 | 7 | import rich_argparse._lazy_rich as r 8 | from rich_argparse._argparse import RichHelpFormatter 9 | from rich_argparse._common import rich_strip, rich_wrap 10 | 11 | 12 | class ParagraphRichHelpFormatter(RichHelpFormatter): 13 | """Rich help message formatter which retains paragraph separation.""" 14 | 15 | def _rich_split_lines(self, text: r.Text, width: int) -> r.Lines: 16 | text = rich_strip(text) 17 | lines = r.Lines() 18 | for paragraph in text.split("\n\n"): 19 | # Normalize whitespace in the paragraph 20 | paragraph = self._rich_whitespace_sub(paragraph) 21 | # Wrap the paragraph to the specified width 22 | paragraph_lines = rich_wrap(self.console, paragraph, width) 23 | # Add the wrapped lines to the output 24 | lines.extend(paragraph_lines) 25 | # Add a blank line between paragraphs 26 | lines.append(r.Text("\n")) 27 | if lines: # pragma: no cover 28 | lines.pop() # Remove trailing newline 29 | return lines 30 | 31 | def _rich_fill_text(self, text: r.Text, width: int, indent: r.Text) -> r.Text: 32 | lines = self._rich_split_lines(text, width) 33 | return r.Text("\n").join(indent + line for line in lines) + "\n" 34 | -------------------------------------------------------------------------------- /rich_argparse/_lazy_rich.py: -------------------------------------------------------------------------------- 1 | # Source code: https://github.com/hamdanal/rich-argparse 2 | # MIT license: Copyright (c) Ali Hamdan 3 | 4 | # for internal use only 5 | from __future__ import annotations 6 | 7 | TYPE_CHECKING = False 8 | if TYPE_CHECKING: 9 | from typing import Any 10 | 11 | from rich.ansi import re_ansi as re_ansi 12 | from rich.console import Console as Console 13 | from rich.console import ConsoleOptions as ConsoleOptions 14 | from rich.console import RenderableType as RenderableType 15 | from rich.console import RenderResult as RenderResult 16 | from rich.containers import Lines as Lines 17 | from rich.control import strip_control_codes as strip_control_codes 18 | from rich.markup import escape as escape 19 | from rich.padding import Padding as Padding 20 | from rich.segment import Segment as Segment 21 | from rich.style import StyleType as StyleType 22 | from rich.text import Span as Span 23 | from rich.text import Text as Text 24 | from rich.theme import Theme as Theme 25 | 26 | __all__ = [ 27 | "re_ansi", 28 | "Console", 29 | "ConsoleOptions", 30 | "RenderableType", 31 | "RenderResult", 32 | "Lines", 33 | "strip_control_codes", 34 | "escape", 35 | "Padding", 36 | "Segment", 37 | "StyleType", 38 | "Span", 39 | "Text", 40 | "Theme", 41 | ] 42 | 43 | 44 | def __getattr__(name: str) -> Any: 45 | if name not in __all__: 46 | raise AttributeError(name) 47 | import rich.ansi 48 | import rich.console 49 | import rich.containers 50 | import rich.control 51 | import rich.markup 52 | import rich.padding 53 | import rich.segment 54 | import rich.style 55 | import rich.text 56 | import rich.theme 57 | 58 | globals().update( 59 | { 60 | "re_ansi": rich.ansi.re_ansi, 61 | "Console": rich.console.Console, 62 | "ConsoleOptions": rich.console.ConsoleOptions, 63 | "RenderableType": rich.console.RenderableType, 64 | "RenderResult": rich.console.RenderResult, 65 | "Lines": rich.containers.Lines, 66 | "strip_control_codes": rich.control.strip_control_codes, 67 | "escape": rich.markup.escape, 68 | "Padding": rich.padding.Padding, 69 | "Segment": rich.segment.Segment, 70 | "StyleType": rich.style.StyleType, 71 | "Span": rich.text.Span, 72 | "Text": rich.text.Text, 73 | "Theme": rich.theme.Theme, 74 | } 75 | ) 76 | return globals()[name] 77 | -------------------------------------------------------------------------------- /rich_argparse/_optparse.py: -------------------------------------------------------------------------------- 1 | # Source code: https://github.com/hamdanal/rich-argparse 2 | # MIT license: Copyright (c) Ali Hamdan 3 | 4 | # for internal use only 5 | from __future__ import annotations 6 | 7 | import optparse 8 | 9 | import rich_argparse._lazy_rich as r 10 | from rich_argparse._common import _HIGHLIGHTS, _fix_legacy_win_text, rich_fill, rich_wrap 11 | 12 | TYPE_CHECKING = False 13 | if TYPE_CHECKING: 14 | from typing import Literal 15 | 16 | GENERATE_USAGE = "==GENERATE_USAGE==" 17 | 18 | 19 | class RichHelpFormatter(optparse.HelpFormatter): 20 | """An optparse HelpFormatter class that renders using rich.""" 21 | 22 | styles: dict[str, r.StyleType] = { 23 | "optparse.args": "cyan", 24 | "optparse.groups": "dark_orange", 25 | "optparse.help": "default", 26 | "optparse.metavar": "dark_cyan", 27 | "optparse.syntax": "bold", 28 | "optparse.text": "default", 29 | "optparse.prog": "grey50", 30 | } 31 | """A dict of rich styles to control the formatter styles. 32 | 33 | The following styles are used: 34 | 35 | - ``optparse.args``: for --options (e.g "--help") 36 | - ``optparse.groups``: for group names (e.g. "Options") 37 | - ``optparse.help``: for options's help text (e.g. "show this help message and exit") 38 | - ``optparse.metavar``: for meta variables (e.g. "FILE" in "--file=FILE") 39 | - ``argparse.prog``: for %prog in generated usage (e.g. "foo" in "Usage: foo [options]") 40 | - ``optparse.syntax``: for highlights of back-tick quoted text (e.g. "``` `some text` ```"), 41 | - ``optparse.text``: for the descriptions and epilog (e.g. "A foo program") 42 | """ 43 | highlights: list[str] = _HIGHLIGHTS[:] 44 | """A list of regex patterns to highlight in the help text. 45 | 46 | It is used in the description, epilog, groups descriptions, and arguments' help. By default, 47 | it highlights ``--words-with-dashes`` with the `optparse.args` style and 48 | ``` `text in backquotes` ``` with the `optparse.syntax` style. 49 | 50 | To disable highlighting, clear this list (``RichHelpFormatter.highlights.clear()``). 51 | """ 52 | 53 | def __init__( 54 | self, 55 | indent_increment: int, 56 | max_help_position: int, 57 | width: int | None, 58 | short_first: bool | Literal[0, 1], 59 | ) -> None: 60 | super().__init__(indent_increment, max_help_position, width, short_first) 61 | self._console: r.Console | None = None 62 | self.rich_option_strings: dict[optparse.Option, r.Text] = {} 63 | 64 | @property 65 | def console(self) -> r.Console: 66 | if self._console is None: 67 | self._console = r.Console(theme=r.Theme(self.styles)) 68 | return self._console 69 | 70 | @console.setter 71 | def console(self, console: r.Console) -> None: 72 | self._console = console 73 | 74 | def _stringify(self, text: r.RenderableType) -> str: 75 | # Render a rich object to a string 76 | with self.console.capture() as capture: 77 | self.console.print(text, highlight=False, soft_wrap=True, end="") 78 | help = capture.get() 79 | help = "\n".join(line.rstrip() for line in help.split("\n")) 80 | return _fix_legacy_win_text(self.console, help) 81 | 82 | def rich_format_usage(self, usage: str) -> r.Text: 83 | raise NotImplementedError("subclasses must implement") 84 | 85 | def rich_format_heading(self, heading: str) -> r.Text: 86 | raise NotImplementedError("subclasses must implement") 87 | 88 | def _rich_format_text(self, text: str) -> r.Text: 89 | # HelpFormatter._format_text() equivalent that produces rich.text.Text 90 | text_width = max(self.width - 2 * self.current_indent, 11) 91 | indent = r.Text(" " * self.current_indent) 92 | rich_text = r.Text.from_markup(text, style="optparse.text") 93 | for highlight in self.highlights: 94 | rich_text.highlight_regex(highlight, style_prefix="optparse.") 95 | return rich_fill(self.console, rich_text, text_width, indent) 96 | 97 | def rich_format_description(self, description: str | None) -> r.Text: 98 | if not description: 99 | return r.Text() 100 | return self._rich_format_text(description) + r.Text("\n") 101 | 102 | def rich_format_epilog(self, epilog: str | None) -> r.Text: 103 | if not epilog: 104 | return r.Text() 105 | return r.Text("\n") + self._rich_format_text(epilog) + r.Text("\n") 106 | 107 | def format_usage(self, usage: str) -> str: 108 | if usage is GENERATE_USAGE: 109 | rich_usage = self._generate_usage() 110 | else: 111 | rich_usage = self.rich_format_usage(usage) 112 | return self._stringify(rich_usage) 113 | 114 | def format_heading(self, heading: str) -> str: 115 | return self._stringify(self.rich_format_heading(heading)) 116 | 117 | def format_description(self, description: str | None) -> str: 118 | return self._stringify(self.rich_format_description(description)) 119 | 120 | def format_epilog(self, epilog: str | None) -> str: 121 | return self._stringify(self.rich_format_epilog(epilog)) 122 | 123 | def rich_expand_default(self, option: optparse.Option) -> r.Text: 124 | assert option.help is not None 125 | if self.parser is None or not self.default_tag: 126 | help = option.help 127 | else: 128 | default_value = self.parser.defaults.get(option.dest) # type: ignore[arg-type] 129 | if default_value is optparse.NO_DEFAULT or default_value is None: 130 | default_value = self.NO_DEFAULT_VALUE 131 | help = option.help.replace(self.default_tag, r.escape(str(default_value))) 132 | rich_help = r.Text.from_markup(help, style="optparse.help") 133 | for highlight in self.highlights: 134 | rich_help.highlight_regex(highlight, style_prefix="optparse.") 135 | return rich_help 136 | 137 | def rich_format_option(self, option: optparse.Option) -> r.Text: 138 | result: list[r.Text] = [] 139 | opts = self.rich_option_strings[option] 140 | opt_width = self.help_position - self.current_indent - 2 141 | if len(opts) > opt_width: 142 | opts.append("\n") 143 | indent_first = self.help_position 144 | else: # start help on same line as opts 145 | opts.set_length(opt_width + 2) 146 | indent_first = 0 147 | opts.pad_left(self.current_indent) 148 | result.append(opts) 149 | if option.help: 150 | help_text = self.rich_expand_default(option) 151 | help_lines = rich_wrap(self.console, help_text, self.help_width) 152 | result.append(r.Text(" " * indent_first) + help_lines[0] + "\n") 153 | indent = r.Text(" " * self.help_position) 154 | for line in help_lines[1:]: 155 | result.append(indent + line + "\n") 156 | elif opts.plain[-1] != "\n": 157 | result.append(r.Text("\n")) 158 | else: 159 | pass # pragma: no cover 160 | return r.Text().join(result) 161 | 162 | def format_option(self, option: optparse.Option) -> str: 163 | return self._stringify(self.rich_format_option(option)) 164 | 165 | def store_option_strings(self, parser: optparse.OptionParser) -> None: 166 | self.indent() 167 | max_len = 0 168 | for opt in parser.option_list: 169 | strings = self.rich_format_option_strings(opt) 170 | self.option_strings[opt] = strings.plain 171 | self.rich_option_strings[opt] = strings 172 | max_len = max(max_len, len(strings) + self.current_indent) 173 | self.indent() 174 | for group in parser.option_groups: 175 | for opt in group.option_list: 176 | strings = self.rich_format_option_strings(opt) 177 | self.option_strings[opt] = strings.plain 178 | self.rich_option_strings[opt] = strings 179 | max_len = max(max_len, len(strings) + self.current_indent) 180 | self.dedent() 181 | self.dedent() 182 | self.help_position = min(max_len + 2, self.max_help_position) 183 | self.help_width = max(self.width - self.help_position, 11) 184 | 185 | def rich_format_option_strings(self, option: optparse.Option) -> r.Text: 186 | if option.takes_value(): 187 | if option.metavar: 188 | metavar = option.metavar 189 | else: 190 | assert option.dest is not None 191 | metavar = option.dest.upper() 192 | s_delim = self._short_opt_fmt.replace("%s", "") 193 | short_opts = [ 194 | r.Text(s_delim).join( 195 | [r.Text(o, "optparse.args"), r.Text(metavar, "optparse.metavar")] 196 | ) 197 | for o in option._short_opts 198 | ] 199 | l_delim = self._long_opt_fmt.replace("%s", "") 200 | long_opts = [ 201 | r.Text(l_delim).join( 202 | [r.Text(o, "optparse.args"), r.Text(metavar, "optparse.metavar")] 203 | ) 204 | for o in option._long_opts 205 | ] 206 | else: 207 | short_opts = [r.Text(o, style="optparse.args") for o in option._short_opts] 208 | long_opts = [r.Text(o, style="optparse.args") for o in option._long_opts] 209 | 210 | if self.short_first: 211 | opts = short_opts + long_opts 212 | else: 213 | opts = long_opts + short_opts 214 | 215 | return r.Text(", ").join(opts) 216 | 217 | def _generate_usage(self) -> r.Text: 218 | """Generate usage string from the parser's actions.""" 219 | if self.parser is None: 220 | raise TypeError("Cannot generate usage if parser is not set") 221 | mark = "==GENERATED_USAGE_MARKER==" 222 | usage_lines: list[r.Text] = [] 223 | prefix = self.rich_format_usage(mark).split(mark)[0] 224 | usage_lines.extend(prefix.split("\n")) 225 | usage_lines[-1].append(self.parser.get_prog_name(), "optparse.prog") 226 | indent = len(usage_lines[-1]) + 1 227 | for option in self.parser.option_list: 228 | if option.help == optparse.SUPPRESS_HELP: 229 | continue 230 | opt_str = option._short_opts[0] if option._short_opts else option.get_opt_string() 231 | option_usage = r.Text("[").append(opt_str, "optparse.args") 232 | if option.takes_value(): 233 | metavar = option.metavar or option.dest.upper() # type: ignore[union-attr] 234 | option_usage.append(" ").append(metavar, "optparse.metavar") 235 | option_usage.append("]") 236 | if len(usage_lines[-1]) + len(option_usage) + 1 > self.width: 237 | usage_lines.append(r.Text(" " * indent) + option_usage) 238 | else: 239 | usage_lines[-1].append(" ").append(option_usage) 240 | usage_lines.append(r.Text()) 241 | return r.Text("\n").join(usage_lines) 242 | 243 | 244 | class IndentedRichHelpFormatter(RichHelpFormatter): 245 | """Format help with indented section bodies.""" 246 | 247 | def __init__( 248 | self, 249 | indent_increment: int = 2, 250 | max_help_position: int = 24, 251 | width: int | None = None, 252 | short_first: bool | Literal[0, 1] = 1, 253 | ) -> None: 254 | super().__init__(indent_increment, max_help_position, width, short_first) 255 | 256 | def rich_format_usage(self, usage: str) -> r.Text: 257 | usage_template = optparse._("Usage: %s\n") # type: ignore[attr-defined] 258 | usage = usage_template % usage 259 | prefix = (usage_template % "").rstrip() 260 | spans = [r.Span(0, len(prefix), "optparse.groups")] 261 | return r.Text(usage, spans=spans) 262 | 263 | def rich_format_heading(self, heading: str) -> r.Text: 264 | text = r.Text(" " * self.current_indent).append(f"{heading}:", "optparse.groups") 265 | return text + r.Text("\n") 266 | 267 | 268 | class TitledRichHelpFormatter(RichHelpFormatter): 269 | """Format help with underlined section headers.""" 270 | 271 | def __init__( 272 | self, 273 | indent_increment: int = 0, 274 | max_help_position: int = 24, 275 | width: int | None = None, 276 | short_first: bool | Literal[0, 1] = 0, 277 | ) -> None: 278 | super().__init__(indent_increment, max_help_position, width, short_first) 279 | 280 | def rich_format_usage(self, usage: str) -> r.Text: 281 | heading = self.rich_format_heading(optparse._("Usage")) # type: ignore[attr-defined] 282 | return r.Text.assemble(heading, " ", usage, "\n") 283 | 284 | def rich_format_heading(self, heading: str) -> r.Text: 285 | underline = "=-"[self.level] * len(heading) 286 | return r.Text.assemble( 287 | (heading, "optparse.groups"), "\n", (underline, "optparse.groups"), "\n" 288 | ) 289 | -------------------------------------------------------------------------------- /rich_argparse/_patching.py: -------------------------------------------------------------------------------- 1 | # Source code: https://github.com/hamdanal/rich-argparse 2 | # MIT license: Copyright (c) Ali Hamdan 3 | 4 | # for internal use only 5 | from __future__ import annotations 6 | 7 | from rich_argparse._argparse import RichHelpFormatter 8 | 9 | 10 | def patch_default_formatter_class( 11 | cls=None, /, *, formatter_class=RichHelpFormatter, method_name="__init__" 12 | ): 13 | """Patch the default `formatter_class` parameter of an argument parser constructor. 14 | 15 | Parameters 16 | ---------- 17 | cls : (type, optional) 18 | The class to patch. If not provided, a decorator is returned. 19 | formatter_class : (type, optional) 20 | The new formatter class to use. Defaults to ``RichHelpFormatter``. 21 | method_name : (str, optional) 22 | The method name to patch. Defaults to ``__init__``. 23 | 24 | Examples 25 | -------- 26 | Can be used as a normal function to patch an existing class:: 27 | 28 | # Patch the default formatter class of `argparse.ArgumentParser` 29 | patch_default_formatter_class(argparse.ArgumentParser) 30 | 31 | # Patch the default formatter class of django commands 32 | from django.core.management.base import BaseCommand, DjangoHelpFormatter 33 | class DjangoRichHelpFormatter(DjangoHelpFormatter, RichHelpFormatter): ... 34 | patch_default_formatter_class( 35 | BaseCommand, formatter_class=DjangoRichHelpFormatter, method_name="create_parser" 36 | ) 37 | Or as a decorator to patch a new class:: 38 | 39 | @patch_default_formatter_class 40 | class MyArgumentParser(argparse.ArgumentParser): 41 | pass 42 | 43 | @patch_default_formatter_class(formatter_class=RawDescriptionRichHelpFormatter) 44 | class MyOtherArgumentParser(argparse.ArgumentParser): 45 | pass 46 | """ 47 | import functools 48 | 49 | def decorator(cls, /): 50 | method = getattr(cls, method_name) 51 | if not callable(method): 52 | raise TypeError(f"'{cls.__name__}.{method_name}' is not callable") 53 | 54 | @functools.wraps(method) 55 | def wrapper(*args, **kwargs): 56 | kwargs.setdefault("formatter_class", formatter_class) 57 | return method(*args, **kwargs) 58 | 59 | setattr(cls, method_name, wrapper) 60 | return cls 61 | 62 | if cls is None: 63 | return decorator 64 | return decorator(cls) 65 | -------------------------------------------------------------------------------- /rich_argparse/_patching.pyi: -------------------------------------------------------------------------------- 1 | # Source code: https://github.com/hamdanal/rich-argparse 2 | # MIT license: Copyright (c) Ali Hamdan 3 | 4 | # for internal use only 5 | from argparse import _FormatterClass 6 | from collections.abc import Callable 7 | from typing import TypeVar, overload 8 | 9 | from rich_argparse._argparse import RichHelpFormatter 10 | 11 | _T = TypeVar("_T", bound=type) 12 | 13 | @overload 14 | def patch_default_formatter_class( 15 | cls: None = None, 16 | /, 17 | *, 18 | formatter_class: _FormatterClass = RichHelpFormatter, 19 | method_name: str = "__init__", 20 | ) -> Callable[[_T], _T]: ... 21 | @overload 22 | def patch_default_formatter_class( 23 | cls: _T, 24 | /, 25 | *, 26 | formatter_class: _FormatterClass = RichHelpFormatter, 27 | method_name: str = "__init__", 28 | ) -> _T: ... 29 | -------------------------------------------------------------------------------- /rich_argparse/contrib.py: -------------------------------------------------------------------------------- 1 | # Source code: https://github.com/hamdanal/rich-argparse 2 | # MIT license: Copyright (c) Ali Hamdan 3 | """Extra formatters for rich help messages. 4 | 5 | The rich_argparse.contrib module contains optional, standard implementations of common patterns of 6 | rich help message formatting. These formatters are not included in the main rich_argparse module 7 | because they do not translate directly to argparse formatters. 8 | """ 9 | 10 | from __future__ import annotations 11 | 12 | from rich_argparse._contrib import ParagraphRichHelpFormatter 13 | 14 | __all__ = [ 15 | "ParagraphRichHelpFormatter", 16 | ] 17 | -------------------------------------------------------------------------------- /rich_argparse/django.py: -------------------------------------------------------------------------------- 1 | # Source code: https://github.com/hamdanal/rich-argparse 2 | # MIT license: Copyright (c) Ali Hamdan 3 | """Django-specific utilities for rich command line help.""" 4 | 5 | from __future__ import annotations 6 | 7 | try: 8 | from django.core.management.base import DjangoHelpFormatter as _DjangoHelpFormatter 9 | except ImportError as e: # pragma: no cover 10 | raise ImportError("rich_argparse.django requires django to be installed.") from e 11 | 12 | from rich_argparse._argparse import RichHelpFormatter as _RichHelpFormatter 13 | from rich_argparse._patching import patch_default_formatter_class as _patch_default_formatter_class 14 | 15 | __all__ = [ 16 | "DjangoRichHelpFormatter", 17 | "richify_command_line_help", 18 | ] 19 | 20 | 21 | class DjangoRichHelpFormatter(_DjangoHelpFormatter, _RichHelpFormatter): 22 | """A rich help formatter for django commands.""" 23 | 24 | 25 | def richify_command_line_help( 26 | formatter_class: type[_RichHelpFormatter] = DjangoRichHelpFormatter, 27 | ) -> None: 28 | """Set a rich default formatter class for ``BaseCommand`` project-wide. 29 | 30 | Calling this function affects all built-in, third-party, and user defined django commands. 31 | 32 | Note that this function only changes the **default** formatter class of commands. User commands 33 | can still override the default by explicitly setting a formatter class. 34 | """ 35 | from django.core.management.base import BaseCommand 36 | 37 | _patch_default_formatter_class( 38 | BaseCommand, formatter_class=formatter_class, method_name="create_parser" 39 | ) 40 | -------------------------------------------------------------------------------- /rich_argparse/optparse.py: -------------------------------------------------------------------------------- 1 | # Source code: https://github.com/hamdanal/rich-argparse 2 | # MIT license: Copyright (c) Ali Hamdan 3 | from __future__ import annotations 4 | 5 | from rich_argparse._optparse import ( 6 | GENERATE_USAGE, 7 | IndentedRichHelpFormatter, 8 | RichHelpFormatter, 9 | TitledRichHelpFormatter, 10 | ) 11 | 12 | __all__ = [ 13 | "RichHelpFormatter", 14 | "IndentedRichHelpFormatter", 15 | "TitledRichHelpFormatter", 16 | "GENERATE_USAGE", 17 | ] 18 | 19 | 20 | if __name__ == "__main__": 21 | import optparse 22 | 23 | IndentedRichHelpFormatter.highlights.append(r"(?P\bregexes\b)") 24 | parser = optparse.OptionParser( 25 | description="I [link https://pypi.org/project/rich]rich[/]ify:trade_mark: optparse help.", 26 | formatter=IndentedRichHelpFormatter(), 27 | prog="python -m rich_arparse.optparse", 28 | epilog=":link: https://github.com/hamdanal/rich-argparse#optparse-support.", 29 | usage=GENERATE_USAGE, 30 | ) 31 | parser.add_option("--formatter", metavar="rich", help="A piece of :cake: isn't it? :wink:") 32 | parser.add_option( 33 | "--styles", metavar="yours", help="Not your style? No biggie, change it :sunglasses:" 34 | ) 35 | parser.add_option( 36 | "--highlights", 37 | action="store_true", 38 | help=":clap: --highlight :clap: all :clap: the :clap: regexes :clap:", 39 | ) 40 | parser.add_option( 41 | "--syntax", action="store_true", help="`backquotes` may be bold, but they are :muscle:" 42 | ) 43 | parser.add_option( 44 | "-s", "--long", metavar="METAVAR", help="That's a lot of metavars for an option!" 45 | ) 46 | 47 | group = parser.add_option_group("Magic", description=":sparkles: :sparkles: :sparkles:") 48 | group.add_option( 49 | "--treasure", action="store_false", help="Mmm, did you find the --hidden :gem:?" 50 | ) 51 | group.add_option("--hidden", action="store_false", dest="treasure", help=optparse.SUPPRESS_HELP) 52 | parser.print_help() 53 | -------------------------------------------------------------------------------- /rich_argparse/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hamdanal/rich-argparse/366b0c1091aa2b45b8b0332302c4ba33b4b359fa/rich_argparse/py.typed -------------------------------------------------------------------------------- /scripts/generate-preview: -------------------------------------------------------------------------------- 1 | #!/bin/env bash 2 | 3 | COLUMNS=128 python -m rich_argparse --generate-rich-argparse-preview 4 | -------------------------------------------------------------------------------- /scripts/release: -------------------------------------------------------------------------------- 1 | #!/bin/env bash 2 | set -euxo pipefail 3 | 4 | # clear the dist directory 5 | rm -rf dist/ 6 | 7 | # build the sdist and wheel 8 | pyproject-build . 9 | 10 | # check the contents of the sdist and wheel 11 | tar -tvf dist/rich_argparse-*.tar.gz 12 | unzip -l dist/rich_argparse-*.whl 13 | 14 | # continue? 15 | [[ "$(read -e -p 'Release? [y/N]> '; echo $REPLY)" == [Yy]* ]] || exit 1; 16 | 17 | # upload the new artifacts to pypi 18 | twine upload -r pypi dist/* 19 | -------------------------------------------------------------------------------- /scripts/run-tests: -------------------------------------------------------------------------------- 1 | #!/bin/env bash 2 | 3 | set -euxo pipefail 4 | 5 | for python in 3.{9..14}; do 6 | uvx --python=${python} --with=. --with-requirements=tests/requirements.txt pytest --cov 7 | done 8 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hamdanal/rich-argparse/366b0c1091aa2b45b8b0332302c4ba33b4b359fa/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | from unittest.mock import patch 5 | 6 | import pytest 7 | 8 | from rich_argparse import RichHelpFormatter 9 | 10 | 11 | # Common fixtures 12 | # =============== 13 | @pytest.fixture(scope="session", autouse=True) 14 | def set_terminal_properties(): 15 | with patch.dict(os.environ, {"COLUMNS": "100", "TERM": "xterm-256color"}): 16 | yield 17 | 18 | 19 | @pytest.fixture(scope="session", autouse=True) 20 | def turnoff_legacy_windows(): 21 | with patch("rich.console.detect_legacy_windows", return_value=False): 22 | yield 23 | 24 | 25 | @pytest.fixture() 26 | def force_color(): 27 | with patch("rich.console.Console.is_terminal", return_value=True): 28 | yield 29 | 30 | 31 | # argparse fixtures 32 | # ================= 33 | @pytest.fixture() 34 | def disable_group_name_formatter(): 35 | with patch.object(RichHelpFormatter, "group_name_formatter", str): 36 | yield 37 | -------------------------------------------------------------------------------- /tests/helpers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import argparse as ap 4 | import functools 5 | import io 6 | import optparse as op 7 | import sys 8 | import textwrap 9 | from collections.abc import Callable 10 | from typing import Any, Generic, TypeVar 11 | from unittest.mock import patch 12 | 13 | import pytest 14 | 15 | if sys.version_info >= (3, 10): # pragma: >=3.10 cover 16 | from typing import Concatenate, ParamSpec 17 | else: # pragma: <3.10 cover 18 | from typing_extensions import Concatenate, ParamSpec 19 | 20 | R = TypeVar("R") # return type 21 | S = TypeVar("S") # self type 22 | P = ParamSpec("P") # other parameters type 23 | PT = TypeVar("PT", bound="ap.ArgumentParser | op.OptionParser") # parser type 24 | GT = TypeVar("GT", bound="ap._ArgumentGroup | op.OptionGroup") # group type 25 | 26 | 27 | def get_cmd_output(parser: ap.ArgumentParser | op.OptionParser, cmd: list[str]) -> str: 28 | __tracebackhide__ = True 29 | stdout = io.StringIO() 30 | with pytest.raises(SystemExit), patch.object(sys, "stdout", stdout): 31 | parser.parse_args(cmd) 32 | return stdout.getvalue() 33 | 34 | 35 | def copy_signature( 36 | func: Callable[Concatenate[Any, P], object], 37 | ) -> Callable[[Callable[Concatenate[S, ...], R]], Callable[Concatenate[S, P], R]]: 38 | """Copy the signature of the given method except self and return types.""" 39 | return functools.wraps(func)(lambda f: f) 40 | 41 | 42 | class BaseGroups(Generic[GT]): 43 | """Base class for argument groups and option groups.""" 44 | 45 | def __init__(self) -> None: 46 | self.groups: list[GT] = [] 47 | 48 | def append(self, group: GT) -> None: 49 | self.groups.append(group) 50 | 51 | 52 | class BaseParsers(Generic[PT]): 53 | """Base class for argument parsers and option parsers.""" 54 | 55 | parsers: list[PT] 56 | 57 | def assert_format_help_equal(self, expected: str | None = None) -> None: 58 | assert self.parsers, "No parsers to compare." 59 | outputs = [parser.format_help() for parser in self.parsers] 60 | if expected is None: # pragma: no cover 61 | expected = outputs.pop() 62 | assert outputs, "No outputs to compare." 63 | for output in outputs: 64 | assert output == expected 65 | 66 | def assert_cmd_output_equal(self, cmd: list[str], expected: str | None = None) -> None: 67 | assert self.parsers, "No parsers to compare." 68 | outputs = [get_cmd_output(parser, cmd) for parser in self.parsers] 69 | if expected is None: # pragma: no cover 70 | expected = outputs.pop() 71 | assert outputs, "No outputs to compare." 72 | for output in outputs: 73 | assert output == expected 74 | 75 | 76 | # argparse 77 | # ======== 78 | class ArgumentGroups(BaseGroups[ap._ArgumentGroup]): 79 | @copy_signature(ap._ArgumentGroup.add_argument) # type: ignore[arg-type] 80 | def add_argument(self, /, *args, **kwds) -> None: 81 | for group in self.groups: 82 | group.add_argument(*args, **kwds) 83 | 84 | 85 | class _SubParsersActions: 86 | def __init__(self) -> None: 87 | self.parents: list[ap.ArgumentParser] = [] 88 | self.subparsers: list[ap._SubParsersAction[ap.ArgumentParser]] = [] 89 | 90 | def append(self, p: ap.ArgumentParser, sp: ap._SubParsersAction[ap.ArgumentParser]) -> None: 91 | self.parents.append(p) 92 | self.subparsers.append(sp) 93 | 94 | @copy_signature(ap._SubParsersAction.add_parser) # type: ignore[arg-type] 95 | def add_parser(self, /, *args, **kwds) -> ArgumentParsers: 96 | parsers = ArgumentParsers() 97 | for parent, subparser in zip(self.parents, self.subparsers): 98 | sp = subparser.add_parser(*args, **kwds, formatter_class=parent.formatter_class) 99 | parsers.parsers.append(sp) 100 | return parsers 101 | 102 | 103 | class ArgumentParsers(BaseParsers[ap.ArgumentParser]): 104 | def __init__( 105 | self, 106 | *formatter_classes: type[ap.HelpFormatter], 107 | prog: str | None = None, 108 | usage: str | None = None, 109 | description: str | None = None, 110 | epilog: str | None = None, 111 | ) -> None: 112 | assert len(set(formatter_classes)) == len(formatter_classes), "Duplicate formatter_class" 113 | self.parsers = [ 114 | ap.ArgumentParser( 115 | prog=prog, 116 | usage=usage, 117 | description=description, 118 | epilog=epilog, 119 | formatter_class=formatter_class, 120 | ) 121 | for formatter_class in formatter_classes 122 | ] 123 | 124 | @copy_signature(ap.ArgumentParser.add_argument) # type: ignore[arg-type] 125 | def add_argument(self, /, *args, **kwds) -> None: 126 | for parser in self.parsers: 127 | parser.add_argument(*args, **kwds) 128 | 129 | @copy_signature(ap.ArgumentParser.add_argument_group) 130 | def add_argument_group(self, /, *args, **kwds) -> ArgumentGroups: 131 | groups = ArgumentGroups() 132 | for parser in self.parsers: 133 | groups.append(parser.add_argument_group(*args, **kwds)) 134 | return groups 135 | 136 | @copy_signature(ap.ArgumentParser.add_subparsers) 137 | def add_subparsers(self, /, *args, **kwds) -> _SubParsersActions: 138 | subparsers = _SubParsersActions() 139 | for parser in self.parsers: 140 | sp = parser.add_subparsers(*args, **kwds) 141 | subparsers.append(parser, sp) 142 | return subparsers 143 | 144 | 145 | def clean_argparse(text: str, dedent: bool = True) -> str: 146 | """Clean argparse help text.""" 147 | # Can be replaced with textwrap.dedent(text) when Python 3.10 is the minimum version 148 | if sys.version_info >= (3, 10): # pragma: >=3.10 cover 149 | # replace "optional arguments:" with "options:" 150 | pos = text.lower().index("optional arguments:") 151 | text = text[: pos + 6] + text[pos + 17 :] 152 | if dedent: 153 | text = textwrap.dedent(text) 154 | return text 155 | 156 | 157 | # optparse 158 | # ======== 159 | class OptionGroups(BaseGroups[op.OptionGroup]): 160 | @copy_signature(op.OptionGroup.add_option) 161 | def add_option(self, /, *args, **kwds) -> None: 162 | for group in self.groups: 163 | group.add_option(*args, **kwds) 164 | 165 | 166 | class OptionParsers(BaseParsers[op.OptionParser]): 167 | def __init__( 168 | self, 169 | *formatters: op.HelpFormatter, 170 | prog: str | None = None, 171 | usage: str | None = None, 172 | description: str | None = None, 173 | epilog: str | None = None, 174 | ) -> None: 175 | assert len(set(formatters)) == len(formatters), "Duplicate formatter" 176 | self.parsers = [ 177 | op.OptionParser( 178 | prog=prog, usage=usage, description=description, epilog=epilog, formatter=formatter 179 | ) 180 | for formatter in formatters 181 | ] 182 | 183 | @copy_signature(op.OptionParser.add_option) 184 | def add_option(self, /, *args, **kwds) -> None: 185 | for parser in self.parsers: 186 | parser.add_option(*args, **kwds) 187 | 188 | @copy_signature(op.OptionParser.add_option_group) 189 | def add_option_group(self, /, *args, **kwds) -> OptionGroups: 190 | groups = OptionGroups() 191 | for parser in self.parsers: 192 | groups.append(parser.add_option_group(*args, **kwds)) 193 | return groups 194 | -------------------------------------------------------------------------------- /tests/requirements.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | coverage[toml] 3 | covdefaults 4 | pytest-cov 5 | typing-extensions; python_version < "3.10" 6 | -------------------------------------------------------------------------------- /tests/test_argparse.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import argparse 4 | import re 5 | import string 6 | import sys 7 | import textwrap 8 | from argparse import ( 9 | SUPPRESS, 10 | Action, 11 | ArgumentDefaultsHelpFormatter, 12 | ArgumentParser, 13 | HelpFormatter, 14 | MetavarTypeHelpFormatter, 15 | RawDescriptionHelpFormatter, 16 | RawTextHelpFormatter, 17 | ) 18 | from contextlib import nullcontext 19 | from unittest.mock import Mock, patch 20 | 21 | import pytest 22 | from rich import get_console 23 | from rich.console import Group 24 | from rich.markdown import Markdown 25 | from rich.table import Table 26 | from rich.text import Text 27 | 28 | import rich_argparse._lazy_rich as r 29 | from rich_argparse import ( 30 | ArgumentDefaultsRichHelpFormatter, 31 | HelpPreviewAction, 32 | MetavarTypeRichHelpFormatter, 33 | RawDescriptionRichHelpFormatter, 34 | RawTextRichHelpFormatter, 35 | RichHelpFormatter, 36 | ) 37 | from rich_argparse._common import _fix_legacy_win_text 38 | from rich_argparse._patching import patch_default_formatter_class 39 | from tests.helpers import ArgumentParsers, clean_argparse, get_cmd_output 40 | 41 | 42 | def test_params_substitution(): 43 | # in text (description, epilog, group description) and version: substitute %(prog)s 44 | # in help message: substitute %(param)s for all param in vars(action) 45 | parser = ArgumentParser( 46 | "awesome_program", 47 | description="This is the %(prog)s program.", 48 | epilog="The epilog of %(prog)s.", 49 | formatter_class=RichHelpFormatter, 50 | ) 51 | parser.add_argument("--version", action="version", version="%(prog)s 1.0.0") 52 | parser.add_argument("--option", default="value", help="help of option (default: %(default)s)") 53 | 54 | expected_help_output = """\ 55 | Usage: awesome_program [-h] [--version] [--option OPTION] 56 | 57 | This is the awesome_program program. 58 | 59 | Optional Arguments: 60 | -h, --help show this help message and exit 61 | --version show program's version number and exit 62 | --option OPTION help of option (default: value) 63 | 64 | The epilog of awesome_program. 65 | """ 66 | assert parser.format_help() == clean_argparse(expected_help_output) 67 | assert get_cmd_output(parser, cmd=["--version"]) == "awesome_program 1.0.0\n" 68 | 69 | 70 | @pytest.mark.parametrize("prog", (None, "PROG"), ids=("no_prog", "prog")) 71 | @pytest.mark.parametrize("usage", (None, "USAGE"), ids=("no_usage", "usage")) 72 | @pytest.mark.parametrize("description", (None, "A description."), ids=("no_desc", "desc")) 73 | @pytest.mark.parametrize("epilog", (None, "An epilog."), ids=("no_epilog", "epilog")) 74 | @pytest.mark.usefixtures("disable_group_name_formatter") 75 | def test_overall_structure(prog, usage, description, epilog): 76 | # The output must be consistent with the original HelpFormatter in these cases: 77 | # 1. no markup/emoji codes are used 78 | # 2. no short and long options with args are used 79 | # 3. group_name_formatter is disabled 80 | # 4. colors are disabled 81 | parsers = ArgumentParsers( 82 | HelpFormatter, 83 | RichHelpFormatter, 84 | prog=prog, 85 | usage=usage, 86 | description=description, 87 | epilog=epilog, 88 | ) 89 | parsers.add_argument("file", default="-", help="A file (default: %(default)s).") 90 | parsers.add_argument("spaces", help="Arg with weird\n\n whitespaces\t\t.") 91 | parsers.add_argument("--very-very-very-very-very-very-very-very-long-option-name", help="help!") 92 | 93 | # all types of empty groups 94 | parsers.add_argument_group("empty group name", description="empty_group description") 95 | parsers.add_argument_group("no description empty group name") 96 | parsers.add_argument_group("", description="empty_name_empty_group description") 97 | parsers.add_argument_group(description="no_name_empty_group description") 98 | parsers.add_argument_group("spaces group", description=" \tspaces_group description ") 99 | parsers.add_argument_group(SUPPRESS, description="suppressed_name_group description") 100 | parsers.add_argument_group(SUPPRESS, description=SUPPRESS) 101 | 102 | # all types of non-empty groups 103 | groups = parsers.add_argument_group("group name", description="group description") 104 | groups.add_argument("arg", help="help inside group") 105 | no_desc_groups = parsers.add_argument_group("no description group name") 106 | no_desc_groups.add_argument("arg", help="arg help inside no_desc_group") 107 | empty_name_groups = parsers.add_argument_group("", description="empty_name_group description") 108 | empty_name_groups.add_argument("arg", help="arg help inside empty_name_group") 109 | no_name_groups = parsers.add_argument_group(description="no_name_group description") 110 | no_name_groups.add_argument("arg", help="arg help inside no_name_group") 111 | no_name_no_desc_groups = parsers.add_argument_group() 112 | no_name_no_desc_groups.add_argument("arg", help="arg help inside no_name_no_desc_group") 113 | suppressed_name_groups = parsers.add_argument_group( 114 | SUPPRESS, description="suppressed_name_group description" 115 | ) 116 | suppressed_name_groups.add_argument("arg", help="arg help inside suppressed_name_group") 117 | suppressed_name_desc_groups = parsers.add_argument_group(SUPPRESS, description=SUPPRESS) 118 | suppressed_name_desc_groups.add_argument( 119 | "arg", help="arg help inside suppressed_name_desc_group" 120 | ) 121 | 122 | parsers.assert_format_help_equal() 123 | 124 | 125 | @pytest.mark.usefixtures("disable_group_name_formatter") 126 | def test_padding_and_wrapping(): 127 | parsers = ArgumentParsers( 128 | HelpFormatter, RichHelpFormatter, prog="PROG", description="-" * 120, epilog="%" * 120 129 | ) 130 | parsers.add_argument("--very-long-option-name", metavar="LONG_METAVAR", help="." * 120) 131 | groups_with_description = parsers.add_argument_group("group", description="*" * 120) 132 | groups_with_description.add_argument("pos-arg", help="#" * 120) 133 | 134 | parsers.add_argument_group( 135 | "= =" * 40, description="group with a very long name that should not wrap" 136 | ) 137 | 138 | expected_help_output = """\ 139 | usage: PROG [-h] [--very-long-option-name LONG_METAVAR] pos-arg 140 | 141 | -------------------------------------------------------------------------------------------------- 142 | ---------------------- 143 | 144 | optional arguments: 145 | -h, --help show this help message and exit 146 | --very-long-option-name LONG_METAVAR 147 | .......................................................................... 148 | .............................................. 149 | 150 | group: 151 | ********************************************************************************************** 152 | ************************** 153 | 154 | pos-arg ########################################################################## 155 | ############################################## 156 | 157 | = == == == == == == == == == == == == == == == == == == == == == == == == == == == == == == == == == == == == == == == =: 158 | group with a very long name that should not wrap 159 | 160 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 161 | %%%%%%%%%%%%%%%%%%%%%% 162 | """ 163 | parsers.assert_format_help_equal(expected=clean_argparse(expected_help_output)) 164 | 165 | 166 | @pytest.mark.xfail(reason="rich wraps differently") 167 | @pytest.mark.usefixtures("disable_group_name_formatter") 168 | def test_wrapping_compatible(): 169 | # needs fixing rich wrapping to be compatible with textwrap.wrap 170 | parsers = ArgumentParsers( 171 | HelpFormatter, RichHelpFormatter, prog="PROG", description="some text " + "-" * 120 172 | ) 173 | parsers.assert_format_help_equal() 174 | 175 | 176 | @pytest.mark.parametrize("title", (None, "available commands"), ids=("no_title", "title")) 177 | @pytest.mark.parametrize("description", (None, "subparsers description"), ids=("no_desc", "desc")) 178 | @pytest.mark.parametrize("dest", (None, "command"), ids=("no_dest", "dest")) 179 | @pytest.mark.parametrize("metavar", (None, ""), ids=("no_mv", "mv")) 180 | @pytest.mark.parametrize("help", (None, "The subcommand to execute"), ids=("no_help", "help")) 181 | @pytest.mark.parametrize("required", (False, True), ids=("opt", "req")) 182 | @pytest.mark.usefixtures("disable_group_name_formatter") 183 | def test_subparsers(title, description, dest, metavar, help, required): 184 | subparsers_kwargs = { 185 | "title": title, 186 | "description": description, 187 | "dest": dest, 188 | "metavar": metavar, 189 | "help": help, 190 | "required": required, 191 | } 192 | subparsers_kwargs = {k: v for k, v in subparsers_kwargs.items() if v is not None} 193 | 194 | parsers = ArgumentParsers(HelpFormatter, RichHelpFormatter) 195 | subparsers_actions = parsers.add_subparsers(**subparsers_kwargs) 196 | subparsers = subparsers_actions.add_parser("help", help="help subcommand.") 197 | parsers.assert_format_help_equal() 198 | subparsers.assert_format_help_equal() 199 | 200 | 201 | @pytest.mark.usefixtures("disable_group_name_formatter") 202 | def test_escape_params(): 203 | # params such as %(prog)s and %(default)s must be escaped when substituted 204 | parsers = ArgumentParsers( 205 | HelpFormatter, 206 | RichHelpFormatter, 207 | prog="[underline]", 208 | usage="%(prog)s [%%options] %% [args]\n%%%(prog)s %%(prog)s [%%%%options] %%%% [args]", 209 | description="%(prog)s description.", 210 | epilog="%(prog)s epilog.", 211 | ) 212 | 213 | class SpecialType(str): ... 214 | 215 | SpecialType.__name__ = "[link]" 216 | 217 | parsers.add_argument("--version", action="version", version="%(prog)s %%1.0.0") 218 | parsers.add_argument("pos-arg", metavar="[italic]", help="help of pos arg with special metavar") 219 | parsers.add_argument( 220 | "--default", default="[default]", help="help with special default: %(default)s" 221 | ) 222 | parsers.add_argument("--type", type=SpecialType, help="help with special type: %(type)s") 223 | parsers.add_argument( 224 | "--metavar", metavar="[bold]", help="help with special metavar: %(metavar)s" 225 | ) 226 | parsers.add_argument( 227 | "--float", type=float, default=1.5, help="help with float conversion: %(default).5f" 228 | ) 229 | parsers.add_argument("--repr", type=str, help="help with repr conversion: %(type)r") 230 | parsers.add_argument( 231 | "--percent", help="help with percent escaping: %%(prog)s %%%(prog)s %% %%%% %%%%prog" 232 | ) 233 | 234 | expected_help_output = """\ 235 | usage: [underline] [%options] % [args] 236 | %[underline] %(prog)s [%%options] %% [args] 237 | 238 | [underline] description. 239 | 240 | positional arguments: 241 | [italic] help of pos arg with special metavar 242 | 243 | optional arguments: 244 | -h, --help show this help message and exit 245 | --version show program's version number and exit 246 | --default DEFAULT help with special default: [default] 247 | --type TYPE help with special type: [link] 248 | --metavar [bold] help with special metavar: [bold] 249 | --float FLOAT help with float conversion: 1.50000 250 | --repr REPR help with repr conversion: 'str' 251 | --percent PERCENT help with percent escaping: %(prog)s %[underline] % %% %%prog 252 | 253 | [underline] epilog. 254 | """ 255 | parsers.assert_format_help_equal(expected=clean_argparse(expected_help_output)) 256 | parsers.assert_cmd_output_equal(cmd=["--version"], expected="[underline] %1.0.0\n") 257 | 258 | 259 | @pytest.mark.usefixtures("force_color") 260 | def test_generated_usage(): 261 | parser = ArgumentParser("PROG", formatter_class=RichHelpFormatter) 262 | parser.add_argument("file") 263 | parser.add_argument("hidden", help=SUPPRESS) 264 | parser.add_argument("--weird", metavar="y)") 265 | hidden_group = parser.add_mutually_exclusive_group() 266 | hidden_group.add_argument("--hidden-group-arg1", help=SUPPRESS) 267 | hidden_group.add_argument("--hidden-group-arg2", help=SUPPRESS) 268 | parser.add_argument("--required", metavar="REQ", required=True) 269 | mut_ex = parser.add_mutually_exclusive_group() 270 | mut_ex.add_argument("--flag", action="store_true", help="Is flag?") 271 | mut_ex.add_argument("--not-flag", action="store_true", help="Is not flag?") 272 | req_mut_ex = parser.add_mutually_exclusive_group(required=True) 273 | req_mut_ex.add_argument("-y", help="Yes.") 274 | req_mut_ex.add_argument("-n", help="No.") 275 | 276 | usage_text = ( 277 | "\x1b[38;5;244mPROG\x1b[0m [\x1b[36m-h\x1b[0m] " 278 | "[\x1b[36m--weird\x1b[0m \x1b[38;5;36my)\x1b[0m] " 279 | "\x1b[36m--required\x1b[0m \x1b[38;5;36mREQ\x1b[0m " 280 | "[\x1b[36m--flag\x1b[0m | \x1b[36m--not-flag\x1b[0m] " 281 | "(\x1b[36m-y\x1b[0m \x1b[38;5;36mY\x1b[0m | \x1b[36m-n\x1b[0m \x1b[38;5;36mN\x1b[0m) " 282 | "\x1b[36mfile\x1b[0m" 283 | ) 284 | if sys.version_info >= (3, 11): # pragma: >=3.11 cover 285 | usage_text = usage_text.replace(" ", " ") 286 | 287 | expected_help_output = f"""\ 288 | \x1b[38;5;208mUsage:\x1b[0m {usage_text} 289 | 290 | \x1b[38;5;208mPositional Arguments:\x1b[0m 291 | \x1b[36mfile\x1b[0m 292 | 293 | \x1b[38;5;208mOptional Arguments:\x1b[0m 294 | \x1b[36m-h\x1b[0m, \x1b[36m--help\x1b[0m \x1b[39mshow this help message and exit\x1b[0m 295 | \x1b[36m--weird\x1b[0m \x1b[38;5;36my)\x1b[0m 296 | \x1b[36m--required\x1b[0m \x1b[38;5;36mREQ\x1b[0m 297 | \x1b[36m--flag\x1b[0m \x1b[39mIs flag?\x1b[0m 298 | \x1b[36m--not-flag\x1b[0m \x1b[39mIs not flag?\x1b[0m 299 | \x1b[36m-y\x1b[0m \x1b[38;5;36mY\x1b[0m \x1b[39mYes.\x1b[0m 300 | \x1b[36m-n\x1b[0m \x1b[38;5;36mN\x1b[0m \x1b[39mNo.\x1b[0m 301 | """ 302 | assert parser.format_help() == clean_argparse(expected_help_output) 303 | 304 | 305 | @pytest.mark.parametrize( 306 | ("usage", "expected", "usage_markup"), 307 | ( 308 | pytest.param( 309 | "%(prog)s [bold] PROG_CMD[/]", 310 | "\x1b[38;5;244mPROG\x1b[0m [bold] PROG_CMD[/]", 311 | None, 312 | id="default", 313 | ), 314 | pytest.param( 315 | "%(prog)s [bold] PROG_CMD[/]", 316 | "\x1b[38;5;244mPROG\x1b[0m [bold] PROG_CMD[/]", 317 | False, 318 | id="no_markup", 319 | ), 320 | pytest.param( 321 | "%(prog)s [bold] PROG_CMD[/]", 322 | "\x1b[38;5;244mPROG\x1b[0m \x1b[1m PROG_CMD\x1b[0m", 323 | True, 324 | id="markup", 325 | ), 326 | pytest.param( 327 | "PROG %(prog)s [bold] %(prog)s [/]\n%(prog)r", 328 | ( 329 | "PROG " 330 | "\x1b[38;5;244mPROG\x1b[0m " 331 | "\x1b[1m \x1b[0m\x1b[1;38;5;244mPROG\x1b[0m" # "\x1b[1m \x1b[0m" 332 | "\n\x1b[38;5;244m'PROG'\x1b[0m" 333 | ), 334 | True, 335 | id="prog_prog", 336 | ), 337 | ), 338 | ) 339 | @pytest.mark.usefixtures("force_color") 340 | def test_user_usage(usage, expected, usage_markup): 341 | parser = ArgumentParser(prog="PROG", usage=usage, formatter_class=RichHelpFormatter) 342 | if usage_markup is not None: 343 | ctx = patch.object(RichHelpFormatter, "usage_markup", usage_markup) 344 | else: 345 | ctx = nullcontext() 346 | with ctx: 347 | assert parser.format_usage() == f"\x1b[38;5;208mUsage:\x1b[0m {expected}\n" 348 | 349 | 350 | @pytest.mark.usefixtures("force_color") 351 | def test_actions_spans_in_usage(): 352 | parser = ArgumentParser("PROG", formatter_class=RichHelpFormatter) 353 | parser.add_argument("required") 354 | parser.add_argument("int", nargs=2) 355 | parser.add_argument("optional", nargs=argparse.OPTIONAL) 356 | parser.add_argument("zom", nargs=argparse.ZERO_OR_MORE) 357 | parser.add_argument("oom", nargs=argparse.ONE_OR_MORE) 358 | parser.add_argument("remainder", nargs=argparse.REMAINDER) 359 | parser.add_argument("parser", nargs=argparse.PARSER) 360 | parser.add_argument("suppress", nargs=argparse.SUPPRESS) 361 | mut_ex = parser.add_mutually_exclusive_group() 362 | mut_ex.add_argument("--opt", nargs="?") 363 | mut_ex.add_argument("--opts", nargs="+") 364 | 365 | usage_text = ( 366 | "\x1b[38;5;208mUsage:\x1b[0m \x1b[38;5;244mPROG\x1b[0m [\x1b[36m-h\x1b[0m] " 367 | "[\x1b[36m--opt\x1b[0m [\x1b[38;5;36mOPT\x1b[0m] | " 368 | "\x1b[36m--opts\x1b[0m \x1b[38;5;36mOPTS\x1b[0m [\x1b[38;5;36mOPTS\x1b[0m \x1b[38;5;36m...\x1b[0m]]\n " 369 | "\x1b[36mrequired\x1b[0m \x1b[36mint\x1b[0m \x1b[36mint\x1b[0m [\x1b[36moptional\x1b[0m] " 370 | "[\x1b[36mzom\x1b[0m \x1b[36m...\x1b[0m] \x1b[36moom\x1b[0m [\x1b[36moom\x1b[0m \x1b[36m...\x1b[0m] \x1b[36m...\x1b[0m \x1b[36mparser\x1b[0m \x1b[36m...\x1b[0m" 371 | ) 372 | expected_help_output = f"""\ 373 | {usage_text} 374 | 375 | \x1b[38;5;208mPositional Arguments:\x1b[0m 376 | \x1b[36mrequired\x1b[0m 377 | \x1b[36mint\x1b[0m 378 | \x1b[36moptional\x1b[0m 379 | \x1b[36mzom\x1b[0m 380 | \x1b[36moom\x1b[0m 381 | \x1b[36mremainder\x1b[0m 382 | \x1b[36mparser\x1b[0m 383 | \x1b[36msuppress\x1b[0m 384 | 385 | \x1b[38;5;208mOptional Arguments:\x1b[0m 386 | \x1b[36m-h\x1b[0m, \x1b[36m--help\x1b[0m \x1b[39mshow this help message and exit\x1b[0m 387 | \x1b[36m--opt\x1b[0m [\x1b[38;5;36mOPT\x1b[0m] 388 | \x1b[36m--opts\x1b[0m \x1b[38;5;36mOPTS\x1b[0m [\x1b[38;5;36mOPTS\x1b[0m \x1b[38;5;36m...\x1b[0m] 389 | """ 390 | assert parser.format_help() == clean_argparse(expected_help_output) 391 | 392 | 393 | @pytest.mark.usefixtures("force_color") 394 | def test_boolean_optional_action_spans(): 395 | parser = ArgumentParser("PROG", formatter_class=RichHelpFormatter) 396 | parser.add_argument("--bool", action=argparse.BooleanOptionalAction) 397 | expected_help_output = """\ 398 | \x1b[38;5;208mUsage:\x1b[0m \x1b[38;5;244mPROG\x1b[0m [\x1b[36m-h\x1b[0m] [\x1b[36m--bool\x1b[0m | \x1b[36m--no-bool\x1b[0m] 399 | 400 | \x1b[38;5;208mOptional Arguments:\x1b[0m 401 | \x1b[36m-h\x1b[0m, \x1b[36m--help\x1b[0m \x1b[39mshow this help message and exit\x1b[0m 402 | \x1b[36m--bool\x1b[0m, \x1b[36m--no-bool\x1b[0m 403 | """ 404 | assert parser.format_help() == clean_argparse(expected_help_output) 405 | 406 | 407 | def test_usage_spans_errors(): 408 | parser = ArgumentParser() 409 | parser._optionals.required = False 410 | actions = parser._actions 411 | groups = [parser._optionals] 412 | 413 | formatter = RichHelpFormatter("PROG") 414 | with patch.object(RichHelpFormatter, "_rich_usage_spans", side_effect=ValueError): 415 | formatter.add_usage(usage=None, actions=actions, groups=groups, prefix=None) 416 | (usage,) = formatter._root_section.rich_items 417 | assert isinstance(usage, Text) 418 | assert str(usage).rstrip() == "Usage: PROG [-h]" 419 | prefix_span, prog_span = usage.spans 420 | assert prefix_span.start == 0 421 | assert prefix_span.end == len("usage:") 422 | assert prefix_span.style == "argparse.groups" 423 | assert prog_span.start == len("usage: ") 424 | assert prog_span.end == len("usage: PROG") 425 | assert prog_span.style == "argparse.prog" 426 | 427 | 428 | def test_no_help(): 429 | formatter = RichHelpFormatter("prog") 430 | formatter.add_usage(usage=SUPPRESS, actions=[], groups=[]) 431 | out = formatter.format_help() 432 | assert not formatter._root_section.rich_items 433 | assert not out 434 | 435 | 436 | @pytest.mark.usefixtures("disable_group_name_formatter") 437 | def test_raw_description_rich_help_formatter(): 438 | long_text = " ".join(["The quick brown fox jumps over the lazy dog."] * 3) 439 | parsers = ArgumentParsers( 440 | RawDescriptionHelpFormatter, 441 | RawDescriptionRichHelpFormatter, 442 | prog="PROG", 443 | description=long_text, 444 | epilog=long_text, 445 | ) 446 | groups = parsers.add_argument_group("group", description=long_text) 447 | groups.add_argument("--long", help=long_text) 448 | 449 | expected_help_output = """\ 450 | usage: PROG [-h] [--long LONG] 451 | 452 | The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. 453 | 454 | optional arguments: 455 | -h, --help show this help message and exit 456 | 457 | group: 458 | The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. 459 | 460 | --long LONG The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the 461 | lazy dog. The quick brown fox jumps over the lazy dog. 462 | 463 | The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. 464 | """ 465 | parsers.assert_format_help_equal(expected=clean_argparse(expected_help_output)) 466 | 467 | 468 | @pytest.mark.usefixtures("disable_group_name_formatter") 469 | def test_raw_text_rich_help_formatter(): 470 | long_text = " ".join(["The quick brown fox jumps over the lazy dog."] * 3) 471 | parsers = ArgumentParsers( 472 | RawTextHelpFormatter, 473 | RawTextRichHelpFormatter, 474 | prog="PROG", 475 | description=long_text, 476 | epilog=long_text, 477 | ) 478 | groups = parsers.add_argument_group("group", description=long_text) 479 | groups.add_argument("--long", help=long_text) 480 | 481 | expected_help_output = """\ 482 | usage: PROG [-h] [--long LONG] 483 | 484 | The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. 485 | 486 | optional arguments: 487 | -h, --help show this help message and exit 488 | 489 | group: 490 | The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. 491 | 492 | --long LONG The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. 493 | 494 | The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. 495 | """ 496 | parsers.assert_format_help_equal(expected=clean_argparse(expected_help_output)) 497 | 498 | 499 | @pytest.mark.usefixtures("disable_group_name_formatter") 500 | def test_argument_default_rich_help_formatter(): 501 | parsers = ArgumentParsers( 502 | ArgumentDefaultsHelpFormatter, ArgumentDefaultsRichHelpFormatter, prog="PROG" 503 | ) 504 | parsers.add_argument("--option", default="def", help="help of option") 505 | 506 | expected_help_output = """\ 507 | usage: PROG [-h] [--option OPTION] 508 | 509 | optional arguments: 510 | -h, --help show this help message and exit 511 | --option OPTION help of option (default: def) 512 | """ 513 | parsers.assert_format_help_equal(expected=clean_argparse(expected_help_output)) 514 | 515 | 516 | @pytest.mark.usefixtures("disable_group_name_formatter") 517 | def test_metavar_type_help_formatter(): 518 | parsers = ArgumentParsers(MetavarTypeHelpFormatter, MetavarTypeRichHelpFormatter, prog="PROG") 519 | parsers.add_argument("--count", type=int, default=0, help="how many?") 520 | 521 | expected_help_output = """\ 522 | usage: PROG [-h] [--count int] 523 | 524 | optional arguments: 525 | -h, --help show this help message and exit 526 | --count int how many? 527 | """ 528 | parsers.assert_format_help_equal(expected=clean_argparse(expected_help_output)) 529 | 530 | 531 | def test_django_rich_help_formatter(): 532 | # https://github.com/django/django/blob/8eed30aec6/django/core/management/base.py#L105-L131 533 | class DjangoHelpFormatter(HelpFormatter): 534 | """ 535 | Customized formatter so that command-specific arguments appear in the 536 | --help output before arguments common to all commands. 537 | """ 538 | 539 | show_last = { 540 | "--version", 541 | "--verbosity", 542 | "--traceback", 543 | "--settings", 544 | "--pythonpath", 545 | "--no-color", 546 | "--force-color", 547 | "--skip-checks", 548 | } 549 | 550 | def _reordered_actions(self, actions): 551 | return sorted(actions, key=lambda a: set(a.option_strings) & self.show_last != set()) 552 | 553 | def add_usage(self, usage, actions, *args, **kwargs): 554 | super().add_usage(usage, self._reordered_actions(actions), *args, **kwargs) 555 | 556 | def add_arguments(self, actions): 557 | super().add_arguments(self._reordered_actions(actions)) 558 | 559 | class DjangoRichHelpFormatter(DjangoHelpFormatter, RichHelpFormatter): 560 | """Rich help message formatter with django's special ordering of arguments.""" 561 | 562 | parser = ArgumentParser("command", formatter_class=DjangoRichHelpFormatter) 563 | parser.add_argument("--version", action="version", version="1.0.0") 564 | parser.add_argument("--traceback", action="store_true", help="show traceback") 565 | parser.add_argument("my-arg", help="custom argument.") 566 | parser.add_argument("--my-option", action="store_true", help="custom option") 567 | parser.add_argument("--verbosity", action="count", help="verbosity level") 568 | parser.add_argument("-a", "--an-option", action="store_true", help="another custom option") 569 | 570 | expected_help_output = """\ 571 | Usage: command [-h] [--my-option] [-a] [--version] [--traceback] [--verbosity] my-arg 572 | 573 | Positional Arguments: 574 | my-arg custom argument. 575 | 576 | Optional Arguments: 577 | -h, --help show this help message and exit 578 | --my-option custom option 579 | -a, --an-option another custom option 580 | --version show program's version number and exit 581 | --traceback show traceback 582 | --verbosity verbosity level 583 | """ 584 | assert parser.format_help() == clean_argparse(expected_help_output) 585 | 586 | 587 | @pytest.mark.parametrize("indent_increment", (1, 3)) 588 | @pytest.mark.parametrize("max_help_position", (25, 26, 27)) 589 | @pytest.mark.parametrize("width", (None, 70)) 590 | @pytest.mark.usefixtures("disable_group_name_formatter") 591 | def test_help_formatter_args(indent_increment, max_help_position, width): 592 | # Note: the length of the option string is chosen to test edge cases where it is less than, 593 | # equal to, and bigger than max_help_position 594 | parsers = ArgumentParsers( 595 | lambda prog: HelpFormatter(prog, indent_increment, max_help_position, width), 596 | lambda prog: RichHelpFormatter(prog, indent_increment, max_help_position, width), 597 | prog="program", 598 | ) 599 | parsers.add_argument("option-of-certain-length", help="This is the help of the said option") 600 | parsers.assert_format_help_equal() 601 | 602 | 603 | def test_return_output(): 604 | parser = ArgumentParser("prog", formatter_class=RichHelpFormatter) 605 | assert parser.format_help() 606 | 607 | 608 | @pytest.mark.usefixtures("force_color") 609 | def test_text_highlighter(): 610 | parser = ArgumentParser("PROG", formatter_class=RichHelpFormatter) 611 | parser.add_argument("arg", help="Did you try `RichHelpFormatter.highlighter`?") 612 | 613 | expected_help_output = """\ 614 | \x1b[38;5;208mUsage:\x1b[0m \x1b[38;5;244mPROG\x1b[0m [\x1b[36m-h\x1b[0m] \x1b[36marg\x1b[0m 615 | 616 | \x1b[38;5;208mPositional Arguments:\x1b[0m 617 | \x1b[36marg\x1b[0m \x1b[39mDid you try `\x1b[0m\x1b[1;39mRichHelpFormatter.highlighter\x1b[0m\x1b[39m`?\x1b[0m 618 | 619 | \x1b[38;5;208mOptional Arguments:\x1b[0m 620 | \x1b[36m-h\x1b[0m, \x1b[36m--help\x1b[0m \x1b[39mshow this help message and exit\x1b[0m 621 | """ 622 | 623 | # Make sure we can use a style multiple times in regexes 624 | pattern_with_duplicate_style = r"'(?P[^']*)'" 625 | RichHelpFormatter.highlights.append(pattern_with_duplicate_style) 626 | assert parser.format_help() == clean_argparse(expected_help_output) 627 | RichHelpFormatter.highlights.remove(pattern_with_duplicate_style) 628 | 629 | 630 | @pytest.mark.usefixtures("force_color") 631 | def test_default_highlights(): 632 | parser = ArgumentParser( 633 | "PROG", 634 | formatter_class=RichHelpFormatter, 635 | description="Description with `syntax` and --options.", 636 | epilog="Epilog with `syntax` and --options.", 637 | ) 638 | # syntax highlights 639 | parser.add_argument("--syntax-normal", action="store_true", help="Start `middle` end") 640 | parser.add_argument("--syntax-start", action="store_true", help="`Start` middle end") 641 | parser.add_argument("--syntax-end", action="store_true", help="Start middle `end`") 642 | # options highlights 643 | parser.add_argument("--option-normal", action="store_true", help="Start --middle end") 644 | parser.add_argument("--option-start", action="store_true", help="--Start middle end") 645 | parser.add_argument("--option-end", action="store_true", help="Start middle --end") 646 | parser.add_argument("--option-comma", action="store_true", help="Start --middle, end") 647 | parser.add_argument("--option-multi", action="store_true", help="Start --middle-word end") 648 | parser.add_argument("--option-not", action="store_true", help="Start middle-word end") 649 | parser.add_argument("--option-short", action="store_true", help="Start -middle end") 650 | # options inside backticks should not be highlighted 651 | parser.add_argument("--not-option", action="store_true", help="Start `not --option` end") 652 | # %(default)s highlights 653 | parser.add_argument("--default", default=10, help="The default value is %(default)s.") 654 | 655 | expected_help_output = """ 656 | \x1b[39mDescription with `\x1b[0m\x1b[1;39msyntax\x1b[0m\x1b[39m` and \x1b[0m\x1b[36m--options\x1b[0m\x1b[39m.\x1b[0m 657 | 658 | \x1b[38;5;208mOptional Arguments:\x1b[0m 659 | \x1b[36m-h\x1b[0m, \x1b[36m--help\x1b[0m \x1b[39mshow this help message and exit\x1b[0m 660 | \x1b[36m--syntax-normal\x1b[0m \x1b[39mStart `\x1b[0m\x1b[1;39mmiddle\x1b[0m\x1b[39m` end\x1b[0m 661 | \x1b[36m--syntax-start\x1b[0m \x1b[39m`\x1b[0m\x1b[1;39mStart\x1b[0m\x1b[39m` middle end\x1b[0m 662 | \x1b[36m--syntax-end\x1b[0m \x1b[39mStart middle `\x1b[0m\x1b[1;39mend\x1b[0m\x1b[39m`\x1b[0m 663 | \x1b[36m--option-normal\x1b[0m \x1b[39mStart \x1b[0m\x1b[36m--middle\x1b[0m\x1b[39m end\x1b[0m 664 | \x1b[36m--option-start\x1b[0m \x1b[36m--Start\x1b[0m\x1b[39m middle end\x1b[0m 665 | \x1b[36m--option-end\x1b[0m \x1b[39mStart middle \x1b[0m\x1b[36m--end\x1b[0m 666 | \x1b[36m--option-comma\x1b[0m \x1b[39mStart \x1b[0m\x1b[36m--middle\x1b[0m\x1b[39m, end\x1b[0m 667 | \x1b[36m--option-multi\x1b[0m \x1b[39mStart \x1b[0m\x1b[36m--middle-word\x1b[0m\x1b[39m end\x1b[0m 668 | \x1b[36m--option-not\x1b[0m \x1b[39mStart middle-word end\x1b[0m 669 | \x1b[36m--option-short\x1b[0m \x1b[39mStart \x1b[0m\x1b[36m-middle\x1b[0m\x1b[39m end\x1b[0m 670 | \x1b[36m--not-option\x1b[0m \x1b[39mStart `\x1b[0m\x1b[1;39mnot --option\x1b[0m\x1b[39m` end\x1b[0m 671 | \x1b[36m--default\x1b[0m \x1b[38;5;36mDEFAULT\x1b[0m \x1b[39mThe default value is \x1b[0m\x1b[3;39m10\x1b[0m\x1b[39m.\x1b[0m 672 | 673 | \x1b[39mEpilog with `\x1b[0m\x1b[1;39msyntax\x1b[0m\x1b[39m` and \x1b[0m\x1b[36m--options\x1b[0m\x1b[39m.\x1b[0m 674 | """ 675 | assert parser.format_help().endswith(clean_argparse(expected_help_output)) 676 | 677 | 678 | @pytest.mark.usefixtures("force_color") 679 | def test_subparsers_usage(): 680 | # Parent uses RichHelpFormatter 681 | rich_parent = ArgumentParser("PROG", formatter_class=RichHelpFormatter) 682 | rich_subparsers = rich_parent.add_subparsers() 683 | rich_child1 = rich_subparsers.add_parser("sp1", formatter_class=RichHelpFormatter) 684 | rich_child2 = rich_subparsers.add_parser("sp2") 685 | assert rich_parent.format_usage() == ( 686 | "\x1b[38;5;208mUsage:\x1b[0m \x1b[38;5;244mPROG\x1b[0m [\x1b[36m-h\x1b[0m] " 687 | "\x1b[36m{sp1,sp2}\x1b[0m \x1b[36m...\x1b[0m\n" 688 | ) 689 | assert rich_child1.format_usage() == ( 690 | "\x1b[38;5;208mUsage:\x1b[0m \x1b[38;5;244mPROG sp1\x1b[0m [\x1b[36m-h\x1b[0m]\n" 691 | ) 692 | assert rich_child2.format_usage() == "usage: PROG sp2 [-h]\n" 693 | 694 | # Parent uses original formatter 695 | orig_parent = ArgumentParser("PROG") 696 | orig_subparsers = orig_parent.add_subparsers() 697 | orig_child1 = orig_subparsers.add_parser("sp1", formatter_class=RichHelpFormatter) 698 | orig_child2 = orig_subparsers.add_parser("sp2") 699 | assert orig_parent.format_usage() == ("usage: PROG [-h] {sp1,sp2} ...\n") 700 | assert orig_child1.format_usage() == ( 701 | "\x1b[38;5;208mUsage:\x1b[0m \x1b[38;5;244mPROG sp1\x1b[0m [\x1b[36m-h\x1b[0m]\n" 702 | ) 703 | assert orig_child2.format_usage() == "usage: PROG sp2 [-h]\n" 704 | 705 | 706 | @pytest.mark.parametrize("ct", string.printable) 707 | def test_expand_help_format_specifier(ct): 708 | prog = 1 if ct in "cdeEfFgGiouxX*" else "PROG" 709 | help_formatter = RichHelpFormatter(prog=prog) 710 | action = Action(["-t"], dest="test", help=f"%(prog){ct}") 711 | try: 712 | expected = help_formatter._expand_help(action) 713 | except ValueError as e: 714 | with pytest.raises(ValueError) as exc_info: 715 | help_formatter._rich_expand_help(action) 716 | assert exc_info.value.args == e.args 717 | else: 718 | assert help_formatter._rich_expand_help(action).plain == expected 719 | 720 | 721 | def test_rich_lazy_import(): 722 | sys_modules_no_rich = { 723 | mod_name: mod 724 | for mod_name, mod in sys.modules.items() 725 | if mod_name != "rich" and not mod_name.startswith("rich.") 726 | } 727 | lazy_rich = {k: v for k, v in r.__dict__.items() if k not in r.__all__} 728 | with ( 729 | patch.dict(sys.modules, sys_modules_no_rich, clear=True), 730 | patch.dict(r.__dict__, lazy_rich, clear=True), 731 | ): 732 | parser = ArgumentParser(formatter_class=RichHelpFormatter) 733 | parser.add_argument("--foo", help="foo help") 734 | args = parser.parse_args(["--foo", "bar"]) 735 | assert args.foo == "bar" 736 | assert sys.modules 737 | assert "rich" not in sys.modules # no help formatting, do not import rich 738 | for mod_name in sys.modules: 739 | assert not mod_name.startswith("rich.") 740 | parser.format_help() 741 | assert "rich" in sys.modules # format help has been called 742 | 743 | formatter = RichHelpFormatter("PROG") 744 | assert formatter._console is None 745 | formatter.console = get_console() 746 | assert formatter._console is not None 747 | 748 | with pytest.raises(AttributeError, match="Foo"): 749 | _ = r.Foo 750 | 751 | 752 | def test_help_with_control_codes(): 753 | parsers = ArgumentParsers(HelpFormatter, RichHelpFormatter, prog="PROG\r\nRAM") 754 | parsers.add_argument( 755 | "--long-option-with-control-codes-in-metavar", metavar="META\r\nVAR", help="%(metavar)s" 756 | ) 757 | orig_parser, rich_parser = parsers.parsers 758 | orig_help = orig_parser.format_help().lower() 759 | rich_help = rich_parser.format_help().lower() 760 | assert rich_help == orig_help.replace("\r", "") # rich strips \r and other control codes 761 | 762 | expected_help_text = """\ 763 | \x1b[38;5;208mUsage:\x1b[0m \x1b[38;5;244mPROG\x1b[0m 764 | \x1b[38;5;244mRAM\x1b[0m [\x1b[36m-h\x1b[0m] [\x1b[36m--long-option-with-control-codes-in-metavar\x1b[0m \x1b[38;5;36mMETA\x1b[0m 765 | \x1b[38;5;36mVAR\x1b[0m] 766 | 767 | \x1b[38;5;208mOptional Arguments:\x1b[0m 768 | \x1b[36m-h\x1b[0m, \x1b[36m--help\x1b[0m \x1b[39mshow this help message and exit\x1b[0m 769 | \x1b[36m--long-option-with-control-codes-in-metavar\x1b[0m \x1b[38;5;36mMETA\x1b[0m 770 | \x1b[38;5;36mVAR\x1b[0m 771 | \x1b[39mMETA VAR\x1b[0m 772 | """ 773 | with patch("rich.console.Console.is_terminal", return_value=True): 774 | colored_help_text = rich_parser.format_help() 775 | # cannot use textwrap.dedent because of the control codes 776 | assert colored_help_text == clean_argparse(expected_help_text, dedent=False) 777 | 778 | 779 | @pytest.mark.skipif(sys.platform != "win32", reason="windows-only test") 780 | @pytest.mark.usefixtures("force_color") 781 | def test_legacy_windows(): # pragma: win32 cover 782 | expected_output = """\ 783 | Usage: PROG [-h] 784 | 785 | Optional Arguments: 786 | -h, --help show this help message and exit 787 | """ 788 | expected_colored_output = """\ 789 | \x1b[38;5;208mUsage:\x1b[0m \x1b[38;5;244mPROG\x1b[0m [\x1b[36m-h\x1b[0m] 790 | 791 | \x1b[38;5;208mOptional Arguments:\x1b[0m 792 | \x1b[36m-h\x1b[0m, \x1b[36m--help\x1b[0m \x1b[39mshow this help message and exit\x1b[0m 793 | """ 794 | 795 | # New windows console => colors: YES, initialization: NO 796 | init_win_colors = Mock(return_value=True) 797 | parser = ArgumentParser("PROG", formatter_class=RichHelpFormatter) 798 | with patch("rich_argparse._common._initialize_win_colors", init_win_colors): 799 | help = parser.format_help() 800 | assert help == clean_argparse(expected_colored_output) 801 | init_win_colors.assert_not_called() 802 | 803 | # Legacy windows console on new windows => colors: YES, initialization: YES 804 | init_win_colors = Mock(return_value=True) 805 | parser = ArgumentParser("PROG", formatter_class=RichHelpFormatter) 806 | with ( 807 | patch("rich.console.detect_legacy_windows", return_value=True), 808 | patch("rich_argparse._common._initialize_win_colors", init_win_colors), 809 | ): 810 | help = parser.format_help() 811 | assert help == clean_argparse(expected_colored_output) 812 | init_win_colors.assert_called_once_with() 813 | 814 | # Legacy windows console on old windows => colors: NO, initialization: YES 815 | init_win_colors = Mock(return_value=False) 816 | parser = ArgumentParser("PROG", formatter_class=RichHelpFormatter) 817 | with ( 818 | patch("rich.console.detect_legacy_windows", return_value=True), 819 | patch("rich_argparse._common._initialize_win_colors", init_win_colors), 820 | ): 821 | help = parser.format_help() 822 | assert help == clean_argparse(expected_output) 823 | init_win_colors.assert_called_once_with() 824 | 825 | # Legacy windows, but colors disabled in formatter => colors: NO, initialization: NO 826 | def fmt_no_color(prog): 827 | fmt = RichHelpFormatter(prog) 828 | fmt.console = r.Console(theme=r.Theme(fmt.styles), color_system=None) 829 | return fmt 830 | 831 | init_win_colors = Mock(return_value=True) 832 | no_colors_parser = ArgumentParser("PROG", formatter_class=fmt_no_color) 833 | with ( 834 | patch("rich.console.detect_legacy_windows", return_value=True), 835 | patch("rich_argparse._common._initialize_win_colors", init_win_colors), 836 | ): 837 | help = no_colors_parser.format_help() 838 | assert help == clean_argparse(expected_output) 839 | init_win_colors.assert_not_called() 840 | 841 | 842 | @pytest.mark.skipif(sys.platform == "win32", reason="non-windows test") 843 | def test_no_win_console_init_on_unix(): # pragma: win32 no cover 844 | text = "\x1b[38;5;208mUsage:\x1b[0m \x1b[38;5;244mPROG\x1b[0m [\x1b[36m-h\x1b[0m]" 845 | console = r.Console(legacy_windows=True, force_terminal=True) 846 | init_win_colors = Mock(return_value=True) 847 | with patch("rich_argparse._common._initialize_win_colors", init_win_colors): 848 | out = _fix_legacy_win_text(console, text) 849 | assert out == text 850 | init_win_colors.assert_not_called() 851 | 852 | 853 | @pytest.mark.usefixtures("force_color") 854 | def test_rich_renderables(): 855 | table = Table("foo", "bar") 856 | table.add_row("1", "2") 857 | parser = ArgumentParser( 858 | "PROG", 859 | formatter_class=RichHelpFormatter, 860 | description=Markdown( 861 | textwrap.dedent( 862 | """\ 863 | This is a **description** 864 | _________________________ 865 | 866 | | foo | bar | 867 | | --- | --- | 868 | | 1 | 2 | 869 | """ 870 | ) 871 | ), 872 | epilog=Group(Markdown("This is an *epilog*"), table, Text("The end.", style="red")), 873 | ) 874 | expected_help = """\ 875 | \x1b[38;5;208mUsage:\x1b[0m \x1b[38;5;244mPROG\x1b[0m [\x1b[36m-h\x1b[0m] 876 | 877 | This is a \x1b[1mdescription\x1b[0m 878 | 879 | \x1b[33m──────────────────────────────────────────────────────────────────────────────────────────────────\x1b[0m 880 | 881 | \x1b[1m \x1b[0m\x1b[1mfoo\x1b[0m\x1b[1m \x1b[0m \x1b[1m \x1b[0m\x1b[1mbar\x1b[0m 882 | ━━━━━━━━━━━ 883 | 1 2 884 | 885 | \x1b[38;5;208mOptional Arguments:\x1b[0m 886 | \x1b[36m-h\x1b[0m, \x1b[36m--help\x1b[0m \x1b[39mshow this help message and exit\x1b[0m 887 | 888 | This is an \x1b[3mepilog\x1b[0m 889 | ┏━━━━━┳━━━━━┓ 890 | ┃\x1b[1m \x1b[0m\x1b[1mfoo\x1b[0m\x1b[1m \x1b[0m┃\x1b[1m \x1b[0m\x1b[1mbar\x1b[0m\x1b[1m \x1b[0m┃ 891 | ┡━━━━━╇━━━━━┩ 892 | │ 1 │ 2 │ 893 | └─────┴─────┘ 894 | \x1b[31mThe end.\x1b[0m 895 | """ 896 | assert parser.format_help() == clean_argparse(expected_help) 897 | 898 | 899 | def test_help_preview_generation(tmp_path): 900 | parser = ArgumentParser("PROG", formatter_class=RichHelpFormatter) 901 | parser.add_argument("--foo", help="foo help") 902 | preview_action = parser.add_argument("--generate", action=HelpPreviewAction) 903 | default_path = tmp_path / "default-preview.svg" 904 | parser.add_argument("--generate-with-default", action=HelpPreviewAction, path=str(default_path)) 905 | 906 | # No namespace pollution 907 | args = parser.parse_args(["--foo", "FOO"]) 908 | assert vars(args) == {"foo": "FOO"} 909 | 910 | # No help pollution 911 | assert "--generate" not in parser.format_help() 912 | 913 | # No file, error 914 | with pytest.raises(SystemExit) as exc_info: 915 | parser.parse_args(["--generate"]) 916 | assert exc_info.value.code == 1 917 | 918 | # Default file, ok 919 | with pytest.raises(SystemExit) as exc_info: 920 | parser.parse_args(["--generate-with-default"]) 921 | assert exc_info.value.code == 0 922 | assert default_path.exists() 923 | 924 | # SVG file 925 | svg_file = tmp_path / "preview.svg" 926 | with pytest.raises(SystemExit) as exc_info: 927 | parser.parse_args(["--generate", str(svg_file)]) 928 | assert exc_info.value.code == 0 929 | assert svg_file.exists() 930 | svg_out = svg_file.read_text() 931 | assert svg_out.startswith("") 943 | assert "Usage" in html_out 944 | 945 | # TXT file 946 | preview_action.export_kwds = {} 947 | txt_file = tmp_path / "preview.txt" 948 | with pytest.raises(SystemExit) as exc_info: 949 | parser.parse_args(["--generate", str(txt_file)]) 950 | assert exc_info.value.code == 0 951 | assert txt_file.exists() 952 | assert txt_file.read_text().startswith("Usage:") 953 | 954 | # Wrong file extension 955 | with pytest.raises(SystemExit) as exc_info: 956 | parser.parse_args(["--generate", str(tmp_path / "preview.png")]) 957 | assert exc_info.value.code == 1 958 | 959 | # Wrong type 960 | with pytest.raises(SystemExit) as exc_info: 961 | parser.parse_args(["--generate", ("",)]) 962 | assert exc_info.value.code == 1 963 | 964 | 965 | def test_disable_help_markup(): 966 | parser = ArgumentParser( 967 | prog="PROG", formatter_class=RichHelpFormatter, description="[red]Description text.[/]" 968 | ) 969 | parser.add_argument("--foo", default="def", help="[red]Help text (default: %(default)s).[/]") 970 | with patch.object(RichHelpFormatter, "help_markup", False): 971 | help_text = parser.format_help() 972 | expected_help_text = """\ 973 | Usage: PROG [-h] [--foo FOO] 974 | 975 | Description text. 976 | 977 | Optional Arguments: 978 | -h, --help show this help message and exit 979 | --foo FOO [red]Help text (default: def).[/] 980 | """ 981 | assert help_text == clean_argparse(expected_help_text) 982 | 983 | 984 | def test_disable_text_markup(): 985 | parser = ArgumentParser( 986 | prog="PROG", formatter_class=RichHelpFormatter, description="[red]Description text.[/]" 987 | ) 988 | parser.add_argument("--foo", help="[red]Help text.[/]") 989 | with patch.object(RichHelpFormatter, "text_markup", False): 990 | help_text = parser.format_help() 991 | expected_help_text = """\ 992 | Usage: PROG [-h] [--foo FOO] 993 | 994 | [red]Description text.[/] 995 | 996 | Optional Arguments: 997 | -h, --help show this help message and exit 998 | --foo FOO Help text. 999 | """ 1000 | assert help_text == clean_argparse(expected_help_text) 1001 | 1002 | 1003 | @pytest.mark.usefixtures("force_color") 1004 | def test_arg_default_spans(): 1005 | parser = ArgumentParser(prog="PROG", formatter_class=RichHelpFormatter) 1006 | parser.add_argument( 1007 | "--foo", 1008 | default="def", 1009 | help="(default: %(default)r) [red](default: %(default)s)[/] (default: %(default)s)", 1010 | ) 1011 | expected_help_text = """\ 1012 | \x1b[38;5;208mUsage:\x1b[0m \x1b[38;5;244mPROG\x1b[0m [\x1b[36m-h\x1b[0m] [\x1b[36m--foo\x1b[0m \x1b[38;5;36mFOO\x1b[0m] 1013 | 1014 | \x1b[38;5;208mOptional Arguments:\x1b[0m 1015 | \x1b[36m-h\x1b[0m, \x1b[36m--help\x1b[0m \x1b[39mshow this help message and exit\x1b[0m 1016 | \x1b[36m--foo\x1b[0m \x1b[38;5;36mFOO\x1b[0m \x1b[39m(default: \x1b[0m\x1b[3;39m'def'\x1b[0m\x1b[39m) \x1b[0m\x1b[31m(default: \x1b[0m\x1b[3;39mdef\x1b[0m\x1b[31m)\x1b[0m\x1b[39m (default: \x1b[0m\x1b[3;39mdef\x1b[0m\x1b[39m)\x1b[0m 1017 | """ 1018 | help_text = parser.format_help() 1019 | assert help_text == clean_argparse(expected_help_text) 1020 | 1021 | 1022 | def test_arg_default_in_markup(): 1023 | parser = ArgumentParser(prog="PROG", formatter_class=RichHelpFormatter) 1024 | parser.add_argument( 1025 | "--foo", 1026 | default="def", 1027 | help=( 1028 | "[%(type)r type](good: %(default)r)[bad: %(default)r] text [bad: %(default)s]" 1029 | "(good: %(default)s) [link bad %(default)s] %(default)s" 1030 | ), 1031 | ) 1032 | expected_help_text = """\ 1033 | Usage: PROG [-h] [--foo FOO] 1034 | 1035 | Optional Arguments: 1036 | -h, --help show this help message and exit 1037 | --foo FOO [None type](good: 'def') text (good: def) def 1038 | """ 1039 | with pytest.warns( 1040 | UserWarning, 1041 | match=re.escape( 1042 | "Failed to process default value in help string of argument '--foo'.\n" 1043 | "Hint: try disabling rich markup: `RichHelpFormatter.help_markup = False`\n" 1044 | " or replace brackets by parenthesis: `[bad: %(default)r]` -> `(bad: %(default)r)`" 1045 | ), 1046 | ): 1047 | help_text = parser.format_help() 1048 | assert help_text == clean_argparse(expected_help_text) 1049 | 1050 | 1051 | @pytest.mark.usefixtures("force_color") 1052 | def test_metavar_spans(): 1053 | # tests exotic metavars (tuples, wrapped, different nargs, etc.) in usage and help text 1054 | parser = argparse.ArgumentParser( 1055 | prog="PROG", formatter_class=lambda prog: RichHelpFormatter(prog, width=20) 1056 | ) 1057 | meg = parser.add_mutually_exclusive_group() 1058 | meg.add_argument("--op1", metavar="MET", nargs="?") 1059 | meg.add_argument("--op2", metavar=("MET1", "MET2"), nargs="*") 1060 | meg.add_argument("--op3", nargs="*") 1061 | meg.add_argument("--op4", metavar=("MET1", "MET2"), nargs="+") 1062 | meg.add_argument("--op5", nargs="+") 1063 | meg.add_argument("--op6", nargs=3) 1064 | meg.add_argument("--op7", metavar=("MET1", "MET2", "MET3"), nargs=3) 1065 | help_text = parser.format_help() 1066 | 1067 | if sys.version_info >= (3, 13): # pragma: >=3.13 cover 1068 | usage_tail = """ | 1069 | \x1b[36m--op2\x1b[0m [\x1b[38;5;36mMET1\x1b[0m [\x1b[38;5;36mMET2\x1b[0m \x1b[38;5;36m...\x1b[0m]] | 1070 | \x1b[36m--op3\x1b[0m [\x1b[38;5;36mOP3\x1b[0m \x1b[38;5;36m...\x1b[0m] | 1071 | \x1b[36m--op4\x1b[0m \x1b[38;5;36mMET1\x1b[0m [\x1b[38;5;36mMET2\x1b[0m \x1b[38;5;36m...\x1b[0m] | 1072 | \x1b[36m--op5\x1b[0m \x1b[38;5;36mOP5\x1b[0m [\x1b[38;5;36mOP5\x1b[0m \x1b[38;5;36m...\x1b[0m] | 1073 | \x1b[36m--op6\x1b[0m \x1b[38;5;36mOP6\x1b[0m \x1b[38;5;36mOP6\x1b[0m \x1b[38;5;36mOP6\x1b[0m | 1074 | \x1b[36m--op7\x1b[0m \x1b[38;5;36mMET1\x1b[0m \x1b[38;5;36mMET2\x1b[0m \x1b[38;5;36mMET3\x1b[0m] 1075 | """ 1076 | else: # pragma: <3.13 cover 1077 | usage_tail = """ 1078 | | \x1b[36m--op2\x1b[0m 1079 | [\x1b[38;5;36mMET1\x1b[0m [\x1b[38;5;36mMET2\x1b[0m \x1b[38;5;36m...\x1b[0m]] 1080 | | \x1b[36m--op3\x1b[0m 1081 | [\x1b[38;5;36mOP3\x1b[0m \x1b[38;5;36m...\x1b[0m] 1082 | | \x1b[36m--op4\x1b[0m 1083 | \x1b[38;5;36mMET1\x1b[0m 1084 | [\x1b[38;5;36mMET2\x1b[0m \x1b[38;5;36m...\x1b[0m] 1085 | | \x1b[36m--op5\x1b[0m 1086 | \x1b[38;5;36mOP5\x1b[0m 1087 | [\x1b[38;5;36mOP5\x1b[0m \x1b[38;5;36m...\x1b[0m] 1088 | | \x1b[36m--op6\x1b[0m 1089 | \x1b[38;5;36mOP6\x1b[0m \x1b[38;5;36mOP6\x1b[0m 1090 | \x1b[38;5;36mOP6\x1b[0m | 1091 | \x1b[36m--op7\x1b[0m 1092 | \x1b[38;5;36mMET1\x1b[0m 1093 | \x1b[38;5;36mMET2\x1b[0m 1094 | \x1b[38;5;36mMET3\x1b[0m] 1095 | """ 1096 | expected_help_text = f"""\ 1097 | \x1b[38;5;208mUsage:\x1b[0m \x1b[38;5;244mPROG\x1b[0m [\x1b[36m-h\x1b[0m] 1098 | [\x1b[36m--op1\x1b[0m [\x1b[38;5;36mMET\x1b[0m]{usage_tail} 1099 | \x1b[38;5;208mOptional Arguments:\x1b[0m 1100 | \x1b[36m-h\x1b[0m, \x1b[36m--help\x1b[0m 1101 | \x1b[39mshow this help\x1b[0m 1102 | \x1b[39mmessage and exit\x1b[0m 1103 | \x1b[36m--op1\x1b[0m [\x1b[38;5;36mMET\x1b[0m] 1104 | \x1b[36m--op2\x1b[0m [\x1b[38;5;36mMET1\x1b[0m [\x1b[38;5;36mMET2\x1b[0m \x1b[38;5;36m...\x1b[0m]] 1105 | \x1b[36m--op3\x1b[0m [\x1b[38;5;36mOP3\x1b[0m \x1b[38;5;36m...\x1b[0m] 1106 | \x1b[36m--op4\x1b[0m \x1b[38;5;36mMET1\x1b[0m [\x1b[38;5;36mMET2\x1b[0m \x1b[38;5;36m...\x1b[0m] 1107 | \x1b[36m--op5\x1b[0m \x1b[38;5;36mOP5\x1b[0m [\x1b[38;5;36mOP5\x1b[0m \x1b[38;5;36m...\x1b[0m] 1108 | \x1b[36m--op6\x1b[0m \x1b[38;5;36mOP6\x1b[0m \x1b[38;5;36mOP6\x1b[0m \x1b[38;5;36mOP6\x1b[0m 1109 | \x1b[36m--op7\x1b[0m \x1b[38;5;36mMET1\x1b[0m \x1b[38;5;36mMET2\x1b[0m \x1b[38;5;36mMET3\x1b[0m 1110 | """ 1111 | assert help_text == clean_argparse(expected_help_text) 1112 | 1113 | 1114 | def test_patching(): 1115 | class MyArgumentParser(ArgumentParser): 1116 | not_callable = None 1117 | 1118 | # Patch existing class 1119 | patch_default_formatter_class(MyArgumentParser) 1120 | assert MyArgumentParser().formatter_class is RichHelpFormatter 1121 | 1122 | # Override previous patch 1123 | patch_default_formatter_class(MyArgumentParser, formatter_class=MetavarTypeRichHelpFormatter) 1124 | assert MyArgumentParser().formatter_class is MetavarTypeRichHelpFormatter 1125 | 1126 | # Patch new class 1127 | @patch_default_formatter_class(formatter_class=ArgumentDefaultsRichHelpFormatter) 1128 | class MyArgumentParser2(ArgumentParser): 1129 | pass 1130 | 1131 | assert MyArgumentParser2().formatter_class is ArgumentDefaultsRichHelpFormatter 1132 | 1133 | # Errors 1134 | with pytest.raises(AttributeError, match=r"'MyArgumentParser' has no attribute 'missing'"): 1135 | patch_default_formatter_class(MyArgumentParser, method_name="missing") 1136 | 1137 | with pytest.raises(TypeError, match=r"'MyArgumentParser\.not_callable' is not callable"): 1138 | patch_default_formatter_class(MyArgumentParser, method_name="not_callable") 1139 | -------------------------------------------------------------------------------- /tests/test_contrib.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from argparse import ArgumentParser 4 | 5 | from rich_argparse.contrib import ParagraphRichHelpFormatter 6 | from tests.helpers import clean_argparse 7 | 8 | 9 | def test_paragraph_rich_help_formatter(): 10 | long_sentence = "The quick brown fox jumps over the lazy dog. " * 3 11 | long_paragraphs = [long_sentence] * 2 12 | long_text = "\n\n\r\n\t " + "\n\n".join(long_paragraphs) + "\n\n\r\n\t " 13 | parser = ArgumentParser( 14 | prog="PROG", 15 | description=long_text, 16 | epilog=long_text, 17 | formatter_class=ParagraphRichHelpFormatter, 18 | ) 19 | group = parser.add_argument_group("group", description=long_text) 20 | group.add_argument("--long", help=long_text) 21 | 22 | expected_help_output = """\ 23 | Usage: PROG [-h] [--long LONG] 24 | 25 | The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The 26 | quick brown fox jumps over the lazy dog. 27 | 28 | The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The 29 | quick brown fox jumps over the lazy dog. 30 | 31 | Optional Arguments: 32 | -h, --help show this help message and exit 33 | 34 | Group: 35 | The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The 36 | quick brown fox jumps over the lazy dog. 37 | 38 | The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The 39 | quick brown fox jumps over the lazy dog. 40 | 41 | --long LONG The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the 42 | lazy dog. The quick brown fox jumps over the lazy dog. 43 | 44 | The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the 45 | lazy dog. The quick brown fox jumps over the lazy dog. 46 | 47 | The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The 48 | quick brown fox jumps over the lazy dog. 49 | 50 | The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The 51 | quick brown fox jumps over the lazy dog. 52 | """ 53 | assert parser.format_help() == clean_argparse(expected_help_output) 54 | -------------------------------------------------------------------------------- /tests/test_django.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from argparse import ArgumentParser, HelpFormatter 4 | from types import ModuleType 5 | from unittest.mock import patch 6 | 7 | import pytest 8 | 9 | 10 | @pytest.fixture(autouse=True) 11 | def patch_django_import(): 12 | class DjangoHelpFormatter(HelpFormatter): ... 13 | 14 | class BaseCommand: 15 | def create_parser(self, *args, **kwargs): 16 | kwargs.setdefault("formatter_class", DjangoHelpFormatter) 17 | return ArgumentParser(*args, **kwargs) 18 | 19 | module = ModuleType("django.core.management.base") 20 | module.DjangoHelpFormatter = DjangoHelpFormatter 21 | module.BaseCommand = BaseCommand 22 | with patch.dict("sys.modules", {"django.core.management.base": module}, clear=False): 23 | yield 24 | 25 | 26 | def test_richify_command_line_help(): 27 | from django.core.management.base import BaseCommand, DjangoHelpFormatter 28 | 29 | from rich_argparse.django import DjangoRichHelpFormatter, richify_command_line_help 30 | 31 | parser = BaseCommand().create_parser("", "") 32 | assert parser.formatter_class is DjangoHelpFormatter 33 | 34 | richify_command_line_help() 35 | parser = BaseCommand().create_parser("", "") 36 | assert parser.formatter_class is DjangoRichHelpFormatter 37 | -------------------------------------------------------------------------------- /tests/test_optparse.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from optparse import SUPPRESS_HELP, IndentedHelpFormatter, OptionParser, TitledHelpFormatter 5 | from textwrap import dedent 6 | from unittest.mock import Mock, patch 7 | 8 | import pytest 9 | from rich import get_console 10 | 11 | import rich_argparse._lazy_rich as r 12 | from rich_argparse.optparse import ( 13 | GENERATE_USAGE, 14 | IndentedRichHelpFormatter, 15 | RichHelpFormatter, 16 | TitledRichHelpFormatter, 17 | ) 18 | from tests.helpers import OptionParsers 19 | 20 | 21 | def test_default_substitution(): 22 | parser = OptionParser(prog="PROG", formatter=IndentedRichHelpFormatter()) 23 | parser.add_option("--option", default="[bold]", help="help of option (default: %default)") 24 | 25 | expected_help_output = """\ 26 | Usage: PROG [options] 27 | 28 | Options: 29 | -h, --help show this help message and exit 30 | --option=OPTION help of option (default: [bold]) 31 | """ 32 | assert parser.format_help() == dedent(expected_help_output) 33 | 34 | 35 | @pytest.mark.parametrize("prog", (None, "PROG"), ids=("no_prog", "prog")) 36 | @pytest.mark.parametrize("usage", (None, "USAGE"), ids=("no_usage", "usage")) 37 | @pytest.mark.parametrize("description", (None, "A description."), ids=("no_desc", "desc")) 38 | @pytest.mark.parametrize("epilog", (None, "An epilog."), ids=("no_epilog", "epilog")) 39 | def test_overall_structure(prog, usage, description, epilog): 40 | # The output must be consistent with the original HelpFormatter in these cases: 41 | # 1. no markup/emoji codes are used 42 | # 4. colors are disabled 43 | parsers = OptionParsers( 44 | IndentedHelpFormatter(), 45 | IndentedRichHelpFormatter(), 46 | prog=prog, 47 | usage=usage, 48 | description=description, 49 | epilog=epilog, 50 | ) 51 | parsers.add_option("--file", default="-", help="A file (default: %default).") 52 | parsers.add_option("--spaces", help="Arg with weird\n\n whitespaces\t\t.") 53 | parsers.add_option("--very-very-very-very-very-very-very-very-long-option-name", help="help!") 54 | parsers.add_option("--very-long-option-that-has-no-help-text") 55 | 56 | # all types of empty groups 57 | parsers.add_option_group("empty group name", description="empty_group description") 58 | parsers.add_option_group("no description empty group name") 59 | parsers.add_option_group("", description="empty_name_empty_group description") 60 | parsers.add_option_group("spaces group", description=" \tspaces_group description ") 61 | 62 | # all types of non-empty groups 63 | groups = parsers.add_option_group("title", description="description") 64 | groups.add_option("--arg1", help="help inside group") 65 | no_desc_groups = parsers.add_option_group("title") 66 | no_desc_groups.add_option("--arg2", help="arg help inside no_desc_group") 67 | empty_title_group = parsers.add_option_group("", description="description") 68 | empty_title_group.add_option("--arg3", help="arg help inside empty_title_group") 69 | 70 | parsers.assert_format_help_equal() 71 | 72 | 73 | def test_padding_and_wrapping(): 74 | parsers = OptionParsers( 75 | IndentedHelpFormatter(), 76 | IndentedRichHelpFormatter(), 77 | prog="PROG", 78 | description="-" * 120, 79 | epilog="%" * 120, 80 | ) 81 | parsers.add_option("--very-long-option-name", metavar="LONG_METAVAR", help="." * 120) 82 | group_with_descriptions = parsers.add_option_group("Group", description="*" * 120) 83 | group_with_descriptions.add_option("--arg", help="#" * 120) 84 | 85 | expected_help_output = """\ 86 | Usage: PROG [options] 87 | 88 | -------------------------------------------------------------------------------------------------- 89 | ---------------------- 90 | 91 | Options: 92 | -h, --help show this help message and exit 93 | --very-long-option-name=LONG_METAVAR 94 | .......................................................................... 95 | .............................................. 96 | 97 | Group: 98 | ****************************************************************************************** 99 | ****************************** 100 | 101 | --arg=ARG ########################################################################## 102 | ############################################## 103 | 104 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 105 | %%%%%%%%%%%%%%%%%%%%%% 106 | """ 107 | 108 | parsers.assert_format_help_equal(expected=dedent(expected_help_output)) 109 | 110 | 111 | @pytest.mark.xfail(reason="rich wraps differently") 112 | def test_wrapping_compatible(): 113 | # needs fixing rich wrapping to be compatible with textwrap.wrap 114 | parsers = OptionParsers( 115 | IndentedHelpFormatter(), 116 | IndentedRichHelpFormatter(), 117 | prog="PROG", 118 | description="some text " + "-" * 120, 119 | ) 120 | parsers.assert_format_help_equal() 121 | 122 | 123 | @pytest.mark.usefixtures("force_color") 124 | def test_with_colors(): 125 | parser = OptionParser(prog="PROG", formatter=IndentedRichHelpFormatter()) 126 | parser.add_option("--file") 127 | parser.add_option("--hidden", help=SUPPRESS_HELP) 128 | parser.add_option("--flag", action="store_true", help="Is flag?") 129 | parser.add_option("--not-flag", action="store_true", help="Is not flag?") 130 | parser.add_option("-y", help="Yes.") 131 | parser.add_option("-n", help="No.") 132 | 133 | expected_help_output = """\ 134 | \x1b[38;5;208mUsage:\x1b[0m PROG [options] 135 | 136 | \x1b[38;5;208mOptions:\x1b[0m 137 | \x1b[36m-h\x1b[0m, \x1b[36m--help\x1b[0m \x1b[39mshow this help message and exit\x1b[0m 138 | \x1b[36m--file\x1b[0m=\x1b[38;5;36mFILE\x1b[0m 139 | \x1b[36m--flag\x1b[0m \x1b[39mIs flag?\x1b[0m 140 | \x1b[36m--not-flag\x1b[0m \x1b[39mIs not flag?\x1b[0m 141 | \x1b[36m-y\x1b[0m \x1b[38;5;36mY\x1b[0m \x1b[39mYes.\x1b[0m 142 | \x1b[36m-n\x1b[0m \x1b[38;5;36mN\x1b[0m \x1b[39mNo.\x1b[0m 143 | """ 144 | assert parser.format_help() == dedent(expected_help_output) 145 | 146 | 147 | @pytest.mark.parametrize("indent_increment", (1, 3)) 148 | @pytest.mark.parametrize("max_help_position", (25, 26, 27)) 149 | @pytest.mark.parametrize("width", (None, 70)) 150 | @pytest.mark.parametrize("short_first", (1, 0)) 151 | def test_help_formatter_args(indent_increment, max_help_position, width, short_first): 152 | parsers = OptionParsers( 153 | IndentedHelpFormatter(indent_increment, max_help_position, width, short_first), 154 | IndentedRichHelpFormatter(indent_increment, max_help_position, width, short_first), 155 | prog="PROG", 156 | ) 157 | # Note: the length of the option string is chosen to test edge cases where it is less than, 158 | # equal to, and bigger than max_help_position 159 | parsers.add_option( 160 | "--option-of-certain-size", action="store_true", help="This is the help of the said option" 161 | ) 162 | parsers.assert_format_help_equal() 163 | 164 | 165 | def test_return_output(): 166 | parser = OptionParser(prog="prog", formatter=IndentedRichHelpFormatter()) 167 | assert parser.format_help() 168 | 169 | 170 | @pytest.mark.usefixtures("force_color") 171 | def test_text_highlighter(): 172 | parser = OptionParser(prog="PROG", formatter=IndentedRichHelpFormatter()) 173 | parser.add_option( 174 | "--arg", action="store_true", help="Did you try `RichHelpFormatter.highlighter`?" 175 | ) 176 | 177 | expected_help_output = """\ 178 | \x1b[38;5;208mUsage:\x1b[0m PROG [options] 179 | 180 | \x1b[38;5;208mOptions:\x1b[0m 181 | \x1b[36m-h\x1b[0m, \x1b[36m--help\x1b[0m \x1b[39mshow this help message and exit\x1b[0m 182 | \x1b[36m--arg\x1b[0m \x1b[39mDid you try `\x1b[0m\x1b[1;39mRichHelpFormatter.highlighter\x1b[0m\x1b[39m`?\x1b[0m 183 | """ 184 | 185 | # Make sure we can use a style multiple times in regexes 186 | pattern_with_duplicate_style = r"'(?P[^']*)'" 187 | RichHelpFormatter.highlights.append(pattern_with_duplicate_style) 188 | assert parser.format_help() == dedent(expected_help_output) 189 | RichHelpFormatter.highlights.remove(pattern_with_duplicate_style) 190 | 191 | 192 | @pytest.mark.usefixtures("force_color") 193 | def test_default_highlights(): 194 | parser = OptionParser( 195 | "PROG", 196 | formatter=IndentedRichHelpFormatter(), 197 | description="Description with `syntax` and --options.", 198 | epilog="Epilog with `syntax` and --options.", 199 | ) 200 | # syntax highlights 201 | parser.add_option("--syntax-normal", action="store_true", help="Start `middle` end") 202 | parser.add_option("--syntax-start", action="store_true", help="`Start` middle end") 203 | parser.add_option("--syntax-end", action="store_true", help="Start middle `end`") 204 | # --options highlights 205 | parser.add_option("--option-normal", action="store_true", help="Start --middle end") 206 | parser.add_option("--option-start", action="store_true", help="--Start middle end") 207 | parser.add_option("--option-end", action="store_true", help="Start middle --end") 208 | parser.add_option("--option-comma", action="store_true", help="Start --middle, end") 209 | parser.add_option("--option-multi", action="store_true", help="Start --middle-word end") 210 | parser.add_option("--option-not", action="store_true", help="Start middle-word end") 211 | parser.add_option("--option-short", action="store_true", help="Start -middle end") 212 | 213 | expected_help_output = """ 214 | \x1b[39mDescription with `\x1b[0m\x1b[1;39msyntax\x1b[0m\x1b[39m` and \x1b[0m\x1b[36m--options\x1b[0m\x1b[39m.\x1b[0m 215 | 216 | \x1b[38;5;208mOptions:\x1b[0m 217 | \x1b[36m-h\x1b[0m, \x1b[36m--help\x1b[0m \x1b[39mshow this help message and exit\x1b[0m 218 | \x1b[36m--syntax-normal\x1b[0m \x1b[39mStart `\x1b[0m\x1b[1;39mmiddle\x1b[0m\x1b[39m` end\x1b[0m 219 | \x1b[36m--syntax-start\x1b[0m \x1b[39m`\x1b[0m\x1b[1;39mStart\x1b[0m\x1b[39m` middle end\x1b[0m 220 | \x1b[36m--syntax-end\x1b[0m \x1b[39mStart middle `\x1b[0m\x1b[1;39mend\x1b[0m\x1b[39m`\x1b[0m 221 | \x1b[36m--option-normal\x1b[0m \x1b[39mStart \x1b[0m\x1b[36m--middle\x1b[0m\x1b[39m end\x1b[0m 222 | \x1b[36m--option-start\x1b[0m \x1b[36m--Start\x1b[0m\x1b[39m middle end\x1b[0m 223 | \x1b[36m--option-end\x1b[0m \x1b[39mStart middle \x1b[0m\x1b[36m--end\x1b[0m 224 | \x1b[36m--option-comma\x1b[0m \x1b[39mStart \x1b[0m\x1b[36m--middle\x1b[0m\x1b[39m, end\x1b[0m 225 | \x1b[36m--option-multi\x1b[0m \x1b[39mStart \x1b[0m\x1b[36m--middle-word\x1b[0m\x1b[39m end\x1b[0m 226 | \x1b[36m--option-not\x1b[0m \x1b[39mStart middle-word end\x1b[0m 227 | \x1b[36m--option-short\x1b[0m \x1b[39mStart \x1b[0m\x1b[36m-middle\x1b[0m\x1b[39m end\x1b[0m 228 | 229 | \x1b[39mEpilog with `\x1b[0m\x1b[1;39msyntax\x1b[0m\x1b[39m` and \x1b[0m\x1b[36m--options\x1b[0m\x1b[39m.\x1b[0m 230 | """ 231 | assert parser.format_help().endswith(dedent(expected_help_output)) 232 | 233 | 234 | def test_empty_fields(): 235 | orig_fmt = IndentedRichHelpFormatter() 236 | rich_fmt = IndentedRichHelpFormatter() 237 | assert rich_fmt.format_usage("") == orig_fmt.format_usage("") 238 | assert rich_fmt.format_heading("") == orig_fmt.format_heading("") 239 | assert rich_fmt.format_description("") == orig_fmt.format_description("") 240 | assert rich_fmt.format_epilog("") == orig_fmt.format_epilog("") 241 | 242 | parser = OptionParser() 243 | option = parser.add_option("--option") 244 | for fmt in (orig_fmt, rich_fmt): 245 | fmt.store_option_strings(parser) 246 | fmt.set_parser(parser) 247 | assert rich_fmt.format_option(option) == orig_fmt.format_option(option) 248 | 249 | option = parser.add_option("--option2", help="help") 250 | for fmt in (orig_fmt, rich_fmt): 251 | fmt.store_option_strings(parser) 252 | fmt.default_tag = None 253 | assert rich_fmt.format_option(option) == orig_fmt.format_option(option) 254 | 255 | 256 | def test_titled_help_formatter(): 257 | parsers = OptionParsers( 258 | TitledHelpFormatter(), 259 | TitledRichHelpFormatter(), 260 | prog="PROG", 261 | description="Description.", 262 | epilog="Epilog.", 263 | ) 264 | parsers.add_option("--option", help="help") 265 | groups = parsers.add_option_group("Group") 266 | groups.add_option("-s", "--short", help="help") 267 | groups.add_option("-o", "-O", help="help") 268 | parsers.assert_format_help_equal() 269 | 270 | 271 | @pytest.mark.usefixtures("force_color") 272 | def test_titled_help_formatter_colors(): 273 | parser = OptionParser( 274 | prog="PROG", 275 | description="Description.", 276 | epilog="Epilog.", 277 | formatter=TitledRichHelpFormatter(), 278 | ) 279 | parser.add_option("--option", help="help") 280 | expected_help_output = """\ 281 | \x1b[38;5;208mUsage\x1b[0m 282 | \x1b[38;5;208m=====\x1b[0m 283 | PROG [options] 284 | 285 | \x1b[39mDescription.\x1b[0m 286 | 287 | \x1b[38;5;208mOptions\x1b[0m 288 | \x1b[38;5;208m=======\x1b[0m 289 | \x1b[36m--help\x1b[0m, \x1b[36m-h\x1b[0m \x1b[39mshow this help message and exit\x1b[0m 290 | \x1b[36m--option\x1b[0m=\x1b[38;5;36mOPTION\x1b[0m \x1b[39mhelp\x1b[0m 291 | 292 | \x1b[39mEpilog.\x1b[0m 293 | """ 294 | assert parser.format_help() == dedent(expected_help_output) 295 | 296 | 297 | def test_rich_lazy_import(): 298 | sys_modules_no_rich = { 299 | mod_name: mod 300 | for mod_name, mod in sys.modules.items() 301 | if mod_name != "rich" and not mod_name.startswith("rich.") 302 | } 303 | lazy_rich = {k: v for k, v in r.__dict__.items() if k not in r.__all__} 304 | with ( 305 | patch.dict(sys.modules, sys_modules_no_rich, clear=True), 306 | patch.dict(r.__dict__, lazy_rich, clear=True), 307 | ): 308 | parser = OptionParser(formatter=IndentedRichHelpFormatter()) 309 | parser.add_option("--foo", help="foo help") 310 | values, args = parser.parse_args(["--foo", "bar"]) 311 | assert values.foo == "bar" 312 | assert not args 313 | assert sys.modules 314 | assert "rich" not in sys.modules # no help formatting, do not import rich 315 | for mod_name in sys.modules: 316 | assert not mod_name.startswith("rich.") 317 | parser.format_help() 318 | assert "rich" in sys.modules # format help has been called 319 | 320 | formatter = IndentedRichHelpFormatter() 321 | assert formatter._console is None 322 | formatter.console = get_console() 323 | assert formatter._console is not None 324 | 325 | with pytest.raises(AttributeError, match="Foo"): 326 | _ = r.Foo 327 | 328 | 329 | @pytest.mark.skipif(sys.platform != "win32", reason="windows-only test") 330 | @pytest.mark.usefixtures("force_color") 331 | @pytest.mark.parametrize( 332 | ("legacy_console", "old_windows", "colors"), 333 | ( 334 | pytest.param(True, False, True, id="legacy_console-new_windows"), 335 | pytest.param(True, True, False, id="legacy_console-old_windows"), 336 | pytest.param(False, None, True, id="new_console"), 337 | ), 338 | ) 339 | def test_legacy_windows(legacy_console, old_windows, colors): # pragma: win32 cover 340 | expected_output = { 341 | False: """\ 342 | Usage: PROG [options] 343 | 344 | Options: 345 | -h, --help show this help message and exit 346 | """, 347 | True: """\ 348 | \x1b[38;5;208mUsage:\x1b[0m PROG [options] 349 | 350 | \x1b[38;5;208mOptions:\x1b[0m 351 | \x1b[36m-h\x1b[0m, \x1b[36m--help\x1b[0m \x1b[39mshow this help message and exit\x1b[0m 352 | """, 353 | }[colors] 354 | 355 | init_win_colors = Mock(return_value=not old_windows) 356 | parser = OptionParser(prog="PROG", formatter=IndentedRichHelpFormatter()) 357 | with ( 358 | patch("rich.console.detect_legacy_windows", return_value=legacy_console), 359 | patch("rich_argparse._common._initialize_win_colors", init_win_colors), 360 | ): 361 | assert parser.format_help() == dedent(expected_output) 362 | if legacy_console: 363 | init_win_colors.assert_called_with() 364 | else: 365 | init_win_colors.assert_not_called() 366 | 367 | 368 | @pytest.mark.parametrize( 369 | ("formatter", "description", "nb_o", "expected"), 370 | ( 371 | pytest.param( 372 | IndentedRichHelpFormatter(), 373 | None, 374 | 2, 375 | """\ 376 | \x1b[38;5;208mUsage:\x1b[0m \x1b[38;5;244mPROG\x1b[0m [\x1b[36m-h\x1b[0m] [\x1b[36m--foo\x1b[0m \x1b[38;5;36mFOO\x1b[0m] 377 | 378 | \x1b[38;5;208mOptions:\x1b[0m 379 | \x1b[36m-h\x1b[0m, \x1b[36m--help\x1b[0m \x1b[39mshow this help message and exit\x1b[0m 380 | \x1b[36m--foo\x1b[0m=\x1b[38;5;36mFOO\x1b[0m \x1b[39mfoo help\x1b[0m 381 | """, 382 | id="indented", 383 | ), 384 | pytest.param( 385 | IndentedRichHelpFormatter(), 386 | "A description.", 387 | 2, 388 | """\ 389 | \x1b[38;5;208mUsage:\x1b[0m \x1b[38;5;244mPROG\x1b[0m [\x1b[36m-h\x1b[0m] [\x1b[36m--foo\x1b[0m \x1b[38;5;36mFOO\x1b[0m] 390 | 391 | \x1b[39mA description.\x1b[0m 392 | 393 | \x1b[38;5;208mOptions:\x1b[0m 394 | \x1b[36m-h\x1b[0m, \x1b[36m--help\x1b[0m \x1b[39mshow this help message and exit\x1b[0m 395 | \x1b[36m--foo\x1b[0m=\x1b[38;5;36mFOO\x1b[0m \x1b[39mfoo help\x1b[0m 396 | """, 397 | id="indented-desc", 398 | ), 399 | pytest.param( 400 | IndentedRichHelpFormatter(), 401 | None, 402 | 30, 403 | """\ 404 | \x1b[38;5;208mUsage:\x1b[0m \x1b[38;5;244mPROG\x1b[0m [\x1b[36m-h\x1b[0m] 405 | [\x1b[36m--foooooooooooooooooooooooooooooo\x1b[0m \x1b[38;5;36mFOOOOOOOOOOOOOOOOOOOOOOOOOOOOOO\x1b[0m] 406 | 407 | \x1b[38;5;208mOptions:\x1b[0m 408 | \x1b[36m-h\x1b[0m, \x1b[36m--help\x1b[0m \x1b[39mshow this help message and exit\x1b[0m 409 | \x1b[36m--foooooooooooooooooooooooooooooo\x1b[0m=\x1b[38;5;36mFOOOOOOOOOOOOOOOOOOOOOOOOOOOOOO\x1b[0m 410 | \x1b[39mfoo help\x1b[0m 411 | """, 412 | id="indented-long", 413 | ), 414 | pytest.param( 415 | TitledRichHelpFormatter(), 416 | None, 417 | 2, 418 | """\ 419 | \x1b[38;5;208mUsage\x1b[0m 420 | \x1b[38;5;208m=====\x1b[0m 421 | \x1b[38;5;244mPROG\x1b[0m [\x1b[36m-h\x1b[0m] [\x1b[36m--foo\x1b[0m \x1b[38;5;36mFOO\x1b[0m] 422 | 423 | \x1b[38;5;208mOptions\x1b[0m 424 | \x1b[38;5;208m=======\x1b[0m 425 | \x1b[36m--help\x1b[0m, \x1b[36m-h\x1b[0m \x1b[39mshow this help message and exit\x1b[0m 426 | \x1b[36m--foo\x1b[0m=\x1b[38;5;36mFOO\x1b[0m \x1b[39mfoo help\x1b[0m 427 | """, 428 | id="titled", 429 | ), 430 | ), 431 | ) 432 | @pytest.mark.usefixtures("force_color") 433 | def test_generated_usage(formatter, description, nb_o, expected): 434 | parser = OptionParser( 435 | prog="PROG", formatter=formatter, usage=GENERATE_USAGE, description=description 436 | ) 437 | parser.add_option("--f" + "o" * nb_o, help="foo help") 438 | parser.add_option("--bar", help=SUPPRESS_HELP) 439 | assert parser.format_help() == dedent(expected) 440 | 441 | 442 | def test_generated_usage_no_parser(): 443 | formatter = IndentedRichHelpFormatter() 444 | with pytest.raises(TypeError) as exc_info: 445 | formatter.format_usage(GENERATE_USAGE) 446 | assert str(exc_info.value) == "Cannot generate usage if parser is not set" 447 | --------------------------------------------------------------------------------