├── .github └── workflows │ └── build.yml ├── .gitignore ├── .readthedocs.yaml ├── CHANGELOG.rst ├── LICENSE.txt ├── README.rst ├── doc ├── .gitignore ├── Makefile ├── make.bat └── source │ ├── api.rst │ ├── changelog.rst │ ├── conf.py │ ├── examples.rst │ ├── features.rst │ └── index.rst ├── examples ├── annotations.py ├── booleans.py ├── choices.py ├── exceptions.py ├── lists.py ├── nested.py ├── parsers.py ├── partials.py ├── short.py ├── starargs.py └── styles.py ├── noxfile.py ├── pyproject.toml ├── src └── defopt.py └── test_defopt.py /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-22.04 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] 12 | steps: 13 | - uses: actions/checkout@v5 14 | - uses: actions/setup-python@v6 15 | with: 16 | python-version: ${{ matrix.python-version }} 17 | allow-prereleases: true 18 | - name: Install 19 | run: | 20 | python -mpip install --upgrade pip setuptools wheel coverage[toml] && 21 | python -mpip install . && 22 | python -mpip list 23 | - name: Test 24 | run: | 25 | python -mcoverage run --append --module unittest --buffer && 26 | # Oldest supported versions. 27 | python -mpip install docutils==0.12 sphinxcontrib-napoleon==0.7.0 && 28 | if [[ ${{ matrix.python-version }} = 3.7 ]]; then 29 | python -mpip install typing_extensions==3.7.4 typing_inspect==0.8.0 30 | fi && 31 | python -mcoverage run --append --module unittest --buffer 32 | - name: Upload coverage 33 | uses: actions/upload-artifact@v4 34 | with: 35 | name: coverage-${{ matrix.python-version }} 36 | include-hidden-files: true 37 | path: .coverage.* 38 | 39 | coverage: 40 | runs-on: ubuntu-latest 41 | needs: build 42 | steps: 43 | - uses: actions/checkout@v5 44 | - uses: actions/setup-python@v6 45 | with: 46 | python-version: "3.12" 47 | - name: Run 48 | run: | 49 | shopt -s globstar && 50 | GH_TOKEN=${{ secrets.GITHUB_TOKEN }} \ 51 | gh run download ${{ github.run-id }} -p 'coverage-*' && 52 | python -mpip install --upgrade coverage && 53 | python -mcoverage combine coverage-* && # Unifies paths across envs. 54 | python -mcoverage annotate && 55 | grep -HnTC2 '^!' **/*,cover | sed s/,cover// && 56 | python -mcoverage report --show-missing 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info/ 2 | .*.swp 3 | *.o 4 | *.pyc 5 | *.pyd 6 | *.so 7 | *,cover 8 | .cache/ 9 | .eggs/ 10 | .ipynb_checkpoints/ 11 | .pytest_cache/ 12 | build/ 13 | dist/ 14 | htmlcov/ 15 | oprofile_data/ 16 | .coverage 17 | .gdb_history 18 | .venv/ 19 | .coverage* 20 | .python-version 21 | .vscode/ 22 | .nox/ 23 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: "ubuntu-24.04" 5 | tools: 6 | python: "3.13" 7 | 8 | sphinx: 9 | configuration: doc/source/conf.py 10 | 11 | python: 12 | install: 13 | - path: . 14 | extra_requirements: 15 | - docs 16 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | 7.0.0 (2025-06-15) 5 | ------------------ 6 | * Dropped support for Python 3.5 and 3.6. 7 | * Added the :envvar:`DEFOPT_DEBUG` environment variable to help troubleshooting 8 | union parser failures. 9 | * Union members that come after `str` or `pathlib.Path` no longer need to have 10 | a valid parser. 11 | * Support optional single-item tuples with None parsers. 12 | * Support toplevel type aliases created with the ``type`` statement. 13 | * Pass-through more Sphinx standard roles. 14 | * Made parsed function docstring available as ``signature(...).doc``, and 15 | tuple of suppressed exception types as ``signature(...).raises``. 16 | On the other hand `defopt.signature` no longer annotates the return type with 17 | the suppressed exceptions, and `defopt.bind` only wraps the returned callable 18 | object in an exception suppressor if needed. 19 | * `defopt.signature` now correctly annotates parameters with no type annotation 20 | using `inspect.Parameter.empty` instead of ``None``. 21 | * `defopt.signature` also accepts docstrings as input. 22 | * Document `defopt.signature` as provisional APIs. 23 | * Removed the deprecated ``strict_kwonly`` in favor of ``cli_options``. 24 | * Support `functools.partial` to set or overwrite function default values. 25 | 26 | 6.4.0 (2022-07-19) 27 | ------------------ 28 | * `defopt.bind` now returns a single `functools.partial` object representing 29 | the call that would be executed by `defopt.run`. 30 | * Added `defopt.bind_known` which supports unknown arguments and returns them 31 | as a separate list. 32 | * Document `defopt.bind` and `defopt.bind_known` as provisional APIs. 33 | * Added ``intermixed`` (on Python>=3.7). 34 | * Support ``python -mdefopt package.name:function_name args ...`` (with a 35 | colon). 36 | 37 | 6.3.0 (2022-02-07) 38 | ------------------ 39 | * Fixed ``typing.Optional[bool]`` to still be treated as a flag. 40 | * Added support for ``tuple[X, ...]`` and ``Collection[X]`` (Thanks to 41 | @neelmraman). 42 | * Disabled syntax highlighting when parsing, thus getting rid of a weak 43 | dependency on pygments. 44 | * Tweaked newline handling in help generation. 45 | 46 | 6.2.0 (2021-11-24) 47 | ------------------ 48 | * Bumped docutils dependency to >=0.12, for setuptools compatibility. 49 | * Added support for Python 3.10, and for ``A | B``-style unions (only on that 50 | version of Python). 51 | * Fixed bad interaction between custom None parsers and tuple parsers. 52 | * Give preference to None parsers in Unions. 53 | * `defopt.signature` annotates the return type with the documented raisable 54 | exception types. 55 | * Added `defopt.bind` to allow preprocessing arguments before performing the 56 | call. 57 | * Added the ability to use nested subcommands. 58 | 59 | 6.1.0 (2021-02-25) 60 | ------------------ 61 | * Boolean flags are now implemented using a variant of 62 | ``argparse.BooleanOptionalAction``. 63 | * Added ``no_negated_flags``. 64 | * A custom parser set for ``type(None)`` now affects parsing of 65 | `typing.Optional` too. 66 | 67 | 6.0.2 (2020-12-08) 68 | ------------------ 69 | * Don't get tripped by Attributes sections. 70 | * Added support for Python 3.9. 71 | 72 | 6.0.1 (2020-09-18) 73 | ------------------ 74 | * Fixed support for container types defaulting to None. 75 | 76 | 6.0.0 (2020-05-11) 77 | ------------------ 78 | * Added support for Union and Literal types. 79 | * Assume that types annotated as constructible from a single str are their own 80 | parser. 81 | * Added support for catching exceptions. 82 | * Added support for passing functions as a ``{name: function}`` mapping (Thanks 83 | to @johnfarina). 84 | * Removed support for Python<=3.4. 85 | * Disallowed ``parsers=None`` as a synonym for ``parsers={}``. 86 | * Added `defopt.signature` to separate the signature-and-docstring parsing from 87 | the ArgumentParser construction. 88 | * Fixed removal of comments from help string. 89 | * Added support for ``--version``. 90 | * Fixed displaying of defaults for parameters with no help, and added 91 | ``show_defaults``. 92 | * Support varargs documented under ``*args`` instead of ``args``. 93 | * Support standard Sphinx roles in the Python domain (``:py:func:``, 94 | ``:func:``, etc.); they are just stripped out. 95 | * Arbitrary type-hinted functions can now by run with 96 | ``python -mdefopt dotted.name args ...``, as if ``dotted.name`` was passed 97 | to `defopt.run`. 98 | * Support more RST constructs: doctest blocks, rubrics (used by Napoleon for 99 | sectioning). 100 | 101 | 5.1.0 (2019-03-01) 102 | ------------------ 103 | * Added ``argparse_kwargs``. 104 | * Fixed short flag generation to avoid collision with ``-h``. 105 | 106 | 5.0.0 (2018-10-18) 107 | ------------------ 108 | * Added default parser for `slice`. 109 | * Removed support for passing multiple functions positionally. 110 | * Added support for Python 3.7. 111 | * Removed support for Python 3.3. 112 | 113 | 4.0.1 (2017-11-26) 114 | ------------------ 115 | * Fixed crash when handing a NamedTuple followed by other arguments 116 | 117 | 4.0.0 (2017-11-07) 118 | ------------------ 119 | * Changed parser generation to only make flags from keyword-only arguments, 120 | treating arguments with defaults as optional positionals 121 | * Changed subparser generation to replace dashes in names with underscores 122 | * Added support for RST lists 123 | * Added support for typed Tuple and NamedTuple arguments 124 | * Added __all__ 125 | * Ignored arguments whose names start with underscores 126 | 127 | 3.2.0 (2017-05-30) 128 | ------------------ 129 | 130 | * Added ``show_types`` option to automatically display variable types 131 | (Thanks to @anntzer) 132 | * Added default parser for `pathlib.Path` when it is available 133 | (Thanks to @anntzer) 134 | * Added annotations example to the generated documentation 135 | 136 | 3.1.1 (2017-04-12) 137 | ------------------ 138 | 139 | * Fixed environment markers in wheels 140 | 141 | 3.1.0 (2017-04-12) 142 | ------------------ 143 | 144 | Thanks to @anntzer for contributing the features in this release. 145 | 146 | * Changed `defopt.run` to take multiple functions as a single list 147 | * Deprecated passing multiple functions positionally 148 | * Added subcommand summaries to the help message for multiple functions 149 | * Added automatic short flags where they are unambiguous 150 | * Added rendering of italic, bold and underlined text from docstrings 151 | * Added Python 3.6 classifier to setup.py 152 | * Dropped nose as a test runner 153 | 154 | 3.0.0 (2016-12-16) 155 | ------------------ 156 | 157 | * Added support for Python 3.6 158 | * Changed keyword-only arguments without defaults to required flags 159 | * Added support for all variants of ``param`` and ``type`` 160 | * Added support for list-typed variable positional arguments 161 | * Fixed help message formatting to avoid argparse's string interpolation 162 | * Added __version__ attribute 163 | 164 | 2.0.1 (2016-09-13) 165 | ------------------ 166 | 167 | * Fixed handling of generic types in Python 3.5.2 (and typing 3.5.2) 168 | 169 | 2.0.0 (2016-05-10) 170 | ------------------ 171 | 172 | * Added ability to specify short flags 173 | * Added automatic ``--name`` and ``--no-name`` flags for optional booleans 174 | * Added automatic translation of underscores to hyphens in all flags 175 | * Removed ``defopt.parser`` 176 | 177 | 1.3.0 (2016-03-21) 178 | ------------------ 179 | 180 | * Added ``parsers`` argument to `defopt.run` 181 | * Deprecated ``defopt.parser`` 182 | 183 | 1.2.0 (2016-02-25) 184 | ------------------ 185 | 186 | * Added support for type annotations 187 | * Added parameter defaults to help text 188 | * Removed default line wrapping of help text 189 | * Added '1' and '0' as accepted values for True and False respectively 190 | 191 | 1.1.0 (2016-02-21) 192 | ------------------ 193 | 194 | * Added support for Google- and Numpy-style docstrings 195 | * Changed `defopt.run` to return the value from the called function 196 | 197 | 1.0.1 (2016-02-14) 198 | ------------------ 199 | 200 | * Added workaround to display raw text of any unparsed element (issue #1) 201 | 202 | 1.0.0 (2016-02-14) 203 | ------------------ 204 | 205 | * Removed decorator interface and added simpler `defopt.run` interface 206 | * Added full documentation hosted on Read the Docs 207 | * Added more informative exceptions for type lookup failures 208 | * Fixed bug where ``defopt.parser`` was not returning the input function 209 | * Fixed type lookups to occur in each respective function's global namespace 210 | * Fixed bug where subcommands did not properly parse Enums 211 | * Fixed Enum handling to display members in the order they were defined 212 | 213 | 0.3.1 (2016-02-10) 214 | ------------------ 215 | 216 | * Added support for docstrings that only contain parameter information 217 | * Added more informative exceptions for insufficiently documented functions 218 | * Fixed type parsing bug on Python 2 when future is installed 219 | * Switched to building universal wheels 220 | 221 | 0.3.0 (2016-02-10) 222 | ------------------ 223 | 224 | * Added support for Python 2.7 225 | * Fixed code that was polluting the logging module's root logger 226 | 227 | 0.2.0 (2016-02-09) 228 | ------------------ 229 | 230 | * Added support for combined parameter type and description definitions 231 | * Fixed crashing bug when an optional Enum-typed flag wasn't specified 232 | 233 | 0.1.0 (2016-02-08) 234 | ------------------ 235 | 236 | * Initial version 237 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016-present Evan Andrews, Antony Lee 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | defopt 2 | ====== 3 | 4 | | |GitHub| |PyPI| |conda-forge| 5 | | |Read the Docs| |Build| 6 | 7 | .. |GitHub| 8 | image:: https://img.shields.io/badge/github-anntzer%2Fdefopt-brightgreen 9 | :target: `GitHub repository`_ 10 | .. |PyPI| 11 | image:: https://img.shields.io/pypi/v/defopt.svg?color=brightgreen 12 | :target: https://pypi.org/project/defopt 13 | .. |conda-forge| 14 | image:: https://img.shields.io/conda/v/conda-forge/defopt.svg?label=conda-forge&color=brightgreen 15 | :target: https://anaconda.org/conda-forge/defopt 16 | .. |Read the Docs| 17 | image:: https://img.shields.io/readthedocs/defopt 18 | :target: `Read the Docs`_ 19 | .. |Build| 20 | image:: https://img.shields.io/github/actions/workflow/status/anntzer/defopt/build.yml?branch=main 21 | :target: https://github.com/anntzer/defopt/actions 22 | 23 | defopt is a lightweight, no-effort argument parser. 24 | 25 | defopt will: 26 | 27 | - Allow functions to be run from code and the command line without modification. 28 | - Reward you for documenting your functions. 29 | - Save you from writing, testing and maintaining argument parsing code. 30 | 31 | defopt will not: 32 | 33 | - Modify your functions in any way. 34 | - Allow you to build highly complex or customized command line tools. 35 | 36 | If you want total control over how your command line looks or behaves, try 37 | docopt_, click_ or argh_. If you just want to write Python code and leave the 38 | command line interface up to someone else, defopt is for you. 39 | 40 | Usage 41 | ----- 42 | 43 | Once you have written and documented_ your function, simply pass it to 44 | `defopt.run()` and you're done. 45 | 46 | .. code-block:: python 47 | 48 | import defopt 49 | 50 | # Use type hints: 51 | def main(greeting: str, *, count: int = 1): 52 | """ 53 | Display a friendly greeting. 54 | 55 | :param greeting: Greeting to display 56 | :param count: Number of times to display the greeting 57 | """ 58 | for _ in range(count): 59 | print(greeting) 60 | 61 | # ... or document parameter types in the docstring: 62 | def main(greeting, *, count=1): 63 | """ 64 | Display a friendly greeting. 65 | 66 | :param str greeting: Greeting to display 67 | :param int count: Number of times to display the greeting 68 | """ 69 | for _ in range(count): 70 | print(greeting) 71 | 72 | if __name__ == '__main__': 73 | defopt.run(main) 74 | 75 | Descriptions of the parameters and the function itself are used to build an 76 | informative help message. 77 | 78 | :: 79 | 80 | $ python test.py -h 81 | usage: test.py [-h] [-c COUNT] greeting 82 | 83 | Display a friendly greeting. 84 | 85 | positional arguments: 86 | greeting Greeting to display 87 | 88 | optional arguments: 89 | -h, --help show this help message and exit 90 | -c COUNT, --count COUNT 91 | Number of times to display the greeting 92 | (default: 1) 93 | 94 | Your function can now be called identically from Python and the command line. 95 | 96 | :: 97 | 98 | >>> from test import main 99 | >>> main('hello!', count=2) 100 | hello! 101 | hello! 102 | 103 | :: 104 | 105 | $ python test.py hello! --count 2 106 | hello! 107 | hello! 108 | 109 | Philosopy 110 | --------- 111 | 112 | defopt was developed with the following guiding principles in mind: 113 | 114 | #. **The interface can be fully understood in seconds.** If it took any longer, 115 | your time would be better spent learning a more flexible tool. 116 | 117 | #. **Anything you learn applies to the existing ecosystem.** The exact same 118 | docstrings used by defopt are also used by Sphinx's autodoc_ extension to 119 | generate documentation, and by your IDE to do type checking. Chances are you 120 | already know everything you need to know to use defopt. 121 | 122 | #. **Everything is handled for you.** If you're using defopt, it's because you 123 | don't want to write any argument parsing code *at all*. You can trust it to 124 | build a logically consistent command line interface to your functions 125 | with no configuration required. 126 | 127 | #. **Your Python functions are never modified.** Type conversions are only ever 128 | applied to data originating from the command line. When used in code, 129 | duck-typing still works exactly as you expect with no surprises. 130 | 131 | Development 132 | ----------- 133 | 134 | For source code, examples, questions, feature requests and bug reports, visit 135 | the `GitHub repository`_. 136 | 137 | Documentation 138 | ------------- 139 | 140 | Documentation is hosted on `Read the Docs`_. 141 | 142 | .. _GitHub repository: https://github.com/anntzer/defopt 143 | .. _Read the Docs: https://defopt.readthedocs.io/en/latest/ 144 | .. _autodoc: https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html 145 | .. _docopt: http://docopt.org/ 146 | .. _click: https://click.palletsprojects.com/ 147 | .. _argh: https://argh.readthedocs.io/en/latest/ 148 | .. _documented: https://defopt.readthedocs.io/en/latest/examples.html#docstring-styles 149 | 150 | .. This document is included in docs/index.rst; table of contents appears here. 151 | -------------------------------------------------------------------------------- /doc/.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = python -msphinx 7 | SOURCEDIR = source 8 | BUILDDIR = build 9 | 10 | # Put it first so that "make" without argument is like "make help". 11 | help: 12 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 13 | 14 | .PHONY: help Makefile 15 | 16 | # Catch-all target: route all unknown targets to Sphinx using the new 17 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 18 | %: Makefile 19 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 20 | -------------------------------------------------------------------------------- /doc/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=python -msphinx 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The Sphinx module was not found. Make sure you have Sphinx installed, 19 | echo.then set the SPHINXBUILD environment variable to point to the full 20 | echo.path of the 'sphinx-build' executable. Alternatively you may add the 21 | echo.Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /doc/source/api.rst: -------------------------------------------------------------------------------- 1 | API reference 2 | ============= 3 | 4 | .. automodule:: defopt 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /doc/source/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../../CHANGELOG.rst 2 | -------------------------------------------------------------------------------- /doc/source/conf.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | sys.path.insert(0, os.path.abspath('../..')) # Needed for examples. 5 | 6 | import defopt 7 | 8 | # -- General configuration ------------------------------------------------ 9 | 10 | needs_sphinx = '4.4.0' 11 | extensions = [ 12 | 'sphinx.ext.autodoc', 13 | 'sphinx.ext.intersphinx', 14 | 'sphinx.ext.napoleon', 15 | ] 16 | 17 | source_suffix = '.rst' 18 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 19 | master_doc = 'index' 20 | 21 | project = 'defopt' 22 | copyright = '2016–present, Evan Andrews, Antony Lee' 23 | author = 'Evan Andrews, Antony Lee' 24 | 25 | version = release = defopt.__version__ 26 | 27 | language = 'en' 28 | 29 | default_role = 'py:obj' 30 | 31 | pygments_style = 'sphinx' 32 | 33 | todo_include_todos = False 34 | 35 | # -- Options for HTML output ---------------------------------------------- 36 | 37 | html_theme = 'alabaster' 38 | html_sidebars = {'**': ['about.html', 'navigation.html', 'localtoc.html']} 39 | html_theme_options = { 40 | 'description': 'A lightweight, no-effort argument parser.', 41 | 'github_user': 'anntzer', 42 | 'github_repo': 'defopt', 43 | 'github_banner': True, 44 | 'github_button': False, 45 | 'code_font_size': '80%', 46 | } 47 | # html_last_updated_fmt = '' # bitprophet/alabaster#93 48 | 49 | htmlhelp_basename = 'defopt_doc' 50 | 51 | # -- Options for LaTeX output --------------------------------------------- 52 | 53 | latex_elements = {} 54 | latex_documents = [( 55 | master_doc, 56 | 'defopt.tex', 57 | 'defopt Documentation', 58 | author, 59 | 'manual', 60 | )] 61 | 62 | # -- Options for manual page output --------------------------------------- 63 | 64 | man_pages = [( 65 | master_doc, 66 | 'defopt', 67 | 'defopt Documentation', 68 | [author], 69 | 1, 70 | )] 71 | 72 | # -- Options for Texinfo output ------------------------------------------- 73 | 74 | texinfo_documents = [( 75 | master_doc, 76 | 'defopt', 77 | 'defopt Documentation', 78 | author, 79 | 'defopt', 80 | 'A lightweight, no-effort argument parser.', 81 | 'Miscellaneous', 82 | )] 83 | 84 | # -- Misc. configuration -------------------------------------------------- 85 | 86 | autodoc_member_order = 'bysource' 87 | autodoc_typehints = 'description' 88 | autodoc_typehints_format = 'short' 89 | autodoc_typehints_description_target = 'documented' 90 | 91 | intersphinx_mapping = { 92 | 'python': ('https://docs.python.org/3/', None), 93 | 'sphinx': ('https://www.sphinx-doc.org/en/master/', None), 94 | } 95 | -------------------------------------------------------------------------------- /doc/source/examples.rst: -------------------------------------------------------------------------------- 1 | Example documentation 2 | ===================== 3 | 4 | One of the great things about defopt is that the same docstrings it uses to 5 | parse arguments are consumed by other tools in the Python ecosystem. 6 | 7 | This is the documentation generated by Sphinx for the examples_. You can click 8 | the "Show Source" button to see the raw RST document that generated this page. 9 | 10 | Annotations 11 | ----------- 12 | 13 | .. automodule:: examples.annotations 14 | :members: 15 | :undoc-members: 16 | :show-inheritance: 17 | 18 | Boolean flags 19 | ------------- 20 | 21 | .. automodule:: examples.booleans 22 | :members: 23 | :undoc-members: 24 | :show-inheritance: 25 | 26 | Choices 27 | ------- 28 | 29 | .. automodule:: examples.choices 30 | :members: 31 | :undoc-members: 32 | :show-inheritance: 33 | 34 | Exceptions 35 | ---------- 36 | 37 | .. automodule:: examples.exceptions 38 | :members: 39 | :undoc-members: 40 | :show-inheritance: 41 | 42 | Lists 43 | ----- 44 | 45 | .. automodule:: examples.lists 46 | :members: 47 | :undoc-members: 48 | :show-inheritance: 49 | 50 | Parsers 51 | ------- 52 | 53 | .. automodule:: examples.parsers 54 | :members: 55 | :undoc-members: 56 | :show-inheritance: 57 | 58 | Short flags 59 | ----------- 60 | 61 | .. automodule:: examples.short 62 | :members: 63 | :undoc-members: 64 | :show-inheritance: 65 | 66 | Variable positional arguments 67 | ----------------------------- 68 | 69 | .. automodule:: examples.starargs 70 | :members: 71 | :undoc-members: 72 | :show-inheritance: 73 | 74 | Docstring styles 75 | ---------------- 76 | 77 | .. automodule:: examples.styles 78 | :members: 79 | :undoc-members: 80 | :show-inheritance: 81 | 82 | Nested subcommands 83 | ------------------ 84 | 85 | .. automodule:: examples.nested 86 | :members: 87 | :undoc-members: 88 | :show-inheritance: 89 | 90 | .. _examples: https://github.com/anntzer/defopt/tree/main/examples 91 | -------------------------------------------------------------------------------- /doc/source/features.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: none 2 | 3 | Features 4 | ======== 5 | 6 | Types 7 | ----- 8 | 9 | Argument types are read from the function's type hints (see 10 | `examples/annotations.py`_) or docstring. If type information for a parameter 11 | is given both as type hint and in the docstring, the types must match. 12 | 13 | .. code-block:: python 14 | 15 | def func(arg1: int, arg2: str): 16 | ... 17 | 18 | Docstrings can use the standard Sphinx_-style 19 | 20 | .. code-block:: rst 21 | 22 | :param : 23 | 24 | .. or 25 | 26 | :param : 27 | :type : 28 | 29 | .. Any of ``param``, ``parameter``, ``arg``, ``argument``, ``key``, and 30 | ``keyword`` can be used interchangeably, as can ``type`` and 31 | ``kwtype``. Consistency is recommended but not enforced. 32 | 33 | or Google_- and Numpy_-style docstrings (see `examples/styles.py`_), which are 34 | converted using Napoleon_ [#]_. If using one of these alternate styles and 35 | generating documentation with `sphinx.ext.autodoc`, be sure to also enable 36 | `sphinx.ext.napoleon`. 37 | 38 | ```` is evaluated in the function's global namespace when `defopt.run` 39 | is called. 40 | 41 | See `Standard types`_, Booleans_, Lists_, Choices_, Tuples_, Unions_, and 42 | Parsers_ for more information on specific types. 43 | 44 | .. [#] While Napoleon is included with Sphinx as `sphinx.ext.napoleon`, defopt 45 | depends on ``sphinxcontrib-napoleon`` so that end users of the command line 46 | tool are not required to install Sphinx and all of its dependencies. 47 | 48 | Subcommands 49 | ----------- 50 | 51 | If a list of commands are passed to `defopt.run`, they are treated as 52 | subcommands which are run by name. 53 | 54 | .. code-block:: python 55 | 56 | defopt.run([func1, func2]) 57 | 58 | The command line usage will indicate this. :: 59 | 60 | usage: test.py [-h] {func1,func2} ... 61 | 62 | positional arguments: 63 | {func1,func2} 64 | 65 | Underscores in function names are replaced by hyphens. 66 | 67 | Friendlier subcommand names can be provided by calling `defopt.run` with a dict 68 | mapping subcommand names to functions. In that case, no underscore replacement 69 | occurs (as one can directly set names with hyphens). 70 | 71 | .. code-block:: python 72 | 73 | defopt.run({"friendly_func": awkward_name, "func2": other_name}) 74 | 75 | Command line usage will use the new names :: 76 | 77 | usage: test.py [-h] {friendly_func,func2} ... 78 | 79 | positional arguments: 80 | {friendly_func,func2} 81 | 82 | Nested Subcommands 83 | ------------------ 84 | 85 | By using dictionaries, the subcommands can also be nested. 86 | 87 | .. code-block:: python 88 | 89 | defopt.run({'func1': func1, 'sub': [func2, func3]}) 90 | 91 | The nested subcommands are accessed e.g. by ``test.py sub func2``. The 92 | subcommands can be nested to an arbitrary level by using nested dictionaries. 93 | 94 | A runnable example is available at `examples/nested.py`_. 95 | 96 | Standard types 97 | -------------- 98 | 99 | For parameters annotated as `str`, `int`, `float`, and `pathlib.Path` (or any 100 | `pathlib.PurePath` subclass), the type constructor is directly called on the 101 | argument passed in. 102 | 103 | For parameters annotated as `slice`, the argument passed in is split at 104 | ``":"``, the resulting fragments evaluated with `ast.literal_eval` (with empty 105 | fragments being converted to None), and the results passed to the `slice` 106 | constructor. For example, ``1::2`` results in ``slice(1, None, 2)``, which 107 | corresponds to the normal indexing syntax. 108 | 109 | Flags 110 | ----- 111 | 112 | Python positional-or-keyword parameters are converted to CLI positional 113 | arguments, with their name unmodified [#]_. Python keyword-only parameters are 114 | converted to CLI flags, with underscores replaced by hyphens. Additionally, 115 | one-letter short flags are generated for all flags that do not share their 116 | initial with other flags. 117 | 118 | Optional Python parameters (i.e. with a default) are converted to optional CLI 119 | arguments (regardless of whether the Python parameter is positional-or-keyword 120 | or keyword-only); required Python parameters (i.e. with no default) are 121 | converted to required CLI arguments. :: 122 | 123 | usage: test.py [-h] --kwonly-no-default KWONLY_NO_DEFAULT [--kwonly-with-default KWONLY_WITH_DEFAULT] 124 | positional_no_default [positional_with_default] 125 | 126 | positional arguments: 127 | positional_no_default 128 | positional_with_default 129 | (default: some_value) 130 | 131 | options: 132 | -h, --help show this help message and exit 133 | --kwonly-no-default KWONLY_NO_DEFAULT 134 | --kwonly-with-default KWONLY_WITH_DEFAULT 135 | (default: some_value) 136 | 137 | Alternatively, one can make all optional Python parameters, regardless of 138 | whether they are keyword-only or not, also map to CLI flags, by passing 139 | ``cli_options='has_default'`` to `defopt.run`. (This behavior is similar to 140 | the informal approach previously commonly found on Python 2, which was to 141 | consider required parameters as positional and optional parameters as keyword.) 142 | 143 | Auto-generated short flags can be overridden by passing a dictionary to 144 | `defopt.run` which maps flag names to single letters: 145 | 146 | .. code-block:: python 147 | 148 | defopt.run(main, short={'keyword-arg': 'a'}) 149 | 150 | Now, ``-a`` is exactly equivalent to ``--keyword-arg``:: 151 | 152 | -a KEYWORD_ARG, --keyword-arg KEYWORD_ARG 153 | 154 | A runnable example is available at `examples/short.py`_. 155 | 156 | Passing an empty dictionary suppresses automatic short flag generation, without 157 | adding new flags. 158 | 159 | .. [#] As an exception, sequence parameters are always converted to flags, as 160 | described below. 161 | 162 | Booleans 163 | -------- 164 | 165 | Boolean keyword-only parameters (or, as above, parameters with defaults, if 166 | ``cli_options='has_default'``) are automatically converted to two separate 167 | flags: ``--name`` which stores `True` and ``--no-name`` which stores `False`. 168 | The help text and the default are displayed next to the ``--name`` flag:: 169 | 170 | --flag Set "flag" to True 171 | (default: False) 172 | --no-flag 173 | 174 | Note that this does not apply to mandatory boolean parameters; these must be 175 | specified as one of ``1/t/true`` or ``0/f/false`` (case insensitive). 176 | 177 | If ``no_negated_flags=True`` is passed to `defopt.run`, no negated flags 178 | (``--no-name``) are generated for boolean arguments that have `False` 179 | as their default value. 180 | 181 | A runnable example is available at `examples/booleans.py`_. 182 | 183 | Lists 184 | ----- 185 | 186 | Lists are automatically converted to flags (regardless of whether they are 187 | positional-or-keyword, or keyword-only) which take zero or more arguments. 188 | 189 | When declaring in a docstring that a parameter is a list, put the contained 190 | type in square brackets, even on Python versions which do not otherwise support 191 | that syntax:: 192 | 193 | :param list[int] numbers: A sequence of numbers 194 | 195 | `typing.List`, `typing.Sequence` and `typing.Iterable` are all treated in the 196 | same way as `list`. 197 | 198 | The list can now be specified on the command line using multiple arguments. :: 199 | 200 | test.py --numbers 1 2 3 201 | 202 | A runnable example is available at `examples/lists.py`_. 203 | 204 | Choices 205 | ------- 206 | 207 | Subclasses of `enum.Enum` are handled specially on the command line to produce 208 | more helpful output. :: 209 | 210 | positional arguments: 211 | {red,blue,yellow} Your favorite color 212 | 213 | This also produces a more helpful message when an invalid option is chosen. :: 214 | 215 | test.py: error: argument color: invalid choice: 'black' 216 | (choose from 'red', 'blue', 'yellow') 217 | 218 | A runnable example is available at `examples/choices.py`_. 219 | 220 | Likewise, `typing.Literal` and its backport ``typing_extensions.Literal`` are 221 | also supported. 222 | 223 | Tuples 224 | ------ 225 | 226 | Typed tuples and typed namedtuples (as defined using `typing.Tuple` and 227 | `typing.NamedTuple`) consume as many command-line arguments as the tuple 228 | has fields, convert each argument to the correct type, and wrap them into the 229 | annotation class. When a `typing.NamedTuple` is used for an optional argument, 230 | the names of the fields are used in the help. 231 | 232 | Unions 233 | ------ 234 | 235 | Union types can be specified with ``typing.Union[type1, type2]``, or, when 236 | using docstring annotations, as ``type1 or type2``. The ``type1 | type2`` 237 | syntax is also supported, if the underlying Python version supports it. When 238 | an argument is annotated with a union type, an attempt is made to convert the 239 | command-line argument with the parser for each of the members of the union, in 240 | the order they are given; the value returned by the first parser that does not 241 | raise a `ValueError` is used. Note that all types in the union must be 242 | parsable, *except* that types that come after ``str`` or ``Path``/``PurePath`` 243 | are not taken into account (as conversion to ``str`` or to ``Path`` will always 244 | succeed). 245 | 246 | ``typing.Optional[type1]``, i.e. ``Union[type1, type(None)]``, is normally 247 | equivalent to ``type1``. This is implemented using a parser for ``type(None)`` 248 | that raises ``ValueError`` on all inputs, and can thus be overloaded by setting 249 | a custom parser for ``type(None)``. As an exception to the "try parsers in 250 | order" rule given above, a parser for ``type(None)`` will always be tried 251 | first; this is so that e.g. ``Optional[str]`` can parse some user-chosen values 252 | as ``None`` and the others as ``str``. 253 | 254 | ``typing.Optional[bool]`` is treated separately, as a special case, to still 255 | act as a boolean flag. Defining a default value of ``None`` for the argument 256 | will result in receiving ``None`` if the option is not specified on the command 257 | line and either ``True`` or ``False`` if one of the two boolean flags are 258 | provided. 259 | 260 | Collection types are not supported in unions; e.g. ``Union[List[type1]]`` 261 | is not supported (with the exception of ``Optional[List[type1]]``, which is 262 | *always* equivalent to ``List[type1]``). 263 | 264 | Note that unfortunately, in certain circumstances, Python will reorder 265 | members of a union. Most notably, ``List[Union[A, B]]`` caches the union 266 | type, so a later ``List[Union[B, A]]`` will be silently converted to 267 | ``List[Union[A, B]]``, which matters if some inputs are accepted by both the 268 | parser for ``A`` and the parser for ``B``. Note that this problem does not 269 | affect ``list[Union[A, B]]``, on versions of Python that support it. 270 | 271 | If the :envvar:`DEFOPT_DEBUG` environment variable is set and a union parser 272 | fails, then the errors associated with each member parser are printed out. 273 | This knob should be considered a debugging help and is not a stable API. 274 | 275 | Parsers 276 | ------- 277 | 278 | Arbitrary argument types can be used as long as functions to parse them from 279 | strings are provided. 280 | 281 | .. code-block:: python 282 | 283 | def parse_person(string): 284 | last, first = string.split(',') 285 | return Person(first.strip(), last.strip()) 286 | 287 | defopt.run(..., parsers={Person: parse_person}) 288 | 289 | ``Person`` objects can be now built directly from the command line. :: 290 | 291 | test.py --person "VAN ROSSUM, Guido" 292 | 293 | A runnable example is available at `examples/parsers.py`_. 294 | 295 | If the type of an annotation can be called with a single parameter and that 296 | parameter is annotated as `str`, then `defopt` will assume that the type is 297 | its own parser. 298 | 299 | .. code-block:: python 300 | 301 | class StrWrapper: 302 | def __init__(self, s: str): 303 | self.s = s 304 | 305 | def main(s: StrWrapper): 306 | pass 307 | 308 | defopt.run(main) 309 | 310 | ``StrWrapper`` objects can now be built directly from the command line. :: 311 | 312 | test.py foo 313 | 314 | Variable positional arguments 315 | ----------------------------- 316 | 317 | If the function definition contains ``*args``, the parser will accept zero or 318 | more positional arguments. When specifying a type, specify the type of the 319 | elements, not the container. 320 | 321 | .. code-block:: python 322 | 323 | def main(*numbers: int): 324 | """:param numbers: Positional numeric arguments""" 325 | 326 | This will create a parser that accepts zero or more positional arguments which 327 | are individually parsed as integers. They are passed as they would be from code 328 | and received as a tuple. :: 329 | 330 | test.py 1 2 3 331 | 332 | If the argument is a list type (see Lists_), this will instead create a flag 333 | that can be specified multiple times, each time creating a new list. 334 | 335 | Variable keyword arguments (``**kwargs``) are not supported. 336 | 337 | A runnable example is available at `examples/starargs.py`_. 338 | 339 | Private arguments 340 | ----------------- 341 | 342 | Arguments whose name start with an underscore will not be added to the parser. 343 | 344 | Exceptions 345 | ---------- 346 | 347 | Exception types can also be listed in the function's docstring, with :: 348 | 349 | :raises : 350 | 351 | If the function call raises an exception whose type is mentioned in such a 352 | ``:raises:`` clause, the exception message is printed and the program exits 353 | with status code 1, but the traceback is suppressed. 354 | 355 | A runnable example is available at `examples/exceptions.py`_. 356 | 357 | Partially applied functions 358 | --------------------------- 359 | 360 | Partially applied functions can be provided using `functools.partial`, for 361 | example to wrap a concrete function to make a required option have a default 362 | (and thus optional). 363 | 364 | A runnable example is available at `examples/partials.py`_. 365 | 366 | Additional parser features 367 | -------------------------- 368 | 369 | Type information can be automatically added to the help text by passing 370 | ``show_types=True`` to `defopt.run`. Defaults are displayed by default (sic), 371 | but this can be turned off by passing ``show_defaults=False``. 372 | 373 | By default, a ``--version`` flag will be added; the version string is 374 | autodetected from the module where the function is defined (and the flag 375 | is suppressed if the version detection fails). Passing ``version="..."`` 376 | to `defopt.run` forces the version string, and passing ``version=False`` 377 | suppresses the flag. 378 | 379 | Entry points 380 | ------------ 381 | 382 | To use a script as a console entry point with setuptools, one needs to create 383 | a function that can be called without arguments. 384 | 385 | .. code-block:: python 386 | 387 | def entry_point(): 388 | defopt.run(main) 389 | 390 | This entry point can now be referenced in the ``setup.py`` file. 391 | 392 | .. code-block:: python 393 | 394 | setup( 395 | ..., 396 | entry_points={'console_scripts': ['name=test:entry_point']} 397 | ) 398 | 399 | Alternatively, to keep scripts importable independently of `defopt`, arbitrary 400 | type-hinted functions can be directly run from the command line with 401 | 402 | .. code-block:: sh 403 | 404 | $ python -m defopt dotted.name args ... 405 | 406 | which is equivalent to passing the ``dotted.name`` function to `defopt.run` and 407 | calling the resulting script with ``args ...``. The ``dotted.name`` can use 408 | a colon to separate the package name from the function name (as supported by 409 | relies on `pkgutil.resolve_name`). 410 | 411 | .. _Sphinx: https://www.sphinx-doc.org/en/master/usage/restructuredtext/domains.html#info-field-lists 412 | .. _Google: https://google.github.io/styleguide/pyguide.html 413 | .. _Numpy: https://numpydoc.readthedocs.io/en/latest/format.html 414 | .. _Napoleon: https://sphinxcontrib-napoleon.readthedocs.io/en/latest/ 415 | .. _examples/annotations.py: https://github.com/anntzer/defopt/blob/main/examples/annotations.py 416 | .. _examples/booleans.py: https://github.com/anntzer/defopt/blob/main/examples/booleans.py 417 | .. _examples/choices.py: https://github.com/anntzer/defopt/blob/main/examples/choices.py 418 | .. _examples/exceptions.py: https://github.com/anntzer/defopt/blob/main/examples/exceptions.py 419 | .. _examples/lists.py: https://github.com/anntzer/defopt/blob/main/examples/lists.py 420 | .. _examples/parsers.py: https://github.com/anntzer/defopt/blob/main/examples/parsers.py 421 | .. _examples/partials.py: https://github.com/anntzer/defopt/blob/main/examples/partials.py 422 | .. _examples/short.py: https://github.com/anntzer/defopt/blob/main/examples/short.py 423 | .. _examples/starargs.py: https://github.com/anntzer/defopt/blob/main/examples/starargs.py 424 | .. _examples/styles.py: https://github.com/anntzer/defopt/blob/main/examples/styles.py 425 | .. _examples/nested.py: https://github.com/anntzer/defopt/blob/main/examples/nested.py 426 | -------------------------------------------------------------------------------- /doc/source/index.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../../README.rst 2 | 3 | .. toctree:: 4 | :maxdepth: 2 5 | 6 | features 7 | api 8 | examples 9 | changelog 10 | -------------------------------------------------------------------------------- /examples/annotations.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example showing annotations in defopt. 3 | 4 | Type hints specified in function annotations may be used 5 | instead of writing types in docstrings. 6 | 7 | ``Iterable[x]``, ``Sequence[x]`` and ``List[x]`` are all treated 8 | in the same way as ``list[x]`` in the docstring itself. 9 | (See the lists example for more information.) 10 | 11 | Code usage:: 12 | 13 | >>> documented([1.2, 3.4], 2) 14 | 15 | Command line usage:: 16 | 17 | $ python annotations.py documented 2 --numbers 1.2 3.4 18 | $ python annotations.py documented --numbers 1.2 3.4 -- 2 19 | """ 20 | 21 | import sys 22 | if sys.version_info >= (3, 9): 23 | from collections.abc import Iterable 24 | else: 25 | from typing import Iterable 26 | 27 | import defopt 28 | 29 | 30 | def documented(numbers: Iterable[float], exponent: int) -> None: 31 | """ 32 | Example function using annotations. 33 | 34 | :param numbers: Numbers to multiply 35 | :param exponent: Power to raise each element to 36 | """ 37 | print([x ** exponent for x in numbers]) 38 | 39 | 40 | def undocumented(numbers: Iterable[float], exponent: int) -> None: 41 | print([x ** exponent for x in numbers]) 42 | 43 | 44 | if __name__ == '__main__': 45 | defopt.run([documented, undocumented]) 46 | -------------------------------------------------------------------------------- /examples/booleans.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example showing boolean flags in defopt. 3 | 4 | Optional boolean parameters are automatically converted to 5 | ``--name`` and ``--no-name`` flags which take no arguments 6 | and store `True` and `False` respectively. 7 | 8 | Code usage:: 9 | 10 | >>> main('hello!', upper=False, repeat=True) 11 | 12 | Command line usage:: 13 | 14 | $ python booleans.py 'hello!' --no-upper --repeat 15 | """ 16 | 17 | import defopt 18 | 19 | 20 | def main(message: str, *, upper: bool = True, repeat: bool = False): 21 | """ 22 | Example function with boolean flags. 23 | 24 | :param message: Message to display 25 | :param upper: Display the message in upper case 26 | :param repeat: Display the message twice 27 | """ 28 | if upper: 29 | message = message.upper() 30 | for _ in range(1 + repeat): 31 | print(message) 32 | 33 | 34 | if __name__ == '__main__': 35 | defopt.run(main) 36 | -------------------------------------------------------------------------------- /examples/choices.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example showing choices in defopt. 3 | 4 | If a parameter's type is a subclass of `enum.Enum` or a `typing.Literal` (or 5 | its backport ``typing_extensions.Literal``), defopt automatically turns this 6 | into a set of string choices on the command line. 7 | 8 | Code usage:: 9 | 10 | >>> choose_enum(Choice.one, opt=Choice.two) 11 | 12 | Command line usage:: 13 | 14 | $ python choices.py one --opt two 15 | """ 16 | 17 | from enum import Enum 18 | try: 19 | from typing import Literal 20 | except ImportError: 21 | from typing_extensions import Literal 22 | 23 | import defopt 24 | 25 | 26 | class Choice(Enum): 27 | one = 1 28 | two = 2.0 29 | three = '03' 30 | 31 | 32 | def choose_enum(arg: Choice, *, opt: Choice = None): 33 | """ 34 | Example function with `enum.Enum` arguments. 35 | 36 | :param arg: Choice to display 37 | :param opt: Optional choice to display 38 | """ 39 | print('{} ({})'.format(arg, arg.value)) 40 | if opt: 41 | print('{} ({})'.format(opt, opt.value)) 42 | 43 | 44 | def choose_literal(arg: Literal["foo", "bar"], *, 45 | opt: Literal["baz", "quu"] = None): 46 | """ 47 | Example function with `typing.Literal` arguments. 48 | 49 | :param arg: Choice to display 50 | :param opt: Optional choice to display 51 | """ 52 | print(arg) 53 | if opt: 54 | print(opt) 55 | 56 | 57 | if __name__ == '__main__': 58 | defopt.run([choose_enum, choose_literal]) 59 | -------------------------------------------------------------------------------- /examples/exceptions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example showing exception handling. 3 | 4 | If the function raises an exception listed in the docstring using ``:raises:``, 5 | the traceback is suppressed (the error is still reported, of course!). 6 | """ 7 | 8 | import defopt 9 | 10 | 11 | def main(arg): 12 | """ 13 | :param int arg: Don't set this to zero! 14 | :raises ValueError: If *arg* is zero. 15 | """ 16 | if arg == 0: 17 | raise ValueError("Don't do this!") 18 | 19 | 20 | if __name__ == "__main__": 21 | defopt.run(main) 22 | -------------------------------------------------------------------------------- /examples/lists.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example showing lists in defopt. 3 | 4 | Lists are automatically converted to required flags 5 | which accept zero or more arguments. 6 | 7 | Code usage:: 8 | 9 | >>> main([1.2, 3.4], 2) 10 | 11 | Command line usage:: 12 | 13 | $ python lists.py 2 --numbers 1.2 3.4 14 | $ python lists.py --numbers 1.2 3.4 -- 2 15 | """ 16 | 17 | import defopt 18 | 19 | 20 | def main(numbers, multiplier): 21 | """ 22 | Example function with a list argument. 23 | 24 | :param list[float] numbers: Numbers to multiply 25 | :param float multiplier: Amount to multiply by 26 | """ 27 | print([x * multiplier for x in numbers]) 28 | 29 | 30 | if __name__ == '__main__': 31 | defopt.run(main) 32 | -------------------------------------------------------------------------------- /examples/nested.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example showing nested parsers in defopt. 3 | 4 | Code usage:: 5 | 6 | >>> main(1.5) 7 | >>> sub1(2.0) 8 | >>> sub2(2.5) 9 | 10 | Command line usage:: 11 | 12 | $ python nested.py main 1.5 13 | $ python nested.py sub sub1 2.0 14 | $ python nested.py sub sub2 2.5 15 | """ 16 | 17 | import defopt 18 | 19 | 20 | def main(number): 21 | """ 22 | Example main function. 23 | 24 | :param float number: Number to print 25 | """ 26 | print(number) 27 | 28 | 29 | def sub1(number): 30 | """ 31 | Example sub command. 32 | 33 | :param float number: Number to print 34 | """ 35 | print(number) 36 | 37 | 38 | def sub2(number): 39 | """ 40 | Example sub command. 41 | 42 | :param float number: Number to print 43 | """ 44 | print(number) 45 | 46 | 47 | if __name__ == '__main__': 48 | defopt.run({'main': main, 'sub': [sub1, sub2]}) 49 | -------------------------------------------------------------------------------- /examples/parsers.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example showing parsers in defopt. 3 | 4 | If a type is not simple enough for defopt to parse on its own, 5 | you can explicitly specify parsers for types by passing a mapping 6 | to `defopt.run`. 7 | 8 | Code usage:: 9 | 10 | >>> main(datetime(2015, 9, 13)) 11 | 12 | Command line usage:: 13 | 14 | $ python parsers.py 2015-09-13 15 | """ 16 | 17 | from datetime import datetime 18 | 19 | import defopt 20 | 21 | 22 | def main(date: datetime): 23 | """ 24 | Example function with a `datetime.datetime` argument. 25 | 26 | :param date: Date to display 27 | """ 28 | print(date) 29 | 30 | 31 | def parse_date(string): 32 | """ 33 | Parse a `datetime.datetime` using a simple string format. 34 | 35 | :param str string: String to parse 36 | :rtype: datetime 37 | """ 38 | return datetime.strptime(string, '%Y-%m-%d') 39 | 40 | 41 | if __name__ == '__main__': 42 | defopt.run(main, parsers={datetime: parse_date}) 43 | -------------------------------------------------------------------------------- /examples/partials.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example showing using partial function applications in defopt. 3 | 4 | `functools.partial` objects may be used to wrap a function specifying a default 5 | for a required parameter, or to change the default for a parameter. 6 | 7 | Code usage:: 8 | 9 | >>> partial(foo, arg=5)() 10 | 5 11 | >>> bar() 12 | 1 13 | >>> partial(bar, arg=6)() 14 | 6 15 | 16 | Command line usage:: 17 | 18 | $ python partials.py foo 19 | 5 20 | $ python partials.py sub bar 21 | 6 22 | """ 23 | 24 | import defopt 25 | from functools import partial 26 | 27 | 28 | def foo(*, arg: int) -> None: 29 | print(arg) 30 | 31 | 32 | def bar(*, arg: int = 1) -> None: 33 | print(arg) 34 | 35 | 36 | if __name__ == '__main__': 37 | defopt.run({"foo": partial(foo, arg=5), "sub": [partial(bar, arg=6)]}) 38 | -------------------------------------------------------------------------------- /examples/short.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example showing short flags in defopt. 3 | 4 | You can add alternative short flags to arguments by passing a 5 | dictionary to `defopt.run` which maps flag names to single letters. 6 | 7 | Code usage:: 8 | 9 | >>> main(count=2) 10 | 11 | Command line usage:: 12 | 13 | $ python short.py -C 2 14 | $ python short.py --count 2 15 | """ 16 | 17 | import defopt 18 | 19 | 20 | def main(*, count: int = 1): 21 | """ 22 | Example function which prints a message. 23 | 24 | :param count: Number of times to print the message 25 | """ 26 | for _ in range(count): 27 | print('hello!') 28 | 29 | 30 | if __name__ == '__main__': 31 | defopt.run(main, short={'count': 'C'}) 32 | -------------------------------------------------------------------------------- /examples/starargs.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example showing ``*args`` handling in defopt. 3 | 4 | Variable positional arguments can be specified any number of times 5 | and are received as a tuple. 6 | 7 | Flags generated by list types (or equivalents) create a new list 8 | each time they are used. 9 | 10 | Code usage:: 11 | 12 | >>> plain(1, 2, 3) 13 | >>> iterable([1, 2], [3, 4, 5]) 14 | 15 | Command line usage:: 16 | 17 | $ python starargs.py plain 1 2 3 18 | $ python starargs.py iterable --groups 1 2 --groups 3 4 5 19 | """ 20 | 21 | import defopt 22 | 23 | 24 | def plain(*numbers): 25 | """ 26 | Example function which accepts multiple positional arguments. 27 | 28 | The arguments are plain integers. 29 | 30 | :param int numbers: Numbers to display 31 | """ 32 | for number in numbers: 33 | print(number) 34 | 35 | 36 | def iterable(*groups): 37 | """ 38 | Example function which accepts multiple positional arguments. 39 | 40 | The arguments are lists of integers. 41 | 42 | :param list[int] groups: Lists of numbers to display 43 | """ 44 | for group in groups: 45 | print(group) 46 | 47 | 48 | if __name__ == '__main__': 49 | defopt.run([plain, iterable]) 50 | -------------------------------------------------------------------------------- /examples/styles.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example showing supported docstring styles in defopt. 3 | 4 | You need to enable the `sphinx.ext.napoleon` extension 5 | to generate documentation for this module. 6 | 7 | Code usage:: 8 | 9 | >>> sphinx(2, farewell='goodbye!') 10 | 11 | Command line usage:: 12 | 13 | $ python styles.py sphinx 2 --farewell goodbye! 14 | """ 15 | 16 | import defopt 17 | 18 | 19 | def sphinx(integer, *, farewell=None): 20 | """ 21 | Example function with a Sphinx-style docstring. 22 | 23 | Squares a given integer. 24 | 25 | .. This is a comment; it won't show up anywhere but here. 26 | Below is a literal block which will be displayed with a 27 | 4-space indent in the help string and as a code block 28 | in the documentation. 29 | 30 | :: 31 | 32 | $ python styles.py sphinx 2 --farewell goodbye! 33 | 4 34 | goodbye! 35 | 36 | 37 | :param int integer: Number to square 38 | :keyword str farewell: Parting message 39 | """ 40 | print(integer ** 2) 41 | if farewell is not None: 42 | print(farewell) 43 | 44 | 45 | def google(integer, *, farewell=None): 46 | """ 47 | Example function with a Google-style docstring. 48 | 49 | Squares a given integer. 50 | 51 | .. This is a comment; it won't show up anywhere but here. 52 | Below is a literal block which will be displayed with a 53 | 4-space indent in the help string and as a code block 54 | in the documentation. 55 | 56 | :: 57 | 58 | $ python styles.py google 2 --farewell goodbye! 59 | 4 60 | goodbye! 61 | 62 | Args: 63 | integer(int): Number to square 64 | 65 | Keyword Arguments: 66 | farewell(str): Parting message 67 | """ 68 | print(integer ** 2) 69 | if farewell is not None: 70 | print(farewell) 71 | 72 | 73 | def numpy(integer, *, farewell=None): 74 | """ 75 | Example function with a Numpy-style docstring. 76 | 77 | Squares a given integer. 78 | 79 | .. This is a comment; it won't show up anywhere but here. 80 | Below is a literal block which will be displayed with a 81 | 4-space indent in the help string and as a code block 82 | in the documentation. 83 | 84 | :: 85 | 86 | $ python styles.py numpy 2 --farewell goodbye! 87 | 4 88 | goodbye! 89 | 90 | Parameters 91 | ---------- 92 | integer : int 93 | Number to square 94 | 95 | Keyword Arguments 96 | ----------------- 97 | farewell : str 98 | Parting message 99 | """ 100 | print(integer ** 2) 101 | if farewell is not None: 102 | print(farewell) 103 | 104 | 105 | if __name__ == '__main__': 106 | defopt.run([sphinx, google, numpy]) 107 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import nox 4 | from nox import Session, session 5 | 6 | python_versions = ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] 7 | nox.options.sessions = ['tests', 'docs'] 8 | nox.options.reuse_existing_virtualenvs = True 9 | 10 | 11 | @session(python=python_versions) 12 | @nox.parametrize('old', [False, True]) 13 | def tests(session: Session, old: bool) -> None: 14 | """Run the tests.""" 15 | args = session.posargs or ['--buffer'] 16 | 17 | session.install('--upgrade', 'pip', 'setuptools', 'wheel', 'coverage') 18 | session.install('-e', '.') 19 | 20 | if old: 21 | # Oldest supported versions 22 | session.install('docutils==0.12', 'sphinxcontrib-napoleon==0.7.0') 23 | if session.python in ['3.7']: 24 | session.install( 25 | 'typing_extensions==3.7.4', 'typing_inspect==0.5.0' 26 | ) 27 | coverage_file = f'.coverage.{session.python}.oldest' 28 | else: 29 | coverage_file = f'.coverage.{session.python}' 30 | 31 | try: 32 | session.run( 33 | 'coverage', 34 | 'run', 35 | '--module', 36 | 'unittest', 37 | *args, 38 | env={'COVERAGE_FILE': coverage_file}, 39 | ) 40 | finally: 41 | if session.interactive: 42 | session.notify('coverage', posargs=[]) 43 | 44 | 45 | @session 46 | def coverage(session: Session) -> None: 47 | """Produce the coverage report.""" 48 | args = session.posargs or ['report', '--show-missing'] 49 | 50 | session.install('coverage') 51 | 52 | if not session.posargs and any(Path().glob('.coverage.*')): 53 | session.run('coverage', 'combine') 54 | 55 | session.run('coverage', *args) 56 | 57 | 58 | @session 59 | def docs(session: Session) -> None: 60 | """Produce the coverage report.""" 61 | args = session.posargs or ['-b', 'html', 'doc/source', 'doc/build'] 62 | 63 | session.install('-e', '.[docs]') 64 | 65 | session.run('sphinx-build', *args) 66 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=61", 4 | "setuptools_scm[toml]>=6.2", 5 | ] 6 | build-backend = "setuptools.build_meta" 7 | 8 | [project] 9 | name = "defopt" 10 | description = "Effortless argument parser" 11 | readme = "README.rst" 12 | authors = [{name = "Antony Lee"}] 13 | urls = {Repository = "https://github.com/anntzer/defopt"} 14 | classifiers = [ 15 | "Programming Language :: Python :: 3", 16 | "Operating System :: OS Independent", 17 | "Development Status :: 5 - Production/Stable", 18 | "Intended Audience :: Developers", 19 | ] 20 | keywords = ["argument parser", "optparse", "argparse", "getopt", "docopt", "sphinx"] 21 | requires-python = ">=3.7" 22 | dependencies = [ 23 | "docutils>=0.12", # First with wheels, for better setuptools compat. 24 | "sphinxcontrib-napoleon>=0.7.0", # More consistent Raises blocks. 25 | "importlib_metadata>=1.0; python_version<'3.8'", 26 | "typing_inspect>=0.8.0; python_version<'3.8'", 27 | "pkgutil_resolve_name; python_version<'3.9'", 28 | "colorama>=0.3.4; sys_platform=='win32'", 29 | ] 30 | dynamic = ["version"] 31 | 32 | [project.optional-dependencies] 33 | docs = [ 34 | "sphinx>=4.4", 35 | ] 36 | 37 | [tool.setuptools_scm] 38 | version_scheme = "post-release" 39 | local_scheme = "node-and-date" 40 | fallback_version = "0+unknown" 41 | 42 | [tool.coverage.run] 43 | branch = true 44 | source_pkgs = ["defopt", "test_defopt"] 45 | concurrency = ["multiprocessing"] 46 | 47 | [tool.coverage.paths] 48 | source = ["src/", "/**/python*/site-packages/"] 49 | 50 | [tool.coverage.report] 51 | exclude_lines = ["assert False"] 52 | fail_under = 95 53 | 54 | [tool.pytest.ini_options] 55 | filterwarnings = [ 56 | "error", 57 | "ignore::DeprecationWarning", 58 | "error::DeprecationWarning:defopt", 59 | ] 60 | -------------------------------------------------------------------------------- /src/defopt.py: -------------------------------------------------------------------------------- 1 | """ 2 | Effortless argument parser. 3 | 4 | Run Python functions from the command line with ``run(func)``. 5 | """ 6 | 7 | import ast 8 | import collections.abc 9 | import contextlib 10 | import functools 11 | import importlib 12 | import inspect 13 | import itertools 14 | import os 15 | import re 16 | import sys 17 | import types 18 | import typing 19 | from argparse import ( 20 | REMAINDER, SUPPRESS, 21 | Action, ArgumentParser, RawTextHelpFormatter, 22 | ArgumentError, ArgumentTypeError) 23 | from collections import defaultdict, namedtuple, Counter 24 | from enum import Enum 25 | from pathlib import PurePath 26 | from types import MethodType 27 | from typing import Any, Callable, Dict, List, Optional, Tuple, Union 28 | 29 | try: 30 | import importlib.metadata as _im 31 | except ImportError: 32 | import importlib_metadata as _im 33 | try: 34 | from pkgutil import resolve_name as _pkgutil_resolve_name 35 | except ImportError: 36 | from pkgutil_resolve_name import resolve_name as _pkgutil_resolve_name 37 | try: 38 | from typing import Literal 39 | except ImportError: 40 | from typing_extensions import Literal 41 | try: 42 | from typing import get_args as _ti_get_args, get_origin as _ti_get_origin 43 | except ImportError: 44 | import typing_inspect as _ti 45 | _ti_get_args = _ti.get_args 46 | _ti_get_origin = _ti.get_origin 47 | 48 | import docutils.core 49 | from docutils.nodes import NodeVisitor, SkipNode, TextElement 50 | from docutils.parsers.rst.states import Body 51 | 52 | try: 53 | collections.Callable = collections.abc.Callable 54 | from sphinxcontrib.napoleon import Config, GoogleDocstring, NumpyDocstring 55 | finally: 56 | del collections.Callable 57 | 58 | try: 59 | # colorama is a dependency on Windows to support ANSI escapes (from rst 60 | # markup). It is optional on Unices, but can still be useful there as it 61 | # strips out ANSI escapes when the output is piped. 62 | from colorama import colorama_text as _colorama_text 63 | except ImportError: 64 | _colorama_text = getattr(contextlib, 'nullcontext', contextlib.ExitStack) 65 | 66 | try: 67 | __version__ = _im.version('defopt') 68 | except ImportError: 69 | __version__ = '0+unknown' 70 | 71 | __all__ = ['run', 'signature', 'bind', 'bind_known'] 72 | 73 | _PARAM_TYPES = ['param', 'parameter', 'arg', 'argument', 'key', 'keyword'] 74 | _TYPE_NAMES = ['type', 'kwtype'] 75 | 76 | 77 | ## Generic utilities. 78 | 79 | 80 | def _check_in_list(_values, **kwargs): 81 | for k, v in kwargs.items(): 82 | if v not in _values: 83 | raise ValueError(f'{k!r} must be one of {_values!r}, not {v!r}') 84 | 85 | 86 | def _issubclass(obj, parent): 87 | return isinstance(obj, type) and issubclass(obj, parent) 88 | 89 | 90 | def _unwrap_partial(func): 91 | return func.func if isinstance(func, functools.partial) else func 92 | 93 | 94 | ## Argparse helpers. 95 | 96 | 97 | class _BooleanOptionalAction(Action): 98 | # Modified from Py3.9's version, plus: 99 | # - a fix to bpo#38956 (by omitting the extraneous help string), 100 | # - support for short aliases for --no-foo, by moving negative flag 101 | # generation to _add_argument (where the negative aliases are available), 102 | # - a hack (_CustomString) to simulate format_usage on Py<3.9 103 | # (_CustomString relies on an Py<3.9 implementation detail: the usage 104 | # string is built using '%s' % option_strings[0] (so there is an 105 | # additional call to str()) whereas the option invocation help directly 106 | # joins the strings). 107 | 108 | class _CustomString(str): 109 | def __str__(self): 110 | return self.action.format_usage() 111 | 112 | def __init__(self, option_strings, **kwargs): 113 | self.negative_option_strings = [] # set by _add_argument 114 | if option_strings: 115 | cs = option_strings[0] = self._CustomString(option_strings[0]) 116 | cs.action = self 117 | super().__init__(option_strings, nargs=0, **kwargs) 118 | 119 | def __call__(self, parser, namespace, values, option_string=None): 120 | if option_string in self.option_strings: 121 | setattr(namespace, self.dest, 122 | option_string not in self.negative_option_strings) 123 | 124 | def format_usage(self): 125 | return ' | '.join(self.option_strings) 126 | 127 | 128 | class _PseudoChoices(list): 129 | """ 130 | Pseudo-type used for ``add_argument(..., choices=...)`` so that usage 131 | strings correctly print choices (as their corresponding str values) for 132 | enums and literals, but without actually checking for containment (as 133 | argparse does that on the type-converted values, which are different). 134 | 135 | Note that abusing metavar to generate the usage string does not work as 136 | well, as that also affects the argument name in generated error messages 137 | (see `argparse._get_action_name`). 138 | """ 139 | 140 | def __init__(self, items): 141 | super().__init__(str(item.name if isinstance(item, Enum) else item) 142 | for item in items) 143 | 144 | def __contains__(self, obj): 145 | return True 146 | 147 | 148 | ## defopt API. 149 | 150 | 151 | class _DefaultList(list): 152 | """ 153 | Marker type used to determine that a parameter corresponds to a varargs, 154 | and thus should have its default value hidden. Varargs are unpacked during 155 | function call, so the caller won't see this type. 156 | """ 157 | 158 | 159 | def _bind_or_bind_known(funcs, *, opts, _known: bool = False): 160 | _check_in_list(['kwonly', 'all', 'has_default'], 161 | cli_options=opts.cli_options) 162 | parser = _create_parser(funcs, opts) 163 | with _colorama_text(): 164 | if not opts.intermixed: 165 | if not _known: 166 | args, rest = parser.parse_args(opts.argv), [] 167 | else: 168 | args, rest = parser.parse_known_args(opts.argv) 169 | else: 170 | if not _known: 171 | args, rest = parser.parse_intermixed_args(opts.argv), [] 172 | else: 173 | args, rest = parser.parse_known_intermixed_args(opts.argv) 174 | parsed_args = vars(args) 175 | try: 176 | func = parsed_args.pop('_func') 177 | except KeyError: 178 | # Workaround for http://bugs.python.org/issue9253#msg186387 (and 179 | # https://bugs.python.org/issue29298 which blocks using required=True). 180 | parser.error('too few arguments') 181 | sig = signature(func) 182 | ba = sig.bind_partial() 183 | ba.arguments.update(parsed_args) 184 | call = functools.partial(func, *ba.args, **ba.kwargs) 185 | 186 | if sig.raises: 187 | @functools.wraps(call) 188 | def wrapper(): 189 | try: 190 | return call() 191 | except sig.raises as e: 192 | sys.exit(e) 193 | 194 | return wrapper, rest 195 | 196 | else: 197 | return call, rest 198 | 199 | 200 | def bind(*args, **kwargs): 201 | """ 202 | Process command-line arguments and bind arguments. 203 | 204 | This function takes the same parameters as `defopt.run`, but returns a 205 | wrapper callable ``call`` such that ``call()`` represents the call that 206 | ``defopt.run`` would execute. Note that ``call`` takes no arguments; they 207 | are bound internally. 208 | 209 | If there are no documented exceptions that ``defopt.run`` needs to 210 | suppress, then ``call`` is a `functools.partial` object, ``call.func`` is 211 | one of the functions passed to ``bind``, and ``call.args`` and 212 | ``call.keywords`` are set according to the command-line arguments. 213 | 214 | If there are documented exceptions that ``defopt.run`` needs to suppress, 215 | then ``call`` is a wrapper around that partial object. 216 | 217 | A generic expression to retrieve the underlying selected function is thus 218 | ``getattr(call, "__wrapped__", call).func``. 219 | 220 | This API is provisional and may be adjusted depending on feedback. 221 | """ 222 | call, rest = _bind_or_bind_known( 223 | *args, opts=_options(**kwargs), _known=False) 224 | assert not rest 225 | return call 226 | 227 | 228 | def bind_known(*args, **kwargs): 229 | """ 230 | Process command-line arguments and bind known arguments. 231 | 232 | This function behaves as `bind`, but returns a pair of 1) the 233 | `~functools.partial` callable, and 2) a list of unknown command-line 234 | arguments, as returned by `~argparse.ArgumentParser.parse_known_args`. 235 | 236 | This API is provisional and may be adjusted depending on feedback. 237 | """ 238 | return _bind_or_bind_known( 239 | *args, opts=_options(**kwargs), _known=True) 240 | 241 | 242 | Funcs = Union[Callable, List[Callable], Dict[str, 'Funcs']] 243 | 244 | 245 | def run( 246 | funcs: Funcs, *, 247 | parsers: Dict[type, Callable[[str], Any]] = {}, 248 | short: Optional[Dict[str, str]] = None, 249 | cli_options: Literal['kwonly', 'all', 'has_default'] = 'kwonly', 250 | show_defaults: bool = True, 251 | show_types: bool = False, 252 | no_negated_flags: bool = False, 253 | version: Union[str, None, bool] = None, 254 | argparse_kwargs: dict = {}, 255 | intermixed: bool = False, 256 | argv: Optional[List[str]] = None, 257 | ): 258 | """ 259 | Process command-line arguments and run the given functions. 260 | 261 | *funcs* can be a single callable, which is parsed and run; or it can 262 | be a list of callables or mappable of strs to callables, in which case 263 | each one is given a subparser with its name (if *funcs* is a list) or 264 | the corresponding key (if *funcs* is a mappable), and only the chosen 265 | callable is run. Nested mappables are also supported; they define nested 266 | subcommands. 267 | 268 | See :doc:`/features` for the detailed mapping from function signature to 269 | command-line parsing. Note that all docstrings must be valid RST 270 | conforming to Sphinx-, Google-, or Numpy-style. 271 | 272 | :param funcs: 273 | Function or functions to process and run. 274 | :param parsers: 275 | Dictionary mapping types to parsers to use for parsing function 276 | arguments. 277 | :param short: 278 | Dictionary mapping parameter names (after conversion of underscores to 279 | dashes) to letters, to use as alternative short flags. Defaults to 280 | `None`, which means to generate short flags for any non-ambiguous 281 | option. Set to ``{}`` to completely disable short flags. 282 | :param cli_options: 283 | The default behavior ('kwonly') is to convert keyword-only parameters 284 | to command line flags, and non-keyword-only parameters with a default 285 | to optional positional command line parameters. 'all' turns all 286 | parameters into command-line flags. 'has_default' turns a parameter 287 | into a command-line flag if and only if it has a default value. 288 | :param show_defaults: 289 | Whether parameter defaults are appended to parameter descriptions. 290 | :param show_types: 291 | Whether parameter types are appended to parameter descriptions. 292 | :param no_negated_flags: 293 | If `False` (default), for any non-positional bool options, two flags 294 | are created: ``--foo`` and ``--no-foo``. If `True`, the ``--no-foo`` 295 | is not created for every such option that has a default value `False`. 296 | :param version: 297 | If a string, add a ``--version`` flag which prints the given version 298 | string and exits. 299 | If `True`, the version string is auto-detected by searching for a 300 | ``__version__`` attribute on the module where the function is defined, 301 | and its parent packages, if any. Error out if such a version cannot be 302 | found, or if multiple callables with different version strings are 303 | passed. 304 | If `None` (the default), behave as for `True`, but don't add a 305 | ``--version`` flag if no version string can be autodetected. 306 | If `False`, do not add a ``--version`` flag. 307 | :param argparse_kwargs: 308 | A mapping of keyword arguments that will be passed to the 309 | `~argparse.ArgumentParser` constructor. 310 | :param intermixed: 311 | Whether to use `~argparse.ArgumentParser.parse_intermixed_args` to 312 | parse the command line. Intermixed parsing imposes many restrictions, 313 | listed in the `argparse` documentation. 314 | :param argv: 315 | Command line arguments to parse (default: ``sys.argv[1:]``). 316 | :return: 317 | The value returned by the function that was run. 318 | """ 319 | return bind( 320 | funcs, parsers=parsers, short=short, cli_options=cli_options, 321 | show_defaults=show_defaults, show_types=show_types, 322 | no_negated_flags=no_negated_flags, version=version, 323 | argparse_kwargs=argparse_kwargs, intermixed=intermixed, argv=argv)() 324 | 325 | 326 | _DefoptOptions = namedtuple( 327 | '_DefoptOptions', 328 | ['parsers', 'short', 'cli_options', 'show_defaults', 'show_types', 329 | 'no_negated_flags', 'version', 'argparse_kwargs', 'intermixed', 'argv']) 330 | 331 | 332 | def _options(**kwargs): 333 | params = inspect.signature(run).parameters 334 | return ( 335 | _DefoptOptions(*[params[k].default for k in _DefoptOptions._fields]) 336 | ._replace(**kwargs)) 337 | 338 | 339 | def _recurse_functions(funcs, subparsers): 340 | if not isinstance(funcs, collections.abc.Mapping): 341 | # If this iterable is not a mapping, then convert it to one using the 342 | # function name itself as the key, but replacing _ with -. 343 | try: 344 | funcs = {_unwrap_partial(func).__name__.replace('_', '-'): func 345 | for func in funcs} 346 | except AttributeError as exc: 347 | # Do not allow a mapping inside of a list 348 | raise ValueError( 349 | 'use dictionaries (mappings) for nesting; other iterables may ' 350 | 'only contain functions (callables)' 351 | ) from exc 352 | 353 | for name, func in funcs.items(): 354 | if callable(func): 355 | # If this item is callable, then add it to the current 356 | # subparser using this name. 357 | doc = inspect.getdoc(_unwrap_partial(func)) 358 | sp_help = signature(doc).doc.split('\n\n', 1)[0] 359 | subparser = subparsers.add_parser( 360 | name, formatter_class=RawTextHelpFormatter, help=sp_help) 361 | yield func, subparser 362 | else: 363 | # If this item is not callable, then add this name as a new 364 | # subparser and recurse the the items. 365 | nestedsubparser = subparsers.add_parser(name) 366 | nestedsubparsers = nestedsubparser.add_subparsers() 367 | yield from _recurse_functions(func, nestedsubparsers) 368 | 369 | 370 | def _create_parser(funcs, opts): 371 | parser = ArgumentParser(**{**{'formatter_class': RawTextHelpFormatter}, 372 | **opts.argparse_kwargs}) 373 | version_sources = [] 374 | if callable(funcs): 375 | _populate_parser(funcs, parser, opts) 376 | version_sources.append(_unwrap_partial(funcs)) 377 | else: 378 | subparsers = parser.add_subparsers() 379 | for func, subparser in _recurse_functions(funcs, subparsers): 380 | _populate_parser(func, subparser, opts) 381 | version_sources.append(_unwrap_partial(func)) 382 | if isinstance(opts.version, str): 383 | version_string = opts.version 384 | elif opts.version is None or opts.version: 385 | version_string = _get_version(version_sources) 386 | if opts.version and version_string is None: 387 | raise ValueError('failed to autodetect version string') 388 | else: 389 | version_string = None 390 | if version_string is not None: 391 | parser.add_argument( 392 | 2 * parser.prefix_chars[0] + 'version', 393 | action='version', version=version_string) 394 | return parser 395 | 396 | 397 | def _get_version(funcs): 398 | 399 | def _get_version1(func): 400 | module_name = getattr(func, '__module__', None) 401 | if not module_name: 402 | return 403 | if module_name == '__main__': 404 | f_globals = getattr(func, '__globals__', {}) 405 | if f_globals.get('__spec__'): 406 | module_name = f_globals['__spec__'].name 407 | else: 408 | return f_globals.get('__version__') 409 | while True: 410 | try: 411 | return importlib.import_module(module_name).__version__ 412 | except AttributeError: 413 | if '.' not in module_name: 414 | return 415 | module_name, _ = module_name.rsplit('.', 1) 416 | 417 | versions = {v for v in map(_get_version1, funcs) if v is not None} 418 | return versions.pop() if len(versions) == 1 else None 419 | 420 | 421 | class Signature(inspect.Signature): 422 | __slots__ = (*inspect.Signature.__slots__, '_doc', '_raises') 423 | doc = property(lambda self: self._doc) 424 | raises = property(lambda self: self._raises) 425 | 426 | def __init__(self, *args, doc=None, raises=(), **kwargs): 427 | super().__init__(*args, **kwargs) 428 | self._doc = doc 429 | self._raises = tuple(raises) 430 | 431 | def replace(self, *, doc=inspect._void, raises=inspect._void, **kwargs): 432 | copy = super().replace(**kwargs) 433 | copy._doc = self._doc if doc is inspect._void else doc 434 | copy._raises = tuple( 435 | self._raises if raises is inspect._void else raises) 436 | return copy 437 | 438 | 439 | class Parameter(inspect.Parameter): 440 | __slots__ = (*inspect.Parameter.__slots__, '_doc') 441 | doc = property(lambda self: self._doc) 442 | 443 | def __init__(self, *args, doc=None, **kwargs): 444 | super().__init__(*args, **kwargs) 445 | self._doc = doc 446 | 447 | def replace(self, *, doc=inspect._void, **kwargs): 448 | copy = super().replace(**kwargs) 449 | copy._doc = self._doc if doc is inspect._void else doc 450 | return copy 451 | 452 | 453 | @functools.lru_cache() 454 | def signature(func: Union[Callable, str]): 455 | """ 456 | Return an enhanced signature for ``func``. 457 | 458 | This function behaves similarly to `inspect.signature`, with the following 459 | differences: 460 | 461 | - The parsed function docstring (which will be used as the parser 462 | description) is available as ``signature.doc``; likewise, parameter 463 | docstrings are available as ``parameter.doc``. The tuple of raisable 464 | exception types is available is available as ``signature.raises``. (This 465 | is done by using subclasses of `inspect.Signature` and 466 | `inspect.Parameter`.) 467 | - Private parameters (starting with an underscore) are not listed. 468 | - Parameter types are also read from ``func``'s docstring (if a parameter's 469 | type is specified both in the signature and the docstring, both types 470 | must match). 471 | - It is also possible to pass a docstring instead of a callable as *func*; 472 | in that case, a Signature is still returned, but all parameters are 473 | considered positional-or-keyword, with no default, and the annotations 474 | are returned as strs. 475 | 476 | This API is provisional and may be adjusted depending on feedback. 477 | """ 478 | if isinstance(func, str) or func is None: 479 | return _parse_docstring(func) 480 | else: 481 | inspect_sig = _preprocess_inspect_signature( 482 | func, inspect.signature(func)) 483 | doc_sig = _preprocess_doc_signature( 484 | func, signature(inspect.getdoc(_unwrap_partial(func)))) 485 | return _merge_signatures(inspect_sig, doc_sig) 486 | 487 | 488 | def _preprocess_inspect_signature(func, sig): 489 | hints = typing.get_type_hints(_unwrap_partial(func)) 490 | parameters = [] 491 | for name, param in sig.parameters.items(): 492 | if param.name.startswith('_'): 493 | if param.default is param.empty: 494 | raise ValueError( 495 | f'parameter {name} of {func.__name__}{sig} is private but ' 496 | f'has no default') 497 | continue 498 | try: 499 | hint = hints[name] 500 | except KeyError: 501 | hint_type = param.empty 502 | else: 503 | if (param.default is None 504 | and param.annotation != hint 505 | and Optional[param.annotation] == hint): 506 | # `f(x: tuple[int, int] = None)` means we support a tuple, but 507 | # not None (to constrain the number of arguments). 508 | hint = param.annotation 509 | hint_type = _get_type_from_hint(hint) 510 | parameters.append(Parameter( 511 | name=name, kind=param.kind, default=param.default, 512 | annotation=hint_type)) 513 | return sig.replace(parameters=parameters) 514 | 515 | 516 | def _preprocess_doc_signature(func, sig): 517 | parameters = [] 518 | for name, param in sig.parameters.items(): 519 | if param.name.startswith('_'): 520 | continue 521 | doc_type = (sig.parameters[name].annotation 522 | if name in sig.parameters else param.empty) 523 | doc_type = ( 524 | _get_type_from_doc(doc_type, _unwrap_partial(func).__globals__) 525 | if doc_type is not param.empty else param.empty) 526 | parameters.append(Parameter( 527 | name=name, kind=param.kind, annotation=doc_type, 528 | doc=(sig.parameters[name].doc 529 | if name in sig.parameters else None))) 530 | return Signature( 531 | parameters, 532 | doc=sig.doc, 533 | raises=[_get_type_from_doc(name, func.__globals__) 534 | for name in sig.raises]) 535 | 536 | 537 | def _merge_signatures(inspect_sig, doc_sig): 538 | parameters = [] 539 | for name, param in inspect_sig.parameters.items(): 540 | doc_param = doc_sig.parameters.get(name) 541 | if doc_param: 542 | anns = set() 543 | if param.annotation is not param.empty: 544 | anns.add(param.annotation) 545 | if doc_param.annotation is not param.empty: 546 | anns.add(doc_param.annotation) 547 | if len(anns) > 1: 548 | raise ValueError( 549 | f'conflicting types found for parameter {name}: ' 550 | f'{param.annotation.__name__}, ' 551 | f'{doc_param.annotation.__name__}') 552 | ann = anns.pop() if anns else param.empty 553 | param = param.replace(annotation=ann, doc=doc_param.doc) 554 | parameters.append(param) 555 | return Signature( 556 | parameters, return_annotation=inspect_sig.return_annotation, 557 | doc=doc_sig.doc, raises=doc_sig.raises) 558 | 559 | 560 | def _get_type_from_doc(name, globalns): 561 | if ' or ' in name: 562 | subtypes = [_get_type_from_doc(part, globalns) 563 | for part in name.split(' or ')] 564 | if any(map(_is_list_like, subtypes)) and None not in subtypes: 565 | raise ValueError( 566 | f'unsupported union including container type: {name}') 567 | return Union[tuple(subtype for subtype in subtypes)] 568 | if sys.version_info < (3, 9): # Support "list[type]", "tuple[type]". 569 | globalns = {**globalns, 'tuple': Tuple, 'list': List} 570 | return _get_type_from_hint(eval(name, globalns)) 571 | 572 | 573 | def _get_type_from_hint(hint): 574 | if _is_list_like(hint): 575 | [type_] = _ti_get_args(hint) 576 | return List[type_] 577 | return hint 578 | 579 | 580 | def _populate_parser(func, parser, opts): 581 | sig = signature(func) 582 | parser.description = sig.doc 583 | 584 | positionals = { 585 | name for name, param in sig.parameters.items() 586 | if ((opts.cli_options == 'kwonly' or 587 | (param.default is param.empty 588 | and opts.cli_options == 'has_default')) 589 | and not any( 590 | _is_list_like(t) or _is_optional_list_like(t) for t in [ 591 | param.annotation.__value__ 592 | if hasattr(typing, 'TypeAliasType') 593 | and isinstance(param.annotation, typing.TypeAliasType) 594 | else param.annotation 595 | ] 596 | ) 597 | and param.kind != param.KEYWORD_ONLY)} 598 | if opts.short is None: 599 | count_initials = Counter(name[0] for name in sig.parameters 600 | if name not in positionals) 601 | if parser.add_help: 602 | count_initials['h'] += 1 603 | opts = opts._replace(short={ 604 | name.replace('_', '-'): name[0] for name in sig.parameters 605 | if name not in positionals and count_initials[name[0]] == 1}) 606 | 607 | actions = [] 608 | for name, param in sig.parameters.items(): 609 | kwargs = {} 610 | if param.doc is not None: 611 | kwargs['help'] = param.doc.replace('%', '%%') 612 | type_ = param.annotation 613 | if (hasattr(typing, 'TypeAliasType') 614 | and isinstance(type_, typing.TypeAliasType)): 615 | type_ = type_.__value__ 616 | if param.kind == param.VAR_KEYWORD: 617 | raise ValueError('**kwargs not supported') 618 | if type_ is param.empty: 619 | raise ValueError(f'no type found for parameter {name}') 620 | hasdefault = param.default is not param.empty 621 | default = param.default if hasdefault else SUPPRESS 622 | required = not hasdefault and param.kind != param.VAR_POSITIONAL 623 | positional = name in positionals 624 | 625 | # Special-case boolean flags. 626 | if type_ in [bool, typing.Optional[bool]] and not positional: 627 | action = ('store_true' 628 | if opts.no_negated_flags and default in [False, None] 629 | else _BooleanOptionalAction) # --name/--no-name 630 | actions.append(_add_argument( 631 | parser, name, opts.short, action=action, default=default, 632 | required=required, # Always False if `default is False`. 633 | **kwargs)) # Add help if available. 634 | continue 635 | 636 | # Always set a default, even for required parameters, so that we can 637 | # later (ab)use default == SUPPRESS (!= None) to detect required 638 | # parameters. 639 | kwargs['default'] = default 640 | if positional: 641 | kwargs['_positional'] = True 642 | if param.default is not param.empty: 643 | kwargs['nargs'] = '?' 644 | if param.kind == param.VAR_POSITIONAL: 645 | kwargs['nargs'] = '*' 646 | kwargs['default'] = _DefaultList() 647 | else: 648 | kwargs['required'] = required 649 | 650 | # If the type is an Optional container, extract only the container. 651 | union_args = _ti_get_args(type_) if _is_union_type(type_) else [] 652 | if any(_is_container(subtype) for subtype in union_args): 653 | non_none = [arg for arg in union_args if arg is not type(None)] 654 | if len(non_none) != 1: 655 | raise ValueError( 656 | f'unsupported union including container type: {type_}') 657 | type_, = non_none 658 | 659 | if _is_list_like(type_): 660 | type_, = _ti_get_args(type_) 661 | kwargs['nargs'] = '*' 662 | if param.kind == param.VAR_POSITIONAL: 663 | kwargs['action'] = 'append' 664 | kwargs['default'] = _DefaultList() 665 | 666 | if _issubclass(type_, Enum): 667 | # Enums must be checked first to handle enums-of-namedtuples. 668 | kwargs['type'] = _get_parser(type_, opts.parsers) 669 | kwargs['choices'] = _PseudoChoices(type_.__members__.values()) 670 | 671 | elif _ti_get_origin(type_) is tuple: 672 | member_types = _ti_get_args(type_) 673 | num_members = len(member_types) 674 | if num_members == 2 and member_types[1] is Ellipsis: 675 | # Variable-length tuples of homogenous type are specified like 676 | # tuple[int, ...] 677 | kwargs['nargs'] = '*' 678 | kwargs['action'] = _make_store_tuple_action_class( 679 | tuple, member_types, opts.parsers, is_variable_length=True) 680 | elif type(None) in union_args and opts.parsers.get(type(None)): 681 | if num_members == 1: 682 | kwargs['nargs'] = 1 683 | kwargs['action'] = _make_store_tuple_action_class( 684 | tuple, member_types, opts.parsers, 685 | with_none_parser=opts.parsers[type(None)]) 686 | else: 687 | raise ValueError( 688 | 'Optional tuples of length > 1 and NoneType parsers ' 689 | 'cannot be used together due to ambiguity') 690 | else: 691 | kwargs['nargs'] = num_members 692 | kwargs['action'] = _make_store_tuple_action_class( 693 | tuple, member_types, opts.parsers) 694 | 695 | elif _issubclass(type_, tuple) and hasattr(type_, '_fields'): 696 | hints = typing.get_type_hints(type_) 697 | member_types = tuple(hints[field] for field in type_._fields) 698 | kwargs['nargs'] = len(member_types) 699 | kwargs['action'] = _make_store_tuple_action_class( 700 | type_, member_types, opts.parsers) 701 | if positional and sys.version_info >= (3, 13, 1): 702 | # http://bugs.python.org/issue14074 703 | kwargs['metavar'] = *( 704 | f"{name}.{field}" for field in type_._fields), 705 | if not positional: 706 | kwargs['metavar'] = type_._fields 707 | 708 | else: 709 | kwargs['type'] = _get_parser(type_, opts.parsers) 710 | if _ti_get_origin(type_) is Literal: 711 | kwargs['choices'] = _PseudoChoices(_ti_get_args(type_)) 712 | 713 | actions.append(_add_argument(parser, name, opts.short, **kwargs)) 714 | 715 | for action in actions: 716 | _update_help_string(action, opts) 717 | 718 | parser.set_defaults(_func=func) 719 | 720 | 721 | def _add_argument(parser, name, short, _positional=False, **kwargs): 722 | negative_option_strings = [] 723 | if _positional: 724 | args = [name] 725 | else: 726 | prefix_char = parser.prefix_chars[0] 727 | name = name.replace('_', '-') 728 | args = [prefix_char * 2 + name] 729 | if name in short: 730 | args.insert(0, prefix_char + short[name]) 731 | if kwargs.get('action') == _BooleanOptionalAction: 732 | no_name = 'no-' + name 733 | if no_name in short: 734 | args.append(prefix_char + short[no_name]) 735 | negative_option_strings.append(args[-1]) 736 | args.append(prefix_char * 2 + no_name) 737 | negative_option_strings.append(args[-1]) 738 | action = parser.add_argument(*args, **kwargs) 739 | if negative_option_strings: 740 | action.negative_option_strings = negative_option_strings 741 | return action 742 | 743 | 744 | def _update_help_string(action, opts): 745 | action_help = action.help or '' 746 | info = [] 747 | if (opts.show_types 748 | and action.type is not None 749 | and action.type.func not in [_make_enum_parser, 750 | _make_literal_parser] 751 | and '%(type)' not in action_help): 752 | info.append('type: %(type)s') 753 | if (opts.show_defaults 754 | and action.const is not False # i.e. action='store_false'. 755 | and not isinstance(action.default, _DefaultList) 756 | and '%(default)' not in action_help 757 | and action.default is not SUPPRESS): 758 | info.append( 759 | 'default: {}'.format(action.default.name.replace('%', '%%')) 760 | if action.type is not None 761 | and action.type.func is _make_enum_parser 762 | and isinstance(action.default, action.type.args) 763 | else 'default: %(default)s') 764 | parts = [action.help, '({})'.format(', '.join(info)) if info else ''] 765 | action.help = '\n'.join(filter(None, parts)) or '' 766 | 767 | 768 | def _is_list_like(type_): 769 | return _ti_get_origin(type_) in [ 770 | list, 771 | collections.abc.Iterable, 772 | collections.abc.Collection, 773 | collections.abc.Sequence, 774 | ] 775 | 776 | 777 | def _is_container(type_): 778 | return _is_list_like(type_) or _ti_get_origin(type_) is tuple 779 | 780 | 781 | def _is_union_type(type_): 782 | return _ti_get_origin(type_) in {Union, getattr(types, 'UnionType', '')} 783 | 784 | 785 | def _is_optional_list_like(type_): 786 | # Assume a union with a list subtype is actually Optional[list[...]] 787 | # because this condition is enforced in other places 788 | return (_is_union_type(type_) 789 | and any(_is_list_like(subtype) for subtype in _ti_get_args(type_))) 790 | 791 | 792 | ## Docstring parsing. 793 | 794 | 795 | @contextlib.contextmanager 796 | def _sphinx_common_roles(): 797 | # Standard roles: 798 | # https://www.sphinx-doc.org/en/master/usage/restructuredtext/roles.html 799 | # Python-domain roles: 800 | # https://www.sphinx-doc.org/en/master/usage/restructuredtext/domains.html 801 | roles = [ 802 | 'abbr', 803 | 'command', 804 | 'dfn', 805 | 'file', 806 | 'guilabel', 807 | 'kbd', 808 | 'mailheader', 809 | 'makevar', 810 | 'manpage', 811 | 'menuselection', 812 | 'mimetype', 813 | 'newsgroup', 814 | 'program', 815 | 'regexp', 816 | 'samp', 817 | 'pep', 818 | 'rfc', 819 | 'py:mod', 820 | 'py:func', 821 | 'py:data', 822 | 'py:const', 823 | 'py:class', 824 | 'py:meth', 825 | 'py:attr', 826 | 'py:exc', 827 | 'py:obj' 828 | ] 829 | # No public unregistration API :( Also done by sphinx. 830 | role_map = docutils.parsers.rst.roles._roles 831 | for role in roles: 832 | for i in range(role.count(':') + 1): 833 | role_map[role.split(':', i)[-1]] = ( # passthrough role. 834 | lambda name, rawtext, text, *args, **kwargs: 835 | ([TextElement(rawtext, text)], []) 836 | ) 837 | try: 838 | yield 839 | finally: 840 | for role in roles: 841 | for i in range(role.count(':') + 1): 842 | role_map.pop(role.split(':', i)[-1]) 843 | 844 | 845 | def _parse_docstring(doc): 846 | """ 847 | Extract documentation from a function's docstring into a `.Signature` 848 | object *with unevaluated annotations*. 849 | """ 850 | 851 | if doc is None: 852 | return Signature(doc='') 853 | 854 | # Convert Google- or Numpy-style docstrings to RST. 855 | # (Should do nothing if not in either style.) 856 | # use_ivar avoids generating an unhandled .. attribute:: directive for 857 | # Attribute blocks, preferring a benign :ivar: field. 858 | doc = inspect.cleandoc(doc) 859 | cfg = Config(napoleon_use_ivar=True) 860 | doc = str(GoogleDocstring(doc, cfg)) 861 | doc = str(NumpyDocstring(doc, cfg)) 862 | 863 | with _sphinx_common_roles(): 864 | tree = docutils.core.publish_doctree( 865 | # - Propagate errors out. 866 | # - Disable syntax highlighting, as 1) pygments is not a dependency 867 | # 2) we don't render with colors and 3) SH breaks the assumption 868 | # that literal blocks contain a single text element. 869 | doc, settings_overrides={ 870 | 'halt_level': 3, 'syntax_highlight': 'none'}) 871 | 872 | class Visitor(NodeVisitor): 873 | optional = [ 874 | 'document', 'docinfo', 875 | 'field_list', 'field_body', 876 | 'literal', 'problematic', 877 | # Introduced by our custom passthrough handlers, but the Visitor 878 | # will recurse into the inner text node by itself. 879 | 'TextElement', 880 | ] 881 | 882 | def __init__(self, document): 883 | super().__init__(document) 884 | self.paragraphs = [] 885 | self.start_lines = [] 886 | self.params = defaultdict(dict) 887 | self.raises = [] 888 | self._current_paragraph = None 889 | self._indent_iterator_stack = [] 890 | self._indent_stack = [] 891 | 892 | def _do_nothing(self, node): 893 | pass 894 | 895 | def visit_paragraph(self, node): 896 | self.start_lines.append(node.line) 897 | self._current_paragraph = [] 898 | 899 | def depart_paragraph(self, node): 900 | text = ''.join(self._current_paragraph) 901 | text = ''.join(self._indent_stack) + text 902 | self._indent_stack = [ 903 | ' ' * len(item) for item in self._indent_stack] 904 | text = text.replace('\n', '\n' + ''.join(self._indent_stack)) 905 | self.paragraphs.append(text) 906 | self._current_paragraph = None 907 | 908 | visit_block_quote = visit_doctest_block = visit_paragraph 909 | depart_block_quote = depart_doctest_block = depart_paragraph 910 | 911 | def visit_Text(self, node): 912 | self._current_paragraph.append(node) 913 | 914 | depart_Text = _do_nothing 915 | 916 | visit_reference = depart_reference = _do_nothing 917 | 918 | def visit_target(self, node): 919 | if self._current_paragraph is None: 920 | raise SkipNode 921 | 922 | if node.get('refuri'): 923 | self._current_paragraph.append(f' ({node["refuri"]})') 924 | else: 925 | self._current_paragraph.append(node.astext()) 926 | raise SkipNode 927 | 928 | def visit_emphasis(self, node): 929 | self._current_paragraph.append('\033[3m') # *foo*: italic 930 | 931 | def visit_strong(self, node): 932 | self._current_paragraph.append('\033[1m') # **foo**: bold 933 | 934 | def visit_title_reference(self, node): 935 | self._current_paragraph.append('\033[4m') # `foo`: underlined 936 | 937 | def _depart_markup(self, node): 938 | self._current_paragraph.append('\033[0m') 939 | 940 | depart_emphasis = depart_strong = depart_title_reference = \ 941 | _depart_markup 942 | 943 | def visit_rubric(self, node): 944 | self.visit_paragraph(node) 945 | 946 | def depart_rubric(self, node): 947 | # Style consistent with "usage:", "positional arguments:", etc. 948 | self._current_paragraph[:] = [ 949 | (t.lower() if t == t.title() else t) + ':' 950 | for t in self._current_paragraph] 951 | self.depart_paragraph(node) 952 | 953 | def visit_literal_block(self, node): 954 | text, = node 955 | self.start_lines.append(node.line) 956 | self.paragraphs.append( 957 | re.sub('^|\n', r'\g<0> ', text)) # indent 958 | raise SkipNode 959 | 960 | def visit_bullet_list(self, node): 961 | self._indent_iterator_stack.append( 962 | (node['bullet'] + ' ' for _ in range(len(node)))) 963 | 964 | def depart_bullet_list(self, node): 965 | self._indent_iterator_stack.pop() 966 | 967 | def visit_enumerated_list(self, node): 968 | enumtype = node['enumtype'] 969 | fmt = {('(', ')'): 'parens', 970 | ('', ')'): 'rparen', 971 | ('', '.'): 'period'}[node['prefix'], node['suffix']] 972 | start = node.get('start', 1) 973 | enumerators = [Body(None).make_enumerator(i, enumtype, fmt)[0] 974 | for i in range(start, start + len(node))] 975 | width = max(map(len, enumerators)) 976 | enumerators = [enum.ljust(width) for enum in enumerators] 977 | self._indent_iterator_stack.append(iter(enumerators)) 978 | 979 | def depart_enumerated_list(self, node): 980 | self._indent_iterator_stack.pop() 981 | 982 | def visit_list_item(self, node): 983 | self._indent_stack.append(next(self._indent_iterator_stack[-1])) 984 | 985 | def depart_list_item(self, node): 986 | self._indent_stack.pop() 987 | 988 | def visit_field(self, node): 989 | field_name_node, field_body_node = node 990 | field_name, = field_name_node 991 | parts = field_name.split() 992 | if len(parts) == 2: 993 | doctype, name = parts 994 | # docutils>=0.16 represents \* as \0* in the doctree. 995 | name = name.lstrip('*\0') 996 | elif len(parts) == 3: 997 | doctype, type_, name = parts 998 | name = name.lstrip('*\0') 999 | if doctype not in _PARAM_TYPES: 1000 | raise SkipNode 1001 | if 'type' in self.params[name]: 1002 | raise ValueError(f'type defined twice for {name}') 1003 | self.params[name]['type'] = type_ 1004 | else: 1005 | raise SkipNode 1006 | if doctype in _PARAM_TYPES: 1007 | doctype = 'param' 1008 | if doctype in _TYPE_NAMES: 1009 | doctype = 'type' 1010 | if doctype in ['param', 'type'] and doctype in self.params[name]: 1011 | raise ValueError(f'{doctype} defined twice for {name}') 1012 | visitor = Visitor(self.document) 1013 | field_body_node.walkabout(visitor) 1014 | if doctype in ['param', 'type']: 1015 | self.params[name][doctype] = '\n\n'.join(visitor.paragraphs) 1016 | elif doctype in ['raises']: 1017 | self.raises.append(name) 1018 | raise SkipNode 1019 | 1020 | def visit_comment(self, node): 1021 | self.paragraphs.append(comment_token) 1022 | # Comments report their line as the *end* line of the comment. 1023 | self.start_lines.append( 1024 | node.line - node.children[0].count('\n') - 1) 1025 | raise SkipNode 1026 | 1027 | def visit_system_message(self, node): 1028 | raise SkipNode 1029 | 1030 | comment_token = object() 1031 | visitor = Visitor(tree) 1032 | tree.walkabout(visitor) 1033 | 1034 | params = [Parameter(name, kind=Parameter.POSITIONAL_OR_KEYWORD, 1035 | annotation=values.get('type', Parameter.empty), 1036 | doc=values.get('param')) 1037 | for name, values in visitor.params.items()] 1038 | text = [] 1039 | if visitor.paragraphs: 1040 | for start, paragraph, next_start in zip( 1041 | visitor.start_lines, 1042 | visitor.paragraphs, 1043 | visitor.start_lines[1:] + [0]): 1044 | if paragraph is comment_token: 1045 | continue 1046 | text.append(paragraph) 1047 | # Insert two newlines to separate paragraphs by a blank line. 1048 | # Actually, paragraphs may or may not already have a trailing 1049 | # newline (e.g. text paragraphs do but literal blocks don't) but 1050 | # argparse will strip extra newlines anyways. This means that 1051 | # extra blank lines in the original docstring will be stripped, but 1052 | # this is less ugly than having a large number of extra blank lines 1053 | # arising e.g. from skipped info fields (which are not rendered). 1054 | # This means that list items are always separated by blank lines, 1055 | # which is an acceptable tradeoff for now. 1056 | text.append('\n\n') 1057 | return Signature(params, doc=''.join(text), raises=visitor.raises) 1058 | 1059 | 1060 | ## Argument parsers. 1061 | 1062 | 1063 | def _get_parser(type_, parsers): 1064 | if type_ in parsers: # Not catching KeyError, to avoid exception chaining. 1065 | parser = functools.partial(parsers[type_]) 1066 | elif type_ in [str, int, float] or _issubclass(type_, PurePath): 1067 | parser = functools.partial(type_) 1068 | elif type_ == bool: 1069 | parser = functools.partial(_parse_bool) 1070 | elif type_ == slice: 1071 | parser = functools.partial(_parse_slice) 1072 | elif type_ == type(None): 1073 | parser = functools.partial(_parse_none) 1074 | elif type_ == list: 1075 | raise ValueError('unable to parse list (try list[type])') 1076 | elif _issubclass(type_, Enum): 1077 | parser = _make_enum_parser(type_) 1078 | elif _is_constructible_from_str(type_): 1079 | parser = functools.partial(type_) 1080 | elif _is_union_type(type_): 1081 | args = _ti_get_args(type_) 1082 | if type(None) in args: 1083 | # If None is in the Union, parse it first. This only matters if 1084 | # there's a custom parser for None, in which case the user should 1085 | # normally have picked values that they *want* to be parsed as 1086 | # None as opposed to anything else, e.g. strs, even if that was 1087 | # possible. 1088 | args = (type(None), 1089 | *[arg for arg in args if arg is not type(None)]) 1090 | elem_parsers = [] 1091 | for arg in args: 1092 | elem_parser = _get_parser(arg, parsers) 1093 | elem_parsers.append(elem_parser) 1094 | if (isinstance(elem_parser, functools.partial) 1095 | and (elem_parser.func is str 1096 | or _issubclass(elem_parser.func, PurePath)) 1097 | and not (elem_parser.args or elem_parser.keywords)): 1098 | # Infaillible parser; skip all following types (which may not 1099 | # even have a parser defined). 1100 | break 1101 | parser = _make_union_parser(type_, elem_parsers) 1102 | elif _ti_get_origin(type_) is Literal: 1103 | args = _ti_get_args(type_) 1104 | parser = _make_literal_parser( 1105 | type_, [_get_parser(type(arg), parsers) for arg in args]) 1106 | else: 1107 | raise Exception('no parser found for type {}'.format( 1108 | # typing types have no __name__. 1109 | getattr(type_, '__name__', repr(type_)))) 1110 | # Set the name that the user expects to see in error messages (we always 1111 | # return a temporary partial object so it's safe to set its __name__). 1112 | # Unions and Literals don't have a __name__, but their str is fine. 1113 | parser.__name__ = getattr(type_, '__name__', str(type_)) 1114 | return parser 1115 | 1116 | 1117 | def _parse_bool(string): 1118 | if string.lower() in ['t', 'true', '1']: 1119 | return True 1120 | elif string.lower() in ['f', 'false', '0']: 1121 | return False 1122 | else: 1123 | raise ValueError(f'{string!r} is not a valid boolean string') 1124 | 1125 | 1126 | def _parse_slice(string): 1127 | slices = [] 1128 | 1129 | class SliceVisitor(ast.NodeVisitor): 1130 | def visit_Slice(self, node): 1131 | start = ast.literal_eval(node.lower) if node.lower else None 1132 | stop = ast.literal_eval(node.upper) if node.upper else None 1133 | step = ast.literal_eval(node.step) if node.step else None 1134 | slices.append(slice(start, stop, step)) 1135 | 1136 | try: 1137 | SliceVisitor().visit(ast.parse(f'_[{string}]')) 1138 | sl, = slices 1139 | except (SyntaxError, ValueError): 1140 | raise ValueError(f'{string!r} is not a valid slice string') 1141 | return sl 1142 | 1143 | 1144 | def _parse_none(string): 1145 | raise ValueError('no string can be converted to None') 1146 | 1147 | 1148 | def _is_constructible_from_str(type_): 1149 | try: 1150 | sig = signature(type_) 1151 | (argname, _), = sig.bind(object()).arguments.items() 1152 | except TypeError: # Can be raised by signature() or Signature.bind(). 1153 | return False 1154 | except ValueError: 1155 | # No relevant info in signature; continue below to also look in 1156 | # `type_.__init__`, in the case where type_ is indeed a type. 1157 | pass 1158 | else: 1159 | if sig.parameters[argname].annotation is str: 1160 | return True 1161 | if isinstance(type_, type): 1162 | # signature() first checks __new__, if it is present. 1163 | # `MethodType(type_.__init__, object())` binds the first parameter of 1164 | # `__init__` -- similarly to `__init__.__get__(object(), type_)`, but 1165 | # the latter can fail for types implemented in C (which may not support 1166 | # binding arbitrary objects). 1167 | return _is_constructible_from_str(MethodType(type_.__init__, object())) 1168 | return False 1169 | 1170 | 1171 | # _make_{enum,literal}_parser raise ArgumentTypeError so that the error message 1172 | # generated for invalid inputs is fully customized to match standard argparse 1173 | # 'choices': "argument x: invalid choice: '{value}' (choose from ...)". 1174 | # The other parsers raise ValueError, which leads to 1175 | # "argument x: invalid {type} value: '{value}'". 1176 | 1177 | 1178 | def _make_enum_parser(enum, value=None): 1179 | if value is None: 1180 | return functools.partial(_make_enum_parser, enum) 1181 | if isinstance(value, enum): 1182 | # If the default is a StrEnum it will be passed to the converter, which 1183 | # must pass it through. 1184 | return value 1185 | try: 1186 | return enum[value] 1187 | except KeyError: 1188 | raise ArgumentTypeError( 1189 | 'invalid choice: {!r} (choose from {})'.format( 1190 | value, ', '.join(map(repr, enum.__members__)))) 1191 | 1192 | 1193 | def _make_literal_parser(literal, parsers, value=None): 1194 | if value is None: 1195 | return functools.partial(_make_literal_parser, literal, parsers) 1196 | for arg, parser in zip(_ti_get_args(literal), parsers): 1197 | try: 1198 | if parser(value) == arg: 1199 | return arg 1200 | except (ValueError, ArgumentTypeError): 1201 | pass 1202 | raise ArgumentTypeError( 1203 | 'invalid choice: {!r} (choose from {})'.format( 1204 | value, ', '.join( 1205 | map(repr, _PseudoChoices(_ti_get_args(literal)))))) 1206 | 1207 | 1208 | def _make_union_parser(union, parsers, value=None): 1209 | if value is None: 1210 | return functools.partial(_make_union_parser, union, parsers) 1211 | suppressed = [] 1212 | for parser in parsers: 1213 | try: 1214 | return parser(value) 1215 | # See ArgumentParser._get_value. 1216 | except (TypeError, ValueError, ArgumentTypeError) as exc: 1217 | suppressed.append((parser, exc)) 1218 | _report_suppressed_exceptions(suppressed) 1219 | raise ValueError(f'{value} could not be parsed as any of {union}') 1220 | 1221 | 1222 | def _make_store_tuple_action_class( 1223 | tuple_type, member_types, parsers, *, 1224 | is_variable_length=False, with_none_parser=None, 1225 | ): 1226 | if is_variable_length: 1227 | parsers = itertools.repeat(_get_parser(member_types[0], parsers)) 1228 | else: 1229 | parsers = [_get_parser(arg, parsers) for arg in member_types] 1230 | 1231 | def parse(action, values): 1232 | if with_none_parser is not None: 1233 | try: 1234 | return with_none_parser(*values) 1235 | except ValueError: 1236 | pass 1237 | try: 1238 | value = tuple( 1239 | parser(value) for parser, value in zip(parsers, values)) 1240 | except (ValueError, ArgumentTypeError) as exc: 1241 | # Custom actions need to raise ArgumentError, not ValueError or 1242 | # ArgumentTypeError. 1243 | raise ArgumentError(action, str(exc)) 1244 | if tuple_type is not tuple: 1245 | value = tuple_type(*value) 1246 | return value 1247 | 1248 | class _StoreTupleAction(Action): 1249 | def __call__(self, parser, namespace, values, option_string=None): 1250 | setattr(namespace, self.dest, parse(self, values)) 1251 | 1252 | return _StoreTupleAction 1253 | 1254 | 1255 | def _report_suppressed_exceptions(suppressed): 1256 | if not os.environ.get("DEFOPT_DEBUG"): 1257 | return 1258 | print("The following parsing failures were suppressed:\n", file=sys.stderr) 1259 | for parser, exc in suppressed: 1260 | print(parser, file=sys.stderr) 1261 | print(exc, file=sys.stderr) 1262 | print(file=sys.stderr) 1263 | 1264 | 1265 | if __name__ == '__main__': 1266 | def main(argv=None): 1267 | parser = ArgumentParser() 1268 | parser.add_argument( 1269 | 'function', 1270 | help='package.name.function_name or package.name:function_name') 1271 | parser.add_argument('args', nargs=REMAINDER) 1272 | args = parser.parse_args(argv) 1273 | func = _pkgutil_resolve_name(args.function) 1274 | argparse_kwargs = ( 1275 | {'prog': ' '.join(sys.argv[:2])} if argv is None else {}) 1276 | retval = run(func, argv=args.args, argparse_kwargs=argparse_kwargs) 1277 | sys.displayhook(retval) 1278 | 1279 | main() 1280 | -------------------------------------------------------------------------------- /test_defopt.py: -------------------------------------------------------------------------------- 1 | import builtins 2 | import contextlib 3 | import functools 4 | import inspect 5 | import multiprocessing as mp 6 | import re 7 | import runpy 8 | import subprocess 9 | import sys 10 | import tokenize 11 | import types 12 | import typing 13 | import unittest 14 | from concurrent.futures import ProcessPoolExecutor 15 | from contextlib import ExitStack 16 | from enum import Enum, auto 17 | from io import StringIO 18 | from pathlib import Path 19 | 20 | from docutils.utils import SystemMessage 21 | 22 | import defopt 23 | from defopt import __version__, _options 24 | from examples import ( 25 | annotations, booleans, choices, exceptions, lists, parsers, partials, 26 | short, starargs, styles) 27 | 28 | 29 | Choice = Enum('Choice', [('one', 1), ('two', 2), ('%', 0.01)]) 30 | Pair = typing.NamedTuple('Pair', [('first', int), ('second', str)]) 31 | class StrChoice(str, Enum): 32 | A = auto() 33 | B = auto() 34 | 35 | 36 | def _parse_none(i): 37 | if i.lower() == ':none:': 38 | return None 39 | else: 40 | raise ValueError(f'{i} is not a valid None string') 41 | 42 | 43 | # Also check that the Attributes section doesn't trip docutils. 44 | class ConstructibleFromStr: 45 | """ 46 | Attributes 47 | ---------- 48 | s : str 49 | The s. 50 | """ 51 | 52 | def __init__(self, s): 53 | """:type s: str""" 54 | self.s = s 55 | 56 | 57 | class NotConstructibleFromStr: 58 | def __init__(self, s): 59 | pass 60 | 61 | 62 | class TestDefopt(unittest.TestCase): 63 | def test_main(self): 64 | def main(foo): 65 | """:type foo: str""" 66 | return foo 67 | self.assertEqual(defopt.run(main, argv=['foo']), 'foo') 68 | 69 | def test_subcommands(self): 70 | def sub(*bar): 71 | """:type bar: float""" 72 | return bar 73 | def sub_with_dash(*, baz=None): 74 | """:type baz: int""" 75 | return baz 76 | self.assertEqual( 77 | defopt.run([sub, sub_with_dash], argv=['sub', '1.1']), (1.1,)) 78 | self.assertEqual( 79 | defopt.run([sub, sub_with_dash], 80 | argv=['sub-with-dash', '--baz', '1']), 1) 81 | self.assertEqual( 82 | defopt.run({"sub1": sub, "sub_2": sub_with_dash}, 83 | argv=['sub1', '1.2']), (1.2,)) 84 | self.assertEqual( 85 | defopt.run({"sub1": sub, "sub_2": sub_with_dash}, 86 | argv=['sub_2', '--baz', '1']), 1) 87 | 88 | def test_nested_lists_invalid(self): 89 | def sub1(*bar): 90 | """:type bar: float""" 91 | def subsub1(*, baz=None): 92 | """:type baz: int""" 93 | def subsub2(*foo): 94 | """:type foo: float""" 95 | with self.assertRaises(ValueError): 96 | defopt.run([sub1, [subsub1, subsub2]], argv=['sub1', '1.2']) 97 | 98 | def test_nested_subcommands1(self): 99 | def sub1(*bar): 100 | """:type bar: float""" 101 | return bar 102 | def subsub1(*, baz=None): 103 | """:type baz: int""" 104 | return baz 105 | def subsub2(*foo): 106 | """:type foo: float""" 107 | return foo 108 | self.assertEqual( 109 | defopt.run({"sub-1": [sub1], "sub-2": [subsub1, subsub2]}, 110 | argv=['sub-1', 'sub1', '1.2']), (1.2,)) 111 | self.assertEqual( 112 | defopt.run({"sub-1": [sub1], "sub-2": [subsub1, subsub2]}, 113 | argv=['sub-2', 'subsub1', '--baz', '1']), 1) 114 | self.assertEqual( 115 | defopt.run({"sub-1": [sub1], "sub-2": [subsub1, subsub2]}, 116 | argv=['sub-2', 'subsub2', '1.5']), (1.5,)) 117 | 118 | def test_nested_subcommands2(self): 119 | def sub1(*bar): 120 | """:type bar: float""" 121 | return bar 122 | def subsub1(*, baz=None): 123 | """:type baz: int""" 124 | return baz 125 | def subsub2(*foo): 126 | """:type foo: float""" 127 | return foo 128 | self.assertEqual( 129 | defopt.run({"sub-1": sub1, "sub-2": [subsub1, subsub2]}, 130 | argv=['sub-1', '1.2']), (1.2,)) 131 | self.assertEqual( 132 | defopt.run({"sub1": sub1, "sub-2": [subsub1, subsub2]}, 133 | argv=['sub-2', 'subsub1', '--baz', '1']), 1) 134 | self.assertEqual( 135 | defopt.run({"sub1": sub1, "sub-2": [subsub1, subsub2]}, 136 | argv=['sub-2', 'subsub2', '1.5']), (1.5,)) 137 | 138 | def test_nested_subcommands3(self): 139 | def sub1(*bar): 140 | """:type bar: float""" 141 | return bar 142 | def subsub1(*, baz=None): 143 | """:type baz: int""" 144 | return baz 145 | def subsub2(*foo): 146 | """:type foo: float""" 147 | return foo 148 | self.assertEqual( 149 | defopt.run({"sub-1": sub1, 150 | "sub-2": {'subsub1': subsub1, 'subsub2': subsub2}}, 151 | argv=['sub-1', '1.2']), (1.2,)) 152 | self.assertEqual( 153 | defopt.run({"sub-1": sub1, 154 | "sub-2": {'subsub1': subsub1, 'subsub2': subsub2}}, 155 | argv=['sub-2', 'subsub1', '--baz', '1']), 1) 156 | self.assertEqual( 157 | defopt.run({"sub-1": sub1, 158 | "sub-2": {'subsub1': subsub1, 'subsub2': subsub2}}, 159 | argv=['sub-2', 'subsub2', '1.5']), (1.5,)) 160 | 161 | def test_nested_subcommands_deep(self): 162 | def sub(*bar): 163 | """:type bar: float""" 164 | return bar 165 | self.assertEqual( 166 | defopt.run({'a': {'b': {'c': {'d': {'e': sub}}}}}, 167 | argv=['a', 'b', 'c', 'd', 'e', '1.2']), (1.2,)) 168 | self.assertEqual( 169 | defopt.run({'a': {'b': {'c': {'d': {'e': [sub]}}}}}, 170 | argv=['a', 'b', 'c', 'd', 'e', 'sub', '1.2']), (1.2,)) 171 | 172 | def test_nested_subcommands_mixed_invalid1(self): 173 | def sub1(*bar): 174 | """:type bar: float""" 175 | def subsub1(*, baz=None): 176 | """:type baz: int""" 177 | def subsub2(*foo): 178 | """:type foo: float""" 179 | with self.assertRaises(ValueError): 180 | defopt.run([sub1, {'sub2': [subsub1, subsub2]}], 181 | argv=['sub1', '1.2']) 182 | with self.assertRaises(ValueError): 183 | defopt.run([sub1, {'sub2': [subsub1, subsub2]}], 184 | argv=['sub2', 'subsub1', '--baz', '1']) 185 | with self.assertRaises(ValueError): 186 | defopt.run([sub1, {'sub2': [subsub1, subsub2]}], 187 | argv=['sub2', 'subsub2', '1.1']) 188 | 189 | def test_nested_subcommands_mixed_invalid2(self): 190 | def sub(*bar): 191 | """:type bar: float""" 192 | def subsub_with_dash(*, baz=None): 193 | """:type baz: int""" 194 | def subsub(*foo): 195 | """:type foo: float""" 196 | with self.assertRaises(ValueError): 197 | defopt.run([sub, {'subsub1': subsub_with_dash, 'subsub2': subsub}], 198 | argv=['sub', '1.2']) 199 | with self.assertRaises(ValueError): 200 | defopt.run([sub, {'subsub1': subsub_with_dash, 'subsub2': subsub}], 201 | argv=['subsub1', '--baz', '1']) 202 | with self.assertRaises(ValueError): 203 | defopt.run([sub, {'subsub1': subsub_with_dash, 'subsub2': subsub}], 204 | argv=['subsub2', '1.5']) 205 | 206 | def test_var_positional(self): 207 | for doc in [ 208 | ":type foo: int", r":type \*foo: int", ":param int foo: doc"]: 209 | def main(*foo): return foo 210 | main.__doc__ = doc 211 | self.assertEqual(defopt.run(main, argv=['1', '2']), (1, 2)) 212 | self.assertEqual(defopt.run(main, argv=[]), ()) 213 | 214 | def test_no_default(self): 215 | def main(a): 216 | """:type a: str""" 217 | with self.assertRaises(SystemExit): 218 | defopt.run(main, argv=[]) 219 | 220 | def test_keyword_only(self): 221 | def main(foo='bar', *, baz='quux'): 222 | """ 223 | :type foo: str 224 | :type baz: str 225 | """ 226 | return foo, baz 227 | self.assertEqual(defopt.run(main, argv=['FOO', '--baz', 'BAZ']), 228 | ('FOO', 'BAZ')) 229 | self.assertEqual(defopt.run(main, argv=[]), ('bar', 'quux')) 230 | 231 | def test_keyword_only_no_default(self): 232 | def main(*, foo): 233 | """:type foo: str""" 234 | return foo 235 | self.assertEqual(defopt.run(main, argv=['--foo', 'baz']), 'baz') 236 | with self.assertRaises(SystemExit): 237 | defopt.run(main, argv=[]) 238 | 239 | def test_var_keywords(self): 240 | def bad(**kwargs): 241 | """:type kwargs: str""" 242 | 243 | with self.assertRaises(ValueError): 244 | defopt.run(bad) 245 | 246 | def test_bad_arg(self): 247 | with self.assertRaises(TypeError): 248 | defopt.run(foo=None) 249 | 250 | def test_no_subparser_specified(self): 251 | def sub1(): assert False 252 | def sub2(): assert False 253 | with self.assertRaises(SystemExit): 254 | defopt.run([sub1, sub2], argv=[]) 255 | 256 | def test_no_param_doc(self): 257 | def bad(foo): 258 | """Test function""" 259 | with self.assertRaisesRegex(ValueError, 'type.*foo'): 260 | defopt.run(bad, argv=['foo']) 261 | 262 | def test_no_type_doc(self): 263 | def bad(foo): 264 | """:param foo: no type info""" 265 | with self.assertRaisesRegex(ValueError, 'type.*foo'): 266 | defopt.run(bad, argv=['foo']) 267 | 268 | def test_return(self): 269 | def one(): return 1 270 | def none(): pass 271 | self.assertEqual(defopt.run([one, none], argv=['one']), 1) 272 | self.assertEqual(defopt.run([one, none], argv=['none']), None) 273 | 274 | def test_underscores(self): 275 | def main(a_b_c, *, d_e_f=None): 276 | """Test function 277 | 278 | :type a_b_c: int 279 | :type d_e_f: int 280 | """ 281 | return a_b_c, d_e_f 282 | self.assertEqual(defopt.run(main, argv=['1', '--d-e-f', '2']), (1, 2)) 283 | 284 | def test_private_with_default(self): 285 | def main(_a=None): pass 286 | defopt.run(main, argv=[]) 287 | 288 | def test_private_without_default(self): 289 | def main(_a: int): assert False 290 | with self.assertRaisesRegex(ValueError, 291 | # Older Pythons have no space post-colon. 292 | r'parameter _a of main\(_a: ?int\) is ' 293 | r'private but has no default'): 294 | defopt.run(main, argv=[]) 295 | 296 | def test_argparse_kwargs(self): 297 | def main(*, a=None): 298 | """:type a: str""" 299 | return a 300 | self.assertEqual( 301 | defopt.run(main, argparse_kwargs={'prefix_chars': '+'}, 302 | argv=['+a', 'foo']), 303 | 'foo') 304 | 305 | def test_intermixed(self): 306 | def main(*args: int, key: str): return args, key 307 | with self.assertRaises(SystemExit): 308 | defopt.run(main, argv=['1', '-kk', '2']) 309 | if sys.version_info >= (3, 7): 310 | self.assertEqual( 311 | defopt.run(main, argv=['1', '-kk', '2'], intermixed=True), 312 | ((1, 2), 'k')) 313 | else: 314 | with self.assertRaises(RuntimeError): 315 | defopt.run(main, argv=['1', '-kk', '2'], intermixed=True) 316 | 317 | 318 | class TestBindKnown(unittest.TestCase): 319 | def test_bind_known(self): 320 | def main(*args: int, key: str): return args, key 321 | call, rest = defopt.bind_known( 322 | main, argv=['1', '2', '-kk', '-qq']) 323 | self.assertEqual((call(), rest), (((1, 2), 'k'), ['-qq'])) 324 | call, rest = defopt.bind_known( 325 | main, argv=['1', '-kk', '2', '-qq']) 326 | self.assertEqual((call(), rest), (((1,), 'k'), ['2', '-qq'])) 327 | if sys.version_info >= (3, 7): 328 | call, rest = defopt.bind_known( 329 | main, argv=['1', '-kk', '2', '-qq'], intermixed=True) 330 | self.assertEqual((call(), rest), (((1, 2), 'k'), ['-qq'])) 331 | 332 | 333 | class TestParsers(unittest.TestCase): 334 | def test_parser(self): 335 | def main(value): 336 | """:type value: int""" 337 | return value 338 | self.assertEqual(defopt.run(main, argv=['1']), 1) 339 | 340 | def test_overridden_parser(self): 341 | def parser(string): 342 | return int(string) * 2 343 | def main(value): 344 | """:type value: int""" 345 | return value 346 | self.assertEqual( 347 | defopt.run(main, parsers={int: parser}, argv=['1']), 2) 348 | 349 | def test_overridden_none_parser(self): 350 | def parser(string): 351 | if string == 'nil': 352 | return None 353 | else: 354 | raise ValueError("Not nil") 355 | def main(ints, strs): 356 | """ 357 | :type ints: typing.List[typing.Optional[int]] 358 | :type strs: typing.List[typing.Optional[str]] 359 | """ 360 | return ints, strs 361 | self.assertEqual( 362 | defopt.run(main, parsers={type(None): parser}, 363 | argv=['-i', 'nil', '0', '-s', 'nil', 's']), 364 | ([None, 0], [None, 's'])) 365 | 366 | def test_parse_bool(self): 367 | parser = defopt._get_parser(bool, {}) 368 | self.assertEqual(parser('t'), True) 369 | self.assertEqual(parser('FALSE'), False) 370 | self.assertEqual(parser('1'), True) 371 | with self.assertRaises(ValueError): 372 | parser('foo') 373 | 374 | def test_parse_path(self): 375 | def main(value): 376 | """:type value: Path""" 377 | return value 378 | self.assertEqual(defopt.run(main, argv=['foo']), Path('foo')) 379 | 380 | def test_parse_slice(self): 381 | parser = defopt._get_parser(slice, {}) 382 | self.assertEqual(parser(':'), slice(None)) 383 | self.assertEqual(parser(':1'), slice(None, 1)) 384 | self.assertEqual(parser('"a":"b":"c"'), slice("a", "b", "c")) 385 | with self.assertRaises(ValueError): 386 | parser('1') 387 | 388 | def test_no_parser(self): 389 | with self.assertRaisesRegex(Exception, 'no parser'): 390 | defopt._get_parser(object, parsers={type: type}) 391 | 392 | def test_containers(self): 393 | def main(foo, bar): 394 | """ 395 | :type foo: tuple[float] 396 | :type bar: list[float] 397 | """ 398 | return foo, bar 399 | self.assertEqual(defopt.run(main, argv=['1.1', '--bar', '2.2', '3.3']), 400 | ((1.1,), [2.2, 3.3])) 401 | 402 | def test_list_kwarg(self): 403 | def main(foo=None): 404 | """:type foo: list[float]""" 405 | return foo 406 | self.assertEqual( 407 | defopt.run(main, argv=['--foo', '1.1', '2.2']), [1.1, 2.2]) 408 | 409 | def test_list_bare(self): 410 | with self.assertRaises(ValueError): 411 | defopt._get_parser(list, {}) 412 | 413 | def test_list_keyword_only(self): 414 | def main(*, foo): 415 | """:type foo: list[int]""" 416 | return foo 417 | self.assertEqual(defopt.run(main, argv=['--foo', '1', '2']), [1, 2]) 418 | with self.assertRaises(SystemExit): 419 | defopt.run(main, argv=[]) 420 | 421 | def test_list_var_positional(self): 422 | def modern(*foo): 423 | """:type foo: typing.Iterable[int]""" 424 | return foo 425 | def legacy(*foo): 426 | """:type foo: list[int]""" 427 | return foo 428 | for func in modern, legacy: 429 | out = defopt.run(func, argv=['--foo', '1', '--foo', '2', '3']) 430 | self.assertEqual(out, ([1], [2, 3])) 431 | self.assertEqual(defopt.run(func, argv=[]), ()) 432 | 433 | def test_bool(self): 434 | def main(foo): 435 | """:type foo: bool""" 436 | return foo 437 | self.assertIs(defopt.run(main, argv=['1']), True) 438 | self.assertIs(defopt.run(main, argv=['0']), False) 439 | with self.assertRaises(SystemExit): 440 | defopt.run(main, argv=[]) 441 | 442 | def test_bool_optional(self): 443 | def main(foo=None): 444 | """:type foo: typing.Optional[bool]""" 445 | return foo 446 | self.assertIs(defopt.run(main, argv=['1'], 447 | parsers={type(None): _parse_none}), True) 448 | self.assertIs(defopt.run(main, argv=['0'], 449 | parsers={type(None): _parse_none}), False) 450 | with self.assertRaises(SystemExit): 451 | defopt.run(main, argv=[':none:']) 452 | self.assertIs(defopt.run(main, argv=[':none:'], 453 | parsers={type(None): _parse_none}), None) 454 | self.assertIs(defopt.run(main, argv=[], 455 | parsers={type(None): _parse_none}), None) 456 | 457 | def test_bool_optional_keyword_none(self): 458 | def main(*, foo=None): 459 | """:type foo: typing.Optional[bool]""" 460 | return foo 461 | self.assertIs(defopt.run(main, argv=['--foo']), True) 462 | self.assertIs(defopt.run(main, argv=['--no-foo']), False) 463 | self.assertIs(defopt.run(main, argv=[]), None) 464 | 465 | def test_bool_optional_keyword_true(self): 466 | def main(*, foo=True): 467 | """:type foo: typing.Optional[bool]""" 468 | return foo 469 | # cannot get a None value in this case from the CLI 470 | self.assertIs(defopt.run(main, argv=['--foo']), True) 471 | self.assertIs(defopt.run(main, argv=['--no-foo']), False) 472 | self.assertIs(defopt.run(main, argv=[]), True) 473 | 474 | def test_bool_optional_keyword_false(self): 475 | def main(*, foo=False): 476 | """:type foo: typing.Optional[bool]""" 477 | return foo 478 | self.assertIs(defopt.run(main, argv=['--foo']), True) 479 | self.assertIs(defopt.run(main, argv=['--no-foo']), False) 480 | self.assertIs(defopt.run(main, argv=[]), False) 481 | 482 | def test_bool_optional_keyword_none_no_negated_flags(self): 483 | def main(*, foo=None): 484 | """:type foo: typing.Optional[bool]""" 485 | return foo 486 | self.assertIs(defopt.run(main, argv=['--foo'], no_negated_flags=True), 487 | True) 488 | with self.assertRaises(SystemExit): 489 | self.assertIs(defopt.run(main, argv=['--no-foo'], 490 | no_negated_flags=True), False) 491 | self.assertIs(defopt.run(main, argv=[], no_negated_flags=True), None) 492 | 493 | def test_bool_optional_keyword_true_no_negated_flags(self): 494 | def main(*, foo=True): 495 | """:type foo: typing.Optional[bool]""" 496 | return foo 497 | self.assertIs(defopt.run(main, argv=['--foo'], no_negated_flags=True), 498 | True) 499 | # negated flag is still added, else foo could only be True 500 | self.assertIs(defopt.run(main, argv=['--no-foo'], 501 | no_negated_flags=True), False) 502 | self.assertIs(defopt.run(main, argv=[], no_negated_flags=True), True) 503 | 504 | def test_bool_optional_keyword_false_no_negated_flags(self): 505 | def main(*, foo=False): 506 | """:type foo: typing.Optional[bool]""" 507 | return foo 508 | self.assertIs(defopt.run(main, argv=['--foo'], no_negated_flags=True), 509 | True) 510 | with self.assertRaises(SystemExit): 511 | self.assertIs(defopt.run(main, argv=['--no-foo'], 512 | no_negated_flags=True), False) 513 | self.assertIs(defopt.run(main, argv=[], no_negated_flags=True), False) 514 | 515 | def test_bool_list(self): 516 | def main(foo): 517 | """:type foo: list[bool]""" 518 | return foo 519 | self.assertEqual( 520 | defopt.run(main, argv=['--foo', '1', '0']), [True, False]) 521 | 522 | def test_bool_var_positional(self): 523 | def main(*foo): 524 | """:type foo: bool""" 525 | return foo 526 | self.assertEqual( 527 | defopt.run(main, argv=['1', '1', '0']), (True, True, False)) 528 | self.assertEqual( 529 | defopt.run(main, argv=[]), ()) 530 | 531 | def test_bool_list_var_positional(self): 532 | def main(*foo): 533 | """:type foo: list[bool]""" 534 | return foo 535 | argv = ['--foo', '1', '--foo', '0', '0'] 536 | self.assertEqual( 537 | defopt.run(main, argv=argv), ([True], [False, False])) 538 | self.assertEqual( 539 | defopt.run(main, argv=[]), ()) 540 | 541 | def test_bool_kwarg(self): 542 | def main(foo='default'): 543 | """:type foo: bool""" 544 | return foo 545 | self.assertIs(defopt.run(main, cli_options='has_default', 546 | argv=[]), 'default') 547 | self.assertIs(defopt.run(main, cli_options='has_default', 548 | argv=['--foo']), True) 549 | self.assertIs(defopt.run(main, cli_options='has_default', 550 | argv=['--no-foo']), False) 551 | self.assertIs(defopt.run(main, cli_options='has_default', 552 | argv=['--foo', '--no-foo']), False) 553 | 554 | def test_bool_keyword_only(self): 555 | def main(*, foo): 556 | """:type foo: bool""" 557 | return foo 558 | self.assertIs(defopt.run(main, argv=['--foo']), True) 559 | self.assertIs(defopt.run(main, argv=['--no-foo']), False) 560 | with self.assertRaises(SystemExit): 561 | defopt.run(main, argv=[]) 562 | 563 | def test_cli_options(self): 564 | def main(foo): 565 | """:type foo: bool""" 566 | return foo 567 | self.assertIs( 568 | defopt.run(main, cli_options='all', argv=['--foo']), True) 569 | self.assertIs( 570 | defopt.run(main, cli_options='all', argv=['--no-foo']), False) 571 | with self.assertRaises(SystemExit): 572 | defopt.run(main, cli_options='all', argv=['1']) 573 | with self.assertRaises(SystemExit): 574 | defopt.run(main, argv=['--foo']) 575 | with self.assertRaises(SystemExit): 576 | defopt.run(main, argv=['--no-foo']) 577 | with self.assertRaises(SystemExit): 578 | defopt.run(main, argv=[]) 579 | 580 | def test_implicit_parser(self): 581 | def ok(foo): 582 | """:type foo: ConstructibleFromStr""" 583 | return foo 584 | 585 | self.assertEqual(defopt.run(ok, argv=["foo"]).s, "foo") 586 | 587 | def test_implicit_noparser(self): 588 | def notok(foo): 589 | """:type foo: NotConstructibleFromStr""" 590 | 591 | with self.assertRaisesRegex(Exception, 'no parser.*NotConstructible'): 592 | defopt.run(notok, argv=["foo"]) 593 | 594 | 595 | class TestFlags(unittest.TestCase): 596 | def test_short_flags(self): 597 | def func(foo=1): 598 | """:type foo: int""" 599 | return foo 600 | self.assertEqual( 601 | defopt.run(func, short={'foo': 'f'}, cli_options='has_default', 602 | argv=['-f', '2']), 603 | 2) 604 | 605 | def test_short_negation(self): 606 | def func(*, foo=False): 607 | """:type foo: bool""" 608 | return foo 609 | self.assertIs( 610 | defopt.run(func, short={'foo': 'f', 'no-foo': 'F'}, argv=['-f']), 611 | True) 612 | self.assertIs( 613 | defopt.run(func, short={'foo': 'f', 'no-foo': 'F'}, argv=['-F']), 614 | False) 615 | 616 | def test_auto_short(self): 617 | def func(*, foo=1, bar=2, baz=3): 618 | """ 619 | :type foo: int 620 | :type bar: int 621 | :type baz: int 622 | """ 623 | return foo 624 | self.assertEqual(defopt.run(func, argv=['-f', '2']), 2) 625 | with self.assertRaises(SystemExit): 626 | defopt.run(func, argv=['-b', '2']) 627 | 628 | def test_auto_short_help(self): 629 | def func(*, hello="world"): 630 | """:type hello: str""" 631 | defopt.run(func, argv=[]) 632 | with self.assertRaises(SystemExit): 633 | defopt.run(func, argv=["-h", ""]) 634 | self.assertEqual( 635 | defopt.run( 636 | func, argparse_kwargs={"add_help": False}, argv=["-h", ""]), 637 | None) 638 | 639 | 640 | class TestEnums(unittest.TestCase): 641 | def test_enum(self): 642 | def main(foo): 643 | """:type foo: Choice""" 644 | return foo 645 | self.assertEqual(defopt.run(main, argv=['one']), Choice.one) 646 | self.assertEqual(defopt.run(main, argv=['two']), Choice.two) 647 | with self.assertRaises(SystemExit): 648 | defopt.run(main, argv=['three']) 649 | 650 | def test_str_enum(self): 651 | def main(*, a: StrChoice) -> None: 652 | """:type a: StrChoice""" 653 | return a 654 | self.assertEqual(defopt.run(main, argv=['--a', 'A']), StrChoice.A) 655 | 656 | def test_str_enum_optional(self): 657 | def main(*, a: StrChoice = StrChoice.A) -> None: 658 | """:type a: StrChoice""" 659 | return a 660 | self.assertEqual(defopt.run(main, argv=[]), StrChoice.A) 661 | 662 | def test_optional(self): 663 | def main(*, foo=None): 664 | """:type foo: Choice""" 665 | return foo 666 | self.assertEqual(defopt.run(main, argv=['--foo', 'one']), Choice.one) 667 | self.assertIs(defopt.run(main, argv=[]), None) 668 | 669 | def test_subcommand(self): 670 | def sub1(foo): 671 | """:type foo: Choice""" 672 | return foo 673 | def sub2(bar): 674 | """:type bar: Choice""" 675 | return bar 676 | self.assertEqual( 677 | defopt.run([sub1, sub2], argv=['sub1', 'one']), Choice.one) 678 | self.assertEqual( 679 | defopt.run([sub1, sub2], argv=['sub2', 'two']), Choice.two) 680 | 681 | 682 | class TestTuple(unittest.TestCase): 683 | def test_tuple(self): 684 | def main(foo): 685 | """:param typing.Tuple[int,str] foo: foo""" 686 | return foo 687 | self.assertEqual(defopt.run(main, argv=['1', '2']), (1, '2')) 688 | 689 | def test_tuple_variable_length(self): 690 | def main(foo): 691 | """:param typing.Tuple[int,...] foo: foo""" 692 | return foo 693 | self.assertEqual(defopt.run(main, argv=['1', '2', '3']), (1, 2, 3)) 694 | 695 | def test_tupleenum(self): 696 | def main(foo: typing.Tuple[Choice] = None): 697 | return foo 698 | self.assertEqual(defopt.run(main, argv=['one']), (Choice.one,)) 699 | 700 | def test_namedtuple(self): 701 | # Add a second argument after the tuple to ensure that the converter 702 | # closes over the correct type. 703 | def main(foo, bar): 704 | """ 705 | :param Pair foo: foo 706 | :param int bar: bar 707 | """ 708 | return foo 709 | # Instances of the Pair class compare equal to tuples, so we compare 710 | # their __str__ instead to make sure that the type is correct too. 711 | self.assertEqual(str(defopt.run(main, argv=['1', '2', '3'])), 712 | str(Pair(1, '2'))) 713 | 714 | def test_enumnamedtuple(self): 715 | class EnumPair(Pair, Enum): 716 | a = Pair('A', 1) 717 | b = Pair('B', 2) 718 | 719 | def main(foo: EnumPair): return foo 720 | self.assertEqual(defopt.run(main, argv=['a']), EnumPair.a) 721 | 722 | def test_tuple_fails_early(self): 723 | def main(foo): 724 | """:param typing.Tuple[int,NotConstructibleFromStr] foo: foo""" 725 | with self.assertRaisesRegex(Exception, 'no parser'): 726 | defopt.run(main, argv=['-h']) 727 | 728 | 729 | class TestUnion(unittest.TestCase): 730 | def test_union(self): 731 | def main(foo, bar=None): 732 | """ 733 | :param typing.Union[int,str] foo: foo 734 | :param bar: bar 735 | :type bar: float or str 736 | """ 737 | return type(foo), type(bar) 738 | self.assertEqual(defopt.run(main, argv=['1', '2']), (int, float)) 739 | self.assertEqual(defopt.run(main, argv=['1', 'b']), (int, str)) 740 | self.assertEqual(defopt.run(main, argv=['a', '2']), (str, float)) 741 | 742 | def test_or_union(self): 743 | if not hasattr(types, 'UnionType'): 744 | raise unittest.SkipTest('A|B-style unions not supported') 745 | def main(foo): 746 | """:param int|str foo: foo""" 747 | return type(foo) 748 | self.assertEqual(defopt.run(main, argv=['1']), int) 749 | self.assertEqual(defopt.run(main, argv=['x']), str) 750 | 751 | def test_bad_parse(self): 752 | def main(foo): 753 | """:param typing.Union[int,float] foo: foo""" 754 | with self.assertRaises(SystemExit): 755 | defopt.run(main, argv=['bar']) 756 | 757 | def test_bad_union(self): 758 | def main(foo): 759 | """:param typing.Union[int,typing.List[str]] foo: foo""" 760 | with self.assertRaises(ValueError): 761 | defopt.run(main, argv=['1']) 762 | def main(foo): 763 | """ 764 | :param foo: foo 765 | :type foo: int or list[str] 766 | """ 767 | with self.assertRaises(ValueError): 768 | defopt.run(main, argv=['1']) 769 | 770 | def test_not_bad_union(self): 771 | # get_type_hints reports a type of Union[List[int], NoneType] so check 772 | # that this doesn't get reported as "unsupported union including 773 | # container type". 774 | def main(foo: typing.List[int] = None): return foo 775 | self.assertEqual(defopt.run(main, argv=['--foo', '1']), [1]) 776 | 777 | def test_union_infaillible_and_unparseable(self): 778 | def main(foo: typing.Union[str, NotConstructibleFromStr], 779 | bar: typing.Union[Path, NotConstructibleFromStr]): 780 | return foo, bar 781 | self.assertEqual(defopt.run(main, argv=['a', 'b']), ('a', Path('b'))) 782 | 783 | def test_union_unparseable_and_infaillible(self): 784 | def main(foo: typing.Union[NotConstructibleFromStr, str]): return foo 785 | with self.assertRaises(Exception): 786 | defopt.run(main, argv=['foo']) 787 | def main(foo: typing.Union[NotConstructibleFromStr, Path]): return foo 788 | with self.assertRaises(Exception): 789 | defopt.run(main, argv=['foo']) 790 | 791 | 792 | class TestOptional(unittest.TestCase): 793 | def test_optional_hint_list(self): 794 | def main(op: typing.Optional[typing.List[str]]): return op 795 | self.assertEqual(defopt.run(main, argv=['--op', '1', '2']), ['1', '2']) 796 | 797 | def test_optional_hint_tuple(self): 798 | def main(op: typing.Optional[typing.Tuple[int]]): return op 799 | self.assertEqual(defopt.run(main, argv=['1']), (1,)) 800 | 801 | def test_optional_doc_list(self): 802 | def main(op): 803 | """:param typing.Optional[typing.List[int]] op: op""" 804 | return op 805 | self.assertEqual(defopt.run(main, argv=['--op', '1', '2']), [1, 2]) 806 | 807 | def test_optional_doc_tuple(self): 808 | def main(op): 809 | """:param typing.Optional[typing.Tuple[str]] op: op""" 810 | return op 811 | self.assertEqual(defopt.run(main, argv=['1']), ('1',)) 812 | 813 | def test_union_operator_hint_list(self): 814 | if not hasattr(types, 'UnionType'): 815 | raise unittest.SkipTest('A|B-style unions not supported') 816 | def main(op: typing.List[str] | None): return op 817 | self.assertEqual(defopt.run(main, argv=['--op', '1', '2']), ['1', '2']) 818 | 819 | def test_union_operator_hint_tuple(self): 820 | if not hasattr(types, 'UnionType'): 821 | raise unittest.SkipTest('A|B-style unions not supported') 822 | def main(op: typing.Tuple[int] | None): return op 823 | self.assertEqual(defopt.run(main, argv=['1']), (1,)) 824 | 825 | def test_union_operator_doc_list(self): 826 | if not hasattr(types, 'UnionType'): 827 | raise unittest.SkipTest('A|B-style unions not supported') 828 | def main(op): 829 | """:param list[int]|None op: op""" 830 | return op 831 | self.assertEqual(defopt.run(main, argv=['--op', '1', '2']), [1, 2]) 832 | 833 | def test_union_operator_doc_one_item_tuple(self): 834 | if not hasattr(types, 'UnionType'): 835 | raise unittest.SkipTest('A|B-style unions not supported') 836 | def main(op): 837 | """:param tuple[int]|None op: op""" 838 | return op 839 | self.assertEqual(defopt.run(main, argv=['1']), (1,)) 840 | 841 | def test_union_operator_doc_multiple_item_tuple(self): 842 | if not hasattr(types, 'UnionType'): 843 | raise unittest.SkipTest('A|B-style unions not supported') 844 | def main(op): 845 | """:param tuple[str,str]|None op: op""" 846 | return op 847 | self.assertEqual(defopt.run(main, argv=['a', 'b']), ('a', 'b')) 848 | 849 | def test_multiple_item_optional_tuple_none_parser(self): 850 | def main(op): 851 | """:param typing.Optional[typing.Tuple[int, int]] op: op""" 852 | with self.assertRaises(ValueError): 853 | defopt.run(main, argv=['1', '2'], 854 | parsers={type(None): _parse_none}) 855 | 856 | def test_one_item_optional_tuple_none_parser(self): 857 | def main(op): 858 | """:param typing.Optional[typing.Tuple[int]] op: op""" 859 | return op 860 | self.assertEqual(defopt.run(main, argv=['1'], 861 | parsers={type(None): _parse_none}), 862 | (1,)) 863 | self.assertEqual(defopt.run(main, argv=[':none:'], 864 | parsers={type(None): _parse_none}), 865 | None) 866 | with self.assertRaises(SystemExit): 867 | defopt.run(main, argv=['foo'], 868 | parsers={type(None): _parse_none}) 869 | 870 | 871 | class TestLiteral(unittest.TestCase): 872 | def test_literal(self): 873 | def main(foo): 874 | """:param defopt.Literal[Choice.one,"bar","baz"] foo: foo""" 875 | return foo 876 | self.assertEqual(defopt.run(main, argv=['bar']), 'bar') 877 | self.assertEqual(defopt.run(main, argv=['one']), Choice.one) 878 | with self.assertRaises(SystemExit): 879 | defopt.run(main, argv=['quux']) 880 | 881 | 882 | class TestExceptions(unittest.TestCase): 883 | def test_exceptions(self): 884 | def main(name): 885 | """ 886 | :param str name: name 887 | :raises RuntimeError: 888 | """ 889 | raise getattr(builtins, name)('oops') 890 | 891 | with self.assertRaises(SystemExit): 892 | defopt.run(main, argv=['RuntimeError']) 893 | with self.assertRaises(ValueError): 894 | defopt.run(main, argv=['ValueError']) 895 | 896 | 897 | class TestDoc(unittest.TestCase): 898 | def test_parse_docstring(self): 899 | doc = """ 900 | Test function 901 | 902 | :param one: first param 903 | :type one: int 904 | :param float two: second param 905 | :returns: str 906 | :junk one two: nothing 907 | """ 908 | doc_sig = defopt._parse_docstring(inspect.cleandoc(doc)) 909 | self.assertEqual(doc_sig.doc, 'Test function\n\n') 910 | one = doc_sig.parameters['one'] 911 | self.assertEqual(one.doc, 'first param') 912 | self.assertEqual(one.annotation, 'int') 913 | two = doc_sig.parameters['two'] 914 | self.assertEqual(two.doc, 'second param') 915 | self.assertEqual(two.annotation, 'float') 916 | 917 | def test_parse_params(self): 918 | doc = """ 919 | Test function 920 | 921 | :param first: first param 922 | :parameter int second: second param 923 | :arg third: third param 924 | :argument float fourth: fourth param 925 | :key fifth: fifth param 926 | :keyword str sixth: sixth param 927 | """ 928 | doc_sig = defopt._parse_docstring(inspect.cleandoc(doc)) 929 | self.assertEqual(doc_sig.parameters['first'].doc, 'first param') 930 | self.assertEqual(doc_sig.parameters['second'].doc, 'second param') 931 | self.assertEqual(doc_sig.parameters['third'].doc, 'third param') 932 | self.assertEqual(doc_sig.parameters['fourth'].doc, 'fourth param') 933 | self.assertEqual(doc_sig.parameters['fifth'].doc, 'fifth param') 934 | self.assertEqual(doc_sig.parameters['sixth'].doc, 'sixth param') 935 | 936 | def test_parse_doubles(self): 937 | doc = """ 938 | Test function 939 | 940 | :param int param: the parameter 941 | :type param: int 942 | """ 943 | with self.assertRaises(ValueError): 944 | defopt._parse_docstring(inspect.cleandoc(doc)) 945 | 946 | doc = """Test function 947 | 948 | :type param: int 949 | :param int param: the parameter 950 | """ 951 | with self.assertRaises(ValueError): 952 | defopt._parse_docstring(inspect.cleandoc(doc)) 953 | 954 | def test_no_doc(self): 955 | doc_sig = defopt._parse_docstring(None) 956 | self.assertEqual(doc_sig.doc, '') 957 | self.assertEqual(doc_sig.parameters, {}) 958 | 959 | def test_param_only(self): 960 | doc_sig = defopt._parse_docstring(""":param int param: test""") 961 | self.assertEqual(doc_sig.doc, '') 962 | param = doc_sig.parameters['param'] 963 | self.assertEqual(param.doc, 'test') 964 | self.assertEqual(param.annotation, 'int') 965 | 966 | def test_implicit_role(self): 967 | doc_sig = defopt._parse_docstring("""start `int` end""") 968 | self.assertEqual(doc_sig.doc, 'start \033[4mint\033[0m end\n\n') 969 | 970 | def test_explicit_role(self): 971 | doc_sig = defopt._parse_docstring( 972 | """start :py:class:`int` :kbd:`ctrl` end""") 973 | self.assertEqual(doc_sig.doc, 'start int ctrl end\n\n') 974 | 975 | def test_sphinx(self): 976 | doc = """ 977 | One line summary. 978 | 979 | Extended description. 980 | 981 | :param int arg1: Description of arg1 982 | :param str arg2: Description of arg2 983 | 984 | And more about arg2 985 | :keyword float arg3: Description of arg3 986 | :returns: Description of return value. 987 | :rtype: str 988 | 989 | .. rubric:: examples 990 | 991 | >>> print("hello, world") 992 | """ 993 | doc_sig = defopt._parse_docstring(inspect.cleandoc(doc)) 994 | self._check_doc(doc_sig) 995 | 996 | def test_google(self): 997 | # Docstring modified from Napoleon's example. 998 | doc = """ 999 | One line summary. 1000 | 1001 | Extended description. 1002 | 1003 | Args: 1004 | arg1(int): Description of arg1 1005 | arg2(str): Description of arg2 1006 | 1007 | And more about arg2 1008 | 1009 | Keyword Arguments: 1010 | arg3(float): Description of arg3 1011 | 1012 | Returns: 1013 | str: Description of return value. 1014 | 1015 | Examples: 1016 | >>> print("hello, world") 1017 | """ 1018 | doc_sig = defopt._parse_docstring(inspect.cleandoc(doc)) 1019 | self._check_doc(doc_sig) 1020 | 1021 | def test_numpy(self): 1022 | # Docstring modified from Napoleon's example. 1023 | doc = """ 1024 | One line summary. 1025 | 1026 | Extended description. 1027 | 1028 | Parameters 1029 | ---------- 1030 | arg1 : int 1031 | Description of arg1 1032 | arg2 : str 1033 | Description of arg2 1034 | 1035 | And more about arg2 1036 | 1037 | Keyword Arguments 1038 | ----------------- 1039 | arg3 : float 1040 | Description of arg3 1041 | 1042 | Returns 1043 | ------- 1044 | str 1045 | Description of return value. 1046 | 1047 | Examples 1048 | -------- 1049 | >>> print("hello, world") 1050 | """ 1051 | doc_sig = defopt._parse_docstring(inspect.cleandoc(doc)) 1052 | self._check_doc(doc_sig) 1053 | 1054 | def _check_doc(self, doc_sig): 1055 | self.assertEqual( 1056 | doc_sig.doc, 1057 | 'One line summary.\n\nExtended description.\n\n' 1058 | 'examples:\n\n>>> print("hello, world")\n\n') 1059 | self.assertEqual(len(doc_sig.parameters), 3) 1060 | self.assertEqual(doc_sig.parameters['arg1'].doc, 'Description of arg1') 1061 | self.assertEqual(doc_sig.parameters['arg1'].annotation, 'int') 1062 | self.assertEqual(doc_sig.parameters['arg2'].doc, 1063 | 'Description of arg2\n\nAnd more about arg2') 1064 | self.assertEqual(doc_sig.parameters['arg2'].annotation, 'str') 1065 | self.assertEqual(doc_sig.parameters['arg3'].doc, 'Description of arg3') 1066 | self.assertEqual(doc_sig.parameters['arg3'].annotation, 'float') 1067 | 1068 | def test_sequence(self): 1069 | self.assertEqual( 1070 | defopt._get_type_from_doc( 1071 | 'Sequence[int]', {'Sequence': typing.Sequence}), 1072 | typing.List[int]) 1073 | 1074 | def test_collection(self): 1075 | self.assertEqual( 1076 | defopt._get_type_from_doc( 1077 | 'Collection[int]', {'Collection': typing.Collection}), 1078 | typing.List[int]) 1079 | 1080 | def test_iterable(self): 1081 | self.assertEqual( 1082 | defopt._get_type_from_doc( 1083 | 'typing.Iterable[int]', {'typing': typing}), 1084 | typing.List[int]) 1085 | 1086 | def test_literal_block(self): 1087 | doc = """ 1088 | :: 1089 | 1090 | Literal block 1091 | Multiple lines 1092 | 1093 | .. code:: python 1094 | 1095 | def foo(): pass 1096 | """ 1097 | doc_sig = defopt._parse_docstring(inspect.cleandoc(doc)) 1098 | self.assertEqual( 1099 | doc_sig.doc, 1100 | ' Literal block\n Multiple lines\n\n' 1101 | ' def foo(): pass\n\n') 1102 | 1103 | def test_newlines(self): 1104 | doc = """ 1105 | Bar 1106 | Baz 1107 | 1108 | .. a comment 1109 | 1110 | - bar 1111 | - baz 1112 | 1113 | quux:: 1114 | 1115 | hello 1116 | 1117 | 1118 | 1. bar 1119 | #. baz 1120 | 1121 | ii. bar 1122 | #. baz 1123 | """ 1124 | doc_sig = defopt._parse_docstring(inspect.cleandoc(doc)) 1125 | self.assertEqual(doc_sig.doc, inspect.cleandoc("""\ 1126 | Bar 1127 | Baz 1128 | 1129 | - bar 1130 | 1131 | - baz 1132 | 1133 | quux: 1134 | 1135 | hello 1136 | 1137 | 1. bar 1138 | 1139 | 2. baz 1140 | 1141 | ii. bar 1142 | 1143 | iii. baz""") + "\n\n") 1144 | 1145 | def test_bad_doc(self): 1146 | doc = """ 1147 | some 1148 | - bad 1149 | - indent 1150 | """ 1151 | with self.assertRaises(SystemMessage): 1152 | defopt._parse_docstring(inspect.cleandoc(doc)) 1153 | 1154 | 1155 | class TestAnnotations(unittest.TestCase): 1156 | def test_simple(self): 1157 | self.assertEqual(defopt._get_type_from_hint(int), int) 1158 | 1159 | def test_container(self): 1160 | self.assertEqual(defopt._get_type_from_hint(typing.Sequence[int]), 1161 | typing.List[int]) 1162 | 1163 | def test_conflicting(self): 1164 | def foo(bar: int): 1165 | """:type bar: float""" 1166 | with self.assertRaisesRegex(ValueError, 'bar.*int.*float'): 1167 | defopt.run(foo, argv=['1']) 1168 | 1169 | def test_none(self): 1170 | def foo(bar): 1171 | """No type information""" 1172 | with self.assertRaisesRegex(ValueError, 'no type'): 1173 | defopt.run(foo, argv=['1']) 1174 | 1175 | def test_same(self): 1176 | def foo(bar: int): 1177 | """:type bar: int""" 1178 | return bar 1179 | self.assertEqual(defopt.run(foo, argv=['1']), 1) 1180 | 1181 | 1182 | class TestAlias(unittest.TestCase): 1183 | def test_alias(self): 1184 | if not hasattr(typing, 'TypeAliasType'): 1185 | raise unittest.SkipTest('TypeAliasType not supported') 1186 | d = {} 1187 | exec('type L = list[int]\ndef foo(bar: L): return bar', d) 1188 | self.assertEqual(defopt.run(d['foo'], argv=['-b1']), [1]) 1189 | 1190 | 1191 | class TestFunctoolsPartial(unittest.TestCase): 1192 | # does not have a default for `bar` 1193 | @staticmethod 1194 | def foo(*, bar: int) -> None: 1195 | """The foo tool 1196 | 1197 | :param int bar: the bar option""" 1198 | return bar 1199 | 1200 | def test_partial_top_level(self): 1201 | actual = defopt.run(functools.partial(self.foo, bar=4), argv=[]) 1202 | self.assertEqual(actual, 4) 1203 | 1204 | def test_partial_sub_command(self): 1205 | funcs = {'foo': functools.partial(self.foo, bar=4)} 1206 | actual = defopt.run(funcs, argv=['foo']) 1207 | self.assertEqual(actual, 4) 1208 | 1209 | def test_partial_sub_commands(self): 1210 | funcs = {'bar': [functools.partial(self.foo, bar=4)]} 1211 | actual = defopt.run(funcs, argv=['bar', 'foo']) 1212 | self.assertEqual(actual, 4) 1213 | 1214 | 1215 | class TestHelp(unittest.TestCase): 1216 | def test_type(self): 1217 | def foo(bar): 1218 | """:param int bar: baz""" 1219 | self.assert_in_help('(type: int)', foo, 'dt') 1220 | self.assert_in_help('(type: int)', foo, 't') 1221 | self.assert_not_in_help('(', foo, 'd') 1222 | self.assert_not_in_help('(', foo, '') 1223 | 1224 | def test_enum_and_no_help(self): 1225 | def foo(bar): 1226 | """:param Choice bar:""" 1227 | self.assert_not_in_help('(', foo, 'dt') 1228 | self.assert_not_in_help('(', foo, 't') 1229 | self.assert_not_in_help('(', foo, 'd') 1230 | self.assert_not_in_help('(', foo, '') 1231 | 1232 | def test_default(self): 1233 | def foo(bar=1): 1234 | """:param int bar: baz""" 1235 | self.assert_in_help('(type: int, default: 1)', foo, 'dt') 1236 | self.assert_in_help('(type: int)', foo, 't') 1237 | self.assert_in_help('(default: 1)', foo, 'd') 1238 | self.assert_not_in_help('(', foo, '') 1239 | 1240 | def test_default_list(self): 1241 | def foo(bar=[]): 1242 | """:param typing.List[int] bar: baz""" 1243 | self.assert_in_help('(type: int, default: [])', foo, 'dt') 1244 | self.assert_in_help('(type: int)', foo, 't') 1245 | self.assert_in_help('(default: [])', foo, 'd') 1246 | self.assert_not_in_help('(', foo, '') 1247 | 1248 | def test_default_bool(self): 1249 | def foo(bar=False): 1250 | """:param bool bar: baz""" 1251 | self.assert_in_help('(default: False)', foo, 'dt') 1252 | self.assert_not_in_help('(', foo, 't') 1253 | self.assert_in_help('(default: False)', foo, 'd') 1254 | self.assert_not_in_help('(', foo, '') 1255 | 1256 | def test_no_negated_flags(self): 1257 | def main(*, foo=False, bar=True): 1258 | """ 1259 | :param bool foo: Foo 1260 | :param bool bar: Bar 1261 | """ 1262 | self.assert_not_in_help('--no-foo', main, 'n') 1263 | self.assert_in_help('--no-bar', main, 'n') 1264 | 1265 | def test_keyword_only(self): 1266 | def foo(*, bar): 1267 | """:param int bar: baz""" 1268 | self.assert_in_help('(type: int)', foo, 'dt') 1269 | self.assert_in_help('(type: int)', foo, 't') 1270 | self.assert_not_in_help('(', foo, 'd') 1271 | self.assert_not_in_help('(', foo, '') 1272 | 1273 | def test_keyword_only_bool(self): 1274 | def foo(*, bar): 1275 | """:param bool bar: baz""" 1276 | self.assert_not_in_help('default', foo, 'dt') 1277 | 1278 | def test_keyword_only_enum_percent_escape(self): 1279 | def foo(*, bar=Choice['%']): 1280 | """:param Choice bar:""" 1281 | self.assert_not_in_help('Choice', foo, 'dt') 1282 | self.assert_in_help('{one,two,%}', foo, 'dt') 1283 | self.assert_not_in_help('Choice', foo, 't') 1284 | self.assert_not_in_help('Choice', foo, 'd') 1285 | self.assert_in_help('{one,two,%}', foo, 'd') 1286 | self.assert_not_in_help('Choice', foo, '') 1287 | # ("{one,two,%}" always shows up at least in the usage line.) 1288 | 1289 | def test_keyword_only_literal_percent_escape(self): 1290 | def foo(*, bar): 1291 | """:param defopt.Literal["1%","everything"] bar: 1%""" 1292 | self.assert_not_in_help('Literal', foo, 'dt') 1293 | self.assert_not_in_help('Literal', foo, 't') 1294 | self.assert_not_in_help('Literal', foo, 'd') 1295 | self.assert_not_in_help('Literal', foo, '') 1296 | 1297 | def test_tuple(self): 1298 | def main(foo=None): 1299 | """:param typing.Tuple[int,str] foo: help""" 1300 | self.assert_in_help('--foo FOO FOO', main, '') 1301 | 1302 | def test_namedtuple(self): 1303 | def main(foo=None): 1304 | """:param Pair foo: help""" 1305 | self.assert_in_help('--foo first second', main, '') 1306 | 1307 | if sys.version_info >= (3, 13, 1): 1308 | def main(foo): 1309 | """:param Pair foo: help""" 1310 | self.assert_in_help('foo.first foo.second', main, '') 1311 | 1312 | def test_var_positional(self): 1313 | for doc in [ 1314 | ":type bar: int", r":type \*bar: int", ":param int bar: baz"]: 1315 | def foo(*bar): assert False 1316 | foo.__doc__ = doc 1317 | self.assert_not_in_help('default', foo, 'dt') 1318 | 1319 | def test_list_var_positional(self): 1320 | def foo(*bar): 1321 | """:param list[int] bar: baz""" 1322 | self.assert_not_in_help('default', foo, 'dt') 1323 | 1324 | def test_private(self): 1325 | def foo(bar, _baz=None): 1326 | """:param int bar: bar help""" 1327 | self.assert_not_in_help('baz', foo, 'dt') 1328 | 1329 | def test_no_interpolation(self): 1330 | def foo(bar): 1331 | """:param int bar: %(prog)s""" 1332 | self.assert_in_help('%(prog)s', foo, '') 1333 | self.assert_not_in_help('%%', foo, '') 1334 | 1335 | def test_hyperlink_plaintext(self): 1336 | def foo(): 1337 | """This site https://www.python.org/ is cool""" 1338 | self.assert_in_help( 1339 | "This site https://www.python.org/ is cool", 1340 | foo, 1341 | "" 1342 | ) 1343 | 1344 | def test_hyperlink_target_embedded(self): 1345 | def foo(): 1346 | """`This site `_ is cool""" 1347 | self.assert_in_help( 1348 | "This site (https://www.python.org/) is cool", 1349 | foo, 1350 | "" 1351 | ) 1352 | 1353 | def test_hyperlink_target_separated(self): 1354 | def foo(): 1355 | """This site_ is cool 1356 | 1357 | .. _site: https://www.python.org/ 1358 | """ 1359 | self.assert_in_help("This site is cool", foo, "") 1360 | self.assert_not_in_help("https://www.python.org/", foo, "") 1361 | 1362 | def test_rst_ansi(self): 1363 | def foo(): 1364 | """**bold** *italic* `underlined`""" 1365 | self.assert_in_help( 1366 | '\033[1mbold\033[0m \033[3mitalic\033[0m \033[4munderlined\033[0m', 1367 | foo, '') 1368 | 1369 | def test_multiple(self): 1370 | def foo(): 1371 | """summary-of-foo 1372 | 1373 | Implements FOO. 1374 | """ 1375 | 1376 | def bar(): 1377 | """summary-of-bar 1378 | 1379 | Implements BAR.""" 1380 | 1381 | self.assert_in_help('summary-of-foo', [foo, bar], '') 1382 | self.assert_not_in_help('FOO', [foo, bar], '') 1383 | 1384 | def test_functools_partial_make_param_default(self): 1385 | def foo(*, bar): 1386 | """The foo tool 1387 | 1388 | :param int bar: the bar option""" 1389 | func = functools.partial(foo, bar=4) 1390 | self.assert_in_help('The foo tool', func, 't') 1391 | self.assert_in_help('(type: int)', func, 't') 1392 | self.assert_in_help('(default: 4)', func, 'd') 1393 | self.assert_in_help('(type: int, default: 4)', func, 'dt') 1394 | 1395 | def test_functools_partial_override_param_default(self): 1396 | def foo(*, bar=5): 1397 | """The foo tool 1398 | 1399 | :param int bar: the bar option""" 1400 | func = functools.partial(foo, bar=4) 1401 | self.assert_in_help('The foo tool', func, 't') 1402 | self.assert_in_help('(type: int)', func, 't') 1403 | self.assert_in_help('(default: 4)', func, 'd') 1404 | self.assert_in_help('(type: int, default: 4)', func, 'dt') 1405 | 1406 | def test_functools_partial_command_list(self): 1407 | def foo(): 1408 | """Foo""" 1409 | def bar(): 1410 | """Bar""" 1411 | func_foo = functools.partial(foo) 1412 | func_bar = functools.partial(bar) 1413 | funcs = [func_foo, func_bar] 1414 | self.assert_in_help('Foo', funcs, 't') 1415 | self.assert_in_help('Bar', funcs, 't') 1416 | 1417 | def assert_in_help(self, s, funcs, flags): 1418 | self.assertIn(s, self._get_help(funcs, flags)) 1419 | 1420 | def assert_not_in_help(self, s, funcs, flags): 1421 | self.assertNotIn(s, self._get_help(funcs, flags)) 1422 | 1423 | def _get_help(self, funcs, flags): 1424 | self.assertLessEqual({*flags}, {'d', 't', 'n'}) 1425 | parser = defopt._create_parser(funcs, _options( 1426 | show_defaults='d' in flags, show_types='t' in flags, 1427 | no_negated_flags='n' in flags, cli_options='has_default')) 1428 | return parser.format_help() 1429 | 1430 | 1431 | @contextlib.contextmanager 1432 | def _assert_streams(self, *, stdout=None, stderr=None): 1433 | with ExitStack() as stack: 1434 | if stdout is not None: 1435 | r_stdout = stack.enter_context( 1436 | contextlib.redirect_stdout(StringIO())) 1437 | stack.callback( 1438 | lambda: self.assertRegex(r_stdout.getvalue(), stdout)) 1439 | if stderr is not None: 1440 | r_stderr = stack.enter_context( 1441 | contextlib.redirect_stderr(StringIO())) 1442 | stack.callback( 1443 | lambda: self.assertRegex(r_stderr.getvalue(), stderr)) 1444 | yield 1445 | 1446 | 1447 | class TestErrorMessage(unittest.TestCase): 1448 | def test_enum(self): 1449 | def foo(x: Choice): pass 1450 | with self.assertRaises(SystemExit), _assert_streams( 1451 | self, stderr="error: argument x: invalid choice: 'three'"): 1452 | defopt.run(foo, argv=['three']) 1453 | 1454 | def test_literal(self): 1455 | def foo(x: defopt.Literal[1, "a"]): pass 1456 | with self.assertRaises(SystemExit), _assert_streams( 1457 | self, stderr="error: argument x: invalid choice: 'three'"): 1458 | defopt.run(foo, argv=['three']) 1459 | 1460 | def test_tuple(self): 1461 | def foo(x: typing.Tuple[int]): pass 1462 | with self.assertRaises(SystemExit), _assert_streams( 1463 | self, stderr="error: argument x: invalid literal for int"): 1464 | defopt.run(foo, argv=['three']) 1465 | 1466 | 1467 | class TestVersion(unittest.TestCase): 1468 | def test_no_version(self): 1469 | for funcs in [[], lambda: None, [lambda: None]]: 1470 | with self.assertRaises(SystemExit), _assert_streams( 1471 | self, 1472 | stdout=r'\A\Z', 1473 | stderr='unrecognized arguments: --version'): 1474 | defopt.run([], version=False, argv=['--version']) 1475 | 1476 | def test_auto_version(self): 1477 | with self.assertRaises(SystemExit), _assert_streams( 1478 | self, stdout=rf'\A{re.escape(__version__)}\n\Z'): 1479 | defopt.run(lambda: None, argv=['--version']) 1480 | with self.assertRaises(SystemExit), _assert_streams( 1481 | self, 1482 | stdout=r'\A\Z', 1483 | stderr='unrecognized arguments: --version'): 1484 | defopt.run([], argv=['--version']) 1485 | 1486 | def test_manual_version(self): 1487 | with self.assertRaises(SystemExit), _assert_streams( 1488 | self, stdout=r'\Afoo 42\n\Z'): 1489 | defopt.run([], version='foo 42', argv=['--version']) 1490 | 1491 | def test_moduleless(self): 1492 | def moduleless(): pass 1493 | moduleless.__module__ = None 1494 | with self.assertRaises(ValueError): 1495 | defopt.run([moduleless], version=True) 1496 | 1497 | 1498 | class TestExamples(unittest.TestCase): 1499 | def test_annotations(self): 1500 | for command in [annotations.documented, annotations.undocumented]: 1501 | with self._assert_stdout('[1, 8]\n'): 1502 | command([1, 2], 3) 1503 | 1504 | def test_annotations_cli(self): 1505 | for command in ['documented', 'undocumented']: 1506 | args = [command, '--numbers', '1', '2', '--', '3'] 1507 | output = self._run_example(annotations, args) 1508 | self.assertEqual(output, b'[1.0, 8.0]\n') 1509 | 1510 | def test_booleans(self): 1511 | with self._assert_stdout('test\ntest\n'): 1512 | booleans.main('test', upper=False, repeat=True) 1513 | with self._assert_stdout('TEST\n'): 1514 | booleans.main('test') 1515 | 1516 | def test_booleans_cli(self): 1517 | output = self._run_example( 1518 | booleans, ['test', '--no-upper', '--repeat']) 1519 | self.assertEqual(output, b'test\ntest\n') 1520 | output = self._run_example( 1521 | booleans, ['test']) 1522 | self.assertEqual(output, b'TEST\n') 1523 | 1524 | def test_choices(self): 1525 | with self._assert_stdout('Choice.one (1)\n'): 1526 | choices.choose_enum(choices.Choice.one) 1527 | with self._assert_stdout('Choice.one (1)\nChoice.two (2.0)\n'): 1528 | choices.choose_enum(choices.Choice.one, opt=choices.Choice.two) 1529 | with self.assertRaises(AttributeError): 1530 | choices.choose_enum('one') 1531 | with self._assert_stdout('foo\n'): 1532 | choices.choose_literal('foo') 1533 | with self._assert_stdout('foo\nbaz\n'): 1534 | choices.choose_literal('foo', opt='baz') 1535 | 1536 | def test_choices_cli(self): 1537 | output = self._run_example(choices, ['choose-enum', 'one']) 1538 | self.assertEqual(output, b'Choice.one (1)\n') 1539 | output = self._run_example( 1540 | choices, ['choose-enum', 'one', '--opt', 'two']) 1541 | self.assertEqual(output, b'Choice.one (1)\nChoice.two (2.0)\n') 1542 | with self.assertRaises(subprocess.CalledProcessError) as error: 1543 | self._run_example(choices, ['choose-enum', 'four']) 1544 | self.assertIn(b'four', error.exception.output) 1545 | self.assertIn(b'{one,two,three}', error.exception.output) 1546 | output = self._run_example(choices, ['choose-literal', 'foo']) 1547 | self.assertEqual(output, b'foo\n') 1548 | output = self._run_example( 1549 | choices, ['choose-literal', 'foo', '--opt', 'baz']) 1550 | self.assertEqual(output, b'foo\nbaz\n') 1551 | with self.assertRaises(subprocess.CalledProcessError) as error: 1552 | self._run_example(choices, ['choose-literal', 'baz']) 1553 | self.assertIn(b'baz', error.exception.output) 1554 | self.assertIn(b'{foo,bar}', error.exception.output) 1555 | 1556 | def test_exceptions(self): 1557 | self._run_example(exceptions, ['1']) 1558 | with self.assertRaises(subprocess.CalledProcessError) as error: 1559 | self._run_example(exceptions, ['0']) 1560 | self.assertIn(b"Don't do this!", error.exception.output) 1561 | self.assertNotIn(b"Traceback", error.exception.output) 1562 | 1563 | def test_lists(self): 1564 | with self._assert_stdout('[2.4, 6.8]\n'): 1565 | lists.main([1.2, 3.4], 2) 1566 | with self._assert_stdout('[2, 4, 6]\n'): 1567 | lists.main([1, 2, 3], 2) 1568 | 1569 | def test_lists_cli(self): 1570 | output = self._run_example( 1571 | lists, ['2', '--numbers', '1.2', '3.4']) 1572 | self.assertEqual(output, b'[2.4, 6.8]\n') 1573 | output = self._run_example( 1574 | lists, ['--numbers', '1.2', '3.4', '--', '2']) 1575 | self.assertEqual(output, b'[2.4, 6.8]\n') 1576 | 1577 | def test_parsers(self): 1578 | date = parsers.datetime(2015, 9, 13) 1579 | with self._assert_stdout(f'{date}\n'): 1580 | parsers.main(date) 1581 | with self._assert_stdout('junk\n'): 1582 | parsers.main('junk') 1583 | 1584 | def test_parsers_cli(self): 1585 | output = self._run_example(parsers, ['2015-09-13']) 1586 | self.assertEqual(output, b'2015-09-13 00:00:00\n') 1587 | with self.assertRaises(subprocess.CalledProcessError) as error: 1588 | self._run_example(parsers, ['junk']) 1589 | self.assertIn(b'datetime', error.exception.output) 1590 | self.assertIn(b'junk', error.exception.output) 1591 | 1592 | def test_partials(self): 1593 | output = self._run_example(partials, ['foo']) 1594 | self.assertEqual(output, b'5\n') 1595 | output = self._run_example(partials, ['sub', 'bar']) 1596 | self.assertEqual(output, b'6\n') 1597 | 1598 | def test_short(self): 1599 | with self._assert_stdout('hello!\n'): 1600 | short.main() 1601 | with self._assert_stdout('hello!\nhello!\n'): 1602 | short.main(count=2) 1603 | 1604 | def test_short_cli(self): 1605 | output = self._run_example(short, ['--count', '2']) 1606 | self.assertEqual(output, b'hello!\nhello!\n') 1607 | output = self._run_example(short, ['-C', '2']) 1608 | self.assertEqual(output, b'hello!\nhello!\n') 1609 | 1610 | def test_starargs(self): 1611 | with self._assert_stdout('1\n2\n3\n'): 1612 | starargs.plain(1, 2, 3) 1613 | with self._assert_stdout('[1, 2]\n[3, 4, 5]\n'): 1614 | starargs.iterable([1, 2], [3, 4, 5]) 1615 | 1616 | def test_starargs_cli(self): 1617 | output = self._run_example(starargs, ['plain', '1', '2', '3']) 1618 | self.assertEqual(output, b'1\n2\n3\n') 1619 | args = ['iterable', '--groups', '1', '2', '--groups', '3', '4', '5'] 1620 | output = self._run_example(starargs, args) 1621 | self.assertEqual(output, b'[1, 2]\n[3, 4, 5]\n') 1622 | 1623 | def test_styles(self): 1624 | for command in [styles.sphinx, styles.google, styles.numpy]: 1625 | with self._assert_stdout('4\n'): 1626 | command(2) 1627 | with self._assert_stdout('4\nbye\n'): 1628 | command(2, farewell='bye') 1629 | 1630 | def test_styles_cli(self): 1631 | for style in ['sphinx', 'google', 'numpy']: 1632 | args = [style, '2', '--farewell', 'bye'] 1633 | output = self._run_example(styles, args) 1634 | self.assertEqual(output, b'4\nbye\n') 1635 | 1636 | @contextlib.contextmanager 1637 | def _assert_stdout(self, s): 1638 | with contextlib.redirect_stdout(StringIO()) as file: 1639 | yield 1640 | self.assertEqual(file.getvalue(), s) 1641 | 1642 | def _run_example(self, example, argv): 1643 | output = subprocess.check_output( 1644 | [sys.executable, '-m', example.__name__] + argv, 1645 | stderr=subprocess.STDOUT) 1646 | return output.replace(b'\r\n', b'\n') 1647 | 1648 | 1649 | class TestRunAny(unittest.TestCase): # TODO: Reuse TestExamples. 1650 | def test_lists_cli(self): 1651 | output = self._run_any( 1652 | 'examples.lists.main', ['2', '--numbers', '1.2', '3.4']) 1653 | self.assertEqual(output, '[2.4, 6.8]\n') 1654 | output = self._run_any( 1655 | 'examples.lists:main', ['--numbers', '1.2', '3.4', '--', '2']) 1656 | self.assertEqual(output, '[2.4, 6.8]\n') 1657 | 1658 | def test_failed_imports(self): 1659 | with self.assertRaises((ImportError, subprocess.CalledProcessError)): 1660 | self._run_any('does_not_exist', []) 1661 | 1662 | @staticmethod 1663 | def _target(command, argv): 1664 | sys.argv[1:] = [command, *argv] # Only executed in a subprocess. 1665 | buf = StringIO() 1666 | with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf): 1667 | runpy.run_module('defopt', run_name='__main__', alter_sys=True) 1668 | return buf.getvalue() 1669 | 1670 | def _run_any(self, command, argv, **kwargs): 1671 | if sys.version_info >= (3, 7): # multiprocessing for proper coverage. 1672 | with ProcessPoolExecutor(mp_context=mp.get_context('spawn')) \ 1673 | as executor: 1674 | fut = executor.submit(self._target, command, argv) 1675 | return fut.result().replace('\r\n', '\n') 1676 | else: # mp_context is not available. 1677 | output = subprocess.check_output( 1678 | [sys.executable, '-m', 'defopt', command] + argv, 1679 | stderr=subprocess.STDOUT, universal_newlines=True, **kwargs) 1680 | return output.replace('\r\n', '\n') 1681 | 1682 | 1683 | class TestDefaultsPreserved(unittest.TestCase): 1684 | def test_defaults_preserved(self): 1685 | """Check that mutable defaults are not modified.""" 1686 | params = inspect.signature(defopt.run).parameters 1687 | self.assertEqual(params['parsers'].default, {}) 1688 | self.assertEqual(params['argparse_kwargs'].default, {}) 1689 | 1690 | 1691 | class TestStyle(unittest.TestCase): 1692 | def _iter_stripped_lines(self): 1693 | for path in [defopt.__file__, __file__]: 1694 | with tokenize.open(path) as src: 1695 | for i, line in enumerate(src, 1): 1696 | yield f'{path}:{i}', line.rstrip('\n') 1697 | 1698 | def test_line_length(self): 1699 | for name, line in self._iter_stripped_lines(): 1700 | if len(line) > 79: 1701 | self.fail(f'{name} is too long') 1702 | 1703 | def test_trailing_whitespace(self): 1704 | for name, line in self._iter_stripped_lines(): 1705 | if line and line[-1].isspace(): 1706 | self.fail(f'{name} has trailing whitespace') 1707 | --------------------------------------------------------------------------------