diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..e32c802 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +root = true + +[*] +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true +end_of_line = lf +charset = utf-8 +max_line_length = 88 + +[*.{yml,yaml,json,js,css,html}] +indent_size = 2 diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md new file mode 100644 index 0000000..09535a6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -0,0 +1,27 @@ +--- +name: Bug report +about: Report a bug in Click (not other projects which depend on Click) +--- + + + + + + + +Environment: + +- Python version: +- Click version: diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..8d8c9b7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: Security issue + url: security@palletsprojects.com + about: Do not report security issues publicly. Email our security contact. + - name: Questions + url: https://stackoverflow.com/questions/tagged/python-click?tab=Frequent + about: Search for and ask questions about your code on Stack Overflow. + - name: Questions and discussions + url: https://discord.gg/pallets + about: Discuss questions about your code on our Discord chat. diff --git a/.github/ISSUE_TEMPLATE/feature-request.md b/.github/ISSUE_TEMPLATE/feature-request.md new file mode 100644 index 0000000..3dce4a4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.md @@ -0,0 +1,15 @@ +--- +name: Feature request +about: Suggest a new feature for Click +--- + + + + diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..86e010d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,8 @@ +version: 2 +updates: +- package-ecosystem: pip + directory: "/" + schedule: + interval: monthly + time: "08:00" + open-pull-requests-limit: 99 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..29fd35f --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,30 @@ + + + + +- fixes # + + + +Checklist: + +- [ ] Add tests that demonstrate the correct behavior of the change. Tests should fail without the change. +- [ ] Add or update relevant docs, in the docs folder and in code. +- [ ] Add an entry in `CHANGES.rst` summarizing the change and linking to the issue. +- [ ] Add `.. versionchanged::` entries in any relevant code docs. +- [ ] Run `pre-commit` hooks and fix any issues. +- [ ] Run `pytest` and `tox`, no tests failed. diff --git a/.github/workflows/lock.yaml b/.github/workflows/lock.yaml new file mode 100644 index 0000000..7128f38 --- /dev/null +++ b/.github/workflows/lock.yaml @@ -0,0 +1,15 @@ +name: 'Lock threads' + +on: + schedule: + - cron: '0 0 * * *' + +jobs: + lock: + runs-on: ubuntu-latest + steps: + - uses: dessant/lock-threads@v2 + with: + github-token: ${{ github.token }} + issue-lock-inactive-days: 14 + pr-lock-inactive-days: 14 diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 0000000..b00a866 --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,60 @@ +name: Tests +on: + push: + branches: + - main + - '*.x' + paths-ignore: + - 'docs/**' + - '*.md' + - '*.rst' + pull_request: + branches: + - main + - '*.x' + paths-ignore: + - 'docs/**' + - '*.md' + - '*.rst' +jobs: + tests: + name: ${{ matrix.name }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - {name: Linux, python: '3.9', os: ubuntu-latest, tox: py39} + - {name: Windows, python: '3.9', os: windows-latest, tox: py39} + - {name: Mac, python: '3.9', os: macos-latest, tox: py39} + - {name: '3.8', python: '3.8', os: ubuntu-latest, tox: py38} + - {name: '3.7', python: '3.7', os: ubuntu-latest, tox: py37} + - {name: '3.6', python: '3.6', os: ubuntu-latest, tox: py36} + - {name: 'PyPy', python: pypy3, os: ubuntu-latest, tox: pypy3} + - {name: Typing, python: '3.9', os: ubuntu-latest, tox: typing} + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python }} + - name: update pip + run: | + pip install -U wheel + pip install -U setuptools + python -m pip install -U pip + - name: get pip cache dir + id: pip-cache + run: echo "::set-output name=dir::$(pip cache dir)" + - name: cache pip + uses: actions/cache@v2 + with: + path: ${{ steps.pip-cache.outputs.dir }} + key: pip|${{ runner.os }}|${{ matrix.python }}|${{ hashFiles('setup.py') }}|${{ hashFiles('requirements/*.txt') }} + - name: cache mypy + uses: actions/cache@v2 + with: + path: ./.mypy_cache + key: mypy|${{ matrix.python }}|${{ hashFiles('setup.cfg') }} + if: matrix.tox == 'typing' + - run: pip install tox + - run: tox -e ${{ matrix.tox }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fc5a65f --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +/.idea/ +/.vscode/ +/env/ +/venv/ +__pycache__/ +*.pyc +*.egg-info/ +/build/ +/dist/ +/.pytest_cache/ +/.tox/ +.coverage +.coverage.* +/htmlcov/ +/docs/_build/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..2a1f9e7 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,30 @@ +ci: + autoupdate_schedule: monthly +repos: + - repo: https://github.com/asottile/pyupgrade + rev: v2.29.0 + hooks: + - id: pyupgrade + args: ["--py36-plus"] + - repo: https://github.com/asottile/reorder_python_imports + rev: v2.6.0 + hooks: + - id: reorder-python-imports + args: ["--application-directories", "src"] + - repo: https://github.com/psf/black + rev: 21.9b0 + hooks: + - id: black + - repo: https://github.com/PyCQA/flake8 + rev: 3.9.2 + hooks: + - id: flake8 + additional_dependencies: + - flake8-bugbear + - flake8-implicit-str-concat + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.0.1 + hooks: + - id: fix-byte-order-marker + - id: trailing-whitespace + - id: end-of-file-fixer diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..0c36363 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,9 @@ +version: 2 +python: + install: + - requirements: requirements/docs.txt + - method: pip + path: . +sphinx: + builder: dirhtml + fail_on_warning: true diff --git a/CHANGES.rst b/CHANGES.rst index 273c00f..55578c6 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,5 +1,287 @@ .. currentmodule:: click +Version 8.0.2 +------------- + +Released 2021-10-08 + +- ``is_bool_flag`` is not set to ``True`` if ``is_flag`` is ``False``. + :issue:`1925` +- Bash version detection is locale independent. :issue:`1940` +- Empty ``default`` value is not shown for ``multiple=True``. + :issue:`1969` +- Fix shell completion for arguments that start with a forward slash + such as absolute file paths. :issue:`1929` +- ``Path`` type with ``resolve_path=True`` resolves relative symlinks + to be relative to the containing directory. :issue:`1921` +- Completion does not skip Python's resource cleanup when exiting, + avoiding some unexpected warning output. :issue:`1738, 2017` +- Fix type annotation for ``type`` argument in ``prompt`` function. + :issue:`2062` +- Fix overline and italic styles, which were incorrectly added when + adding underline. :pr:`2058` +- An option with ``count=True`` will not show "[x>=0]" in help text. + :issue:`2072` +- Default values are not cast to the parameter type twice during + processing. :issue:`2085` +- Options with ``multiple`` and ``flag_value`` use the flag value + instead of leaving an internal placeholder. :issue:`2001` + + +Version 8.0.1 +------------- + +Released 2021-05-19 + +- Mark top-level names as exported so type checking understand imports + in user projects. :issue:`1879` +- Annotate ``Context.obj`` as ``Any`` so type checking allows all + operations on the arbitrary object. :issue:`1885` +- Fix some types that weren't available in Python 3.6.0. :issue:`1882` +- Fix type checking for iterating over ``ProgressBar`` object. + :issue:`1892` +- The ``importlib_metadata`` backport package is installed on Python < + 3.8. :issue:`1889` +- Arguments with ``nargs=-1`` only use env var value if no command + line values are given. :issue:`1903` +- Flag options guess their type from ``flag_value`` if given, like + regular options do from ``default``. :issue:`1886` +- Added documentation that custom parameter types may be passed + already valid values in addition to strings. :issue:`1898` +- Resolving commands returns the name that was given, not + ``command.name``, fixing an unintended change to help text and + ``default_map`` lookups. When using patterns like ``AliasedGroup``, + override ``resolve_command`` to change the name that is returned if + needed. :issue:`1895` +- If a default value is invalid, it does not prevent showing help + text. :issue:`1889` +- Pass ``windows_expand_args=False`` when calling the main command to + disable pattern expansion on Windows. There is no way to escape + patterns in CMD, so if the program needs to pass them on as-is then + expansion must be disabled. :issue:`1901` + + +Version 8.0.0 +------------- + +Released 2021-05-11 + +- Drop support for Python 2 and 3.5. +- Colorama is always installed on Windows in order to provide style + and color support. :pr:`1784` +- Adds a repr to Command, showing the command name for friendlier + debugging. :issue:`1267`, :pr:`1295` +- Add support for distinguishing the source of a command line + parameter. :issue:`1264`, :pr:`1329` +- Add an optional parameter to ``ProgressBar.update`` to set the + ``current_item``. :issue:`1226`, :pr:`1332` +- ``version_option`` uses ``importlib.metadata`` (or the + ``importlib_metadata`` backport) instead of ``pkg_resources``. The + version is detected based on the package name, not the entry point + name. The Python package name must match the installed package + name, or be passed with ``package_name=``. :issue:`1582` +- If validation fails for a prompt with ``hide_input=True``, the value + is not shown in the error message. :issue:`1460` +- An ``IntRange`` or ``FloatRange`` option shows the accepted range in + its help text. :issue:`1525`, :pr:`1303` +- ``IntRange`` and ``FloatRange`` bounds can be open (``<``) instead + of closed (``<=``) by setting ``min_open`` and ``max_open``. Error + messages have changed to reflect this. :issue:`1100` +- An option defined with duplicate flag names (``"--foo/--foo"``) + raises a ``ValueError``. :issue:`1465` +- ``echo()`` will not fail when using pytest's ``capsys`` fixture on + Windows. :issue:`1590` +- Resolving commands returns the canonical command name instead of the + matched name. This makes behavior such as help text and + ``Context.invoked_subcommand`` consistent when using patterns like + ``AliasedGroup``. :issue:`1422` +- The ``BOOL`` type accepts the values "on" and "off". :issue:`1629` +- A ``Group`` with ``invoke_without_command=True`` will always invoke + its result callback. :issue:`1178` +- ``nargs == -1`` and ``nargs > 1`` is parsed and validated for + values from environment variables and defaults. :issue:`729` +- Detect the program name when executing a module or package with + ``python -m name``. :issue:`1603` +- Include required parent arguments in help synopsis of subcommands. + :issue:`1475` +- Help for boolean flags with ``show_default=True`` shows the flag + name instead of ``True`` or ``False``. :issue:`1538` +- Non-string objects passed to ``style()`` and ``secho()`` will be + converted to string. :pr:`1146` +- ``edit(require_save=True)`` will detect saves for editors that exit + very fast on filesystems with 1 second resolution. :pr:`1050` +- New class attributes make it easier to use custom core objects + throughout an entire application. :pr:`938` + + - ``Command.context_class`` controls the context created when + running the command. + - ``Context.invoke`` creates new contexts of the same type, so a + custom type will persist to invoked subcommands. + - ``Context.formatter_class`` controls the formatter used to + generate help and usage. + - ``Group.command_class`` changes the default type for + subcommands with ``@group.command()``. + - ``Group.group_class`` changes the default type for subgroups + with ``@group.group()``. Setting it to ``type`` will create + subgroups of the same type as the group itself. + - Core objects use ``super()`` consistently for better support of + subclassing. + +- Use ``Context.with_resource()`` to manage resources that would + normally be used in a ``with`` statement, allowing them to be used + across subcommands and callbacks, then cleaned up when the context + ends. :pr:`1191` +- The result object returned by the test runner's ``invoke()`` method + has a ``return_value`` attribute with the value returned by the + invoked command. :pr:`1312` +- Required arguments with the ``Choice`` type show the choices in + curly braces to indicate that one is required (``{a|b|c}``). + :issue:`1272` +- If only a name is passed to ``option()``, Click suggests renaming it + to ``--name``. :pr:`1355` +- A context's ``show_default`` parameter defaults to the value from + the parent context. :issue:`1565` +- ``click.style()`` can output 256 and RGB color codes. Most modern + terminals support these codes. :pr:`1429` +- When using ``CliRunner.invoke()``, the replaced ``stdin`` file has + ``name`` and ``mode`` attributes. This lets ``File`` options with + the ``-`` value match non-testing behavior. :issue:`1064` +- When creating a ``Group``, allow passing a list of commands instead + of a dict. :issue:`1339` +- When a long option name isn't valid, use ``difflib`` to make better + suggestions for possible corrections. :issue:`1446` +- Core objects have a ``to_info_dict()`` method. This gathers + information about the object's structure that could be useful for a + tool generating user-facing documentation. To get the structure of + an entire CLI, use ``Context(cli).to_info_dict()``. :issue:`461` +- Redesign the shell completion system. :issue:`1484`, :pr:`1622` + + - Support Bash >= 4.4, Zsh, and Fish, with the ability for + extensions to add support for other shells. + - Allow commands, groups, parameters, and types to override their + completions suggestions. + - Groups complete the names commands were registered with, which + can differ from the name they were created with. + - The ``autocompletion`` parameter for options and arguments is + renamed to ``shell_complete``. The function must take + ``ctx, param, incomplete``, must do matching rather than return + all values, and must return a list of strings or a list of + ``CompletionItem``. The old name and behavior is deprecated and + will be removed in 8.1. + - The env var values used to start completion have changed order. + The shell now comes first, such as ``{shell}_source`` rather + than ``source_{shell}``, and is always required. + +- Completion correctly parses command line strings with incomplete + quoting or escape sequences. :issue:`1708` +- Extra context settings (``obj=...``, etc.) are passed on to the + completion system. :issue:`942` +- Include ``--help`` option in completion. :pr:`1504` +- ``ParameterSource`` is an ``enum.Enum`` subclass. :issue:`1530` +- Boolean and UUID types strip surrounding space before converting. + :issue:`1605` +- Adjusted error message from parameter type validation to be more + consistent. Quotes are used to distinguish the invalid value. + :issue:`1605` +- The default value for a parameter with ``nargs`` > 1 and + ``multiple=True`` must be a list of tuples. :issue:`1649` +- When getting the value for a parameter, the default is tried in the + same section as other sources to ensure consistent processing. + :issue:`1649` +- All parameter types accept a value that is already the correct type. + :issue:`1649` +- For shell completion, an argument is considered incomplete if its + value did not come from the command line args. :issue:`1649` +- Added ``ParameterSource.PROMPT`` to track parameter values that were + prompted for. :issue:`1649` +- Options with ``nargs`` > 1 no longer raise an error if a default is + not given. Parameters with ``nargs`` > 1 default to ``None``, and + parameters with ``multiple=True`` or ``nargs=-1`` default to an + empty tuple. :issue:`472` +- Handle empty env vars as though the option were not passed. This + extends the change introduced in 7.1 to be consistent in more cases. + :issue:`1285` +- ``Parameter.get_default()`` checks ``Context.default_map`` to + handle overrides consistently in help text, ``invoke()``, and + prompts. :issue:`1548` +- Add ``prompt_required`` param to ``Option``. When set to ``False``, + the user will only be prompted for an input if no value was passed. + :issue:`736` +- Providing the value to an option can be made optional through + ``is_flag=False``, and the value can instead be prompted for or + passed in as a default value. + :issue:`549, 736, 764, 921, 1015, 1618` +- Fix formatting when ``Command.options_metavar`` is empty. :pr:`1551` +- Revert adding space between option help text that wraps. + :issue:`1831` +- The default value passed to ``prompt`` will be cast to the correct + type like an input value would be. :pr:`1517` +- Automatically generated short help messages will stop at the first + ending of a phrase or double linebreak. :issue:`1082` +- Skip progress bar render steps for efficiency with very fast + iterators by setting ``update_min_steps``. :issue:`676` +- Respect ``case_sensitive=False`` when doing shell completion for + ``Choice`` :issue:`1692` +- Use ``mkstemp()`` instead of ``mktemp()`` in pager implementation. + :issue:`1752` +- If ``Option.show_default`` is a string, it is displayed even if + ``default`` is ``None``. :issue:`1732` +- ``click.get_terminal_size()`` is deprecated and will be removed in + 8.1. Use :func:`shutil.get_terminal_size` instead. :issue:`1736` +- Control the location of the temporary directory created by + ``CLIRunner.isolated_filesystem`` by passing ``temp_dir``. A custom + directory will not be removed automatically. :issue:`395` +- ``click.confirm()`` will prompt until input is given if called with + ``default=None``. :issue:`1381` +- Option prompts validate the value with the option's callback in + addition to its type. :issue:`457` +- ``confirmation_prompt`` can be set to a custom string. :issue:`723` +- Allow styled output in Jupyter on Windows. :issue:`1271` +- ``style()`` supports the ``strikethrough``, ``italic``, and + ``overline`` styles. :issue:`805, 1821` +- Multiline marker is removed from short help text. :issue:`1597` +- Restore progress bar behavior of echoing only the label if the file + is not a TTY. :issue:`1138` +- Progress bar output is shown even if execution time is less than 0.5 + seconds. :issue:`1648` +- Progress bar ``item_show_func`` shows the current item, not the + previous item. :issue:`1353` +- The ``Path`` param type can be passed ``path_type=pathlib.Path`` to + return a path object instead of a string. :issue:`405` +- ``TypeError`` is raised when parameter with ``multiple=True`` or + ``nargs > 1`` has non-iterable default. :issue:`1749` +- Add a ``pass_meta_key`` decorator for passing a key from + ``Context.meta``. This is useful for extensions using ``meta`` to + store information. :issue:`1739` +- ``Path`` ``resolve_path`` resolves symlinks on Windows Python < 3.8. + :issue:`1813` +- Command deprecation notice appears at the start of the help text, as + well as in the short help. The notice is not in all caps. + :issue:`1791` +- When taking arguments from ``sys.argv`` on Windows, glob patterns, + user dir, and env vars are expanded. :issue:`1096` +- Marked messages shown by the CLI with ``gettext()`` to allow + applications to translate Click's built-in strings. :issue:`303` +- Writing invalid characters to ``stderr`` when using the test runner + does not raise a ``UnicodeEncodeError``. :issue:`848` +- Fix an issue where ``readline`` would clear the entire ``prompt()`` + line instead of only the input when pressing backspace. :issue:`665` +- Add all kwargs passed to ``Context.invoke()`` to ``ctx.params``. + Fixes an inconsistency when nesting ``Context.forward()`` calls. + :issue:`1568` +- The ``MultiCommand.resultcallback`` decorator is renamed to + ``result_callback``. The old name is deprecated. :issue:`1160` +- Fix issues with ``CliRunner`` output when using ``echo_stdin=True``. + :issue:`1101` +- Fix a bug of ``click.utils.make_default_short_help`` for which the + returned string could be as long as ``max_width + 3``. :issue:`1849` +- When defining a parameter, ``default`` is validated with + ``multiple`` and ``nargs``. More validation is done for values being + processed as well. :issue:`1806` +- ``HelpFormatter.write_text`` uses the full line width when wrapping + text. :issue:`1871` + + Version 7.1.2 ------------- @@ -46,7 +328,7 @@ Released 2020-03-09 :issue:`1277`, :pr:`1318` - Add ``no_args_is_help`` option to ``click.Command``, defaults to False :pr:`1167` -- Add ``show_defaults`` parameter to ``Context`` to enable showing +- Add ``show_default`` parameter to ``Context`` to enable showing defaults globally. :issue:`1018` - Handle ``env MYPATH=''`` as though the option were not passed. :issue:`1196` @@ -90,6 +372,8 @@ Released 2020-03-09 - Make the warning about old 2-arg parameter callbacks a deprecation warning, to be removed in 8.0. This has been a warning since Click 2.0. :pr:`1492` +- Adjust error messages to standardize the types of quotes used so + they match error messages from Python. Version 7.0 @@ -480,15 +764,16 @@ Version 3.0 Released 2014-08-12, codename "clonk clonk" -- Formatter now no longer attempts to accomodate for terminals smaller - than 50 characters. If that happens it just assumes a minimal width. +- Formatter now no longer attempts to accommodate for terminals + smaller than 50 characters. If that happens it just assumes a + minimal width. - Added a way to not swallow exceptions in the test system. - Added better support for colors with pagers and ways to override the autodetection. - The CLI runner's result object now has a traceback attached. - Improved automatic short help detection to work better with dots that do not terminate sentences. -- When definining options without actual valid option strings now, +- When defining options without actual valid option strings now, Click will give an error message instead of silently passing. This should catch situations where users wanted to created arguments instead of options. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..f4ba197 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,76 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, sex characteristics, gender identity and expression, +level of experience, education, socio-economic status, nationality, personal +appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or + advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at report@palletsprojects.com. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see +https://www.contributor-covenant.org/faq diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst new file mode 100644 index 0000000..1ad3bdc --- /dev/null +++ b/CONTRIBUTING.rst @@ -0,0 +1,222 @@ +How to contribute to Click +========================== + +Thank you for considering contributing to Click! + + +Support questions +----------------- + +Please don't use the issue tracker for this. The issue tracker is a tool +to address bugs and feature requests in Click itself. Use one of the +following resources for questions about using Click or issues with your +own code: + +- The ``#get-help`` channel on our Discord chat: + https://discord.gg/pallets +- The mailing list flask@python.org for long term discussion or larger + issues. +- Ask on `Stack Overflow`_. Search with Google first using: + ``site:stackoverflow.com python click {search term, exception message, etc.}`` + +.. _Stack Overflow: https://stackoverflow.com/questions/tagged/python-click?tab=Frequent + + +Reporting issues +---------------- + +Include the following information in your post: + +- Describe what you expected to happen. +- If possible, include a `minimal reproducible example`_ to help us + identify the issue. This also helps check that the issue is not with + your own code. +- Describe what actually happened. Include the full traceback if there + was an exception. +- List your Python and Click versions. If possible, check if this + issue is already fixed in the latest releases or the latest code in + the repository. + +.. _minimal reproducible example: https://stackoverflow.com/help/minimal-reproducible-example + + +Submitting patches +------------------ + +If there is not an open issue for what you want to submit, prefer +opening one for discussion before working on a PR. You can work on any +issue that doesn't have an open PR linked to it or a maintainer assigned +to it. These show up in the sidebar. No need to ask if you can work on +an issue that interests you. + +Include the following in your patch: + +- Use `Black`_ to format your code. This and other tools will run + automatically if you install `pre-commit`_ using the instructions + below. +- Include tests if your patch adds or changes code. Make sure the test + fails without your patch. +- Update any relevant docs pages and docstrings. Docs pages and + docstrings should be wrapped at 72 characters. +- Add an entry in ``CHANGES.rst``. Use the same style as other + entries. Also include ``.. versionchanged::`` inline changelogs in + relevant docstrings. + +.. _Black: https://black.readthedocs.io +.. _pre-commit: https://pre-commit.com + + +First time setup +~~~~~~~~~~~~~~~~ + +- Download and install the `latest version of git`_. +- Configure git with your `username`_ and `email`_. + + .. code-block:: text + + $ git config --global user.name 'your name' + $ git config --global user.email 'your email' + +- Make sure you have a `GitHub account`_. +- Fork Click to your GitHub account by clicking the `Fork`_ button. +- `Clone`_ the main repository locally. + + .. code-block:: text + + $ git clone https://github.com/pallets/click + $ cd click + +- Add your fork as a remote to push your work to. Replace + ``{username}`` with your username. This names the remote "fork", the + default Pallets remote is "origin". + + .. code-block:: text + + $ git remote add fork https://github.com/{username}/click + +- Create a virtualenv. + + .. code-block:: text + + $ python3 -m venv env + $ . env/bin/activate + + On Windows, activating is different. + + .. code-block:: text + + > env\Scripts\activate + +- Upgrade pip and setuptools. + + .. code-block:: text + + $ python -m pip install --upgrade pip setuptools + +- Install the development dependencies, then install Click in + editable mode. + + .. code-block:: text + + $ pip install -r requirements/dev.txt && pip install -e . + +- Install the pre-commit hooks. + + .. code-block:: text + + $ pre-commit install + +.. _latest version of git: https://git-scm.com/downloads +.. _username: https://docs.github.com/en/github/using-git/setting-your-username-in-git +.. _email: https://docs.github.com/en/github/setting-up-and-managing-your-github-user-account/setting-your-commit-email-address +.. _GitHub account: https://github.com/join +.. _Fork: https://github.com/pallets/click/fork +.. _Clone: https://docs.github.com/en/github/getting-started-with-github/fork-a-repo#step-2-create-a-local-clone-of-your-fork + + +Start coding +~~~~~~~~~~~~ + +- Create a branch to identify the issue you would like to work on. If + you're submitting a bug or documentation fix, branch off of the + latest ".x" branch. + + .. code-block:: text + + $ git fetch origin + $ git checkout -b your-branch-name origin/8.0.x + + If you're submitting a feature addition or change, branch off of the + "main" branch. + + .. code-block:: text + + $ git fetch origin + $ git checkout -b your-branch-name origin/main + +- Using your favorite editor, make your changes, + `committing as you go`_. +- Include tests that cover any code changes you make. Make sure the + test fails without your patch. Run the tests as described below. +- Push your commits to your fork on GitHub and + `create a pull request`_. Link to the issue being addressed with + ``fixes #123`` in the pull request. + + .. code-block:: text + + $ git push --set-upstream fork your-branch-name + +.. _committing as you go: https://dont-be-afraid-to-commit.readthedocs.io/en/latest/git/commandlinegit.html#commit-your-changes +.. _create a pull request: https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request + + +Running the tests +~~~~~~~~~~~~~~~~~ + +Run the basic test suite with pytest. + +.. code-block:: text + + $ pytest + +This runs the tests for the current environment, which is usually +sufficient. CI will run the full suite when you submit your pull +request. You can run the full test suite with tox if you don't want to +wait. + +.. code-block:: text + + $ tox + + +Running test coverage +~~~~~~~~~~~~~~~~~~~~~ + +Generating a report of lines that do not have test coverage can indicate +where to start contributing. Run ``pytest`` using ``coverage`` and +generate a report. + +.. code-block:: text + + $ pip install coverage + $ coverage run -m pytest + $ coverage html + +Open ``htmlcov/index.html`` in your browser to explore the report. + +Read more about `coverage `__. + + +Building the docs +~~~~~~~~~~~~~~~~~ + +Build the docs in the ``docs`` directory using Sphinx. + +.. code-block:: text + + $ cd docs + $ make html + +Open ``_build/html/index.html`` in your browser to view the docs. + +Read more about `Sphinx `__. diff --git a/MANIFEST.in b/MANIFEST.in index b130d04..e5b231d 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,8 +1,10 @@ include CHANGES.rst include tox.ini +include requirements/*.txt graft artwork graft docs prune docs/_build graft examples graft tests +include src/click/py.typed global-exclude *.pyc diff --git a/PKG-INFO b/PKG-INFO deleted file mode 100644 index ee63c82..0000000 --- a/PKG-INFO +++ /dev/null @@ -1,100 +0,0 @@ -Metadata-Version: 1.2 -Name: click -Version: 7.1.2 -Summary: Composable command line interface toolkit -Home-page: https://palletsprojects.com/p/click/ -Maintainer: Pallets -Maintainer-email: contact@palletsprojects.com -License: BSD-3-Clause -Project-URL: Documentation, https://click.palletsprojects.com/ -Project-URL: Code, https://github.com/pallets/click -Project-URL: Issue tracker, https://github.com/pallets/click/issues -Description: \$ click\_ - ========== - - Click is a Python package for creating beautiful command line interfaces - in a composable way with as little code as necessary. It's the "Command - Line Interface Creation Kit". It's highly configurable but comes with - sensible defaults out of the box. - - It aims to make the process of writing command line tools quick and fun - while also preventing any frustration caused by the inability to - implement an intended CLI API. - - Click in three points: - - - Arbitrary nesting of commands - - Automatic help page generation - - Supports lazy loading of subcommands at runtime - - - Installing - ---------- - - Install and update using `pip`_: - - .. code-block:: text - - $ pip install -U click - - .. _pip: https://pip.pypa.io/en/stable/quickstart/ - - - A Simple Example - ---------------- - - .. code-block:: python - - import click - - @click.command() - @click.option("--count", default=1, help="Number of greetings.") - @click.option("--name", prompt="Your name", help="The person to greet.") - def hello(count, name): - """Simple program that greets NAME for a total of COUNT times.""" - for _ in range(count): - click.echo(f"Hello, {name}!") - - if __name__ == '__main__': - hello() - - .. code-block:: text - - $ python hello.py --count=3 - Your name: Click - Hello, Click! - Hello, Click! - Hello, Click! - - - Donate - ------ - - The Pallets organization develops and supports Click and other popular - packages. In order to grow the community of contributors and users, and - allow the maintainers to devote more time to the projects, `please - donate today`_. - - .. _please donate today: https://palletsprojects.com/donate - - - Links - ----- - - - Website: https://palletsprojects.com/p/click/ - - Documentation: https://click.palletsprojects.com/ - - Releases: https://pypi.org/project/click/ - - Code: https://github.com/pallets/click - - Issue tracker: https://github.com/pallets/click/issues - - Test status: https://dev.azure.com/pallets/click/_build - - Official chat: https://discord.gg/t6rrQZH - -Platform: UNKNOWN -Classifier: Development Status :: 5 - Production/Stable -Classifier: Intended Audience :: Developers -Classifier: License :: OSI Approved :: BSD License -Classifier: Operating System :: OS Independent -Classifier: Programming Language :: Python -Classifier: Programming Language :: Python :: 2 -Classifier: Programming Language :: Python :: 3 -Requires-Python: >=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.* diff --git a/README.rst b/README.rst index 8c4fea9..84c8750 100644 --- a/README.rst +++ b/README.rst @@ -26,7 +26,7 @@ Install and update using `pip`_: $ pip install -U click -.. _pip: https://pip.pypa.io/en/stable/quickstart/ +.. _pip: https://pip.pypa.io/en/stable/getting-started/ A Simple Example @@ -70,10 +70,11 @@ donate today`_. Links ----- -- Website: https://palletsprojects.com/p/click/ - Documentation: https://click.palletsprojects.com/ -- Releases: https://pypi.org/project/click/ -- Code: https://github.com/pallets/click -- Issue tracker: https://github.com/pallets/click/issues -- Test status: https://dev.azure.com/pallets/click/_build -- Official chat: https://discord.gg/t6rrQZH +- Changes: https://click.palletsprojects.com/changes/ +- PyPI Releases: https://pypi.org/project/click/ +- Source Code: https://github.com/pallets/click +- Issue Tracker: https://github.com/pallets/click/issues +- Website: https://palletsprojects.com/p/click +- Twitter: https://twitter.com/PalletsTeam +- Chat: https://discord.gg/pallets diff --git a/docs/.DS_Store b/docs/.DS_Store new file mode 100644 index 0000000..5a91862 Binary files /dev/null and b/docs/.DS_Store differ diff --git a/docs/advanced.rst b/docs/advanced.rst index f2569fe..3df492a 100644 --- a/docs/advanced.rst +++ b/docs/advanced.rst @@ -13,7 +13,7 @@ Command Aliases --------------- Many tools support aliases for commands (see `Command alias example -`_). +`_). For instance, you can configure ``git`` to accept ``git ci`` as alias for ``git commit``. Other tools also support auto-discovery for aliases by automatically shortening them. @@ -35,7 +35,6 @@ it would accept ``pus`` as an alias (so long as it was unique): .. click:example:: class AliasedGroup(click.Group): - def get_command(self, ctx, cmd_name): rv = click.Group.get_command(self, ctx, cmd_name) if rv is not None: @@ -46,7 +45,12 @@ it would accept ``pus`` as an alias (so long as it was unique): return None elif len(matches) == 1: return click.Group.get_command(self, ctx, matches[0]) - ctx.fail('Too many matches: %s' % ', '.join(sorted(matches))) + ctx.fail(f"Too many matches: {', '.join(sorted(matches))}") + + def resolve_command(self, ctx, args): + # always return the full command name + _, cmd, args = super().resolve_command(ctx, args) + return cmd.name, cmd, args And it can then be used like this: @@ -92,7 +96,7 @@ it's good to know that the system works this way. @click.option('--url', callback=open_url) def cli(url, fp=None): if fp is not None: - click.echo('%s: %s' % (url, fp.code)) + click.echo(f"{url}: {fp.code}") In this case the callback returns the URL unchanged but also passes a second ``fp`` value to the callback. What's more recommended is to pass @@ -116,7 +120,7 @@ the information in a wrapper however: @click.option('--url', callback=open_url) def cli(url): if url is not None: - click.echo('%s: %s' % (url.url, url.fp.code)) + click.echo(f"{url.url}: {url.fp.code}") Token Normalization @@ -140,7 +144,7 @@ function that converts the token to lowercase: @click.command(context_settings=CONTEXT_SETTINGS) @click.option('--name', default='Pete') def cli(name): - click.echo('Name: %s' % name) + click.echo(f"Name: {name}") And how it works on the command line: @@ -171,7 +175,7 @@ Example: @cli.command() @click.option('--count', default=1) def test(count): - click.echo('Count: %d' % count) + click.echo(f'Count: {count}') @cli.command() @click.option('--count', default=1) @@ -300,7 +304,7 @@ In the end you end up with something like this: """A fake wrapper around Python's timeit.""" cmdline = ['echo', 'python', '-mtimeit'] + list(timeit_args) if verbose: - click.echo('Invoking: %s' % ' '.join(cmdline)) + click.echo(f"Invoking: {' '.join(cmdline)}") call(cmdline) And what it looks like: @@ -379,3 +383,106 @@ do. However if you do use this for threading you need to be very careful as the vast majority of the context is not thread safe! You are only allowed to read from the context, but not to perform any modifications on it. + + +Detecting the Source of a Parameter +----------------------------------- + +In some situations it's helpful to understand whether or not an option +or parameter came from the command line, the environment, the default +value, or :attr:`Context.default_map`. The +:meth:`Context.get_parameter_source` method can be used to find this +out. It will return a member of the :class:`~click.core.ParameterSource` +enum. + +.. click:example:: + + @click.command() + @click.argument('port', nargs=1, default=8080, envvar="PORT") + @click.pass_context + def cli(ctx, port): + source = ctx.get_parameter_source("port") + click.echo(f"Port came from {source.name}") + +.. click:run:: + + invoke(cli, prog_name='cli', args=['8080']) + println() + invoke(cli, prog_name='cli', args=[], env={"PORT": "8080"}) + println() + invoke(cli, prog_name='cli', args=[]) + println() + + +Managing Resources +------------------ + +It can be useful to open a resource in a group, to be made available to +subcommands. Many types of resources need to be closed or otherwise +cleaned up after use. The standard way to do this in Python is by using +a context manager with the ``with`` statement. + +For example, the ``Repo`` class from :doc:`complex` might actually be +defined as a context manager: + +.. code-block:: python + + class Repo: + def __init__(self, home=None): + self.home = os.path.abspath(home or ".") + self.db = None + + def __enter__(self): + path = os.path.join(self.home, "repo.db") + self.db = open_database(path) + + def __exit__(self, exc_type, exc_value, tb): + self.db.close() + +Ordinarily, it would be used with the ``with`` statement: + +.. code-block:: python + + with Repo() as repo: + repo.db.query(...) + +However, a ``with`` block in a group would exit and close the database +before it could be used by a subcommand. + +Instead, use the context's :meth:`~click.Context.with_resource` method +to enter the context manager and return the resource. When the group and +any subcommands finish, the context's resources are cleaned up. + +.. code-block:: python + + @click.group() + @click.option("--repo-home", default=".repo") + @click.pass_context + def cli(ctx, repo_home): + ctx.obj = ctx.with_resource(Repo(repo_home)) + + @cli.command() + @click.pass_obj + def log(obj): + # obj is the repo opened in the cli group + for entry in obj.db.query(...): + click.echo(entry) + +If the resource isn't a context manager, usually it can be wrapped in +one using something from :mod:`contextlib`. If that's not possible, use +the context's :meth:`~click.Context.call_on_close` method to register a +cleanup function. + +.. code-block:: python + + @click.group() + @click.option("--name", default="repo.db") + @click.pass_context + def cli(ctx, repo_home): + ctx.obj = db = open_db(repo_home) + + @ctx.call_on_close + def close_db(): + db.record_use() + db.save() + db.close() diff --git a/docs/api.rst b/docs/api.rst index 22dd39f..5133085 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -31,6 +31,9 @@ Decorators .. autofunction:: make_pass_decorator +.. autofunction:: click.decorators.pass_meta_key + + Utilities --------- @@ -108,6 +111,11 @@ Context .. autofunction:: get_current_context +.. autoclass:: click.core.ParameterSource + :members: + :member-order: bysource + + Types ----- @@ -131,6 +139,10 @@ Types .. autoclass:: IntRange +.. autoclass:: FloatRange + +.. autoclass:: DateTime + .. autoclass:: Tuple .. autoclass:: ParamType @@ -169,6 +181,24 @@ Parsing .. autoclass:: OptionParser :members: + +Shell Completion +---------------- + +See :doc:`/shell-completion` for information about enabling and +customizing Click's shell completion system. + +.. currentmodule:: click.shell_completion + +.. autoclass:: CompletionItem + +.. autoclass:: ShellComplete + :members: + :member-order: bysource + +.. autofunction:: add_completion_class + + Testing ------- diff --git a/docs/arguments.rst b/docs/arguments.rst index 6ae3538..e5765c9 100644 --- a/docs/arguments.rst +++ b/docs/arguments.rst @@ -55,7 +55,7 @@ Example: def copy(src, dst): """Move file SRC to DST.""" for fn in src: - click.echo('move %s to folder %s' % (fn, dst)) + click.echo(f"move {fn} to folder {dst}") And what it looks like: diff --git a/docs/bashcomplete.rst b/docs/bashcomplete.rst deleted file mode 100644 index b2bb1b6..0000000 --- a/docs/bashcomplete.rst +++ /dev/null @@ -1,163 +0,0 @@ -Shell Completion -================ - -.. versionadded:: 2.0 - -Click can provide tab completion for commands, options, and choice -values. Bash, Zsh, and Fish are supported - -Completion is only available if a script is installed and invoked -through an entry point, not through the ``python`` command. See -:ref:`setuptools-integration`. - - -What it Completes ------------------ - -Generally, the shell completion support will complete commands, -options, and any option or argument values where the type is -:class:`click.Choice`. Options are only listed if at least a dash has -been entered. - -.. code-block:: text - - $ repo - clone commit copy delete setuser - $ repo clone - - --deep --help --rev --shallow -r - -Custom completions can be provided for argument and option values by -providing an ``autocompletion`` function that returns a list of strings. -This is useful when the suggestions need to be dynamically generated -completion time. The callback function will be passed 3 keyword -arguments: - -- ``ctx`` - The current command context. -- ``args`` - The list of arguments passed in. -- ``incomplete`` - The partial word that is being completed. May - be an empty string if no characters have been entered yet. - -Here is an example of using a callback function to generate dynamic -suggestions: - -.. code-block:: python - - import os - - def get_env_vars(ctx, args, incomplete): - return [k for k in os.environ.keys() if incomplete in k] - - @click.command() - @click.argument("envvar", type=click.STRING, autocompletion=get_env_vars) - def cmd1(envvar): - click.echo('Environment variable: %s' % envvar) - click.echo('Value: %s' % os.environ[envvar]) - - -Completion help strings ------------------------ - -ZSH and fish support showing documentation strings for completions. -These are taken from the help parameters of options and subcommands. For -dynamically generated completions a help string can be provided by -returning a tuple instead of a string. The first element of the tuple is -the completion and the second is the help string to display. - -Here is an example of using a callback function to generate dynamic -suggestions with help strings: - -.. code-block:: python - - import os - - def get_colors(ctx, args, incomplete): - colors = [('red', 'a warm color'), - ('blue', 'a cool color'), - ('green', 'the other starter color')] - return [c for c in colors if incomplete in c[0]] - - @click.command() - @click.argument("color", type=click.STRING, autocompletion=get_colors) - def cmd1(color): - click.echo('Chosen color is %s' % color) - - -Activation ----------- - -In order to activate shell completion, you need to inform your shell -that completion is available for your script. Any Click application -automatically provides support for that. If the program is executed with -a special ``__COMPLETE`` variable, the completion mechanism -is triggered instead of the normal command. ```` is the -executable name in uppercase with dashes replaced by underscores. - -If your tool is called ``foo-bar``, then the variable is called -``_FOO_BAR_COMPLETE``. By exporting it with the ``source_{shell}`` -value it will output the activation script to evaluate. - -Here are examples for a ``foo-bar`` script. - -For Bash, add this to ``~/.bashrc``: - -.. code-block:: text - - eval "$(_FOO_BAR_COMPLETE=source_bash foo-bar)" - -For Zsh, add this to ``~/.zshrc``: - -.. code-block:: text - - eval "$(_FOO_BAR_COMPLETE=source_zsh foo-bar)" - -For Fish, add this to ``~/.config/fish/completions/foo-bar.fish``: - -.. code-block:: text - - eval (env _FOO_BAR_COMPLETE=source_fish foo-bar) - -Open a new shell to enable completion. Or run the ``eval`` command -directly in your current shell to enable it temporarily. - - -Activation Script ------------------ - -The above ``eval`` examples will invoke your application every time a -shell is started. This may slow down shell startup time significantly. - -Alternatively, export the generated completion code as a static script -to be executed. You can ship this file with your builds; tools like Git -do this. At least Zsh will also cache the results of completion files, -but not ``eval`` scripts. - -For Bash: - -.. code-block:: text - - _FOO_BAR_COMPLETE=source_bash foo-bar > foo-bar-complete.sh - -For Zsh: - -.. code-block:: text - - _FOO_BAR_COMPLETE=source_zsh foo-bar > foo-bar-complete.sh - -For Fish: - -.. code-block:: text - - _FOO_BAR_COMPLETE=source_zsh foo-bar > foo-bar-complete.sh - -In ``.bashrc`` or ``.zshrc``, source the script instead of the ``eval`` -command: - -.. code-block:: text - - . /path/to/foo-bar-complete.sh - -For Fish, add the file to the completions directory: - -.. code-block:: text - - _FOO_BAR_COMPLETE=source_fish foo-bar > ~/.config/fish/completions/foo-bar-complete.fish diff --git a/docs/changelog.rst b/docs/changes.rst similarity index 100% rename from docs/changelog.rst rename to docs/changes.rst diff --git a/docs/commands.rst b/docs/commands.rst index c32e917..b70992e 100644 --- a/docs/commands.rst +++ b/docs/commands.rst @@ -25,7 +25,7 @@ when an inner command runs: @click.group() @click.option('--debug/--no-debug', default=False) def cli(debug): - click.echo('Debug mode is %s' % ('on' if debug else 'off')) + click.echo(f"Debug mode is {'on' if debug else 'off'}") @cli.command() # @cli, not @click! def sync(): @@ -96,7 +96,7 @@ script like this: @cli.command() @click.pass_context def sync(ctx): - click.echo('Debug is %s' % (ctx.obj['DEBUG'] and 'on' or 'off')) + click.echo(f"Debug is {'on' if ctx.obj['DEBUG'] else 'off'}") if __name__ == '__main__': cli(obj={}) @@ -166,7 +166,7 @@ Example: if ctx.invoked_subcommand is None: click.echo('I was invoked without subcommand') else: - click.echo('I am about to invoke %s' % ctx.invoked_subcommand) + click.echo(f"I am about to invoke {ctx.invoked_subcommand}") @cli.command() def sync(): @@ -202,7 +202,7 @@ A custom multi command just needs to implement a list and load method: def list_commands(self, ctx): rv = [] for filename in os.listdir(plugin_folder): - if filename.endswith('.py'): + if filename.endswith('.py') and filename != '__init__.py': rv.append(filename[:-3]) rv.sort() return rv @@ -287,7 +287,7 @@ Multi Command Chaining Sometimes it is useful to be allowed to invoke more than one subcommand in one go. For instance if you have installed a setuptools package before you might be familiar with the ``setup.py sdist bdist_wheel upload`` -command chain which invokes ``dist`` before ``bdist_wheel`` before +command chain which invokes ``sdist`` before ``bdist_wheel`` before ``upload``. Starting with Click 3.0 this is very simple to implement. All you have to do is to pass ``chain=True`` to your multicommand: @@ -351,7 +351,7 @@ how to do its processing. At that point it then returns a processing function and returns. Where do the returned functions go? The chained multicommand can register -a callback with :meth:`MultiCommand.resultcallback` that goes over all +a callback with :meth:`MultiCommand.result_callback` that goes over all these functions and then invoke them. To make this a bit more concrete consider this example: @@ -363,7 +363,7 @@ To make this a bit more concrete consider this example: def cli(input): pass - @cli.resultcallback() + @cli.result_callback() def process_pipeline(processors, input): iterator = (x.rstrip('\r\n') for x in input) for processor in processors: @@ -422,7 +422,7 @@ to not use the file type and manually open the file through For a more complex example that also improves upon handling of the pipelines have a look at the `imagepipe multi command chaining demo -`__ in +`__ in the Click repository. It implements a pipeline based image editing tool that has a nice internal structure for the pipelines. @@ -466,7 +466,7 @@ Example usage: @cli.command() @click.option('--port', default=8000) def runserver(port): - click.echo('Serving on http://127.0.0.1:%d/' % port) + click.echo(f"Serving on http://127.0.0.1:{port}/") if __name__ == '__main__': cli(default_map={ @@ -512,7 +512,7 @@ This example does the same as the previous example: @cli.command() @click.option('--port', default=8000) def runserver(port): - click.echo('Serving on http://127.0.0.1:%d/' % port) + click.echo(f"Serving on http://127.0.0.1:{port}/") if __name__ == '__main__': cli() diff --git a/docs/conf.py b/docs/conf.py index f04804c..b0ed9ff 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,12 +1,17 @@ from pallets_sphinx_themes import get_version from pallets_sphinx_themes import ProjectLink +import click._compat + +# compat until pallets-sphinx-themes is updated +click._compat.text_type = str + # Project -------------------------------------------------------------- project = "Click" copyright = "2014 Pallets" author = "Pallets" -release, version = get_version("Click", version_length=1) +release, version = get_version("Click") # General -------------------------------------------------------------- @@ -17,7 +22,9 @@ extensions = [ "sphinxcontrib.log_cabinet", "pallets_sphinx_themes", "sphinx_issues", + "sphinx_tabs.tabs", ] +autodoc_typehints = "description" intersphinx_mapping = {"python": ("https://docs.python.org/3/", None)} issues_github_path = "pallets/click" @@ -27,18 +34,20 @@ html_theme = "click" html_theme_options = {"index_sidebar_logo": False} html_context = { "project_links": [ - ProjectLink("Donate to Pallets", "https://palletsprojects.com/donate"), - ProjectLink("Click Website", "https://palletsprojects.com/p/click/"), - ProjectLink("PyPI releases", "https://pypi.org/project/click/"), + ProjectLink("Donate", "https://palletsprojects.com/donate"), + ProjectLink("PyPI Releases", "https://pypi.org/project/click/"), ProjectLink("Source Code", "https://github.com/pallets/click/"), ProjectLink("Issue Tracker", "https://github.com/pallets/click/issues/"), + ProjectLink("Website", "https://palletsprojects.com/p/click"), + ProjectLink("Twitter", "https://twitter.com/PalletsTeam"), + ProjectLink("Chat", "https://discord.gg/pallets"), ] } html_sidebars = { - "index": ["project.html", "localtoc.html", "searchbox.html"], - "**": ["localtoc.html", "relations.html", "searchbox.html"], + "index": ["project.html", "localtoc.html", "searchbox.html", "ethicalads.html"], + "**": ["localtoc.html", "relations.html", "searchbox.html", "ethicalads.html"], } -singlehtml_sidebars = {"index": ["project.html", "localtoc.html"]} +singlehtml_sidebars = {"index": ["project.html", "localtoc.html", "ethicalads.html"]} html_static_path = ["_static"] html_favicon = "_static/click-icon.png" html_logo = "_static/click-logo-sidebar.png" diff --git a/docs/documentation.rst b/docs/documentation.rst index 982ad42..181c5b3 100644 --- a/docs/documentation.rst +++ b/docs/documentation.rst @@ -24,7 +24,7 @@ Simple example: def hello(count, name): """This script prints hello NAME COUNT times.""" for x in range(count): - click.echo('Hello %s!' % name) + click.echo(f"Hello {name}!") And what it looks like: @@ -173,7 +173,7 @@ desired. This can be customized at all levels: def hello(count, name): """This script prints hello times.""" for x in range(count): - click.echo('Hello %s!' % name) + click.echo(f"Hello {name}!") Example: diff --git a/docs/index.rst b/docs/index.rst index 7fac849..ad965a3 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -36,7 +36,7 @@ What does it look like? Here is an example of a simple Click program: def hello(count, name): """Simple program that greets NAME for a total of COUNT times.""" for x in range(count): - click.echo('Hello %s!' % name) + click.echo(f"Hello {name}!") if __name__ == '__main__': hello() @@ -79,9 +79,9 @@ usage patterns. advanced testing utils - bashcomplete + shell-completion exceptions - python3 + unicode-support wincmd API Reference @@ -102,6 +102,6 @@ Miscellaneous Pages :maxdepth: 2 contrib - changelog upgrading license + changes diff --git a/docs/options.rst b/docs/options.rst index ac5aeae..277a98b 100644 --- a/docs/options.rst +++ b/docs/options.rst @@ -85,7 +85,7 @@ simply pass in `required=True` as an argument to the decorator. @click.option('--from', '-f', 'from_') @click.option('--to', '-t') def reserved_param_name(from_, to): - click.echo('from %s to %s' % (from_, to)) + click.echo(f"from {from_} to {to}") And on the command line: @@ -121,7 +121,8 @@ the ``nargs`` parameter. The values are then stored as a tuple. @click.command() @click.option('--pos', nargs=2, type=float) def findme(pos): - click.echo('%s / %s' % pos) + a, b = pos + click.echo(f"{a} / {b}") And on the command line: @@ -146,7 +147,8 @@ the tuple. For this you can directly specify a tuple as type: @click.command() @click.option('--item', type=(str, int)) def putitem(item): - click.echo('name=%s id=%d' % item) + name, id = item + click.echo(f"name={name} id={id}") And on the command line: @@ -163,7 +165,8 @@ used. The above example is thus equivalent to this: @click.command() @click.option('--item', nargs=2, type=click.Tuple([str, int])) def putitem(item): - click.echo('name=%s id=%d' % item) + name, id = item + click.echo(f"name={name} id={id}") .. _multiple-options: @@ -212,7 +215,7 @@ for instance: @click.command() @click.option('-v', '--verbose', count=True) def log(verbose): - click.echo('Verbosity: %s' % verbose) + click.echo(f"Verbosity: {verbose}") And on the command line: @@ -250,6 +253,7 @@ And on the command line: invoke(info, args=['--shout']) invoke(info, args=['--no-shout']) + invoke(info) If you really don't want an off-switch, you can just define one and manually inform Click that something is a flag: @@ -271,6 +275,7 @@ And on the command line: .. click:run:: invoke(info, args=['--shout']) + invoke(info) Note that if a slash is contained in your option already (for instance, if you use Windows-style parameters where ``/`` is the prefix character), you @@ -281,7 +286,7 @@ can alternatively split the parameters through ``;`` instead: @click.command() @click.option('/debug;/no-debug') def log(debug): - click.echo('debug=%s' % debug) + click.echo(f"debug={debug}") if __name__ == '__main__': log() @@ -402,7 +407,7 @@ Example: @click.command() @click.option('--name', prompt=True) def hello(name): - click.echo('Hello %s!' % name) + click.echo(f"Hello {name}!") And what it looks like: @@ -419,7 +424,7 @@ a different one: @click.command() @click.option('--name', prompt='Your name please') def hello(name): - click.echo('Hello %s!' % name) + click.echo(f"Hello {name}!") What it looks like: @@ -430,6 +435,10 @@ What it looks like: It is advised that prompt not be used in conjunction with the multiple flag set to True. Instead, prompt in the function interactively. +By default, the user will be prompted for an input if one was not passed +through the command line. To turn this behavior off, see +:ref:`optional-value`. + Password Prompts ---------------- @@ -439,27 +448,30 @@ useful for password input: .. click:example:: - @click.command() - @click.option('--password', prompt=True, hide_input=True, - confirmation_prompt=True) - def encrypt(password): - click.echo('Encrypting password to %s' % password.encode('rot13')) + import codecs -What it looks like: + @click.command() + @click.option( + "--password", prompt=True, hide_input=True, + confirmation_prompt=True + ) + def encode(password): + click.echo(f"encoded: {codecs.encode(password, 'rot13')}") .. click:run:: - invoke(encrypt, input=['secret', 'secret']) + invoke(encode, input=['secret', 'secret']) Because this combination of parameters is quite common, this can also be replaced with the :func:`password_option` decorator: -.. click:example:: +.. code-block:: python @click.command() @click.password_option() def encrypt(password): - click.echo('Encrypting password to %s' % password.encode('rot13')) + click.echo(f"encoded: to {codecs.encode(password, 'rot13')}") + Dynamic Defaults for Prompts ---------------------------- @@ -474,28 +486,37 @@ prompted if the option isn't specified on the command line, you can do so by supplying a callable as the default value. For example, to get a default from the environment: -.. click:example:: +.. code-block:: python + + import os @click.command() - @click.option('--username', prompt=True, - default=lambda: os.environ.get('USER', '')) + @click.option( + "--username", prompt=True, + default=lambda: os.environ.get("USER", "") + ) def hello(username): - print("Hello,", username) + click.echo(f"Hello, {username}!") To describe what the default value will be, set it in ``show_default``. .. click:example:: + import os + @click.command() - @click.option('--username', prompt=True, - default=lambda: os.environ.get('USER', ''), - show_default='current user') + @click.option( + "--username", prompt=True, + default=lambda: os.environ.get("USER", ""), + show_default="current user" + ) def hello(username): - print("Hello,", username) + click.echo(f"Hello, {username}!") .. click:run:: - invoke(hello, args=['--help']) + invoke(hello, args=["--help"]) + Callbacks and Eager Options --------------------------- @@ -625,7 +646,7 @@ Example usage: @click.command() @click.option('--username') def greet(username): - click.echo('Hello %s!' % username) + click.echo(f'Hello {username}!') if __name__ == '__main__': greet(auto_envvar_prefix='GREETER') @@ -650,12 +671,12 @@ Example: @click.group() @click.option('--debug/--no-debug') def cli(debug): - click.echo('Debug mode is %s' % ('on' if debug else 'off')) + click.echo(f"Debug mode is {'on' if debug else 'off'}") @cli.command() @click.option('--username') def greet(username): - click.echo('Hello %s!' % username) + click.echo(f"Hello {username}!") if __name__ == '__main__': cli(auto_envvar_prefix='GREETER') @@ -677,7 +698,7 @@ Example usage: @click.command() @click.option('--username', envvar='USERNAME') def greet(username): - click.echo('Hello %s!' % username) + click.echo(f"Hello {username}!") if __name__ == '__main__': greet() @@ -726,7 +747,7 @@ And from the command line: .. click:run:: import os - invoke(perform, env={'PATHS': './foo/bar%s./test' % os.path.pathsep}) + invoke(perform, env={"PATHS": f"./foo/bar{os.path.pathsep}./test"}) Other Prefix Characters ----------------------- @@ -742,7 +763,7 @@ POSIX semantics. However in certain situations this can be useful: @click.command() @click.option('+w/-w') def chmod(w): - click.echo('writable=%s' % w) + click.echo(f"writable={w}") if __name__ == '__main__': chmod() @@ -762,7 +783,7 @@ boolean flag you need to separate it with ``;`` instead of ``/``: @click.command() @click.option('/debug;/no-debug') def log(debug): - click.echo('debug=%s' % debug) + click.echo(f"debug={debug}") if __name__ == '__main__': log() @@ -772,39 +793,34 @@ boolean flag you need to separate it with ``;`` instead of ``/``: Range Options ------------- -A special mention should go to the :class:`IntRange` type, which works very -similarly to the :data:`INT` type, but restricts the value to fall into a -specific range (inclusive on both edges). It has two modes: +The :class:`IntRange` type extends the :data:`INT` type to ensure the +value is contained in the given range. The :class:`FloatRange` type does +the same for :data:`FLOAT`. -- the default mode (non-clamping mode) where a value that falls outside - of the range will cause an error. -- an optional clamping mode where a value that falls outside of the - range will be clamped. This means that a range of ``0-5`` would - return ``5`` for the value ``10`` or ``0`` for the value ``-1`` (for - example). +If ``min`` or ``max`` is omitted, that side is *unbounded*. Any value in +that direction is accepted. By default, both bounds are *closed*, which +means the boundary value is included in the accepted range. ``min_open`` +and ``max_open`` can be used to exclude that boundary from the range. -Example: +If ``clamp`` mode is enabled, a value that is outside the range is set +to the boundary instead of failing. For example, the range ``0, 5`` +would return ``5`` for the value ``10``, or ``0`` for the value ``-1``. +When using :class:`FloatRange`, ``clamp`` can only be enabled if both +bounds are *closed* (the default). .. click:example:: @click.command() - @click.option('--count', type=click.IntRange(0, 20, clamp=True)) - @click.option('--digit', type=click.IntRange(0, 10)) + @click.option("--count", type=click.IntRange(0, 20, clamp=True)) + @click.option("--digit", type=click.IntRange(0, 9)) def repeat(count, digit): click.echo(str(digit) * count) - if __name__ == '__main__': - repeat() - -And from the command line: - .. click:run:: - invoke(repeat, args=['--count=1000', '--digit=5']) - invoke(repeat, args=['--count=1000', '--digit=12']) + invoke(repeat, args=['--count=100', '--digit=5']) + invoke(repeat, args=['--count=6', '--digit=12']) -If you pass ``None`` for any of the edges, it means that the range is open -at that side. Callbacks for Validation ------------------------ @@ -812,37 +828,87 @@ Callbacks for Validation .. versionchanged:: 2.0 If you want to apply custom validation logic, you can do this in the -parameter callbacks. These callbacks can both modify values as well as -raise errors if the validation does not work. +parameter callbacks. These callbacks can both modify values as well as +raise errors if the validation does not work. The callback runs after +type conversion. It is called for all sources, including prompts. In Click 1.0, you can only raise the :exc:`UsageError` but starting with Click 2.0, you can also raise the :exc:`BadParameter` error, which has the added advantage that it will automatically format the error message to also contain the parameter name. -Example: - .. click:example:: def validate_rolls(ctx, param, value): + if isinstance(value, tuple): + return value + try: - rolls, dice = map(int, value.split('d', 2)) - return (dice, rolls) + rolls, _, dice = value.partition("d") + return int(dice), int(rolls) except ValueError: - raise click.BadParameter('rolls need to be in format NdM') + raise click.BadParameter("format must be 'NdM'") @click.command() - @click.option('--rolls', callback=validate_rolls, default='1d6') + @click.option( + "--rolls", type=click.UNPROCESSED, callback=validate_rolls, + default="1d6", prompt=True, + ) def roll(rolls): - click.echo('Rolling a %d-sided dice %d time(s)' % rolls) - - if __name__ == '__main__': - roll() - -And what it looks like: + sides, times = rolls + click.echo(f"Rolling a {sides}-sided dice {times} time(s)") .. click:run:: - invoke(roll, args=['--rolls=42']) + invoke(roll, args=["--rolls=42"]) println() - invoke(roll, args=['--rolls=2d12']) + invoke(roll, args=["--rolls=2d12"]) + println() + invoke(roll, input=["42", "2d12"]) + + +.. _optional-value: + +Optional Value +-------------- + +Providing the value to an option can be made optional, in which case +providing only the option's flag without a value will either show a +prompt or use its ``flag_value``. + +Setting ``is_flag=False, flag_value=value`` tells Click that the option +can still be passed a value, but if only the flag is given the +``flag_value`` is used. + +.. click:example:: + + @click.command() + @click.option("--name", is_flag=False, flag_value="Flag", default="Default") + def hello(name): + click.echo(f"Hello, {name}!") + +.. click:run:: + + invoke(hello, args=[]) + invoke(hello, args=["--name", "Value"]) + invoke(hello, args=["--name"]) + +If the option has ``prompt`` enabled, then setting +``prompt_required=False`` tells Click to only show the prompt if the +option's flag is given, instead of if the option is not provided at all. + +.. click:example:: + + @click.command() + @click.option('--name', prompt=True, prompt_required=False, default="Default") + def hello(name): + click.echo(f"Hello {name}!") + +.. click:run:: + + invoke(hello) + invoke(hello, args=["--name", "Value"]) + invoke(hello, args=["--name"], input="Prompt") + +If ``required=True``, then the option will still prompt if it is not +given, but it will also prompt if only the flag is given. diff --git a/docs/parameters.rst b/docs/parameters.rst index f5f181f..b3604e7 100644 --- a/docs/parameters.rst +++ b/docs/parameters.rst @@ -47,10 +47,10 @@ different behavior and some are supported out of the box: A parameter that only accepts floating point values. ``bool`` / :data:`click.BOOL`: - A parameter that accepts boolean values. This is automatically used - for boolean flags. If used with string values ``1``, ``yes``, ``y``, ``t`` - and ``true`` convert to `True` and ``0``, ``no``, ``n``, ``f`` and ``false`` - convert to `False`. + A parameter that accepts boolean values. This is automatically used + for boolean flags. The string values "1", "true", "t", "yes", "y", + and "on" convert to ``True``. "0", "false", "f", "no", "n", and + "off" convert to ``False``. :data:`click.UUID`: A parameter that accepts UUID values. This is not automatically @@ -118,19 +118,15 @@ integers. name = "integer" def convert(self, value, param, ctx): + if isinstance(value, int): + return value + try: if value[:2].lower() == "0x": return int(value[2:], 16) elif value[:1] == "0": return int(value, 8) return int(value, 10) - except TypeError: - self.fail( - "expected string for int() conversion, got " - f"{value!r} of type {type(value).__name__}", - param, - ctx, - ) except ValueError: self.fail(f"{value!r} is not a valid integer", param, ctx) @@ -140,3 +136,8 @@ The :attr:`~ParamType.name` attribute is optional and is used for documentation. Call :meth:`~ParamType.fail` if conversion fails. The ``param`` and ``ctx`` arguments may be ``None`` in some cases such as prompts. + +Values from user input or the command line will be strings, but default +values and Python arguments may already be the correct type. The custom +type should check at the top if the value is already valid and pass it +through to support those cases. diff --git a/docs/python3.rst b/docs/python3.rst deleted file mode 100644 index c1736a8..0000000 --- a/docs/python3.rst +++ /dev/null @@ -1,191 +0,0 @@ -Python 3 Support -================ - -.. currentmodule:: click - -Click supports Python 3, but like all other command line utility libraries, -it suffers from the Unicode text model in Python 3. All examples in the -documentation were written so that they could run on both Python 2.x and -Python 3.4 or higher. - -.. _python3-limitations: - -Python 3 Limitations --------------------- - -At the moment, Click suffers from a few problems with Python 3: - -* The command line in Unix traditionally is in bytes, not Unicode. While - there are encoding hints for all of this, there are generally some - situations where this can break. The most common one is SSH - connections to machines with different locales. - - Misconfigured environments can currently cause a wide range of Unicode - problems in Python 3 due to the lack of support for roundtripping - surrogate escapes. This will not be fixed in Click itself! - - For more information see :ref:`python3-surrogates`. - -* Standard input and output in Python 3 is opened in Unicode mode by - default. Click has to reopen the stream in binary mode in certain - situations. Because there is no standardized way to do this, this - might not always work. Primarily this can become a problem when - testing command-line applications. - - This is not supported:: - - sys.stdin = io.StringIO('Input here') - sys.stdout = io.StringIO() - - Instead you need to do this:: - - input = 'Input here' - in_stream = io.BytesIO(input.encode('utf-8')) - sys.stdin = io.TextIOWrapper(in_stream, encoding='utf-8') - out_stream = io.BytesIO() - sys.stdout = io.TextIOWrapper(out_stream, encoding='utf-8') - - Remember that in that case, you need to use ``out_stream.getvalue()`` - and not ``sys.stdout.getvalue()`` if you want to access the buffer - contents as the wrapper will not forward that method. - -Python 2 and 3 Differences --------------------------- - -Click attempts to minimize the differences between Python 2 and Python 3 -by following best practices for both languages. - -In Python 2, the following is true: - -* ``sys.stdin``, ``sys.stdout``, and ``sys.stderr`` are opened in binary - mode, but under some circumstances they support Unicode output. Click - attempts to not subvert this but provides support for forcing streams - to be Unicode-based. -* ``sys.argv`` is always byte-based. Click will pass bytes to all - input types and convert as necessary. The :class:`STRING` type - automatically will decode properly the input value into a string by - trying the most appropriate encodings. -* When dealing with files, Click will never go through the Unicode APIs - and will instead use the operating system's byte APIs to open the - files. - -In Python 3, the following is true: - -* ``sys.stdin``, ``sys.stdout`` and ``sys.stderr`` are by default - text-based. When Click needs a binary stream, it attempts to discover - the underlying binary stream. See :ref:`python3-limitations` for how - this works. -* ``sys.argv`` is always Unicode-based. This also means that the native - type for input values to the types in Click is Unicode, and not bytes. - - This causes problems if the terminal is incorrectly set and Python - does not figure out the encoding. In that case, the Unicode string - will contain error bytes encoded as surrogate escapes. -* When dealing with files, Click will always use the Unicode file system - API calls by using the operating system's reported or guessed - filesystem encoding. Surrogates are supported for filenames, so it - should be possible to open files through the :class:`File` type even - if the environment is misconfigured. - -.. _python3-surrogates: - -Python 3 Surrogate Handling ---------------------------- - -Click in Python 3 does all the Unicode handling in the standard library -and is subject to its behavior. In Python 2, Click does all the Unicode -handling itself, which means there are differences in error behavior. - -The most glaring difference is that in Python 2, Unicode will "just work", -while in Python 3, it requires extra care. The reason for this is that in -Python 3, the encoding detection is done in the interpreter, and on Linux -and certain other operating systems, its encoding handling is problematic. - -The biggest source of frustration is that Click scripts invoked by -init systems (sysvinit, upstart, systemd, etc.), deployment tools (salt, -puppet), or cron jobs (cron) will refuse to work unless a Unicode locale is -exported. - -If Click encounters such an environment it will prevent further execution -to force you to set a locale. This is done because Click cannot know -about the state of the system once it's invoked and restore the values -before Python's Unicode handling kicked in. - -If you see something like this error in Python 3:: - - Traceback (most recent call last): - ... - RuntimeError: Click will abort further execution because Python 3 was - configured to use ASCII as encoding for the environment. Either switch - to Python 2 or consult the Python 3 section of the docs for - mitigation steps. - -.. note:: - - In Python 3.7 and later you will no longer get a ``RuntimeError`` in - many cases thanks to :pep:`538` and :pep:`540`, which changed the - default assumption in unconfigured environments. - -You are dealing with an environment where Python 3 thinks you are -restricted to ASCII data. The solution to these problems is different -depending on which locale your computer is running in. - -For instance, if you have a German Linux machine, you can fix the problem -by exporting the locale to ``de_DE.utf-8``:: - - export LC_ALL=de_DE.utf-8 - export LANG=de_DE.utf-8 - -If you are on a US machine, ``en_US.utf-8`` is the encoding of choice. On -some newer Linux systems, you could also try ``C.UTF-8`` as the locale:: - - export LC_ALL=C.UTF-8 - export LANG=C.UTF-8 - -On some systems it was reported that `UTF-8` has to be written as `UTF8` -and vice versa. To see which locales are supported you can invoke -``locale -a``:: - - locale -a - -You need to do this before you invoke your Python script. If you are -curious about the reasons for this, you can join the discussions in the -Python 3 bug tracker: - -* `ASCII is a bad filesystem default encoding - `_ -* `Use surrogateescape as default error handler - `_ -* `Python 3 raises Unicode errors in the C locale - `_ -* `LC_CTYPE=C: pydoc leaves terminal in an unusable state - `_ (this is relevant to Click - because the pager support is provided by the stdlib pydoc module) - -Note (Python 3.7 onwards): Even though your locale may not be properly -configured, Python 3.7 Click will not raise the above exception because Python -3.7 programs are better at choosing default locales. This doesn't change the -general issue that your locale may be misconfigured. - -Unicode Literals ----------------- - -Starting with Click 5.0 there will be a warning for the use of the -``unicode_literals`` future import in Python 2. This has been done due to -the negative consequences of this import with regards to unintentionally -causing bugs due to introducing Unicode data to APIs that are incapable of -handling them. For some examples of this issue, see the discussion on -this github issue: `python-future#22 -`_. - -If you use ``unicode_literals`` in any file that defines a Click command -or that invokes a click command you will be given a warning. You are -strongly encouraged to not use ``unicode_literals`` and instead use -explicit ``u`` prefixes for your Unicode strings. - -If you do want to ignore the warning and continue using -``unicode_literals`` on your own peril, you can disable the warning as -follows:: - - import click - click.disable_unicode_literals_warning = True diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 3644509..90cf467 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -32,12 +32,7 @@ install separate copies of Python, but it does provide a clever way to keep different project environments isolated. Let's see how virtualenv works. -If you are on Mac OS X or Linux, chances are that one of the following two -commands will work for you:: - - $ sudo easy_install virtualenv - -or even better:: +If you are on Mac OS X or Linux:: $ pip install virtualenv --user @@ -84,7 +79,7 @@ After doing this, the prompt of your shell should be as familiar as before. Now, let's move on. Enter the following command to get Click activated in your virtualenv:: - $ pip install Click + $ pip install click A few seconds later and you are good to go. @@ -102,23 +97,23 @@ Examples of Click applications can be found in the documentation as well as in the GitHub repository together with readme files: * ``inout``: `File input and output - `_ + `_ * ``naval``: `Port of docopt naval example - `_ + `_ * ``aliases``: `Command alias example - `_ + `_ * ``repo``: `Git-/Mercurial-like command line interface - `_ + `_ * ``complex``: `Complex example with plugin loading - `_ + `_ * ``validation``: `Custom parameter validation example - `_ -* ``colors``: `Colorama ANSI color support - `_ + `_ +* ``colors``: `Color support demo + `_ * ``termui``: `Terminal UI functions demo - `_ + `_ * ``imagepipe``: `Multi command chaining demo - `_ + `_ Basic Concepts - Creating a Command ----------------------------------- @@ -162,7 +157,7 @@ Echoing Why does this example use :func:`echo` instead of the regular :func:`print` function? The answer to this question is that Click -attempts to support both Python 2 and Python 3 the same way and to be very +attempts to support different environments consistently and to be very robust even when the environment is misconfigured. Click wants to be functional at least on a basic level even if everything is completely broken. @@ -171,12 +166,10 @@ What this means is that the :func:`echo` function applies some error correction in case the terminal is misconfigured instead of dying with an :exc:`UnicodeError`. -As an added benefit, starting with Click 2.0, the echo function also -has good support for ANSI colors. It will automatically strip ANSI codes -if the output stream is a file and if colorama is supported, ANSI colors -will also work on Windows. Note that in Python 2, the :func:`echo` function -does not parse color code information from bytearrays. See :ref:`ansi-colors` -for more information. +The echo function also supports color and other styles in output. It +will automatically remove styles if the output stream is a file. On +Windows, colorama is automatically installed and used. See +:ref:`ansi-colors`. If you don't need this, you can also use the `print()` construct / function. @@ -233,6 +226,30 @@ other invocations:: if __name__ == '__main__': cli() + +Registering Commands Later +-------------------------- + +Instead of using the ``@group.command()`` decorator, commands can be +decorated with the plain ``@click.command()`` decorator and registered +with a group later with ``group.add_command()``. This could be used to +split commands into multiple Python modules. + +.. code-block:: python + + @click.command() + def greet(): + click.echo("Hello, World!") + +.. code-block:: python + + @click.group() + def group(): + pass + + group.add_command(greet) + + Adding Parameters ----------------- @@ -245,7 +262,7 @@ To add parameters, use the :func:`option` and :func:`argument` decorators: @click.argument('name') def hello(count, name): for x in range(count): - click.echo('Hello %s!' % name) + click.echo(f"Hello {name}!") What it looks like: diff --git a/docs/requirements.txt b/docs/requirements.txt deleted file mode 100644 index a8f28e2..0000000 --- a/docs/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -Sphinx~=2.4.4 -Pallets-Sphinx-Themes~=1.2.3 -sphinxcontrib-log-cabinet~=1.0.1 -sphinx-issues~=1.2.0 diff --git a/docs/setuptools.rst b/docs/setuptools.rst index 870591a..62999d7 100644 --- a/docs/setuptools.rst +++ b/docs/setuptools.rst @@ -43,7 +43,9 @@ Introduction To bundle your script with setuptools, all you need is the script in a Python package and a ``setup.py`` file. -Imagine this directory structure:: +Imagine this directory structure: + +.. code-block:: text yourscript.py setup.py @@ -59,21 +61,24 @@ Contents of ``yourscript.py``: """Example script.""" click.echo('Hello World!') -Contents of ``setup.py``:: +Contents of ``setup.py``: + +.. code-block:: python from setuptools import setup setup( name='yourscript', - version='0.1', + version='0.1.0', py_modules=['yourscript'], install_requires=[ 'Click', ], - entry_points=''' - [console_scripts] - yourscript=yourscript:cli - ''', + entry_points={ + 'console_scripts': [ + 'yourscript = yourscript:cli', + ], + }, ) The magic is in the ``entry_points`` parameter. Below @@ -88,7 +93,9 @@ Testing The Script ------------------ To test the script, you can make a new virtualenv and then install your -package:: +package: + +.. code-block:: console $ virtualenv venv $ . venv/bin/activate @@ -105,35 +112,42 @@ Scripts in Packages If your script is growing and you want to switch over to your script being contained in a Python package the changes necessary are minimal. Let's -assume your directory structure changed to this:: +assume your directory structure changed to this: - yourpackage/ - __init__.py - main.py - utils.py - scripts/ +.. code-block:: text + + project/ + yourpackage/ __init__.py - yourscript.py + main.py + utils.py + scripts/ + __init__.py + yourscript.py + setup.py In this case instead of using ``py_modules`` in your ``setup.py`` file you can use ``packages`` and the automatic package finding support of setuptools. In addition to that it's also recommended to include other package data. -These would be the modified contents of ``setup.py``:: +These would be the modified contents of ``setup.py``: + +.. code-block:: python from setuptools import setup, find_packages setup( name='yourpackage', - version='0.1', + version='0.1.0', packages=find_packages(), include_package_data=True, install_requires=[ 'Click', ], - entry_points=''' - [console_scripts] - yourscript=yourpackage.scripts.yourscript:cli - ''', + entry_points={ + 'console_scripts': [ + 'yourscript = yourpackage.scripts.yourscript:cli', + ], + }, ) diff --git a/docs/shell-completion.rst b/docs/shell-completion.rst new file mode 100644 index 0000000..699d25d --- /dev/null +++ b/docs/shell-completion.rst @@ -0,0 +1,285 @@ +.. currentmodule:: click.shell_completion + +Shell Completion +================ + +Click provides tab completion support for Bash (version 4.4 and up), +Zsh, and Fish. It is possible to add support for other shells too, and +suggestions can be customized at multiple levels. + +Shell completion suggests command names, option names, and values for +choice, file, and path parameter types. Options are only listed if at +least a dash has been entered. Hidden commands and options are not +shown. + +.. code-block:: text + + $ repo + clone commit copy delete setuser + $ repo clone - + --deep --help --rev --shallow -r + + +Enabling Completion +------------------- + +Completion is only available if a script is installed and invoked +through an entry point, not through the ``python`` command. See +:doc:`/setuptools`. Once the executable is installed, calling it with +a special environment variable will put Click in completion mode. + +In order for completion to be used, the user needs to register a special +function with their shell. The script is different for every shell, and +Click will output it when called with ``_{PROG_NAME}_COMPLETE`` set to +``{shell}_source``. ``{PROG_NAME}`` is the executable name in uppercase +with dashes replaced by underscores. The built-in shells are ``bash``, +``zsh``, and ``fish``. + +Provide your users with the following instructions customized to your +program name. This uses ``foo-bar`` as an example. + +.. tabs:: + + .. group-tab:: Bash + + Add this to ``~/.bashrc``: + + .. code-block:: bash + + eval "$(_FOO_BAR_COMPLETE=bash_source foo-bar)" + + .. group-tab:: Zsh + + Add this to ``~/.zshrc``: + + .. code-block:: zsh + + eval "$(_FOO_BAR_COMPLETE=zsh_source foo-bar)" + + .. group-tab:: Fish + + Add this to ``~/.config/fish/completions/foo-bar.fish``: + + .. code-block:: fish + + eval (env _FOO_BAR_COMPLETE=fish_source foo-bar) + + This is the same file used for the activation script method + below. For Fish it's probably always easier to use that method. + +Using ``eval`` means that the command is invoked and evaluated every +time a shell is started, which can delay shell responsiveness. To speed +it up, write the generated script to a file, then source that. You can +generate the files ahead of time and distribute them with your program +to save your users a step. + +.. tabs:: + + .. group-tab:: Bash + + Save the script somewhere. + + .. code-block:: bash + + _FOO_BAR_COMPLETE=bash_source foo-bar > ~/.foo-bar-complete.bash + + Source the file in ``~/.bashrc``. + + .. code-block:: bash + + . ~/.foo-bar-complete.bash + + .. group-tab:: Zsh + + Save the script somewhere. + + .. code-block:: bash + + _FOO_BAR_COMPLETE=zsh_source foo-bar > ~/.foo-bar-complete.zsh + + Source the file in ``~/.zshrc``. + + .. code-block:: bash + + . ~/.foo-bar-complete.zsh + + .. group-tab:: Fish + + Save the script to ``~/.config/fish/completions/foo-bar.fish``: + + .. code-block:: fish + + _FOO_BAR_COMPLETE=fish_source foo-bar > ~/.config/fish/completions/foo-bar.fish + +After modifying the shell config, you need to start a new shell in order +for the changes to be loaded. + + +Custom Type Completion +---------------------- + +When creating a custom :class:`~click.ParamType`, override its +:meth:`~click.ParamType.shell_complete` method to provide shell +completion for parameters with the type. The method must return a list +of :class:`~CompletionItem` objects. Besides the value, these objects +hold metadata that shell support might use. The built-in implementations +use ``type`` to indicate special handling for paths, and ``help`` for +shells that support showing a help string next to a suggestion. + +In this example, the type will suggest environment variables that start +with the incomplete value. + +.. code-block:: python + + class EnvVarType(ParamType): + def shell_complete(self, ctx, param, incomplete): + return [ + CompletionItem(name) + for name in os.environ if name.startswith(incomplete) + ] + + @click.command() + @click.option("--ev", type=EnvVarType()) + def cli(ev): + click.echo(os.environ[ev]) + + +Overriding Value Completion +--------------------------- + +Value completions for a parameter can be customized without a custom +type by providing a ``shell_complete`` function. The function is used +instead of any completion provided by the type. It is passed 3 keyword +arguments: + +- ``ctx`` - The current command context. +- ``param`` - The current parameter requesting completion. +- ``incomplete`` - The partial word that is being completed. May + be an empty string if no characters have been entered yet. + +It must return a list of :class:`CompletionItem` objects, or as a +shortcut it can return a list of strings. + +In this example, the command will suggest environment variables that +start with the incomplete value. + +.. code-block:: python + + def complete_env_vars(ctx, param, incomplete): + return [k for k in os.environ if k.startswith(incomplete)] + + @click.command() + @click.argument("name", shell_complete=complete_env_vars) + def cli(name): + click.echo(f"Name: {name}") + click.echo(f"Value: {os.environ[name]}") + + +Adding Support for a Shell +-------------------------- + +Support can be added for shells that do not come built in. Be sure to +check PyPI to see if there's already a package that adds support for +your shell. This topic is very technical, you'll want to look at Click's +source to study the built-in implementations. + +Shell support is provided by subclasses of :class:`ShellComplete` +registered with :func:`add_completion_class`. When Click is invoked in +completion mode, it calls :meth:`~ShellComplete.source` to output the +completion script, or :meth:`~ShellComplete.complete` to output +completions. The base class provides default implementations that +require implementing some smaller parts. + +First, you'll need to figure out how your shell's completion system +works and write a script to integrate it with Click. It must invoke your +program with the environment variable ``_{PROG_NAME}_COMPLETE`` set to +``{shell}_complete`` and pass the complete args and incomplete value. +How it passes those values, and the format of the completion response +from Click is up to you. + +In your subclass, set :attr:`~ShellComplete.source_template` to the +completion script. The default implementation will perform ``%`` +formatting with the following variables: + +- ``complete_func`` - A safe name for the completion function defined + in the script. +- ``complete_var`` - The environment variable name for passing the + ``{shell}_complete`` instruction. +- ``prog_name`` - The name of the executable being completed. + +The example code is for a made up shell "My Shell" or "mysh" for short. + +.. code-block:: python + + from click.shell_completion import add_completion_class + from click.shell_completion import ShellComplete + + _mysh_source = """\ + %(complete_func)s { + response=$(%(complete_var)s=mysh_complete %(prog_name)s) + # parse response and set completions somehow + } + call-on-complete %(prog_name)s %(complete_func)s + """ + + @add_completion_class + class MyshComplete(ShellComplete): + name = "mysh" + source_template = _mysh_source + +Next, implement :meth:`~ShellComplete.get_completion_args`. This must +get, parse, and return the complete args and incomplete value from the +completion script. For example, for the Bash implementation the +``COMP_WORDS`` env var contains the command line args as a string, and +the ``COMP_CWORD`` env var contains the index of the incomplete arg. The +method must return a ``(args, incomplete)`` tuple. + +.. code-block:: python + + import os + from click.parser import split_arg_string + + class MyshComplete(ShellComplete): + ... + + def get_completion_args(self): + args = split_arg_string(os.environ["COMP_WORDS"]) + + if os.environ["COMP_PARTIAL"] == "1": + incomplete = args.pop() + return args, incomplete + + return args, "" + +Finally, implement :meth:`~ShellComplete.format_completion`. This is +called to format each :class:`CompletionItem` into a string. For +example, the Bash implementation returns ``f"{item.type},{item.value}`` +(it doesn't support help strings), and the Zsh implementation returns +each part separated by a newline, replacing empty help with a ``_`` +placeholder. This format is entirely up to what you parse with your +completion script. + +The ``type`` value is usually ``plain``, but it can be another value +that the completion script can switch on. For example, ``file`` or +``dir`` can tell the shell to handle path completion, since the shell is +better at that than Click. + +.. code-block:: python + + class MyshComplete(ShellComplete): + ... + + def format_completion(self, item): + return f"{item.type}\t{item.value}" + +With those three things implemented, the new shell support is ready. In +case those weren't sufficient, there are more parts that can be +overridden, but that probably isn't necessary. + +The activation instructions will again depend on how your shell works. +Use the following to generate the completion script, then load it into +the shell somehow. + +.. code-block:: text + + _FOO_BAR_COMPLETE=mysh_source foo-bar diff --git a/docs/testing.rst b/docs/testing.rst index 52a888d..baacefc 100644 --- a/docs/testing.rst +++ b/docs/testing.rst @@ -30,7 +30,7 @@ data, exit code, and optional exception attached: @click.command() @click.argument('name') def hello(name): - click.echo('Hello %s!' % name) + click.echo(f'Hello {name}!') .. code-block:: python :caption: test_hello.py @@ -54,7 +54,7 @@ For subcommand testing, a subcommand name must be specified in the `args` parame @click.group() @click.option('--debug/--no-debug', default=False) def cli(debug): - click.echo('Debug mode is %s' % ('on' if debug else 'off')) + click.echo(f"Debug mode is {'on' if debug else 'off'}") @cli.command() def sync(): @@ -112,6 +112,19 @@ current working directory to a new, empty folder. assert result.exit_code == 0 assert result.output == 'Hello World!\n' +Pass ``temp_dir`` to control where the temporary directory is created. +The directory will not be removed by Click in this case. This is useful +to integrate with a framework like Pytest that manages temporary files. + +.. code-block:: python + + def test_keep_dir(tmp_path): + runner = CliRunner() + + with runner.isolated_filesystem(temp_dir=tmp_path) as td: + ... + + Input Streams ------------- @@ -126,7 +139,7 @@ stream (stdin). This is very useful for testing prompts, for instance: @click.command() @click.option('--foo', prompt=True) def prompt(foo): - click.echo('foo=%s' % foo) + click.echo(f"foo={foo}") .. code-block:: python :caption: test_prompt.py diff --git a/docs/unicode-support.rst b/docs/unicode-support.rst new file mode 100644 index 0000000..680e739 --- /dev/null +++ b/docs/unicode-support.rst @@ -0,0 +1,112 @@ +Unicode Support +=============== + +.. currentmodule:: click + +Click has to take extra care to support Unicode text in different +environments. + +* The command line in Unix is traditionally bytes, not Unicode. While + there are encoding hints, there are some situations where this can + break. The most common one is SSH connections to machines with + different locales. + + Misconfigured environments can cause a wide range of Unicode + problems due to the lack of support for roundtripping surrogate + escapes. This will not be fixed in Click itself! + +* Standard input and output is opened in text mode by default. Click + has to reopen the stream in binary mode in certain situations. + Because there is no standard way to do this, it might not always + work. Primarily this can become a problem when testing command-line + applications. + + This is not supported:: + + sys.stdin = io.StringIO('Input here') + sys.stdout = io.StringIO() + + Instead you need to do this:: + + input = 'Input here' + in_stream = io.BytesIO(input.encode('utf-8')) + sys.stdin = io.TextIOWrapper(in_stream, encoding='utf-8') + out_stream = io.BytesIO() + sys.stdout = io.TextIOWrapper(out_stream, encoding='utf-8') + + Remember in that case, you need to use ``out_stream.getvalue()`` + and not ``sys.stdout.getvalue()`` if you want to access the buffer + contents as the wrapper will not forward that method. + +* ``sys.stdin``, ``sys.stdout`` and ``sys.stderr`` are by default + text-based. When Click needs a binary stream, it attempts to + discover the underlying binary stream. + +* ``sys.argv`` is always text. This means that the native type for + input values to the types in Click is Unicode, not bytes. + + This causes problems if the terminal is incorrectly set and Python + does not figure out the encoding. In that case, the Unicode string + will contain error bytes encoded as surrogate escapes. + +* When dealing with files, Click will always use the Unicode file + system API by using the operating system's reported or guessed + filesystem encoding. Surrogates are supported for filenames, so it + should be possible to open files through the :class:`File` type even + if the environment is misconfigured. + + +Surrogate Handling +------------------ + +Click does all the Unicode handling in the standard library and is +subject to its behavior. Unicode requires extra care. The reason for +this is that the encoding detection is done in the interpreter, and on +Linux and certain other operating systems, its encoding handling is +problematic. + +The biggest source of frustration is that Click scripts invoked by init +systems, deployment tools, or cron jobs will refuse to work unless a +Unicode locale is exported. + +If Click encounters such an environment it will prevent further +execution to force you to set a locale. This is done because Click +cannot know about the state of the system once it's invoked and restore +the values before Python's Unicode handling kicked in. + +If you see something like this error:: + + Traceback (most recent call last): + ... + RuntimeError: Click will abort further execution because Python was + configured to use ASCII as encoding for the environment. Consult + https://click.palletsprojects.com/unicode-support/ for mitigation + steps. + +You are dealing with an environment where Python thinks you are +restricted to ASCII data. The solution to these problems is different +depending on which locale your computer is running in. + +For instance, if you have a German Linux machine, you can fix the +problem by exporting the locale to ``de_DE.utf-8``:: + + export LC_ALL=de_DE.utf-8 + export LANG=de_DE.utf-8 + +If you are on a US machine, ``en_US.utf-8`` is the encoding of choice. +On some newer Linux systems, you could also try ``C.UTF-8`` as the +locale:: + + export LC_ALL=C.UTF-8 + export LANG=C.UTF-8 + +On some systems it was reported that ``UTF-8`` has to be written as +``UTF8`` and vice versa. To see which locales are supported you can +invoke ``locale -a``. + +You need to export the values before you invoke your Python script. + +In Python 3.7 and later you will no longer get a ``RuntimeError`` in +many cases thanks to :pep:`538` and :pep:`540`, which changed the +default assumption in unconfigured environments. This doesn't change the +general issue that your locale may be misconfigured. diff --git a/docs/upgrading.rst b/docs/upgrading.rst index ea082bb..c6fa554 100644 --- a/docs/upgrading.rst +++ b/docs/upgrading.rst @@ -90,7 +90,7 @@ restored. If you do require the know which exact commands will be invoked there are different ways to cope with this. The first one is to let the subcommands all return functions and then to invoke the functions in a -:meth:`Context.resultcallback`. +:meth:`Context.result_callback`. .. _upgrade-to-2.0: diff --git a/docs/utils.rst b/docs/utils.rst index 902e5fd..995c171 100644 --- a/docs/utils.rst +++ b/docs/utils.rst @@ -13,9 +13,7 @@ Printing to Stdout The most obvious helper is the :func:`echo` function, which in many ways works like the Python ``print`` statement or function. The main difference is -that it works the same in Python 2 and 3, it intelligently detects -misconfigured output streams, and it will never fail (except in Python 3; for -more information see :ref:`python3-limitations`). +that it works the same in many different terminal environments. Example:: @@ -23,10 +21,8 @@ Example:: click.echo('Hello World!') -Most importantly, it can print both Unicode and binary data, unlike the -built-in ``print`` function in Python 3, which cannot output any bytes. It -will, however, emit a trailing newline by default, which needs to be -suppressed by passing ``nl=False``:: +It can output both text and binary data. It will emit a trailing newline +by default, which needs to be suppressed by passing ``nl=False``:: click.echo(b'\xe2\x98\x83', nl=False) @@ -34,19 +30,17 @@ Last but not least :func:`echo` uses click's intelligent internal output streams to stdout and stderr which support unicode output on the Windows console. This means for as long as you are using `click.echo` you can output unicode characters (there are some limitations on the default font -with regards to which characters can be displayed). This functionality is -new in Click 6.0. +with regards to which characters can be displayed). .. versionadded:: 6.0 -Click now emulates output streams on Windows to support unicode to the +Click emulates output streams on Windows to support unicode to the Windows console through separate APIs. For more information see :doc:`wincmd`. .. versionadded:: 3.0 -Starting with Click 3.0 you can also easily print to standard error by -passing ``err=True``:: +You can also easily print to standard error by passing ``err=True``:: click.echo('Hello World!', err=True) @@ -58,11 +52,8 @@ ANSI Colors .. versionadded:: 2.0 -Starting with Click 2.0, the :func:`echo` function gained extra -functionality to deal with ANSI colors and styles. Note that on Windows, -this functionality is only available if `colorama`_ is installed. If it -is installed, then ANSI codes are intelligently handled. Note that in Python -2, the echo function doesn't parse color code information from bytearrays. +The :func:`echo` function supports ANSI colors and styles. On Windows +this uses `colorama`_. Primarily this means that: @@ -73,12 +64,8 @@ Primarily this means that: that colors will work on Windows the same way they do on other operating systems. -Note for `colorama` support: Click will automatically detect when `colorama` -is available and use it. Do *not* call ``colorama.init()``! - -To install `colorama`, run this command:: - - $ pip install colorama +On Windows, Click uses colorama without calling ``colorama.init()``. You +can still call that in your code, but it's not required for Click. For styling a string, the :func:`style` function can be used:: @@ -112,15 +99,14 @@ Example: @click.command() def less(): - click.echo_via_pager('\n'.join('Line %d' % idx - for idx in range(200))) + click.echo_via_pager("\n".join(f"Line {idx}" for idx in range(200))) If you want to use the pager for a lot of text, especially if generating everything in advance would take a lot of time, you can pass a generator (or generator function) instead of a string: .. click:example:: def _generate_output(): for idx in range(50000): - yield "Line %d\n" % idx + yield f"Line {idx}\n" @click.command() def less(): @@ -253,9 +239,7 @@ Printing Filenames ------------------ Because filenames might not be Unicode, formatting them can be a bit -tricky. Generally, this is easier in Python 2 than on 3, as you can just -write the bytes to stdout with the ``print`` function, but in Python 3, you -will always need to operate in Unicode. +tricky. The way this works with click is through the :func:`format_filename` function. It does a best-effort conversion of the filename to Unicode and @@ -264,7 +248,7 @@ context of a full Unicode string. Example:: - click.echo('Path: %s' % click.format_filename(b'foo.txt')) + click.echo(f"Path: {click.format_filename(b'foo.txt')}") Standard Streams @@ -281,8 +265,7 @@ Because of this, click provides the :func:`get_binary_stream` and different Python versions and for a wide variety of terminal configurations. The end result is that these functions will always return a functional -stream object (except in very odd cases in Python 3; see -:ref:`python3-limitations`). +stream object (except in very odd cases; see :doc:`/unicode-support`). Example:: @@ -349,20 +332,25 @@ Example usage:: rv = {} for section in parser.sections(): for key, value in parser.items(section): - rv['%s.%s' % (section, key)] = value + rv[f"{section}.{key}"] = value return rv Showing Progress Bars --------------------- -.. versionadded:: 2.0 - Sometimes, you have command line scripts that need to process a lot of data, but you want to quickly show the user some progress about how long that will take. Click supports simple progress bar rendering for that through the :func:`progressbar` function. +.. note:: + + If you find that you have requirements beyond what Click's progress + bar supports, try using `tqdm`_. + + .. _tqdm: https://tqdm.github.io/ + The basic usage is very simple: the idea is that you have an iterable that you want to operate on. For each item in the iterable it might take some time to do processing. So say you have a loop like this:: @@ -396,7 +384,7 @@ loop. So code like this will render correctly:: with click.progressbar([1, 2, 3]) as bar: for x in bar: - print('sleep({})...'.format(x)) + print(f"sleep({x})...") time.sleep(x) Another useful feature is to associate a label with the progress bar which diff --git a/docs/why.rst b/docs/why.rst index 9418bfe..29f3d6c 100644 --- a/docs/why.rst +++ b/docs/why.rst @@ -7,16 +7,15 @@ why does Click exist? This question is easy to answer: because there is not a single command line utility for Python out there which ticks the following boxes: -* is lazily composable without restrictions -* supports implementation of Unix/POSIX command line conventions -* supports loading values from environment variables out of the box -* support for prompting of custom values -* is fully nestable and composable -* works the same in Python 2 and 3 -* supports file handling out of the box -* comes with useful common helpers (getting terminal dimensions, +* Is lazily composable without restrictions. +* Supports implementation of Unix/POSIX command line conventions. +* Supports loading values from environment variables out of the box. +* Support for prompting of custom values. +* Is fully nestable and composable. +* Supports file handling out of the box. +* Comes with useful common helpers (getting terminal dimensions, ANSI colors, fetching direct keyboard input, screen clearing, - finding config paths, launching apps and editors, etc.) + finding config paths, launching apps and editors, etc.). There are many alternatives to Click; the obvious ones are ``optparse`` and ``argparse`` from the standard library. Have a look to see if something @@ -47,15 +46,15 @@ Why not Argparse? Click is internally based on ``optparse`` instead of ``argparse``. This is an implementation detail that a user does not have to be concerned -with. Click is not based on argparse because it has some behaviors that +with. Click is not based on ``argparse`` because it has some behaviors that make handling arbitrary command line interfaces hard: -* argparse has built-in behavior to guess if something is an +* ``argparse`` has built-in behavior to guess if something is an argument or an option. This becomes a problem when dealing with incomplete command lines; the behaviour becomes unpredictable without full knowledge of a command line. This goes against Click's ambitions of dispatching to subparsers. -* argparse does not support disabling interspersed arguments. Without +* ``argparse`` does not support disabling interspersed arguments. Without this feature, it's not possible to safely implement Click's nested parsing. @@ -135,7 +134,7 @@ Why No Auto Correction? ----------------------- The question came up why Click does not auto correct parameters given that -even optparse and argparse support automatic expansion of long arguments. +even optparse and ``argparse`` support automatic expansion of long arguments. The reason for this is that it's a liability for backwards compatibility. If people start relying on automatically modified parameters and someone adds a new parameter in the future, the script might stop working. These diff --git a/docs/wincmd.rst b/docs/wincmd.rst index 901ee95..5727f2f 100644 --- a/docs/wincmd.rst +++ b/docs/wincmd.rst @@ -3,11 +3,7 @@ Windows Console Notes .. versionadded:: 6.0 -Until Click 6.0 there are various bugs and limitations with using Click on -a Windows console. Most notably the decoding of command line arguments -was performed with the wrong encoding on Python 2 and on all versions of -Python output of unicode characters was impossible. Starting with Click -6.0 we now emulate output streams on Windows to support unicode to the +Click emulates output streams on Windows to support unicode to the Windows console through separate APIs and we perform different decoding of parameters. @@ -22,18 +18,10 @@ performed to the type expected value as late as possible. This has some advantages as it allows us to accept the data in the most appropriate form for the operating system and Python version. -For instance paths are left as bytes on Python 2 unless you explicitly -tell it otherwise. - This caused some problems on Windows where initially the wrong encoding was used and garbage ended up in your input data. We not only fixed the encoding part, but we also now extract unicode parameters from `sys.argv`. -This means that on Python 2 under Windows, the arguments processed will -*most likely* be of unicode nature and not bytes. This was something that -previously did not really happen unless you explicitly passed in unicode -parameters so your custom types need to be aware of this. - There is also another limitation with this: if `sys.argv` was modified prior to invoking a click handler, we have to fall back to the regular byte input in which case not all unicode values are available but only a @@ -55,10 +43,6 @@ stream will also use ``utf-16-le`` as internal encoding. However there is some hackery going on that the underlying raw IO buffer is still bypassing the unicode APIs and byte output through an indirection is still possible. -This hackery is used on both Python 2 and Python 3 as neither version of -Python has native support for cmd.exe with unicode characters. There are -some limitations you need to be aware of: - * This unicode support is limited to ``click.echo``, ``click.prompt`` as well as ``click.get_text_stream``. * Depending on if unicode values or byte strings are passed the control diff --git a/examples/aliases/aliases.py b/examples/aliases/aliases.py index 250c646..af3caa6 100644 --- a/examples/aliases/aliases.py +++ b/examples/aliases/aliases.py @@ -1,14 +1,10 @@ +import configparser import os import click -try: - import configparser -except ImportError: - import ConfigParser as configparser - -class Config(object): +class Config: """The config in this example only holds aliases.""" def __init__(self): @@ -53,7 +49,7 @@ class AliasedGroup(click.Group): # will create the config object is missing. cfg = ctx.ensure_object(Config) - # Step three: lookup an explicit command aliase in the config + # Step three: look up an explicit command alias in the config if cmd_name in cfg.aliases: actual_cmd = cfg.aliases[cmd_name] return click.Group.get_command(self, ctx, actual_cmd) @@ -69,7 +65,12 @@ class AliasedGroup(click.Group): return None elif len(matches) == 1: return click.Group.get_command(self, ctx, matches[0]) - ctx.fail("Too many matches: {}".format(", ".join(sorted(matches)))) + ctx.fail(f"Too many matches: {', '.join(sorted(matches))}") + + def resolve_command(self, ctx, args): + # always return the command's name, not the alias + _, cmd, args = super().resolve_command(ctx, args) + return cmd.name, cmd, args def read_config(ctx, param, value): @@ -125,7 +126,7 @@ def commit(): @pass_config def status(config): """Shows the status.""" - click.echo("Status for {}".format(config.path)) + click.echo(f"Status for {config.path}") @cli.command() @@ -139,4 +140,4 @@ def alias(config, alias_, cmd, config_file): """Adds an alias to the specified configuration file.""" config.add_alias(alias_, cmd) config.write_config(config_file) - click.echo("Added '{}' as alias for '{}'".format(alias_, cmd)) + click.echo(f"Added '{alias_}' as alias for '{cmd}'") diff --git a/examples/bashcompletion/README b/examples/bashcompletion/README deleted file mode 100644 index f8a0d51..0000000 --- a/examples/bashcompletion/README +++ /dev/null @@ -1,12 +0,0 @@ -$ bashcompletion - - bashcompletion is a simple example of an application that - tries to autocomplete commands, arguments and options. - - This example requires Click 2.0 or higher. - -Usage: - - $ pip install --editable . - $ eval "$(_BASHCOMPLETION_COMPLETE=source bashcompletion)" - $ bashcompletion --help diff --git a/examples/bashcompletion/bashcompletion.py b/examples/bashcompletion/bashcompletion.py deleted file mode 100644 index 0502dbc..0000000 --- a/examples/bashcompletion/bashcompletion.py +++ /dev/null @@ -1,45 +0,0 @@ -import os - -import click - - -@click.group() -def cli(): - pass - - -def get_env_vars(ctx, args, incomplete): - # Completions returned as strings do not have a description displayed. - for key in os.environ.keys(): - if incomplete in key: - yield key - - -@cli.command(help="A command to print environment variables") -@click.argument("envvar", type=click.STRING, autocompletion=get_env_vars) -def cmd1(envvar): - click.echo("Environment variable: {}".format(envvar)) - click.echo("Value: {}".format(os.environ[envvar])) - - -@click.group(help="A group that holds a subcommand") -def group(): - pass - - -def list_users(ctx, args, incomplete): - # You can generate completions with descriptions by returning - # tuples in the form (completion, description). - users = [("bob", "butcher"), ("alice", "baker"), ("jerry", "candlestick maker")] - # Ths will allow completion matches based on matches within the - # description string too! - return [user for user in users if incomplete in user[0] or incomplete in user[1]] - - -@group.command(help="Choose a user") -@click.argument("user", type=click.STRING, autocompletion=list_users) -def subcmd(user): - click.echo("Chosen user is {}".format(user)) - - -cli.add_command(group) diff --git a/examples/colors/README b/examples/colors/README index 4b5b44f..7aec8ef 100644 --- a/examples/colors/README +++ b/examples/colors/README @@ -3,7 +3,7 @@ $ colors_ colors is a simple example that shows how you can colorize text. - For this to work on Windows, colorama is required. + Uses colorama on Windows. Usage: diff --git a/examples/colors/colors.py b/examples/colors/colors.py index 012538d..1d94417 100644 --- a/examples/colors/colors.py +++ b/examples/colors/colors.py @@ -23,22 +23,17 @@ all_colors = ( @click.command() def cli(): - """This script prints some colors. If colorama is installed this will - also work on Windows. It will also automatically remove all ANSI - styles if data is piped into a file. + """This script prints some colors. It will also automatically remove + all ANSI styles if data is piped into a file. Give it a try! """ for color in all_colors: - click.echo(click.style("I am colored {}".format(color), fg=color)) + click.echo(click.style(f"I am colored {color}", fg=color)) for color in all_colors: - click.echo( - click.style("I am colored {} and bold".format(color), fg=color, bold=True) - ) + click.echo(click.style(f"I am colored {color} and bold", fg=color, bold=True)) for color in all_colors: - click.echo( - click.style("I am reverse colored {}".format(color), fg=color, reverse=True) - ) + click.echo(click.style(f"I am reverse colored {color}", fg=color, reverse=True)) click.echo(click.style("I am blinking", blink=True)) click.echo(click.style("I am underlined", underline=True)) diff --git a/examples/colors/setup.py b/examples/colors/setup.py index 6d892dd..3e1a594 100644 --- a/examples/colors/setup.py +++ b/examples/colors/setup.py @@ -5,11 +5,7 @@ setup( version="1.0", py_modules=["colors"], include_package_data=True, - install_requires=[ - "click", - # Colorama is only required for Windows. - "colorama", - ], + install_requires=["click"], entry_points=""" [console_scripts] colors=colors:cli diff --git a/examples/completion/README b/examples/completion/README new file mode 100644 index 0000000..372b1d4 --- /dev/null +++ b/examples/completion/README @@ -0,0 +1,28 @@ +$ completion +============ + +Demonstrates Click's shell completion support. + +.. code-block:: bash + + pip install --editable . + +For Bash: + +.. code-block:: bash + + eval "$(_COMPLETION_COMPLETE=bash_source completion)" + +For Zsh: + +.. code-block:: zsh + + eval "$(_COMPLETION_COMPLETE=zsh_source completion)" + +For Fish: + +.. code-block:: fish + + eval (env _COMPLETION_COMPLETE=fish_source completion) + +Now press tab (maybe twice) after typing something to see completions. diff --git a/examples/completion/completion.py b/examples/completion/completion.py new file mode 100644 index 0000000..6c45a1f --- /dev/null +++ b/examples/completion/completion.py @@ -0,0 +1,56 @@ +import os + +import click +from click.shell_completion import CompletionItem + + +@click.group() +def cli(): + pass + + +@cli.command() +@click.option("--dir", type=click.Path(file_okay=False)) +def ls(dir): + click.echo("\n".join(os.listdir(dir))) + + +def get_env_vars(ctx, param, incomplete): + # Returning a list of values is a shortcut to returning a list of + # CompletionItem(value). + return [k for k in os.environ if incomplete in k] + + +@cli.command(help="A command to print environment variables") +@click.argument("envvar", shell_complete=get_env_vars) +def show_env(envvar): + click.echo(f"Environment variable: {envvar}") + click.echo(f"Value: {os.environ[envvar]}") + + +@cli.group(help="A group that holds a subcommand") +def group(): + pass + + +def list_users(ctx, param, incomplete): + # You can generate completions with help strings by returning a list + # of CompletionItem. You can match on whatever you want, including + # the help. + items = [("bob", "butcher"), ("alice", "baker"), ("jerry", "candlestick maker")] + out = [] + + for value, help in items: + if incomplete in value or incomplete in help: + out.append(CompletionItem(value, help=help)) + + return out + + +@group.command(help="Choose a user") +@click.argument("user", shell_complete=list_users) +def select_user(user): + click.echo(f"Chosen user is {user}") + + +cli.add_command(group) diff --git a/examples/bashcompletion/setup.py b/examples/completion/setup.py similarity index 60% rename from examples/bashcompletion/setup.py rename to examples/completion/setup.py index f9a2c29..a78d140 100644 --- a/examples/bashcompletion/setup.py +++ b/examples/completion/setup.py @@ -1,13 +1,13 @@ from setuptools import setup setup( - name="click-example-bashcompletion", + name="click-example-completion", version="1.0", - py_modules=["bashcompletion"], + py_modules=["completion"], include_package_data=True, install_requires=["click"], entry_points=""" [console_scripts] - bashcompletion=bashcompletion:cli + completion=completion:cli """, ) diff --git a/examples/complex/complex/cli.py b/examples/complex/complex/cli.py index c539fe8..5d00dba 100644 --- a/examples/complex/complex/cli.py +++ b/examples/complex/complex/cli.py @@ -7,7 +7,7 @@ import click CONTEXT_SETTINGS = dict(auto_envvar_prefix="COMPLEX") -class Environment(object): +class Environment: def __init__(self): self.verbose = False self.home = os.getcwd() @@ -39,11 +39,7 @@ class ComplexCLI(click.MultiCommand): def get_command(self, ctx, name): try: - if sys.version_info[0] == 2: - name = name.encode("ascii", "replace") - mod = __import__( - "complex.commands.cmd_{}".format(name), None, None, ["cli"] - ) + mod = __import__(f"complex.commands.cmd_{name}", None, None, ["cli"]) except ImportError: return return mod.cli diff --git a/examples/complex/complex/commands/cmd_init.py b/examples/complex/complex/commands/cmd_init.py index c2cf770..8802458 100644 --- a/examples/complex/complex/commands/cmd_init.py +++ b/examples/complex/complex/commands/cmd_init.py @@ -10,4 +10,4 @@ def cli(ctx, path): """Initializes a repository.""" if path is None: path = ctx.home - ctx.log("Initialized the repository in %s", click.format_filename(path)) + ctx.log(f"Initialized the repository in {click.format_filename(path)}") diff --git a/examples/imagepipe/imagepipe.py b/examples/imagepipe/imagepipe.py index d46c33f..e2d2f03 100644 --- a/examples/imagepipe/imagepipe.py +++ b/examples/imagepipe/imagepipe.py @@ -20,7 +20,7 @@ def cli(): """ -@cli.resultcallback() +@cli.result_callback() def process_commands(processors): """This result callback is invoked with an iterable of all the chained subcommands. As in this example each subcommand returns a function @@ -60,10 +60,8 @@ def generator(f): @processor def new_func(stream, *args, **kwargs): - for item in stream: - yield item - for item in f(*args, **kwargs): - yield item + yield from stream + yield from f(*args, **kwargs) return update_wrapper(new_func, f) @@ -89,7 +87,7 @@ def open_cmd(images): """ for image in images: try: - click.echo("Opening '{}'".format(image)) + click.echo(f"Opening '{image}'") if image == "-": img = Image.open(click.get_binary_stdin()) img.filename = "-" @@ -97,7 +95,7 @@ def open_cmd(images): img = Image.open(image) yield img except Exception as e: - click.echo("Could not open image '{}': {}".format(image, e), err=True) + click.echo(f"Could not open image '{image}': {e}", err=True) @cli.command("save") @@ -114,12 +112,10 @@ def save_cmd(images, filename): for idx, image in enumerate(images): try: fn = filename.format(idx + 1) - click.echo("Saving '{}' as '{}'".format(image.filename, fn)) + click.echo(f"Saving '{image.filename}' as '{fn}'") yield image.save(fn) except Exception as e: - click.echo( - "Could not save image '{}': {}".format(image.filename, e), err=True - ) + click.echo(f"Could not save image '{image.filename}': {e}", err=True) @cli.command("display") @@ -127,7 +123,7 @@ def save_cmd(images, filename): def display_cmd(images): """Opens all images in an image viewer.""" for image in images: - click.echo("Displaying '{}'".format(image.filename)) + click.echo(f"Displaying '{image.filename}'") image.show() yield image @@ -142,7 +138,7 @@ def resize_cmd(images, width, height): """ for image in images: w, h = (width or image.size[0], height or image.size[1]) - click.echo("Resizing '{}' to {}x{}".format(image.filename, w, h)) + click.echo(f"Resizing '{image.filename}' to {w}x{h}") image.thumbnail((w, h)) yield image @@ -160,7 +156,7 @@ def crop_cmd(images, border): if border is not None: for idx, val in enumerate(box): box[idx] = max(0, val - border) - click.echo("Cropping '{}' by {}px".format(image.filename, border)) + click.echo(f"Cropping '{image.filename}' by {border}px") yield copy_filename(image.crop(box), image) else: yield image @@ -176,7 +172,7 @@ def convert_rotation(ctx, param, value): return (Image.ROTATE_180, 180) if value in ("-90", "270", "l", "left"): return (Image.ROTATE_270, 270) - raise click.BadParameter("invalid rotation '{}'".format(value)) + raise click.BadParameter(f"invalid rotation '{value}'") def convert_flip(ctx, param, value): @@ -187,7 +183,7 @@ def convert_flip(ctx, param, value): return (Image.FLIP_LEFT_RIGHT, "left to right") if value in ("tb", "topbottom", "upsidedown", "ud"): return (Image.FLIP_LEFT_RIGHT, "top to bottom") - raise click.BadParameter("invalid flip '{}'".format(value)) + raise click.BadParameter(f"invalid flip '{value}'") @cli.command("transpose") @@ -201,11 +197,11 @@ def transpose_cmd(images, rotate, flip): for image in images: if rotate is not None: mode, degrees = rotate - click.echo("Rotate '{}' by {}deg".format(image.filename, degrees)) + click.echo(f"Rotate '{image.filename}' by {degrees}deg") image = copy_filename(image.transpose(mode), image) if flip is not None: mode, direction = flip - click.echo("Flip '{}' {}".format(image.filename, direction)) + click.echo(f"Flip '{image.filename}' {direction}") image = copy_filename(image.transpose(mode), image) yield image @@ -217,7 +213,7 @@ def blur_cmd(images, radius): """Applies gaussian blur.""" blur = ImageFilter.GaussianBlur(radius) for image in images: - click.echo("Blurring '{}' by {}px".format(image.filename, radius)) + click.echo(f"Blurring '{image.filename}' by {radius}px") yield copy_filename(image.filter(blur), image) @@ -234,9 +230,8 @@ def smoothen_cmd(images, iterations): """Applies a smoothening filter.""" for image in images: click.echo( - "Smoothening '{}' {} time{}".format( - image.filename, iterations, "s" if iterations != 1 else "" - ) + f"Smoothening {image.filename!r} {iterations}" + f" time{'s' if iterations != 1 else ''}" ) for _ in range(iterations): image = copy_filename(image.filter(ImageFilter.BLUR), image) @@ -248,7 +243,7 @@ def smoothen_cmd(images, iterations): def emboss_cmd(images): """Embosses an image.""" for image in images: - click.echo("Embossing '{}'".format(image.filename)) + click.echo(f"Embossing '{image.filename}'") yield copy_filename(image.filter(ImageFilter.EMBOSS), image) @@ -260,7 +255,7 @@ def emboss_cmd(images): def sharpen_cmd(images, factor): """Sharpens an image.""" for image in images: - click.echo("Sharpen '{}' by {}".format(image.filename, factor)) + click.echo(f"Sharpen '{image.filename}' by {factor}") enhancer = ImageEnhance.Sharpness(image) yield copy_filename(enhancer.enhance(max(1.0, factor)), image) @@ -282,13 +277,12 @@ def paste_cmd(images, left, right): yield image return - click.echo("Paste '{}' on '{}'".format(to_paste.filename, image.filename)) + click.echo(f"Paste '{to_paste.filename}' on '{image.filename}'") mask = None if to_paste.mode == "RGBA" or "transparency" in to_paste.info: mask = to_paste image.paste(to_paste, (left, right), mask) - image.filename += "+{}".format(to_paste.filename) + image.filename += f"+{to_paste.filename}" yield image - for image in imageiter: - yield image + yield from imageiter diff --git a/examples/naval/naval.py b/examples/naval/naval.py index b8d31e1..7310e6d 100644 --- a/examples/naval/naval.py +++ b/examples/naval/naval.py @@ -21,7 +21,7 @@ def ship(): @click.argument("name") def ship_new(name): """Creates a new ship.""" - click.echo("Created ship {}".format(name)) + click.echo(f"Created ship {name}") @ship.command("move") @@ -31,7 +31,7 @@ def ship_new(name): @click.option("--speed", metavar="KN", default=10, help="Speed in knots.") def ship_move(ship, x, y, speed): """Moves SHIP to the new location X,Y.""" - click.echo("Moving ship {} to {},{} with speed {}".format(ship, x, y, speed)) + click.echo(f"Moving ship {ship} to {x},{y} with speed {speed}") @ship.command("shoot") @@ -40,7 +40,7 @@ def ship_move(ship, x, y, speed): @click.argument("y", type=float) def ship_shoot(ship, x, y): """Makes SHIP fire to X,Y.""" - click.echo("Ship {} fires to {},{}".format(ship, x, y)) + click.echo(f"Ship {ship} fires to {x},{y}") @cli.group("mine") @@ -61,7 +61,7 @@ def mine(): @click.option("ty", "--drifting", flag_value="drifting", help="Drifting mine.") def mine_set(x, y, ty): """Sets a mine at a specific coordinate.""" - click.echo("Set {} mine at {},{}".format(ty, x, y)) + click.echo(f"Set {ty} mine at {x},{y}") @mine.command("remove") @@ -69,4 +69,4 @@ def mine_set(x, y, ty): @click.argument("y", type=float) def mine_remove(x, y): """Removes a mine at a specific coordinate.""" - click.echo("Removed mine at {},{}".format(x, y)) + click.echo(f"Removed mine at {x},{y}") diff --git a/examples/repo/repo.py b/examples/repo/repo.py index 5fd6ead..b773f3a 100644 --- a/examples/repo/repo.py +++ b/examples/repo/repo.py @@ -5,7 +5,7 @@ import sys import click -class Repo(object): +class Repo: def __init__(self, home): self.home = home self.config = {} @@ -14,10 +14,10 @@ class Repo(object): def set_config(self, key, value): self.config[key] = value if self.verbose: - click.echo(" config[{}] = {}".format(key, value), file=sys.stderr) + click.echo(f" config[{key}] = {value}", file=sys.stderr) def __repr__(self): - return "".format(self.home) + return f"" pass_repo = click.make_pass_decorator(Repo) @@ -78,11 +78,11 @@ def clone(repo, src, dest, shallow, rev): """ if dest is None: dest = posixpath.split(src)[-1] or "." - click.echo("Cloning repo {} to {}".format(src, os.path.abspath(dest))) + click.echo(f"Cloning repo {src} to {os.path.basename(dest)}") repo.home = dest if shallow: click.echo("Making shallow checkout") - click.echo("Checking out revision {}".format(rev)) + click.echo(f"Checking out revision {rev}") @cli.command() @@ -93,7 +93,7 @@ def delete(repo): This will throw away the current repository. """ - click.echo("Destroying repo {}".format(repo.home)) + click.echo(f"Destroying repo {repo.home}") click.echo("Deleted!") @@ -136,7 +136,7 @@ def commit(repo, files, message): marker = "# Files to be committed:" hint = ["", "", marker, "#"] for file in files: - hint.append("# U {}".format(file)) + hint.append(f"# U {file}") message = click.edit("\n".join(hint)) if message is None: click.echo("Aborted!") @@ -147,8 +147,8 @@ def commit(repo, files, message): return else: msg = "\n".join(message) - click.echo("Files to be committed: {}".format(files)) - click.echo("Commit message:\n{}".format(msg)) + click.echo(f"Files to be committed: {files}") + click.echo(f"Commit message:\n{msg}") @cli.command(short_help="Copies files.") @@ -163,4 +163,4 @@ def copy(repo, src, dst, force): files from SRC to DST. """ for fn in src: - click.echo("Copy from {} -> {}".format(fn, dst)) + click.echo(f"Copy from {fn} -> {dst}") diff --git a/examples/termui/setup.py b/examples/termui/setup.py index 7791bae..c1ac109 100644 --- a/examples/termui/setup.py +++ b/examples/termui/setup.py @@ -5,11 +5,7 @@ setup( version="1.0", py_modules=["termui"], include_package_data=True, - install_requires=[ - "click", - # Colorama is only required for Windows. - "colorama", - ], + install_requires=["click"], entry_points=""" [console_scripts] termui=termui:cli diff --git a/examples/termui/termui.py b/examples/termui/termui.py index 7b3da43..e72e65e 100644 --- a/examples/termui/termui.py +++ b/examples/termui/termui.py @@ -1,4 +1,3 @@ -# coding: utf-8 import math import random import time @@ -16,8 +15,8 @@ def cli(): def colordemo(): """Demonstrates ANSI color support.""" for color in "red", "green", "blue": - click.echo(click.style("I am colored {}".format(color), fg=color)) - click.echo(click.style("I am background colored {}".format(color), bg=color)) + click.echo(click.style(f"I am colored {color}", fg=color)) + click.echo(click.style(f"I am background colored {color}", bg=color)) @cli.command() @@ -25,7 +24,7 @@ def pager(): """Demonstrates using the pager.""" lines = [] for x in range(200): - lines.append("{}. Hello World!".format(click.style(str(x), fg="green"))) + lines.append(f"{click.style(str(x), fg='green')}. Hello World!") click.echo_via_pager("\n".join(lines)) @@ -56,7 +55,7 @@ def progress(count): def show_item(item): if item is not None: - return "Item #{}".format(item) + return f"Item #{item}" with click.progressbar( filter(items), @@ -71,7 +70,7 @@ def progress(count): length=count, label="Counting", bar_template="%(label)s %(bar)s | %(info)s", - fill_char=click.style(u"█", fg="cyan"), + fill_char=click.style("█", fg="cyan"), empty_char=" ", ) as bar: for item in bar: @@ -94,7 +93,7 @@ def progress(count): length=count, show_percent=False, label="Slowing progress bar", - fill_char=click.style(u"█", fg="green"), + fill_char=click.style("█", fg="green"), ) as bar: for item in steps: time.sleep(item) @@ -119,13 +118,13 @@ def locate(url): def edit(): """Opens an editor with some text in it.""" MARKER = "# Everything below is ignored\n" - message = click.edit("\n\n{}".format(MARKER)) + message = click.edit(f"\n\n{MARKER}") if message is not None: msg = message.split(MARKER, 1)[0].rstrip("\n") if not msg: click.echo("Empty message!") else: - click.echo("Message:\n{}".format(msg)) + click.echo(f"Message:\n{msg}") else: click.echo("You did not enter anything!") @@ -146,7 +145,7 @@ def pause(): def menu(): """Shows a simple menu.""" menu = "main" - while 1: + while True: if menu == "main": click.echo("Main menu:") click.echo(" d: debug menu") diff --git a/examples/validation/validation.py b/examples/validation/validation.py index c4f7352..3f78df0 100644 --- a/examples/validation/validation.py +++ b/examples/validation/validation.py @@ -1,9 +1,6 @@ -import click +from urllib import parse as urlparse -try: - from urllib import parse as urlparse -except ImportError: - import urlparse +import click def validate_count(ctx, param, value): @@ -20,8 +17,7 @@ class URL(click.ParamType): value = urlparse.urlparse(value) if value.scheme not in ("http", "https"): self.fail( - "invalid URL scheme ({}). Only HTTP URLs are" - " allowed".format(value.scheme), + f"invalid URL scheme ({value.scheme}). Only HTTP URLs are allowed", param, ctx, ) @@ -47,6 +43,6 @@ def cli(count, foo, url): 'If a value is provided it needs to be the value "wat".', param_hint=["--foo"], ) - click.echo("count: {}".format(count)) - click.echo("foo: {}".format(foo)) - click.echo("url: {!r}".format(url)) + click.echo(f"count: {count}") + click.echo(f"foo: {foo}") + click.echo(f"url: {url!r}") diff --git a/requirements/dev.in b/requirements/dev.in new file mode 100644 index 0000000..2588467 --- /dev/null +++ b/requirements/dev.in @@ -0,0 +1,6 @@ +-r docs.in +-r tests.in +-r typing.in +pip-tools +pre-commit +tox diff --git a/requirements/dev.txt b/requirements/dev.txt new file mode 100644 index 0000000..48f65e4 --- /dev/null +++ b/requirements/dev.txt @@ -0,0 +1,143 @@ +# +# This file is autogenerated by pip-compile +# To update, run: +# +# pip-compile requirements/dev.in +# +alabaster==0.7.12 + # via sphinx +attrs==21.2.0 + # via pytest +babel==2.9.1 + # via sphinx +backports.entry-points-selectable==1.1.0 + # via virtualenv +certifi==2021.5.30 + # via requests +cfgv==3.3.1 + # via pre-commit +charset-normalizer==2.0.6 + # via requests +click==8.0.1 + # via pip-tools +distlib==0.3.3 + # via virtualenv +docutils==0.16 + # via + # sphinx + # sphinx-tabs +filelock==3.3.0 + # via + # tox + # virtualenv +identify==2.3.0 + # via pre-commit +idna==3.2 + # via requests +imagesize==1.2.0 + # via sphinx +iniconfig==1.1.1 + # via pytest +jinja2==3.0.2 + # via sphinx +markupsafe==2.0.1 + # via jinja2 +mypy-extensions==0.4.3 + # via mypy +mypy==0.910 + # via -r requirements/typing.in +nodeenv==1.6.0 + # via pre-commit +packaging==21.0 + # via + # pallets-sphinx-themes + # pytest + # sphinx + # tox +pallets-sphinx-themes==2.0.1 + # via -r requirements/docs.in +pep517==0.11.0 + # via pip-tools +pip-tools==6.3.0 + # via -r requirements/dev.in +platformdirs==2.4.0 + # via virtualenv +pluggy==1.0.0 + # via + # pytest + # tox +pre-commit==2.15.0 + # via -r requirements/dev.in +py==1.10.0 + # via + # pytest + # tox +pygments==2.10.0 + # via + # sphinx + # sphinx-tabs +pyparsing==2.4.7 + # via packaging +pytest==6.2.5 + # via -r requirements/tests.in +pytz==2021.3 + # via babel +pyyaml==5.4.1 + # via pre-commit +requests==2.26.0 + # via sphinx +six==1.16.0 + # via + # tox + # virtualenv +snowballstemmer==2.1.0 + # via sphinx +sphinx-issues==1.2.0 + # via -r requirements/docs.in +sphinx-tabs==3.2.0 + # via -r requirements/docs.in +sphinx==4.2.0 + # via + # -r requirements/docs.in + # pallets-sphinx-themes + # sphinx-issues + # sphinx-tabs + # sphinxcontrib-log-cabinet +sphinxcontrib-applehelp==1.0.2 + # via sphinx +sphinxcontrib-devhelp==1.0.2 + # via sphinx +sphinxcontrib-htmlhelp==2.0.0 + # via sphinx +sphinxcontrib-jsmath==1.0.1 + # via sphinx +sphinxcontrib-log-cabinet==1.0.1 + # via -r requirements/docs.in +sphinxcontrib-qthelp==1.0.3 + # via sphinx +sphinxcontrib-serializinghtml==1.1.5 + # via sphinx +toml==0.10.2 + # via + # mypy + # pre-commit + # pytest + # tox +tomli==1.2.1 + # via pep517 +tox==3.24.4 + # via -r requirements/dev.in +typing-extensions==3.10.0.2 + # via mypy +urllib3==1.26.7 + # via requests +virtualenv==20.8.1 + # via + # pre-commit + # tox +wheel==0.37.0 + # via pip-tools + +# The following packages are considered to be unsafe in a requirements file: +# pip +# setuptools diff --git a/requirements/docs.in b/requirements/docs.in new file mode 100644 index 0000000..3ee050a --- /dev/null +++ b/requirements/docs.in @@ -0,0 +1,5 @@ +Pallets-Sphinx-Themes +Sphinx +sphinx-issues +sphinxcontrib-log-cabinet +sphinx-tabs diff --git a/requirements/docs.txt b/requirements/docs.txt new file mode 100644 index 0000000..00eefc0 --- /dev/null +++ b/requirements/docs.txt @@ -0,0 +1,74 @@ +# +# This file is autogenerated by pip-compile +# To update, run: +# +# pip-compile requirements/docs.in +# +alabaster==0.7.12 + # via sphinx +babel==2.9.1 + # via sphinx +certifi==2021.5.30 + # via requests +charset-normalizer==2.0.6 + # via requests +docutils==0.16 + # via + # sphinx + # sphinx-tabs +idna==3.2 + # via requests +imagesize==1.2.0 + # via sphinx +jinja2==3.0.2 + # via sphinx +markupsafe==2.0.1 + # via jinja2 +packaging==21.0 + # via + # pallets-sphinx-themes + # sphinx +pallets-sphinx-themes==2.0.1 + # via -r requirements/docs.in +pygments==2.10.0 + # via + # sphinx + # sphinx-tabs +pyparsing==2.4.7 + # via packaging +pytz==2021.3 + # via babel +requests==2.26.0 + # via sphinx +snowballstemmer==2.1.0 + # via sphinx +sphinx-issues==1.2.0 + # via -r requirements/docs.in +sphinx-tabs==3.2.0 + # via -r requirements/docs.in +sphinx==4.2.0 + # via + # -r requirements/docs.in + # pallets-sphinx-themes + # sphinx-issues + # sphinx-tabs + # sphinxcontrib-log-cabinet +sphinxcontrib-applehelp==1.0.2 + # via sphinx +sphinxcontrib-devhelp==1.0.2 + # via sphinx +sphinxcontrib-htmlhelp==2.0.0 + # via sphinx +sphinxcontrib-jsmath==1.0.1 + # via sphinx +sphinxcontrib-log-cabinet==1.0.1 + # via -r requirements/docs.in +sphinxcontrib-qthelp==1.0.3 + # via sphinx +sphinxcontrib-serializinghtml==1.1.5 + # via sphinx +urllib3==1.26.7 + # via requests + +# The following packages are considered to be unsafe in a requirements file: +# setuptools diff --git a/requirements/tests.in b/requirements/tests.in new file mode 100644 index 0000000..e079f8a --- /dev/null +++ b/requirements/tests.in @@ -0,0 +1 @@ +pytest diff --git a/requirements/tests.txt b/requirements/tests.txt new file mode 100644 index 0000000..b899729 --- /dev/null +++ b/requirements/tests.txt @@ -0,0 +1,22 @@ +# +# This file is autogenerated by pip-compile +# To update, run: +# +# pip-compile requirements/tests.in +# +attrs==21.2.0 + # via pytest +iniconfig==1.1.1 + # via pytest +packaging==21.0 + # via pytest +pluggy==1.0.0 + # via pytest +py==1.10.0 + # via pytest +pyparsing==2.4.7 + # via packaging +pytest==6.2.5 + # via -r requirements/tests.in +toml==0.10.2 + # via pytest diff --git a/requirements/typing.in b/requirements/typing.in new file mode 100644 index 0000000..f0aa93a --- /dev/null +++ b/requirements/typing.in @@ -0,0 +1 @@ +mypy diff --git a/requirements/typing.txt b/requirements/typing.txt new file mode 100644 index 0000000..f5af819 --- /dev/null +++ b/requirements/typing.txt @@ -0,0 +1,14 @@ +# +# This file is autogenerated by pip-compile +# To update, run: +# +# pip-compile requirements/typing.in +# +mypy-extensions==0.4.3 + # via mypy +mypy==0.910 + # via -r requirements/typing.in +toml==0.10.2 + # via mypy +typing-extensions==3.10.0.2 + # via mypy diff --git a/setup.cfg b/setup.cfg index d242860..45c6b89 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,37 +1,100 @@ [metadata] -license_file = LICENSE.rst +name = click +version = attr: click.__version__ +url = https://palletsprojects.com/p/click/ +project_urls = + Donate = https://palletsprojects.com/donate + Documentation = https://click.palletsprojects.com/ + Changes = https://click.palletsprojects.com/changes/ + Source Code = https://github.com/pallets/click/ + Issue Tracker = https://github.com/pallets/click/issues/ + Twitter = https://twitter.com/PalletsTeam + Chat = https://discord.gg/pallets +license = BSD-3-Clause +license_files = LICENSE.rst +author = Armin Ronacher +author_email = armin.ronacher@active-4.com +maintainer = Pallets +maintainer_email = contact@palletsprojects.com +description = Composable command line interface toolkit +long_description = file: README.rst +long_description_content_type = text/x-rst +classifiers = + Development Status :: 5 - Production/Stable + Intended Audience :: Developers + License :: OSI Approved :: BSD License + Operating System :: OS Independent + Programming Language :: Python -[bdist_wheel] -universal = 1 +[options] +packages = find: +package_dir = = src +include_package_data = true +python_requires = >= 3.6 +# Dependencies are in setup.py for GitHub's dependency graph. + +[options.packages.find] +where = src [tool:pytest] testpaths = tests -filterwarnings = - error +filterwarnings = + error [coverage:run] -branch = True -source = - src - tests +branch = true +source = + click + tests [coverage:paths] -source = - click - */site-packages +source = + click + */site-packages [flake8] +# B = bugbear +# E = pycodestyle errors +# F = flake8 pyflakes +# W = pycodestyle warnings +# B9 = bugbear opinions, +# ISC = implicit str concat select = B, E, F, W, B9, ISC -ignore = - E203 - E501 - E722 - W503 +ignore = + # slice notation whitespace, invalid + E203 + # line length, handled by bugbear B950 + E501 + # bare except, handled by bugbear B001 + E722 + # bin op line break, invalid + W503 +# up to 88 allowed by bugbear B950 max-line-length = 80 -per-file-ignores = - src/click/__init__.py: F401 +per-file-ignores = + # __init__ module exports names + src/click/__init__.py: F401 -[egg_info] -tag_build = -tag_date = 0 +[mypy] +files = src/click +python_version = 3.6 +disallow_subclassing_any = True +disallow_untyped_calls = True +disallow_untyped_defs = True +disallow_incomplete_defs = True +check_untyped_defs = True +no_implicit_optional = True +local_partial_types = True +no_implicit_reexport = True +strict_equality = True +warn_redundant_casts = True +warn_unused_configs = True +warn_unused_ignores = True +warn_return_any = True +warn_unreachable = True +[mypy-colorama.*] +ignore_missing_imports = True + +[mypy-importlib_metadata.*] +ignore_missing_imports = True diff --git a/setup.py b/setup.py index a7af7f1..0a74d41 100644 --- a/setup.py +++ b/setup.py @@ -1,40 +1,9 @@ -import io -import re - -from setuptools import find_packages from setuptools import setup -with io.open("README.rst", "rt", encoding="utf8") as f: - readme = f.read() - -with io.open("src/click/__init__.py", "rt", encoding="utf8") as f: - version = re.search(r'__version__ = "(.*?)"', f.read()).group(1) - setup( name="click", - version=version, - url="https://palletsprojects.com/p/click/", - project_urls={ - "Documentation": "https://click.palletsprojects.com/", - "Code": "https://github.com/pallets/click", - "Issue tracker": "https://github.com/pallets/click/issues", - }, - license="BSD-3-Clause", - maintainer="Pallets", - maintainer_email="contact@palletsprojects.com", - description="Composable command line interface toolkit", - long_description=readme, - packages=find_packages("src"), - package_dir={"": "src"}, - include_package_data=True, - python_requires=">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*", - classifiers=[ - "Development Status :: 5 - Production/Stable", - "Intended Audience :: Developers", - "License :: OSI Approved :: BSD License", - "Operating System :: OS Independent", - "Programming Language :: Python", - "Programming Language :: Python :: 2", - "Programming Language :: Python :: 3", + install_requires=[ + "colorama; platform_system == 'Windows'", + "importlib-metadata; python_version < '3.8'", ], ) diff --git a/src/click.egg-info/PKG-INFO b/src/click.egg-info/PKG-INFO deleted file mode 100644 index ee63c82..0000000 --- a/src/click.egg-info/PKG-INFO +++ /dev/null @@ -1,100 +0,0 @@ -Metadata-Version: 1.2 -Name: click -Version: 7.1.2 -Summary: Composable command line interface toolkit -Home-page: https://palletsprojects.com/p/click/ -Maintainer: Pallets -Maintainer-email: contact@palletsprojects.com -License: BSD-3-Clause -Project-URL: Documentation, https://click.palletsprojects.com/ -Project-URL: Code, https://github.com/pallets/click -Project-URL: Issue tracker, https://github.com/pallets/click/issues -Description: \$ click\_ - ========== - - Click is a Python package for creating beautiful command line interfaces - in a composable way with as little code as necessary. It's the "Command - Line Interface Creation Kit". It's highly configurable but comes with - sensible defaults out of the box. - - It aims to make the process of writing command line tools quick and fun - while also preventing any frustration caused by the inability to - implement an intended CLI API. - - Click in three points: - - - Arbitrary nesting of commands - - Automatic help page generation - - Supports lazy loading of subcommands at runtime - - - Installing - ---------- - - Install and update using `pip`_: - - .. code-block:: text - - $ pip install -U click - - .. _pip: https://pip.pypa.io/en/stable/quickstart/ - - - A Simple Example - ---------------- - - .. code-block:: python - - import click - - @click.command() - @click.option("--count", default=1, help="Number of greetings.") - @click.option("--name", prompt="Your name", help="The person to greet.") - def hello(count, name): - """Simple program that greets NAME for a total of COUNT times.""" - for _ in range(count): - click.echo(f"Hello, {name}!") - - if __name__ == '__main__': - hello() - - .. code-block:: text - - $ python hello.py --count=3 - Your name: Click - Hello, Click! - Hello, Click! - Hello, Click! - - - Donate - ------ - - The Pallets organization develops and supports Click and other popular - packages. In order to grow the community of contributors and users, and - allow the maintainers to devote more time to the projects, `please - donate today`_. - - .. _please donate today: https://palletsprojects.com/donate - - - Links - ----- - - - Website: https://palletsprojects.com/p/click/ - - Documentation: https://click.palletsprojects.com/ - - Releases: https://pypi.org/project/click/ - - Code: https://github.com/pallets/click - - Issue tracker: https://github.com/pallets/click/issues - - Test status: https://dev.azure.com/pallets/click/_build - - Official chat: https://discord.gg/t6rrQZH - -Platform: UNKNOWN -Classifier: Development Status :: 5 - Production/Stable -Classifier: Intended Audience :: Developers -Classifier: License :: OSI Approved :: BSD License -Classifier: Operating System :: OS Independent -Classifier: Programming Language :: Python -Classifier: Programming Language :: Python :: 2 -Classifier: Programming Language :: Python :: 3 -Requires-Python: >=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.* diff --git a/src/click.egg-info/SOURCES.txt b/src/click.egg-info/SOURCES.txt deleted file mode 100644 index 02b10c3..0000000 --- a/src/click.egg-info/SOURCES.txt +++ /dev/null @@ -1,114 +0,0 @@ -CHANGES.rst -LICENSE.rst -MANIFEST.in -README.rst -setup.cfg -setup.py -tox.ini -artwork/logo.svg -docs/Makefile -docs/advanced.rst -docs/api.rst -docs/arguments.rst -docs/bashcomplete.rst -docs/changelog.rst -docs/commands.rst -docs/complex.rst -docs/conf.py -docs/contrib.rst -docs/documentation.rst -docs/exceptions.rst -docs/index.rst -docs/license.rst -docs/make.bat -docs/options.rst -docs/parameters.rst -docs/prompts.rst -docs/python3.rst -docs/quickstart.rst -docs/requirements.txt -docs/setuptools.rst -docs/testing.rst -docs/upgrading.rst -docs/utils.rst -docs/why.rst -docs/wincmd.rst -docs/_static/click-icon.png -docs/_static/click-logo-sidebar.png -docs/_static/click-logo.png -examples/README -examples/aliases/README -examples/aliases/aliases.ini -examples/aliases/aliases.py -examples/aliases/setup.py -examples/bashcompletion/README -examples/bashcompletion/bashcompletion.py -examples/bashcompletion/setup.py -examples/colors/README -examples/colors/colors.py -examples/colors/setup.py -examples/complex/README -examples/complex/setup.py -examples/complex/complex/__init__.py -examples/complex/complex/cli.py -examples/complex/complex/commands/__init__.py -examples/complex/complex/commands/cmd_init.py -examples/complex/complex/commands/cmd_status.py -examples/imagepipe/.gitignore -examples/imagepipe/README -examples/imagepipe/example01.jpg -examples/imagepipe/example02.jpg -examples/imagepipe/imagepipe.py -examples/imagepipe/setup.py -examples/inout/README -examples/inout/inout.py -examples/inout/setup.py -examples/naval/README -examples/naval/naval.py -examples/naval/setup.py -examples/repo/README -examples/repo/repo.py -examples/repo/setup.py -examples/termui/README -examples/termui/setup.py -examples/termui/termui.py -examples/validation/README -examples/validation/setup.py -examples/validation/validation.py -src/click/__init__.py -src/click/_bashcomplete.py -src/click/_compat.py -src/click/_termui_impl.py -src/click/_textwrap.py -src/click/_unicodefun.py -src/click/_winconsole.py -src/click/core.py -src/click/decorators.py -src/click/exceptions.py -src/click/formatting.py -src/click/globals.py -src/click/parser.py -src/click/termui.py -src/click/testing.py -src/click/types.py -src/click/utils.py -src/click.egg-info/PKG-INFO -src/click.egg-info/SOURCES.txt -src/click.egg-info/dependency_links.txt -src/click.egg-info/top_level.txt -tests/conftest.py -tests/test_arguments.py -tests/test_bashcomplete.py -tests/test_basic.py -tests/test_chain.py -tests/test_commands.py -tests/test_compat.py -tests/test_context.py -tests/test_defaults.py -tests/test_formatting.py -tests/test_imports.py -tests/test_normalization.py -tests/test_options.py -tests/test_termui.py -tests/test_testing.py -tests/test_utils.py \ No newline at end of file diff --git a/src/click.egg-info/dependency_links.txt b/src/click.egg-info/dependency_links.txt deleted file mode 100644 index 8b13789..0000000 --- a/src/click.egg-info/dependency_links.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/click.egg-info/top_level.txt b/src/click.egg-info/top_level.txt deleted file mode 100644 index dca9a90..0000000 --- a/src/click.egg-info/top_level.txt +++ /dev/null @@ -1 +0,0 @@ -click diff --git a/src/click/__init__.py b/src/click/__init__.py index 2b6008f..478e744 100644 --- a/src/click/__init__.py +++ b/src/click/__init__.py @@ -4,76 +4,72 @@ writing command line scripts fun. Unlike other modules, it's based around a simple API that does not come with too much magic and is composable. """ -from .core import Argument -from .core import BaseCommand -from .core import Command -from .core import CommandCollection -from .core import Context -from .core import Group -from .core import MultiCommand -from .core import Option -from .core import Parameter -from .decorators import argument -from .decorators import command -from .decorators import confirmation_option -from .decorators import group -from .decorators import help_option -from .decorators import make_pass_decorator -from .decorators import option -from .decorators import pass_context -from .decorators import pass_obj -from .decorators import password_option -from .decorators import version_option -from .exceptions import Abort -from .exceptions import BadArgumentUsage -from .exceptions import BadOptionUsage -from .exceptions import BadParameter -from .exceptions import ClickException -from .exceptions import FileError -from .exceptions import MissingParameter -from .exceptions import NoSuchOption -from .exceptions import UsageError -from .formatting import HelpFormatter -from .formatting import wrap_text -from .globals import get_current_context -from .parser import OptionParser -from .termui import clear -from .termui import confirm -from .termui import echo_via_pager -from .termui import edit -from .termui import get_terminal_size -from .termui import getchar -from .termui import launch -from .termui import pause -from .termui import progressbar -from .termui import prompt -from .termui import secho -from .termui import style -from .termui import unstyle -from .types import BOOL -from .types import Choice -from .types import DateTime -from .types import File -from .types import FLOAT -from .types import FloatRange -from .types import INT -from .types import IntRange -from .types import ParamType -from .types import Path -from .types import STRING -from .types import Tuple -from .types import UNPROCESSED -from .types import UUID -from .utils import echo -from .utils import format_filename -from .utils import get_app_dir -from .utils import get_binary_stream -from .utils import get_os_args -from .utils import get_text_stream -from .utils import open_file +from .core import Argument as Argument +from .core import BaseCommand as BaseCommand +from .core import Command as Command +from .core import CommandCollection as CommandCollection +from .core import Context as Context +from .core import Group as Group +from .core import MultiCommand as MultiCommand +from .core import Option as Option +from .core import Parameter as Parameter +from .decorators import argument as argument +from .decorators import command as command +from .decorators import confirmation_option as confirmation_option +from .decorators import group as group +from .decorators import help_option as help_option +from .decorators import make_pass_decorator as make_pass_decorator +from .decorators import option as option +from .decorators import pass_context as pass_context +from .decorators import pass_obj as pass_obj +from .decorators import password_option as password_option +from .decorators import version_option as version_option +from .exceptions import Abort as Abort +from .exceptions import BadArgumentUsage as BadArgumentUsage +from .exceptions import BadOptionUsage as BadOptionUsage +from .exceptions import BadParameter as BadParameter +from .exceptions import ClickException as ClickException +from .exceptions import FileError as FileError +from .exceptions import MissingParameter as MissingParameter +from .exceptions import NoSuchOption as NoSuchOption +from .exceptions import UsageError as UsageError +from .formatting import HelpFormatter as HelpFormatter +from .formatting import wrap_text as wrap_text +from .globals import get_current_context as get_current_context +from .parser import OptionParser as OptionParser +from .termui import clear as clear +from .termui import confirm as confirm +from .termui import echo_via_pager as echo_via_pager +from .termui import edit as edit +from .termui import get_terminal_size as get_terminal_size +from .termui import getchar as getchar +from .termui import launch as launch +from .termui import pause as pause +from .termui import progressbar as progressbar +from .termui import prompt as prompt +from .termui import secho as secho +from .termui import style as style +from .termui import unstyle as unstyle +from .types import BOOL as BOOL +from .types import Choice as Choice +from .types import DateTime as DateTime +from .types import File as File +from .types import FLOAT as FLOAT +from .types import FloatRange as FloatRange +from .types import INT as INT +from .types import IntRange as IntRange +from .types import ParamType as ParamType +from .types import Path as Path +from .types import STRING as STRING +from .types import Tuple as Tuple +from .types import UNPROCESSED as UNPROCESSED +from .types import UUID as UUID +from .utils import echo as echo +from .utils import format_filename as format_filename +from .utils import get_app_dir as get_app_dir +from .utils import get_binary_stream as get_binary_stream +from .utils import get_os_args as get_os_args +from .utils import get_text_stream as get_text_stream +from .utils import open_file as open_file -# Controls if click should emit the warning about the use of unicode -# literals. -disable_unicode_literals_warning = False - -__version__ = "7.1.2" +__version__ = "8.0.2" diff --git a/src/click/_bashcomplete.py b/src/click/_bashcomplete.py deleted file mode 100644 index 8bca244..0000000 --- a/src/click/_bashcomplete.py +++ /dev/null @@ -1,375 +0,0 @@ -import copy -import os -import re - -from .core import Argument -from .core import MultiCommand -from .core import Option -from .parser import split_arg_string -from .types import Choice -from .utils import echo - -try: - from collections import abc -except ImportError: - import collections as abc - -WORDBREAK = "=" - -# Note, only BASH version 4.4 and later have the nosort option. -COMPLETION_SCRIPT_BASH = """ -%(complete_func)s() { - local IFS=$'\n' - COMPREPLY=( $( env COMP_WORDS="${COMP_WORDS[*]}" \\ - COMP_CWORD=$COMP_CWORD \\ - %(autocomplete_var)s=complete $1 ) ) - return 0 -} - -%(complete_func)setup() { - local COMPLETION_OPTIONS="" - local BASH_VERSION_ARR=(${BASH_VERSION//./ }) - # Only BASH version 4.4 and later have the nosort option. - if [ ${BASH_VERSION_ARR[0]} -gt 4 ] || ([ ${BASH_VERSION_ARR[0]} -eq 4 ] \ -&& [ ${BASH_VERSION_ARR[1]} -ge 4 ]); then - COMPLETION_OPTIONS="-o nosort" - fi - - complete $COMPLETION_OPTIONS -F %(complete_func)s %(script_names)s -} - -%(complete_func)setup -""" - -COMPLETION_SCRIPT_ZSH = """ -#compdef %(script_names)s - -%(complete_func)s() { - local -a completions - local -a completions_with_descriptions - local -a response - (( ! $+commands[%(script_names)s] )) && return 1 - - response=("${(@f)$( env COMP_WORDS=\"${words[*]}\" \\ - COMP_CWORD=$((CURRENT-1)) \\ - %(autocomplete_var)s=\"complete_zsh\" \\ - %(script_names)s )}") - - for key descr in ${(kv)response}; do - if [[ "$descr" == "_" ]]; then - completions+=("$key") - else - completions_with_descriptions+=("$key":"$descr") - fi - done - - if [ -n "$completions_with_descriptions" ]; then - _describe -V unsorted completions_with_descriptions -U - fi - - if [ -n "$completions" ]; then - compadd -U -V unsorted -a completions - fi - compstate[insert]="automenu" -} - -compdef %(complete_func)s %(script_names)s -""" - -COMPLETION_SCRIPT_FISH = ( - "complete --no-files --command %(script_names)s --arguments" - ' "(env %(autocomplete_var)s=complete_fish' - " COMP_WORDS=(commandline -cp) COMP_CWORD=(commandline -t)" - ' %(script_names)s)"' -) - -_completion_scripts = { - "bash": COMPLETION_SCRIPT_BASH, - "zsh": COMPLETION_SCRIPT_ZSH, - "fish": COMPLETION_SCRIPT_FISH, -} - -_invalid_ident_char_re = re.compile(r"[^a-zA-Z0-9_]") - - -def get_completion_script(prog_name, complete_var, shell): - cf_name = _invalid_ident_char_re.sub("", prog_name.replace("-", "_")) - script = _completion_scripts.get(shell, COMPLETION_SCRIPT_BASH) - return ( - script - % { - "complete_func": "_{}_completion".format(cf_name), - "script_names": prog_name, - "autocomplete_var": complete_var, - } - ).strip() + ";" - - -def resolve_ctx(cli, prog_name, args): - """Parse into a hierarchy of contexts. Contexts are connected - through the parent variable. - - :param cli: command definition - :param prog_name: the program that is running - :param args: full list of args - :return: the final context/command parsed - """ - ctx = cli.make_context(prog_name, args, resilient_parsing=True) - args = ctx.protected_args + ctx.args - while args: - if isinstance(ctx.command, MultiCommand): - if not ctx.command.chain: - cmd_name, cmd, args = ctx.command.resolve_command(ctx, args) - if cmd is None: - return ctx - ctx = cmd.make_context( - cmd_name, args, parent=ctx, resilient_parsing=True - ) - args = ctx.protected_args + ctx.args - else: - # Walk chained subcommand contexts saving the last one. - while args: - cmd_name, cmd, args = ctx.command.resolve_command(ctx, args) - if cmd is None: - return ctx - sub_ctx = cmd.make_context( - cmd_name, - args, - parent=ctx, - allow_extra_args=True, - allow_interspersed_args=False, - resilient_parsing=True, - ) - args = sub_ctx.args - ctx = sub_ctx - args = sub_ctx.protected_args + sub_ctx.args - else: - break - return ctx - - -def start_of_option(param_str): - """ - :param param_str: param_str to check - :return: whether or not this is the start of an option declaration - (i.e. starts "-" or "--") - """ - return param_str and param_str[:1] == "-" - - -def is_incomplete_option(all_args, cmd_param): - """ - :param all_args: the full original list of args supplied - :param cmd_param: the current command paramter - :return: whether or not the last option declaration (i.e. starts - "-" or "--") is incomplete and corresponds to this cmd_param. In - other words whether this cmd_param option can still accept - values - """ - if not isinstance(cmd_param, Option): - return False - if cmd_param.is_flag: - return False - last_option = None - for index, arg_str in enumerate( - reversed([arg for arg in all_args if arg != WORDBREAK]) - ): - if index + 1 > cmd_param.nargs: - break - if start_of_option(arg_str): - last_option = arg_str - - return True if last_option and last_option in cmd_param.opts else False - - -def is_incomplete_argument(current_params, cmd_param): - """ - :param current_params: the current params and values for this - argument as already entered - :param cmd_param: the current command parameter - :return: whether or not the last argument is incomplete and - corresponds to this cmd_param. In other words whether or not the - this cmd_param argument can still accept values - """ - if not isinstance(cmd_param, Argument): - return False - current_param_values = current_params[cmd_param.name] - if current_param_values is None: - return True - if cmd_param.nargs == -1: - return True - if ( - isinstance(current_param_values, abc.Iterable) - and cmd_param.nargs > 1 - and len(current_param_values) < cmd_param.nargs - ): - return True - return False - - -def get_user_autocompletions(ctx, args, incomplete, cmd_param): - """ - :param ctx: context associated with the parsed command - :param args: full list of args - :param incomplete: the incomplete text to autocomplete - :param cmd_param: command definition - :return: all the possible user-specified completions for the param - """ - results = [] - if isinstance(cmd_param.type, Choice): - # Choices don't support descriptions. - results = [ - (c, None) for c in cmd_param.type.choices if str(c).startswith(incomplete) - ] - elif cmd_param.autocompletion is not None: - dynamic_completions = cmd_param.autocompletion( - ctx=ctx, args=args, incomplete=incomplete - ) - results = [ - c if isinstance(c, tuple) else (c, None) for c in dynamic_completions - ] - return results - - -def get_visible_commands_starting_with(ctx, starts_with): - """ - :param ctx: context associated with the parsed command - :starts_with: string that visible commands must start with. - :return: all visible (not hidden) commands that start with starts_with. - """ - for c in ctx.command.list_commands(ctx): - if c.startswith(starts_with): - command = ctx.command.get_command(ctx, c) - if not command.hidden: - yield command - - -def add_subcommand_completions(ctx, incomplete, completions_out): - # Add subcommand completions. - if isinstance(ctx.command, MultiCommand): - completions_out.extend( - [ - (c.name, c.get_short_help_str()) - for c in get_visible_commands_starting_with(ctx, incomplete) - ] - ) - - # Walk up the context list and add any other completion - # possibilities from chained commands - while ctx.parent is not None: - ctx = ctx.parent - if isinstance(ctx.command, MultiCommand) and ctx.command.chain: - remaining_commands = [ - c - for c in get_visible_commands_starting_with(ctx, incomplete) - if c.name not in ctx.protected_args - ] - completions_out.extend( - [(c.name, c.get_short_help_str()) for c in remaining_commands] - ) - - -def get_choices(cli, prog_name, args, incomplete): - """ - :param cli: command definition - :param prog_name: the program that is running - :param args: full list of args - :param incomplete: the incomplete text to autocomplete - :return: all the possible completions for the incomplete - """ - all_args = copy.deepcopy(args) - - ctx = resolve_ctx(cli, prog_name, args) - if ctx is None: - return [] - - has_double_dash = "--" in all_args - - # In newer versions of bash long opts with '='s are partitioned, but - # it's easier to parse without the '=' - if start_of_option(incomplete) and WORDBREAK in incomplete: - partition_incomplete = incomplete.partition(WORDBREAK) - all_args.append(partition_incomplete[0]) - incomplete = partition_incomplete[2] - elif incomplete == WORDBREAK: - incomplete = "" - - completions = [] - if not has_double_dash and start_of_option(incomplete): - # completions for partial options - for param in ctx.command.params: - if isinstance(param, Option) and not param.hidden: - param_opts = [ - param_opt - for param_opt in param.opts + param.secondary_opts - if param_opt not in all_args or param.multiple - ] - completions.extend( - [(o, param.help) for o in param_opts if o.startswith(incomplete)] - ) - return completions - # completion for option values from user supplied values - for param in ctx.command.params: - if is_incomplete_option(all_args, param): - return get_user_autocompletions(ctx, all_args, incomplete, param) - # completion for argument values from user supplied values - for param in ctx.command.params: - if is_incomplete_argument(ctx.params, param): - return get_user_autocompletions(ctx, all_args, incomplete, param) - - add_subcommand_completions(ctx, incomplete, completions) - # Sort before returning so that proper ordering can be enforced in custom types. - return sorted(completions) - - -def do_complete(cli, prog_name, include_descriptions): - cwords = split_arg_string(os.environ["COMP_WORDS"]) - cword = int(os.environ["COMP_CWORD"]) - args = cwords[1:cword] - try: - incomplete = cwords[cword] - except IndexError: - incomplete = "" - - for item in get_choices(cli, prog_name, args, incomplete): - echo(item[0]) - if include_descriptions: - # ZSH has trouble dealing with empty array parameters when - # returned from commands, use '_' to indicate no description - # is present. - echo(item[1] if item[1] else "_") - - return True - - -def do_complete_fish(cli, prog_name): - cwords = split_arg_string(os.environ["COMP_WORDS"]) - incomplete = os.environ["COMP_CWORD"] - args = cwords[1:] - - for item in get_choices(cli, prog_name, args, incomplete): - if item[1]: - echo("{arg}\t{desc}".format(arg=item[0], desc=item[1])) - else: - echo(item[0]) - - return True - - -def bashcomplete(cli, prog_name, complete_var, complete_instr): - if "_" in complete_instr: - command, shell = complete_instr.split("_", 1) - else: - command = complete_instr - shell = "bash" - - if command == "source": - echo(get_completion_script(prog_name, complete_var, shell)) - return True - elif command == "complete": - if shell == "fish": - return do_complete_fish(cli, prog_name) - elif shell in {"bash", "zsh"}: - return do_complete(cli, prog_name, shell == "zsh") - - return False diff --git a/src/click/_compat.py b/src/click/_compat.py index 60cb115..b9e1f0d 100644 --- a/src/click/_compat.py +++ b/src/click/_compat.py @@ -1,12 +1,11 @@ -# flake8: noqa import codecs import io import os import re import sys +import typing as t from weakref import WeakKeyDictionary -PY2 = sys.version_info[0] == 2 CYGWIN = sys.platform.startswith("cygwin") MSYS2 = sys.platform.startswith("win") and ("GCC" in sys.version) # Determine local App Engine environment, per Google's own suggestion @@ -14,19 +13,21 @@ APP_ENGINE = "APPENGINE_RUNTIME" in os.environ and "Development/" in os.environ. "SERVER_SOFTWARE", "" ) WIN = sys.platform.startswith("win") and not APP_ENGINE and not MSYS2 -DEFAULT_COLUMNS = 80 - - +auto_wrap_for_ansi: t.Optional[t.Callable[[t.TextIO], t.TextIO]] = None _ansi_re = re.compile(r"\033\[[;?0-9]*[a-zA-Z]") -def get_filesystem_encoding(): +def get_filesystem_encoding() -> str: return sys.getfilesystemencoding() or sys.getdefaultencoding() def _make_text_stream( - stream, encoding, errors, force_readable=False, force_writable=False -): + stream: t.BinaryIO, + encoding: t.Optional[str], + errors: t.Optional[str], + force_readable: bool = False, + force_writable: bool = False, +) -> t.TextIO: if encoding is None: encoding = get_best_encoding(stream) if errors is None: @@ -41,7 +42,7 @@ def _make_text_stream( ) -def is_ascii_encoding(encoding): +def is_ascii_encoding(encoding: str) -> bool: """Checks if a given encoding is ascii.""" try: return codecs.lookup(encoding).name == "ascii" @@ -49,7 +50,7 @@ def is_ascii_encoding(encoding): return False -def get_best_encoding(stream): +def get_best_encoding(stream: t.IO) -> str: """Returns the default stream encoding if not found.""" rv = getattr(stream, "encoding", None) or sys.getdefaultencoding() if is_ascii_encoding(rv): @@ -60,46 +61,30 @@ def get_best_encoding(stream): class _NonClosingTextIOWrapper(io.TextIOWrapper): def __init__( self, - stream, - encoding, - errors, - force_readable=False, - force_writable=False, - **extra - ): - self._stream = stream = _FixupStream(stream, force_readable, force_writable) - io.TextIOWrapper.__init__(self, stream, encoding, errors, **extra) + stream: t.BinaryIO, + encoding: t.Optional[str], + errors: t.Optional[str], + force_readable: bool = False, + force_writable: bool = False, + **extra: t.Any, + ) -> None: + self._stream = stream = t.cast( + t.BinaryIO, _FixupStream(stream, force_readable, force_writable) + ) + super().__init__(stream, encoding, errors, **extra) - # The io module is a place where the Python 3 text behavior - # was forced upon Python 2, so we need to unbreak - # it to look like Python 2. - if PY2: - - def write(self, x): - if isinstance(x, str) or is_bytes(x): - try: - self.flush() - except Exception: - pass - return self.buffer.write(str(x)) - return io.TextIOWrapper.write(self, x) - - def writelines(self, lines): - for line in lines: - self.write(line) - - def __del__(self): + def __del__(self) -> None: try: self.detach() except Exception: pass - def isatty(self): + def isatty(self) -> bool: # https://bitbucket.org/pypy/pypy/issue/1803 return self._stream.isatty() -class _FixupStream(object): +class _FixupStream: """The new io interface needs more from streams than streams traditionally implement. As such, this fix-up code is necessary in some circumstances. @@ -109,45 +94,47 @@ class _FixupStream(object): of jupyter notebook). """ - def __init__(self, stream, force_readable=False, force_writable=False): + def __init__( + self, + stream: t.BinaryIO, + force_readable: bool = False, + force_writable: bool = False, + ): self._stream = stream self._force_readable = force_readable self._force_writable = force_writable - def __getattr__(self, name): + def __getattr__(self, name: str) -> t.Any: return getattr(self._stream, name) - def read1(self, size): + def read1(self, size: int) -> bytes: f = getattr(self._stream, "read1", None) + if f is not None: - return f(size) - # We only dispatch to readline instead of read in Python 2 as we - # do not want cause problems with the different implementation - # of line buffering. - if PY2: - return self._stream.readline(size) + return t.cast(bytes, f(size)) + return self._stream.read(size) - def readable(self): + def readable(self) -> bool: if self._force_readable: return True x = getattr(self._stream, "readable", None) if x is not None: - return x() + return t.cast(bool, x()) try: self._stream.read(0) except Exception: return False return True - def writable(self): + def writable(self) -> bool: if self._force_writable: return True x = getattr(self._stream, "writable", None) if x is not None: - return x() + return t.cast(bool, x()) try: - self._stream.write("") + self._stream.write("") # type: ignore except Exception: try: self._stream.write(b"") @@ -155,10 +142,10 @@ class _FixupStream(object): return False return True - def seekable(self): + def seekable(self) -> bool: x = getattr(self._stream, "seekable", None) if x is not None: - return x() + return t.cast(bool, x()) try: self._stream.seek(self._stream.tell()) except Exception: @@ -166,351 +153,239 @@ class _FixupStream(object): return True -if PY2: - text_type = unicode - raw_input = raw_input - string_types = (str, unicode) - int_types = (int, long) - iteritems = lambda x: x.iteritems() - range_type = xrange - - def is_bytes(x): - return isinstance(x, (buffer, bytearray)) - - _identifier_re = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$") - - # For Windows, we need to force stdout/stdin/stderr to binary if it's - # fetched for that. This obviously is not the most correct way to do - # it as it changes global state. Unfortunately, there does not seem to - # be a clear better way to do it as just reopening the file in binary - # mode does not change anything. - # - # An option would be to do what Python 3 does and to open the file as - # binary only, patch it back to the system, and then use a wrapper - # stream that converts newlines. It's not quite clear what's the - # correct option here. - # - # This code also lives in _winconsole for the fallback to the console - # emulation stream. - # - # There are also Windows environments where the `msvcrt` module is not - # available (which is why we use try-catch instead of the WIN variable - # here), such as the Google App Engine development server on Windows. In - # those cases there is just nothing we can do. - def set_binary_mode(f): - return f - +def _is_binary_reader(stream: t.IO, default: bool = False) -> bool: try: - import msvcrt - except ImportError: - pass - else: + return isinstance(stream.read(0), bytes) + except Exception: + return default + # This happens in some cases where the stream was already + # closed. In this case, we assume the default. - def set_binary_mode(f): - try: - fileno = f.fileno() - except Exception: - pass - else: - msvcrt.setmode(fileno, os.O_BINARY) - return f +def _is_binary_writer(stream: t.IO, default: bool = False) -> bool: try: - import fcntl - except ImportError: - pass + stream.write(b"") + except Exception: + try: + stream.write("") + return False + except Exception: + pass + return default + return True + + +def _find_binary_reader(stream: t.IO) -> t.Optional[t.BinaryIO]: + # We need to figure out if the given stream is already binary. + # This can happen because the official docs recommend detaching + # the streams to get binary streams. Some code might do this, so + # we need to deal with this case explicitly. + if _is_binary_reader(stream, False): + return t.cast(t.BinaryIO, stream) + + buf = getattr(stream, "buffer", None) + + # Same situation here; this time we assume that the buffer is + # actually binary in case it's closed. + if buf is not None and _is_binary_reader(buf, True): + return t.cast(t.BinaryIO, buf) + + return None + + +def _find_binary_writer(stream: t.IO) -> t.Optional[t.BinaryIO]: + # We need to figure out if the given stream is already binary. + # This can happen because the official docs recommend detaching + # the streams to get binary streams. Some code might do this, so + # we need to deal with this case explicitly. + if _is_binary_writer(stream, False): + return t.cast(t.BinaryIO, stream) + + buf = getattr(stream, "buffer", None) + + # Same situation here; this time we assume that the buffer is + # actually binary in case it's closed. + if buf is not None and _is_binary_writer(buf, True): + return t.cast(t.BinaryIO, buf) + + return None + + +def _stream_is_misconfigured(stream: t.TextIO) -> bool: + """A stream is misconfigured if its encoding is ASCII.""" + # If the stream does not have an encoding set, we assume it's set + # to ASCII. This appears to happen in certain unittest + # environments. It's not quite clear what the correct behavior is + # but this at least will force Click to recover somehow. + return is_ascii_encoding(getattr(stream, "encoding", None) or "ascii") + + +def _is_compat_stream_attr(stream: t.TextIO, attr: str, value: t.Optional[str]) -> bool: + """A stream attribute is compatible if it is equal to the + desired value or the desired value is unset and the attribute + has a value. + """ + stream_value = getattr(stream, attr, None) + return stream_value == value or (value is None and stream_value is not None) + + +def _is_compatible_text_stream( + stream: t.TextIO, encoding: t.Optional[str], errors: t.Optional[str] +) -> bool: + """Check if a stream's encoding and errors attributes are + compatible with the desired values. + """ + return _is_compat_stream_attr( + stream, "encoding", encoding + ) and _is_compat_stream_attr(stream, "errors", errors) + + +def _force_correct_text_stream( + text_stream: t.IO, + encoding: t.Optional[str], + errors: t.Optional[str], + is_binary: t.Callable[[t.IO, bool], bool], + find_binary: t.Callable[[t.IO], t.Optional[t.BinaryIO]], + force_readable: bool = False, + force_writable: bool = False, +) -> t.TextIO: + if is_binary(text_stream, False): + binary_reader = t.cast(t.BinaryIO, text_stream) else: + text_stream = t.cast(t.TextIO, text_stream) + # If the stream looks compatible, and won't default to a + # misconfigured ascii encoding, return it as-is. + if _is_compatible_text_stream(text_stream, encoding, errors) and not ( + encoding is None and _stream_is_misconfigured(text_stream) + ): + return text_stream - def set_binary_mode(f): - try: - fileno = f.fileno() - except Exception: - pass - else: - flags = fcntl.fcntl(fileno, fcntl.F_GETFL) - fcntl.fcntl(fileno, fcntl.F_SETFL, flags & ~os.O_NONBLOCK) - return f + # Otherwise, get the underlying binary reader. + possible_binary_reader = find_binary(text_stream) - def isidentifier(x): - return _identifier_re.search(x) is not None + # If that's not possible, silently use the original reader + # and get mojibake instead of exceptions. + if possible_binary_reader is None: + return text_stream - def get_binary_stdin(): - return set_binary_mode(sys.stdin) + binary_reader = possible_binary_reader - def get_binary_stdout(): - _wrap_std_stream("stdout") - return set_binary_mode(sys.stdout) + # Default errors to replace instead of strict in order to get + # something that works. + if errors is None: + errors = "replace" - def get_binary_stderr(): - _wrap_std_stream("stderr") - return set_binary_mode(sys.stderr) - - def get_text_stdin(encoding=None, errors=None): - rv = _get_windows_console_stream(sys.stdin, encoding, errors) - if rv is not None: - return rv - return _make_text_stream(sys.stdin, encoding, errors, force_readable=True) - - def get_text_stdout(encoding=None, errors=None): - _wrap_std_stream("stdout") - rv = _get_windows_console_stream(sys.stdout, encoding, errors) - if rv is not None: - return rv - return _make_text_stream(sys.stdout, encoding, errors, force_writable=True) - - def get_text_stderr(encoding=None, errors=None): - _wrap_std_stream("stderr") - rv = _get_windows_console_stream(sys.stderr, encoding, errors) - if rv is not None: - return rv - return _make_text_stream(sys.stderr, encoding, errors, force_writable=True) - - def filename_to_ui(value): - if isinstance(value, bytes): - value = value.decode(get_filesystem_encoding(), "replace") - return value - - -else: - import io - - text_type = str - raw_input = input - string_types = (str,) - int_types = (int,) - range_type = range - isidentifier = lambda x: x.isidentifier() - iteritems = lambda x: iter(x.items()) - - def is_bytes(x): - return isinstance(x, (bytes, memoryview, bytearray)) - - def _is_binary_reader(stream, default=False): - try: - return isinstance(stream.read(0), bytes) - except Exception: - return default - # This happens in some cases where the stream was already - # closed. In this case, we assume the default. - - def _is_binary_writer(stream, default=False): - try: - stream.write(b"") - except Exception: - try: - stream.write("") - return False - except Exception: - pass - return default - return True - - def _find_binary_reader(stream): - # We need to figure out if the given stream is already binary. - # This can happen because the official docs recommend detaching - # the streams to get binary streams. Some code might do this, so - # we need to deal with this case explicitly. - if _is_binary_reader(stream, False): - return stream - - buf = getattr(stream, "buffer", None) - - # Same situation here; this time we assume that the buffer is - # actually binary in case it's closed. - if buf is not None and _is_binary_reader(buf, True): - return buf - - def _find_binary_writer(stream): - # We need to figure out if the given stream is already binary. - # This can happen because the official docs recommend detatching - # the streams to get binary streams. Some code might do this, so - # we need to deal with this case explicitly. - if _is_binary_writer(stream, False): - return stream - - buf = getattr(stream, "buffer", None) - - # Same situation here; this time we assume that the buffer is - # actually binary in case it's closed. - if buf is not None and _is_binary_writer(buf, True): - return buf - - def _stream_is_misconfigured(stream): - """A stream is misconfigured if its encoding is ASCII.""" - # If the stream does not have an encoding set, we assume it's set - # to ASCII. This appears to happen in certain unittest - # environments. It's not quite clear what the correct behavior is - # but this at least will force Click to recover somehow. - return is_ascii_encoding(getattr(stream, "encoding", None) or "ascii") - - def _is_compat_stream_attr(stream, attr, value): - """A stream attribute is compatible if it is equal to the - desired value or the desired value is unset and the attribute - has a value. - """ - stream_value = getattr(stream, attr, None) - return stream_value == value or (value is None and stream_value is not None) - - def _is_compatible_text_stream(stream, encoding, errors): - """Check if a stream's encoding and errors attributes are - compatible with the desired values. - """ - return _is_compat_stream_attr( - stream, "encoding", encoding - ) and _is_compat_stream_attr(stream, "errors", errors) - - def _force_correct_text_stream( - text_stream, + # Wrap the binary stream in a text stream with the correct + # encoding parameters. + return _make_text_stream( + binary_reader, encoding, errors, - is_binary, - find_binary, - force_readable=False, - force_writable=False, - ): - if is_binary(text_stream, False): - binary_reader = text_stream - else: - # If the stream looks compatible, and won't default to a - # misconfigured ascii encoding, return it as-is. - if _is_compatible_text_stream(text_stream, encoding, errors) and not ( - encoding is None and _stream_is_misconfigured(text_stream) - ): - return text_stream - - # Otherwise, get the underlying binary reader. - binary_reader = find_binary(text_stream) - - # If that's not possible, silently use the original reader - # and get mojibake instead of exceptions. - if binary_reader is None: - return text_stream - - # Default errors to replace instead of strict in order to get - # something that works. - if errors is None: - errors = "replace" - - # Wrap the binary stream in a text stream with the correct - # encoding parameters. - return _make_text_stream( - binary_reader, - encoding, - errors, - force_readable=force_readable, - force_writable=force_writable, - ) - - def _force_correct_text_reader(text_reader, encoding, errors, force_readable=False): - return _force_correct_text_stream( - text_reader, - encoding, - errors, - _is_binary_reader, - _find_binary_reader, - force_readable=force_readable, - ) - - def _force_correct_text_writer(text_writer, encoding, errors, force_writable=False): - return _force_correct_text_stream( - text_writer, - encoding, - errors, - _is_binary_writer, - _find_binary_writer, - force_writable=force_writable, - ) - - def get_binary_stdin(): - reader = _find_binary_reader(sys.stdin) - if reader is None: - raise RuntimeError("Was not able to determine binary stream for sys.stdin.") - return reader - - def get_binary_stdout(): - writer = _find_binary_writer(sys.stdout) - if writer is None: - raise RuntimeError( - "Was not able to determine binary stream for sys.stdout." - ) - return writer - - def get_binary_stderr(): - writer = _find_binary_writer(sys.stderr) - if writer is None: - raise RuntimeError( - "Was not able to determine binary stream for sys.stderr." - ) - return writer - - def get_text_stdin(encoding=None, errors=None): - rv = _get_windows_console_stream(sys.stdin, encoding, errors) - if rv is not None: - return rv - return _force_correct_text_reader( - sys.stdin, encoding, errors, force_readable=True - ) - - def get_text_stdout(encoding=None, errors=None): - rv = _get_windows_console_stream(sys.stdout, encoding, errors) - if rv is not None: - return rv - return _force_correct_text_writer( - sys.stdout, encoding, errors, force_writable=True - ) - - def get_text_stderr(encoding=None, errors=None): - rv = _get_windows_console_stream(sys.stderr, encoding, errors) - if rv is not None: - return rv - return _force_correct_text_writer( - sys.stderr, encoding, errors, force_writable=True - ) - - def filename_to_ui(value): - if isinstance(value, bytes): - value = value.decode(get_filesystem_encoding(), "replace") - else: - value = value.encode("utf-8", "surrogateescape").decode("utf-8", "replace") - return value + force_readable=force_readable, + force_writable=force_writable, + ) -def get_streerror(e, default=None): - if hasattr(e, "strerror"): - msg = e.strerror - else: - if default is not None: - msg = default - else: - msg = str(e) - if isinstance(msg, bytes): - msg = msg.decode("utf-8", "replace") - return msg +def _force_correct_text_reader( + text_reader: t.IO, + encoding: t.Optional[str], + errors: t.Optional[str], + force_readable: bool = False, +) -> t.TextIO: + return _force_correct_text_stream( + text_reader, + encoding, + errors, + _is_binary_reader, + _find_binary_reader, + force_readable=force_readable, + ) -def _wrap_io_open(file, mode, encoding, errors): - """On Python 2, :func:`io.open` returns a text file wrapper that - requires passing ``unicode`` to ``write``. Need to open the file in - binary mode then wrap it in a subclass that can write ``str`` and - ``unicode``. - - Also handles not passing ``encoding`` and ``errors`` in binary mode. - """ - binary = "b" in mode - - if binary: - kwargs = {} - else: - kwargs = {"encoding": encoding, "errors": errors} - - if not PY2 or binary: - return io.open(file, mode, **kwargs) - - f = io.open(file, "{}b".format(mode.replace("t", ""))) - return _make_text_stream(f, **kwargs) +def _force_correct_text_writer( + text_writer: t.IO, + encoding: t.Optional[str], + errors: t.Optional[str], + force_writable: bool = False, +) -> t.TextIO: + return _force_correct_text_stream( + text_writer, + encoding, + errors, + _is_binary_writer, + _find_binary_writer, + force_writable=force_writable, + ) -def open_stream(filename, mode="r", encoding=None, errors="strict", atomic=False): +def get_binary_stdin() -> t.BinaryIO: + reader = _find_binary_reader(sys.stdin) + if reader is None: + raise RuntimeError("Was not able to determine binary stream for sys.stdin.") + return reader + + +def get_binary_stdout() -> t.BinaryIO: + writer = _find_binary_writer(sys.stdout) + if writer is None: + raise RuntimeError("Was not able to determine binary stream for sys.stdout.") + return writer + + +def get_binary_stderr() -> t.BinaryIO: + writer = _find_binary_writer(sys.stderr) + if writer is None: + raise RuntimeError("Was not able to determine binary stream for sys.stderr.") + return writer + + +def get_text_stdin( + encoding: t.Optional[str] = None, errors: t.Optional[str] = None +) -> t.TextIO: + rv = _get_windows_console_stream(sys.stdin, encoding, errors) + if rv is not None: + return rv + return _force_correct_text_reader(sys.stdin, encoding, errors, force_readable=True) + + +def get_text_stdout( + encoding: t.Optional[str] = None, errors: t.Optional[str] = None +) -> t.TextIO: + rv = _get_windows_console_stream(sys.stdout, encoding, errors) + if rv is not None: + return rv + return _force_correct_text_writer(sys.stdout, encoding, errors, force_writable=True) + + +def get_text_stderr( + encoding: t.Optional[str] = None, errors: t.Optional[str] = None +) -> t.TextIO: + rv = _get_windows_console_stream(sys.stderr, encoding, errors) + if rv is not None: + return rv + return _force_correct_text_writer(sys.stderr, encoding, errors, force_writable=True) + + +def _wrap_io_open( + file: t.Union[str, os.PathLike, int], + mode: str, + encoding: t.Optional[str], + errors: t.Optional[str], +) -> t.IO: + """Handles not passing ``encoding`` and ``errors`` in binary mode.""" + if "b" in mode: + return open(file, mode) + + return open(file, mode, encoding=encoding, errors=errors) + + +def open_stream( + filename: str, + mode: str = "r", + encoding: t.Optional[str] = None, + errors: t.Optional[str] = "strict", + atomic: bool = False, +) -> t.Tuple[t.IO, bool]: binary = "b" in mode # Standard streams first. These are simple because they don't need @@ -549,7 +424,7 @@ def open_stream(filename, mode="r", encoding=None, errors="strict", atomic=False import random try: - perm = os.stat(filename).st_mode + perm: t.Optional[int] = os.stat(filename).st_mode except OSError: perm = None @@ -561,7 +436,7 @@ def open_stream(filename, mode="r", encoding=None, errors="strict", atomic=False while True: tmp_filename = os.path.join( os.path.dirname(filename), - ".__atomic-write{:08x}".format(random.randrange(1 << 32)), + f".__atomic-write{random.randrange(1 << 32):08x}", ) try: fd = os.open(tmp_filename, flags, 0o666 if perm is None else perm) @@ -580,76 +455,55 @@ def open_stream(filename, mode="r", encoding=None, errors="strict", atomic=False os.chmod(tmp_filename, perm) # in case perm includes bits in umask f = _wrap_io_open(fd, mode, encoding, errors) - return _AtomicFile(f, tmp_filename, os.path.realpath(filename)), True + af = _AtomicFile(f, tmp_filename, os.path.realpath(filename)) + return t.cast(t.IO, af), True -# Used in a destructor call, needs extra protection from interpreter cleanup. -if hasattr(os, "replace"): - _replace = os.replace - _can_replace = True -else: - _replace = os.rename - _can_replace = not WIN - - -class _AtomicFile(object): - def __init__(self, f, tmp_filename, real_filename): +class _AtomicFile: + def __init__(self, f: t.IO, tmp_filename: str, real_filename: str) -> None: self._f = f self._tmp_filename = tmp_filename self._real_filename = real_filename self.closed = False @property - def name(self): + def name(self) -> str: return self._real_filename - def close(self, delete=False): + def close(self, delete: bool = False) -> None: if self.closed: return self._f.close() - if not _can_replace: - try: - os.remove(self._real_filename) - except OSError: - pass - _replace(self._tmp_filename, self._real_filename) + os.replace(self._tmp_filename, self._real_filename) self.closed = True - def __getattr__(self, name): + def __getattr__(self, name: str) -> t.Any: return getattr(self._f, name) - def __enter__(self): + def __enter__(self) -> "_AtomicFile": return self - def __exit__(self, exc_type, exc_value, tb): + def __exit__(self, exc_type, exc_value, tb): # type: ignore self.close(delete=exc_type is not None) - def __repr__(self): + def __repr__(self) -> str: return repr(self._f) -auto_wrap_for_ansi = None -colorama = None -get_winterm_size = None - - -def strip_ansi(value): +def strip_ansi(value: str) -> str: return _ansi_re.sub("", value) -def _is_jupyter_kernel_output(stream): - if WIN: - # TODO: Couldn't test on Windows, should't try to support until - # someone tests the details wrt colorama. - return - +def _is_jupyter_kernel_output(stream: t.IO) -> bool: while isinstance(stream, (_FixupStream, _NonClosingTextIOWrapper)): stream = stream._stream return stream.__class__.__module__.startswith("ipykernel.") -def should_strip_ansi(stream=None, color=None): +def should_strip_ansi( + stream: t.Optional[t.IO] = None, color: t.Optional[bool] = None +) -> bool: if color is None: if stream is None: stream = sys.stdin @@ -657,99 +511,85 @@ def should_strip_ansi(stream=None, color=None): return not color -# If we're on Windows, we provide transparent integration through -# colorama. This will make ANSI colors through the echo function -# work automatically. -if WIN: - # Windows has a smaller terminal - DEFAULT_COLUMNS = 79 +# On Windows, wrap the output streams with colorama to support ANSI +# color codes. +# NOTE: double check is needed so mypy does not analyze this on Linux +if sys.platform.startswith("win") and WIN: + from ._winconsole import _get_windows_console_stream - from ._winconsole import _get_windows_console_stream, _wrap_std_stream - - def _get_argv_encoding(): + def _get_argv_encoding() -> str: import locale return locale.getpreferredencoding() - if PY2: + _ansi_stream_wrappers: t.MutableMapping[t.TextIO, t.TextIO] = WeakKeyDictionary() - def raw_input(prompt=""): - sys.stderr.flush() - if prompt: - stdout = _default_text_stdout() - stdout.write(prompt) - stdin = _default_text_stdin() - return stdin.readline().rstrip("\r\n") + def auto_wrap_for_ansi( + stream: t.TextIO, color: t.Optional[bool] = None + ) -> t.TextIO: + """Support ANSI color and style codes on Windows by wrapping a + stream with colorama. + """ + try: + cached = _ansi_stream_wrappers.get(stream) + except Exception: + cached = None + + if cached is not None: + return cached - try: import colorama - except ImportError: - pass - else: - _ansi_stream_wrappers = WeakKeyDictionary() - def auto_wrap_for_ansi(stream, color=None): - """This function wraps a stream so that calls through colorama - are issued to the win32 console API to recolor on demand. It - also ensures to reset the colors if a write call is interrupted - to not destroy the console afterwards. - """ + strip = should_strip_ansi(stream, color) + ansi_wrapper = colorama.AnsiToWin32(stream, strip=strip) + rv = t.cast(t.TextIO, ansi_wrapper.stream) + _write = rv.write + + def _safe_write(s): try: - cached = _ansi_stream_wrappers.get(stream) - except Exception: - cached = None - if cached is not None: - return cached - strip = should_strip_ansi(stream, color) - ansi_wrapper = colorama.AnsiToWin32(stream, strip=strip) - rv = ansi_wrapper.stream - _write = rv.write + return _write(s) + except BaseException: + ansi_wrapper.reset_all() + raise - def _safe_write(s): - try: - return _write(s) - except: - ansi_wrapper.reset_all() - raise + rv.write = _safe_write - rv.write = _safe_write - try: - _ansi_stream_wrappers[stream] = rv - except Exception: - pass - return rv + try: + _ansi_stream_wrappers[stream] = rv + except Exception: + pass - def get_winterm_size(): - win = colorama.win32.GetConsoleScreenBufferInfo( - colorama.win32.STDOUT - ).srWindow - return win.Right - win.Left, win.Bottom - win.Top + return rv else: - def _get_argv_encoding(): + def _get_argv_encoding() -> str: return getattr(sys.stdin, "encoding", None) or get_filesystem_encoding() - _get_windows_console_stream = lambda *x: None - _wrap_std_stream = lambda *x: None + def _get_windows_console_stream( + f: t.TextIO, encoding: t.Optional[str], errors: t.Optional[str] + ) -> t.Optional[t.TextIO]: + return None -def term_len(x): +def term_len(x: str) -> int: return len(strip_ansi(x)) -def isatty(stream): +def isatty(stream: t.IO) -> bool: try: return stream.isatty() except Exception: return False -def _make_cached_stream_func(src_func, wrapper_func): - cache = WeakKeyDictionary() +def _make_cached_stream_func( + src_func: t.Callable[[], t.TextIO], wrapper_func: t.Callable[[], t.TextIO] +) -> t.Callable[[], t.TextIO]: + cache: t.MutableMapping[t.TextIO, t.TextIO] = WeakKeyDictionary() - def func(): + def func() -> t.TextIO: stream = src_func() try: rv = cache.get(stream) @@ -759,7 +599,6 @@ def _make_cached_stream_func(src_func, wrapper_func): return rv rv = wrapper_func() try: - stream = src_func() # In case wrapper_func() modified the stream cache[stream] = rv except Exception: pass @@ -773,13 +612,15 @@ _default_text_stdout = _make_cached_stream_func(lambda: sys.stdout, get_text_std _default_text_stderr = _make_cached_stream_func(lambda: sys.stderr, get_text_stderr) -binary_streams = { +binary_streams: t.Mapping[str, t.Callable[[], t.BinaryIO]] = { "stdin": get_binary_stdin, "stdout": get_binary_stdout, "stderr": get_binary_stderr, } -text_streams = { +text_streams: t.Mapping[ + str, t.Callable[[t.Optional[str], t.Optional[str]], t.TextIO] +] = { "stdin": get_text_stdin, "stdout": get_text_stdout, "stderr": get_text_stderr, diff --git a/src/click/_termui_impl.py b/src/click/_termui_impl.py index 88bec37..39c1d08 100644 --- a/src/click/_termui_impl.py +++ b/src/click/_termui_impl.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ This module contains implementations for the termui module. To keep the import time of Click down, some infrequently used functionality is @@ -9,20 +8,22 @@ import math import os import sys import time +import typing as t +from gettext import gettext as _ from ._compat import _default_text_stdout from ._compat import CYGWIN from ._compat import get_best_encoding -from ._compat import int_types from ._compat import isatty from ._compat import open_stream -from ._compat import range_type from ._compat import strip_ansi from ._compat import term_len from ._compat import WIN from .exceptions import ClickException from .utils import echo +V = t.TypeVar("V") + if os.name == "nt": BEFORE_BAR = "\r" AFTER_BAR = "\n" @@ -31,42 +32,25 @@ else: AFTER_BAR = "\033[?25h\n" -def _length_hint(obj): - """Returns the length hint of an object.""" - try: - return len(obj) - except (AttributeError, TypeError): - try: - get_hint = type(obj).__length_hint__ - except AttributeError: - return None - try: - hint = get_hint(obj) - except TypeError: - return None - if hint is NotImplemented or not isinstance(hint, int_types) or hint < 0: - return None - return hint - - -class ProgressBar(object): +class ProgressBar(t.Generic[V]): def __init__( self, - iterable, - length=None, - fill_char="#", - empty_char=" ", - bar_template="%(bar)s", - info_sep=" ", - show_eta=True, - show_percent=None, - show_pos=False, - item_show_func=None, - label=None, - file=None, - color=None, - width=30, - ): + iterable: t.Optional[t.Iterable[V]], + length: t.Optional[int] = None, + fill_char: str = "#", + empty_char: str = " ", + bar_template: str = "%(bar)s", + info_sep: str = " ", + show_eta: bool = True, + show_percent: t.Optional[bool] = None, + show_pos: bool = False, + item_show_func: t.Optional[t.Callable[[t.Optional[V]], t.Optional[str]]] = None, + label: t.Optional[str] = None, + file: t.Optional[t.TextIO] = None, + color: t.Optional[bool] = None, + update_min_steps: int = 1, + width: int = 30, + ) -> None: self.fill_char = fill_char self.empty_char = empty_char self.bar_template = bar_template @@ -80,45 +64,50 @@ class ProgressBar(object): file = _default_text_stdout() self.file = file self.color = color + self.update_min_steps = update_min_steps + self._completed_intervals = 0 self.width = width self.autowidth = width == 0 if length is None: - length = _length_hint(iterable) + from operator import length_hint + + length = length_hint(iterable, -1) + + if length == -1: + length = None if iterable is None: if length is None: raise TypeError("iterable or length is required") - iterable = range_type(length) + iterable = t.cast(t.Iterable[V], range(length)) self.iter = iter(iterable) self.length = length - self.length_known = length is not None self.pos = 0 - self.avg = [] + self.avg: t.List[float] = [] self.start = self.last_eta = time.time() self.eta_known = False self.finished = False - self.max_width = None + self.max_width: t.Optional[int] = None self.entered = False - self.current_item = None + self.current_item: t.Optional[V] = None self.is_hidden = not isatty(self.file) - self._last_line = None - self.short_limit = 0.5 + self._last_line: t.Optional[str] = None - def __enter__(self): + def __enter__(self) -> "ProgressBar": self.entered = True self.render_progress() return self - def __exit__(self, exc_type, exc_value, tb): + def __exit__(self, exc_type, exc_value, tb): # type: ignore self.render_finish() - def __iter__(self): + def __iter__(self) -> t.Iterator[V]: if not self.entered: raise RuntimeError("You need to use progress bars in a with block.") self.render_progress() return self.generator() - def __next__(self): + def __next__(self) -> V: # Iteration is defined in terms of a generator function, # returned by iter(self); use that to define next(). This works # because `self.iter` is an iterable consumed by that generator, @@ -126,37 +115,31 @@ class ProgressBar(object): # twice works and does "what you want". return next(iter(self)) - # Python 2 compat - next = __next__ - - def is_fast(self): - return time.time() - self.start <= self.short_limit - - def render_finish(self): - if self.is_hidden or self.is_fast(): + def render_finish(self) -> None: + if self.is_hidden: return self.file.write(AFTER_BAR) self.file.flush() @property - def pct(self): + def pct(self) -> float: if self.finished: return 1.0 - return min(self.pos / (float(self.length) or 1), 1.0) + return min(self.pos / (float(self.length or 1) or 1), 1.0) @property - def time_per_iteration(self): + def time_per_iteration(self) -> float: if not self.avg: return 0.0 return sum(self.avg) / float(len(self.avg)) @property - def eta(self): - if self.length_known and not self.finished: + def eta(self) -> float: + if self.length is not None and not self.finished: return self.time_per_iteration * (self.length - self.pos) return 0.0 - def format_eta(self): + def format_eta(self) -> str: if self.eta_known: t = int(self.eta) seconds = t % 60 @@ -166,44 +149,44 @@ class ProgressBar(object): hours = t % 24 t //= 24 if t > 0: - return "{}d {:02}:{:02}:{:02}".format(t, hours, minutes, seconds) + return f"{t}d {hours:02}:{minutes:02}:{seconds:02}" else: - return "{:02}:{:02}:{:02}".format(hours, minutes, seconds) + return f"{hours:02}:{minutes:02}:{seconds:02}" return "" - def format_pos(self): + def format_pos(self) -> str: pos = str(self.pos) - if self.length_known: - pos += "/{}".format(self.length) + if self.length is not None: + pos += f"/{self.length}" return pos - def format_pct(self): - return "{: 4}%".format(int(self.pct * 100))[1:] + def format_pct(self) -> str: + return f"{int(self.pct * 100): 4}%"[1:] - def format_bar(self): - if self.length_known: + def format_bar(self) -> str: + if self.length is not None: bar_length = int(self.pct * self.width) bar = self.fill_char * bar_length bar += self.empty_char * (self.width - bar_length) elif self.finished: bar = self.fill_char * self.width else: - bar = list(self.empty_char * (self.width or 1)) + chars = list(self.empty_char * (self.width or 1)) if self.time_per_iteration != 0: - bar[ + chars[ int( (math.cos(self.pos * self.time_per_iteration) / 2.0 + 0.5) * self.width ) ] = self.fill_char - bar = "".join(bar) + bar = "".join(chars) return bar - def format_progress_line(self): + def format_progress_line(self) -> str: show_percent = self.show_percent info_bits = [] - if self.length_known and show_percent is None: + if self.length is not None and show_percent is None: show_percent = not self.show_pos if self.show_pos: @@ -226,10 +209,16 @@ class ProgressBar(object): } ).rstrip() - def render_progress(self): - from .termui import get_terminal_size + def render_progress(self) -> None: + import shutil if self.is_hidden: + # Only output the label as it changes if the output is not a + # TTY. Use file=stderr if you expect to be piping stdout. + if self._last_line != self.label: + self._last_line = self.label + echo(self.label, file=self.file, color=self.color) + return buf = [] @@ -238,10 +227,10 @@ class ProgressBar(object): old_width = self.width self.width = 0 clutter_length = term_len(self.format_progress_line()) - new_width = max(0, get_terminal_size()[0] - clutter_length) + new_width = max(0, shutil.get_terminal_size().columns - clutter_length) if new_width < old_width: buf.append(BEFORE_BAR) - buf.append(" " * self.max_width) + buf.append(" " * self.max_width) # type: ignore self.max_width = new_width self.width = new_width @@ -260,14 +249,14 @@ class ProgressBar(object): line = "".join(buf) # Render the line only if it changed. - if line != self._last_line and not self.is_fast(): + if line != self._last_line: self._last_line = line echo(line, file=self.file, color=self.color, nl=False) self.file.flush() - def make_step(self, n_steps): + def make_step(self, n_steps: int) -> None: self.pos += n_steps - if self.length_known and self.pos >= self.length: + if self.length is not None and self.pos >= self.length: self.finished = True if (time.time() - self.last_eta) < 1.0: @@ -285,18 +274,40 @@ class ProgressBar(object): self.avg = self.avg[-6:] + [step] - self.eta_known = self.length_known + self.eta_known = self.length is not None - def update(self, n_steps): - self.make_step(n_steps) - self.render_progress() + def update(self, n_steps: int, current_item: t.Optional[V] = None) -> None: + """Update the progress bar by advancing a specified number of + steps, and optionally set the ``current_item`` for this new + position. - def finish(self): - self.eta_known = 0 + :param n_steps: Number of steps to advance. + :param current_item: Optional item to set as ``current_item`` + for the updated position. + + .. versionchanged:: 8.0 + Added the ``current_item`` optional parameter. + + .. versionchanged:: 8.0 + Only render when the number of steps meets the + ``update_min_steps`` threshold. + """ + if current_item is not None: + self.current_item = current_item + + self._completed_intervals += n_steps + + if self._completed_intervals >= self.update_min_steps: + self.make_step(self._completed_intervals) + self.render_progress() + self._completed_intervals = 0 + + def finish(self) -> None: + self.eta_known = False self.current_item = None self.finished = True - def generator(self): + def generator(self) -> t.Iterator[V]: """Return a generator which yields the items added to the bar during construction, and updates the progress bar *after* the yielded block returns. @@ -312,18 +323,25 @@ class ProgressBar(object): raise RuntimeError("You need to use progress bars in a with block.") if self.is_hidden: - for rv in self.iter: - yield rv + yield from self.iter else: for rv in self.iter: self.current_item = rv + + # This allows show_item_func to be updated before the + # item is processed. Only trigger at the beginning of + # the update interval. + if self._completed_intervals == 0: + self.render_progress() + yield rv self.update(1) + self.finish() self.render_progress() -def pager(generator, color=None): +def pager(generator: t.Iterable[str], color: t.Optional[bool] = None) -> None: """Decide what method to use for paging through text.""" stdout = _default_text_stdout() if not isatty(sys.stdin) or not isatty(stdout): @@ -345,14 +363,14 @@ def pager(generator, color=None): fd, filename = tempfile.mkstemp() os.close(fd) try: - if hasattr(os, "system") and os.system('more "{}"'.format(filename)) == 0: + if hasattr(os, "system") and os.system(f'more "{filename}"') == 0: return _pipepager(generator, "more", color) return _nullpager(stdout, generator, color) finally: os.unlink(filename) -def _pipepager(generator, cmd, color): +def _pipepager(generator: t.Iterable[str], cmd: str, color: t.Optional[bool]) -> None: """Page through text by feeding it to another program. Invoking a pager through this might support colors. """ @@ -364,7 +382,7 @@ def _pipepager(generator, cmd, color): # condition that cmd_detail = cmd.rsplit("/", 1)[-1].split() if color is None and cmd_detail[0] == "less": - less_flags = "{}{}".format(os.environ.get("LESS", ""), " ".join(cmd_detail[1:])) + less_flags = f"{os.environ.get('LESS', '')}{' '.join(cmd_detail[1:])}" if not less_flags: env["LESS"] = "-R" color = True @@ -372,17 +390,18 @@ def _pipepager(generator, cmd, color): color = True c = subprocess.Popen(cmd, shell=True, stdin=subprocess.PIPE, env=env) - encoding = get_best_encoding(c.stdin) + stdin = t.cast(t.BinaryIO, c.stdin) + encoding = get_best_encoding(stdin) try: for text in generator: if not color: text = strip_ansi(text) - c.stdin.write(text.encode(encoding, "replace")) - except (IOError, KeyboardInterrupt): + stdin.write(text.encode(encoding, "replace")) + except (OSError, KeyboardInterrupt): pass else: - c.stdin.close() + stdin.close() # Less doesn't respect ^C, but catches it for its own UI purposes (aborting # search or other commands inside less). @@ -401,11 +420,13 @@ def _pipepager(generator, cmd, color): break -def _tempfilepager(generator, cmd, color): +def _tempfilepager( + generator: t.Iterable[str], cmd: str, color: t.Optional[bool] +) -> None: """Page through text by invoking a program on a temporary file.""" import tempfile - filename = tempfile.mktemp() + fd, filename = tempfile.mkstemp() # TODO: This never terminates if the passed generator never terminates. text = "".join(generator) if not color: @@ -414,12 +435,15 @@ def _tempfilepager(generator, cmd, color): with open_stream(filename, "wb")[0] as f: f.write(text.encode(encoding)) try: - os.system('{} "{}"'.format(cmd, filename)) + os.system(f'{cmd} "{filename}"') finally: + os.close(fd) os.unlink(filename) -def _nullpager(stream, generator, color): +def _nullpager( + stream: t.TextIO, generator: t.Iterable[str], color: t.Optional[bool] +) -> None: """Simply print unformatted text. This is the ultimate fallback.""" for text in generator: if not color: @@ -427,14 +451,20 @@ def _nullpager(stream, generator, color): stream.write(text) -class Editor(object): - def __init__(self, editor=None, env=None, require_save=True, extension=".txt"): +class Editor: + def __init__( + self, + editor: t.Optional[str] = None, + env: t.Optional[t.Mapping[str, str]] = None, + require_save: bool = True, + extension: str = ".txt", + ) -> None: self.editor = editor self.env = env self.require_save = require_save self.extension = extension - def get_editor(self): + def get_editor(self) -> str: if self.editor is not None: return self.editor for key in "VISUAL", "EDITOR": @@ -444,48 +474,62 @@ class Editor(object): if WIN: return "notepad" for editor in "sensible-editor", "vim", "nano": - if os.system("which {} >/dev/null 2>&1".format(editor)) == 0: + if os.system(f"which {editor} >/dev/null 2>&1") == 0: return editor return "vi" - def edit_file(self, filename): + def edit_file(self, filename: str) -> None: import subprocess editor = self.get_editor() + environ: t.Optional[t.Dict[str, str]] = None + if self.env: environ = os.environ.copy() environ.update(self.env) - else: - environ = None + try: - c = subprocess.Popen( - '{} "{}"'.format(editor, filename), env=environ, shell=True, - ) + c = subprocess.Popen(f'{editor} "{filename}"', env=environ, shell=True) exit_code = c.wait() if exit_code != 0: - raise ClickException("{}: Editing failed!".format(editor)) + raise ClickException( + _("{editor}: Editing failed").format(editor=editor) + ) except OSError as e: - raise ClickException("{}: Editing failed: {}".format(editor, e)) + raise ClickException( + _("{editor}: Editing failed: {e}").format(editor=editor, e=e) + ) from e - def edit(self, text): + def edit(self, text: t.Optional[t.AnyStr]) -> t.Optional[t.AnyStr]: import tempfile - text = text or "" - if text and not text.endswith("\n"): - text += "\n" + if not text: + data = b"" + elif isinstance(text, (bytes, bytearray)): + data = text + else: + if text and not text.endswith("\n"): + text += "\n" + + if WIN: + data = text.replace("\n", "\r\n").encode("utf-8-sig") + else: + data = text.encode("utf-8") fd, name = tempfile.mkstemp(prefix="editor-", suffix=self.extension) - try: - if WIN: - encoding = "utf-8-sig" - text = text.replace("\n", "\r\n") - else: - encoding = "utf-8" - text = text.encode(encoding) + f: t.BinaryIO - f = os.fdopen(fd, "wb") - f.write(text) - f.close() + try: + with os.fdopen(fd, "wb") as f: + f.write(data) + + # If the filesystem resolution is 1 second, like Mac OS + # 10.12 Extended, or 2 seconds, like FAT32, and the editor + # closes very fast, require_save can fail. Set the modified + # time to be 2 seconds in the past to work around this. + os.utime(name, (os.path.getatime(name), os.path.getmtime(name) - 2)) + # Depending on the resolution, the exact value might not be + # recorded, so get the new recorded value. timestamp = os.path.getmtime(name) self.edit_file(name) @@ -493,26 +537,26 @@ class Editor(object): if self.require_save and os.path.getmtime(name) == timestamp: return None - f = open(name, "rb") - try: + with open(name, "rb") as f: rv = f.read() - finally: - f.close() - return rv.decode("utf-8-sig").replace("\r\n", "\n") + + if isinstance(text, (bytes, bytearray)): + return rv + + return rv.decode("utf-8-sig").replace("\r\n", "\n") # type: ignore finally: os.unlink(name) -def open_url(url, wait=False, locate=False): +def open_url(url: str, wait: bool = False, locate: bool = False) -> int: import subprocess - def _unquote_file(url): - try: - import urllib - except ImportError: - import urllib + def _unquote_file(url: str) -> str: + from urllib.parse import unquote + if url.startswith("file://"): - url = urllib.unquote(url[7:]) + url = unquote(url[7:]) + return url if sys.platform == "darwin": @@ -529,19 +573,21 @@ def open_url(url, wait=False, locate=False): null.close() elif WIN: if locate: - url = _unquote_file(url) - args = 'explorer /select,"{}"'.format(_unquote_file(url.replace('"', ""))) + url = _unquote_file(url.replace('"', "")) + args = f'explorer /select,"{url}"' else: - args = 'start {} "" "{}"'.format( - "/WAIT" if wait else "", url.replace('"', "") - ) + url = url.replace('"', "") + wait_str = "/WAIT" if wait else "" + args = f'start {wait_str} "" "{url}"' return os.system(args) elif CYGWIN: if locate: - url = _unquote_file(url) - args = 'cygstart "{}"'.format(os.path.dirname(url).replace('"', "")) + url = os.path.dirname(_unquote_file(url).replace('"', "")) + args = f'cygstart "{url}"' else: - args = 'cygstart {} "{}"'.format("-w" if wait else "", url.replace('"', "")) + url = url.replace('"', "") + wait_str = "-w" if wait else "" + args = f'cygstart {wait_str} "{url}"' return os.system(args) try: @@ -562,23 +608,27 @@ def open_url(url, wait=False, locate=False): return 1 -def _translate_ch_to_exc(ch): - if ch == u"\x03": +def _translate_ch_to_exc(ch: str) -> t.Optional[BaseException]: + if ch == "\x03": raise KeyboardInterrupt() - if ch == u"\x04" and not WIN: # Unix-like, Ctrl+D + + if ch == "\x04" and not WIN: # Unix-like, Ctrl+D raise EOFError() - if ch == u"\x1a" and WIN: # Windows, Ctrl+Z + + if ch == "\x1a" and WIN: # Windows, Ctrl+Z raise EOFError() + return None + if WIN: import msvcrt @contextlib.contextmanager - def raw_terminal(): - yield + def raw_terminal() -> t.Iterator[int]: + yield -1 - def getchar(echo): + def getchar(echo: bool) -> str: # The function `getch` will return a bytes object corresponding to # the pressed character. Since Windows 10 build 1803, it will also # return \x00 when called a second time after pressing a regular key. @@ -608,16 +658,20 @@ if WIN: # # Anyway, Click doesn't claim to do this Right(tm), and using `getwch` # is doing the right thing in more situations than with `getch`. + func: t.Callable[[], str] + if echo: - func = msvcrt.getwche + func = msvcrt.getwche # type: ignore else: - func = msvcrt.getwch + func = msvcrt.getwch # type: ignore rv = func() - if rv in (u"\x00", u"\xe0"): + + if rv in ("\x00", "\xe0"): # \x00 and \xe0 are control characters that indicate special key, # see above. rv += func() + _translate_ch_to_exc(rv) return rv @@ -627,31 +681,38 @@ else: import termios @contextlib.contextmanager - def raw_terminal(): + def raw_terminal() -> t.Iterator[int]: + f: t.Optional[t.TextIO] + fd: int + if not isatty(sys.stdin): f = open("/dev/tty") fd = f.fileno() else: fd = sys.stdin.fileno() f = None + try: old_settings = termios.tcgetattr(fd) + try: tty.setraw(fd) yield fd finally: termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) sys.stdout.flush() + if f is not None: f.close() except termios.error: pass - def getchar(echo): + def getchar(echo: bool) -> str: with raw_terminal() as fd: - ch = os.read(fd, 32) - ch = ch.decode(get_best_encoding(sys.stdin), "replace") + ch = os.read(fd, 32).decode(get_best_encoding(sys.stdin), "replace") + if echo and isatty(sys.stdout): sys.stdout.write(ch) + _translate_ch_to_exc(ch) return ch diff --git a/src/click/_textwrap.py b/src/click/_textwrap.py index 6959087..b47dcbd 100644 --- a/src/click/_textwrap.py +++ b/src/click/_textwrap.py @@ -1,9 +1,16 @@ import textwrap +import typing as t from contextlib import contextmanager class TextWrapper(textwrap.TextWrapper): - def _handle_long_word(self, reversed_chunks, cur_line, cur_len, width): + def _handle_long_word( + self, + reversed_chunks: t.List[str], + cur_line: t.List[str], + cur_len: int, + width: int, + ) -> None: space_left = max(width - cur_len, 1) if self.break_long_words: @@ -16,22 +23,27 @@ class TextWrapper(textwrap.TextWrapper): cur_line.append(reversed_chunks.pop()) @contextmanager - def extra_indent(self, indent): + def extra_indent(self, indent: str) -> t.Iterator[None]: old_initial_indent = self.initial_indent old_subsequent_indent = self.subsequent_indent self.initial_indent += indent self.subsequent_indent += indent + try: yield finally: self.initial_indent = old_initial_indent self.subsequent_indent = old_subsequent_indent - def indent_only(self, text): + def indent_only(self, text: str) -> str: rv = [] + for idx, line in enumerate(text.splitlines()): indent = self.initial_indent + if idx > 0: indent = self.subsequent_indent - rv.append(indent + line) + + rv.append(f"{indent}{line}") + return "\n".join(rv) diff --git a/src/click/_unicodefun.py b/src/click/_unicodefun.py index 781c365..9cb30c3 100644 --- a/src/click/_unicodefun.py +++ b/src/click/_unicodefun.py @@ -1,131 +1,100 @@ import codecs import os -import sys - -from ._compat import PY2 +from gettext import gettext as _ -def _find_unicode_literals_frame(): - import __future__ - - if not hasattr(sys, "_getframe"): # not all Python implementations have it - return 0 - frm = sys._getframe(1) - idx = 1 - while frm is not None: - if frm.f_globals.get("__name__", "").startswith("click."): - frm = frm.f_back - idx += 1 - elif frm.f_code.co_flags & __future__.unicode_literals.compiler_flag: - return idx - else: - break - return 0 - - -def _check_for_unicode_literals(): - if not __debug__: - return - - from . import disable_unicode_literals_warning - - if not PY2 or disable_unicode_literals_warning: - return - bad_frame = _find_unicode_literals_frame() - if bad_frame <= 0: - return - from warnings import warn - - warn( - Warning( - "Click detected the use of the unicode_literals __future__" - " import. This is heavily discouraged because it can" - " introduce subtle bugs in your code. You should instead" - ' use explicit u"" literals for your unicode strings. For' - " more information see" - " https://click.palletsprojects.com/python3/" - ), - stacklevel=bad_frame, - ) - - -def _verify_python3_env(): - """Ensures that the environment is good for unicode on Python 3.""" - if PY2: - return +def _verify_python_env() -> None: + """Ensures that the environment is good for Unicode.""" try: - import locale + from locale import getpreferredencoding - fs_enc = codecs.lookup(locale.getpreferredencoding()).name + fs_enc = codecs.lookup(getpreferredencoding()).name except Exception: fs_enc = "ascii" + if fs_enc != "ascii": return - extra = "" + extra = [ + _( + "Click will abort further execution because Python was" + " configured to use ASCII as encoding for the environment." + " Consult https://click.palletsprojects.com/unicode-support/" + " for mitigation steps." + ) + ] + if os.name == "posix": import subprocess try: rv = subprocess.Popen( - ["locale", "-a"], stdout=subprocess.PIPE, stderr=subprocess.PIPE + ["locale", "-a"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="ascii", + errors="replace", ).communicate()[0] except OSError: - rv = b"" + rv = "" + good_locales = set() has_c_utf8 = False - # Make sure we're operating on text here. - if isinstance(rv, bytes): - rv = rv.decode("ascii", "replace") - for line in rv.splitlines(): locale = line.strip() + if locale.lower().endswith((".utf-8", ".utf8")): good_locales.add(locale) + if locale.lower() in ("c.utf8", "c.utf-8"): has_c_utf8 = True - extra += "\n\n" if not good_locales: - extra += ( - "Additional information: on this system no suitable" - " UTF-8 locales were discovered. This most likely" - " requires resolving by reconfiguring the locale" - " system." + extra.append( + _( + "Additional information: on this system no suitable" + " UTF-8 locales were discovered. This most likely" + " requires resolving by reconfiguring the locale" + " system." + ) ) elif has_c_utf8: - extra += ( - "This system supports the C.UTF-8 locale which is" - " recommended. You might be able to resolve your issue" - " by exporting the following environment variables:\n\n" - " export LC_ALL=C.UTF-8\n" - " export LANG=C.UTF-8" + extra.append( + _( + "This system supports the C.UTF-8 locale which is" + " recommended. You might be able to resolve your" + " issue by exporting the following environment" + " variables:" + ) ) + extra.append(" export LC_ALL=C.UTF-8\n export LANG=C.UTF-8") else: - extra += ( - "This system lists a couple of UTF-8 supporting locales" - " that you can pick from. The following suitable" - " locales were discovered: {}".format(", ".join(sorted(good_locales))) + extra.append( + _( + "This system lists some UTF-8 supporting locales" + " that you can pick from. The following suitable" + " locales were discovered: {locales}" + ).format(locales=", ".join(sorted(good_locales))) ) bad_locale = None - for locale in os.environ.get("LC_ALL"), os.environ.get("LANG"): - if locale and locale.lower().endswith((".utf-8", ".utf8")): - bad_locale = locale - if locale is not None: + + for env_locale in os.environ.get("LC_ALL"), os.environ.get("LANG"): + if env_locale and env_locale.lower().endswith((".utf-8", ".utf8")): + bad_locale = env_locale + + if env_locale is not None: break + if bad_locale is not None: - extra += ( - "\n\nClick discovered that you exported a UTF-8 locale" - " but the locale system could not pick up from it" - " because it does not exist. The exported locale is" - " '{}' but it is not supported".format(bad_locale) + extra.append( + _( + "Click discovered that you exported a UTF-8 locale" + " but the locale system could not pick up from it" + " because it does not exist. The exported locale is" + " {locale!r} but it is not supported." + ).format(locale=bad_locale) ) - raise RuntimeError( - "Click will abort further execution because Python 3 was" - " configured to use ASCII as encoding for the environment." - " Consult https://click.palletsprojects.com/python3/ for" - " mitigation steps.{}".format(extra) - ) + raise RuntimeError("\n\n".join(extra)) diff --git a/src/click/_winconsole.py b/src/click/_winconsole.py index b6c4274..6b20df3 100644 --- a/src/click/_winconsole.py +++ b/src/click/_winconsole.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # This module is based on the excellent work by Adam Bartoš who # provided a lot of what went into the implementation here in # the discussion to issue1602 in the Python bug tracker. @@ -6,13 +5,11 @@ # There are some general differences in regards to how this works # compared to the original patches as we do not need to patch # the entire interpreter but just work in our little world of -# echo and prmopt. -import ctypes +# echo and prompt. import io -import os import sys import time -import zlib +import typing as t from ctypes import byref from ctypes import c_char from ctypes import c_char_p @@ -22,28 +19,18 @@ from ctypes import c_ulong from ctypes import c_void_p from ctypes import POINTER from ctypes import py_object -from ctypes import windll -from ctypes import WinError -from ctypes import WINFUNCTYPE +from ctypes import Structure from ctypes.wintypes import DWORD from ctypes.wintypes import HANDLE from ctypes.wintypes import LPCWSTR from ctypes.wintypes import LPWSTR -import msvcrt - from ._compat import _NonClosingTextIOWrapper -from ._compat import PY2 -from ._compat import text_type - -try: - from ctypes import pythonapi - - PyObject_GetBuffer = pythonapi.PyObject_GetBuffer - PyBuffer_Release = pythonapi.PyBuffer_Release -except ImportError: - pythonapi = None +assert sys.platform == "win32" +import msvcrt # noqa: E402 +from ctypes import windll # noqa: E402 +from ctypes import WINFUNCTYPE # noqa: E402 c_ssize_p = POINTER(c_ssize_t) @@ -57,16 +44,12 @@ GetCommandLineW = WINFUNCTYPE(LPWSTR)(("GetCommandLineW", windll.kernel32)) CommandLineToArgvW = WINFUNCTYPE(POINTER(LPWSTR), LPCWSTR, POINTER(c_int))( ("CommandLineToArgvW", windll.shell32) ) -LocalFree = WINFUNCTYPE(ctypes.c_void_p, ctypes.c_void_p)( - ("LocalFree", windll.kernel32) -) - +LocalFree = WINFUNCTYPE(c_void_p, c_void_p)(("LocalFree", windll.kernel32)) STDIN_HANDLE = GetStdHandle(-10) STDOUT_HANDLE = GetStdHandle(-11) STDERR_HANDLE = GetStdHandle(-12) - PyBUF_SIMPLE = 0 PyBUF_WRITABLE = 1 @@ -81,36 +64,37 @@ STDERR_FILENO = 2 EOF = b"\x1a" MAX_BYTES_WRITTEN = 32767 - -class Py_buffer(ctypes.Structure): - _fields_ = [ - ("buf", c_void_p), - ("obj", py_object), - ("len", c_ssize_t), - ("itemsize", c_ssize_t), - ("readonly", c_int), - ("ndim", c_int), - ("format", c_char_p), - ("shape", c_ssize_p), - ("strides", c_ssize_p), - ("suboffsets", c_ssize_p), - ("internal", c_void_p), - ] - - if PY2: - _fields_.insert(-1, ("smalltable", c_ssize_t * 2)) - - -# On PyPy we cannot get buffers so our ability to operate here is -# serverly limited. -if pythonapi is None: +try: + from ctypes import pythonapi +except ImportError: + # On PyPy we cannot get buffers so our ability to operate here is + # severely limited. get_buffer = None else: + class Py_buffer(Structure): + _fields_ = [ + ("buf", c_void_p), + ("obj", py_object), + ("len", c_ssize_t), + ("itemsize", c_ssize_t), + ("readonly", c_int), + ("ndim", c_int), + ("format", c_char_p), + ("shape", c_ssize_p), + ("strides", c_ssize_p), + ("suboffsets", c_ssize_p), + ("internal", c_void_p), + ] + + PyObject_GetBuffer = pythonapi.PyObject_GetBuffer + PyBuffer_Release = pythonapi.PyBuffer_Release + def get_buffer(obj, writable=False): buf = Py_buffer() flags = PyBUF_WRITABLE if writable else PyBUF_SIMPLE PyObject_GetBuffer(py_object(obj), byref(buf), flags) + try: buffer_type = c_char * buf.len return buffer_type.from_address(buf.buf) @@ -123,7 +107,7 @@ class _WindowsConsoleRawIOBase(io.RawIOBase): self.handle = handle def isatty(self): - io.RawIOBase.isatty(self) + super().isatty() return True @@ -155,7 +139,7 @@ class _WindowsConsoleReader(_WindowsConsoleRawIOBase): # wait for KeyboardInterrupt time.sleep(0.1) if not rv: - raise OSError("Windows error: {}".format(GetLastError())) + raise OSError(f"Windows error: {GetLastError()}") if buffer[0] == EOF: return 0 @@ -172,7 +156,7 @@ class _WindowsConsoleWriter(_WindowsConsoleRawIOBase): return "ERROR_SUCCESS" elif errno == ERROR_NOT_ENOUGH_MEMORY: return "ERROR_NOT_ENOUGH_MEMORY" - return "Windows error {}".format(errno) + return f"Windows error {errno}" def write(self, b): bytes_to_be_written = len(b) @@ -194,17 +178,17 @@ class _WindowsConsoleWriter(_WindowsConsoleRawIOBase): return bytes_written -class ConsoleStream(object): - def __init__(self, text_stream, byte_stream): +class ConsoleStream: + def __init__(self, text_stream: t.TextIO, byte_stream: t.BinaryIO) -> None: self._text_stream = text_stream self.buffer = byte_stream @property - def name(self): + def name(self) -> str: return self.buffer.name - def write(self, x): - if isinstance(x, text_type): + def write(self, x: t.AnyStr) -> int: + if isinstance(x, str): return self._text_stream.write(x) try: self.flush() @@ -212,159 +196,84 @@ class ConsoleStream(object): pass return self.buffer.write(x) - def writelines(self, lines): + def writelines(self, lines: t.Iterable[t.AnyStr]) -> None: for line in lines: self.write(line) - def __getattr__(self, name): + def __getattr__(self, name: str) -> t.Any: return getattr(self._text_stream, name) - def isatty(self): + def isatty(self) -> bool: return self.buffer.isatty() def __repr__(self): - return "".format( - self.name, self.encoding - ) + return f"" -class WindowsChunkedWriter(object): - """ - Wraps a stream (such as stdout), acting as a transparent proxy for all - attribute access apart from method 'write()' which we wrap to write in - limited chunks due to a Windows limitation on binary console streams. - """ - - def __init__(self, wrapped): - # double-underscore everything to prevent clashes with names of - # attributes on the wrapped stream object. - self.__wrapped = wrapped - - def __getattr__(self, name): - return getattr(self.__wrapped, name) - - def write(self, text): - total_to_write = len(text) - written = 0 - - while written < total_to_write: - to_write = min(total_to_write - written, MAX_BYTES_WRITTEN) - self.__wrapped.write(text[written : written + to_write]) - written += to_write - - -_wrapped_std_streams = set() - - -def _wrap_std_stream(name): - # Python 2 & Windows 7 and below - if ( - PY2 - and sys.getwindowsversion()[:2] <= (6, 1) - and name not in _wrapped_std_streams - ): - setattr(sys, name, WindowsChunkedWriter(getattr(sys, name))) - _wrapped_std_streams.add(name) - - -def _get_text_stdin(buffer_stream): +def _get_text_stdin(buffer_stream: t.BinaryIO) -> t.TextIO: text_stream = _NonClosingTextIOWrapper( io.BufferedReader(_WindowsConsoleReader(STDIN_HANDLE)), "utf-16-le", "strict", line_buffering=True, ) - return ConsoleStream(text_stream, buffer_stream) + return t.cast(t.TextIO, ConsoleStream(text_stream, buffer_stream)) -def _get_text_stdout(buffer_stream): +def _get_text_stdout(buffer_stream: t.BinaryIO) -> t.TextIO: text_stream = _NonClosingTextIOWrapper( io.BufferedWriter(_WindowsConsoleWriter(STDOUT_HANDLE)), "utf-16-le", "strict", line_buffering=True, ) - return ConsoleStream(text_stream, buffer_stream) + return t.cast(t.TextIO, ConsoleStream(text_stream, buffer_stream)) -def _get_text_stderr(buffer_stream): +def _get_text_stderr(buffer_stream: t.BinaryIO) -> t.TextIO: text_stream = _NonClosingTextIOWrapper( io.BufferedWriter(_WindowsConsoleWriter(STDERR_HANDLE)), "utf-16-le", "strict", line_buffering=True, ) - return ConsoleStream(text_stream, buffer_stream) + return t.cast(t.TextIO, ConsoleStream(text_stream, buffer_stream)) -if PY2: - - def _hash_py_argv(): - return zlib.crc32("\x00".join(sys.argv[1:])) - - _initial_argv_hash = _hash_py_argv() - - def _get_windows_argv(): - argc = c_int(0) - argv_unicode = CommandLineToArgvW(GetCommandLineW(), byref(argc)) - if not argv_unicode: - raise WinError() - try: - argv = [argv_unicode[i] for i in range(0, argc.value)] - finally: - LocalFree(argv_unicode) - del argv_unicode - - if not hasattr(sys, "frozen"): - argv = argv[1:] - while len(argv) > 0: - arg = argv[0] - if not arg.startswith("-") or arg == "-": - break - argv = argv[1:] - if arg.startswith(("-c", "-m")): - break - - return argv[1:] - - -_stream_factories = { +_stream_factories: t.Mapping[int, t.Callable[[t.BinaryIO], t.TextIO]] = { 0: _get_text_stdin, 1: _get_text_stdout, 2: _get_text_stderr, } -def _is_console(f): +def _is_console(f: t.TextIO) -> bool: if not hasattr(f, "fileno"): return False try: fileno = f.fileno() - except OSError: + except (OSError, io.UnsupportedOperation): return False handle = msvcrt.get_osfhandle(fileno) return bool(GetConsoleMode(handle, byref(DWORD()))) -def _get_windows_console_stream(f, encoding, errors): +def _get_windows_console_stream( + f: t.TextIO, encoding: t.Optional[str], errors: t.Optional[str] +) -> t.Optional[t.TextIO]: if ( get_buffer is not None - and encoding in ("utf-16-le", None) - and errors in ("strict", None) + and encoding in {"utf-16-le", None} + and errors in {"strict", None} and _is_console(f) ): func = _stream_factories.get(f.fileno()) if func is not None: - if not PY2: - f = getattr(f, "buffer", None) - if f is None: - return None - else: - # If we are on Python 2 we need to set the stream that we - # deal with to binary mode as otherwise the exercise if a - # bit moot. The same problems apply as for - # get_binary_stdin and friends from _compat. - msvcrt.setmode(f.fileno(), os.O_BINARY) - return func(f) + b = getattr(f, "buffer", None) + + if b is None: + return None + + return func(b) diff --git a/src/click/core.py b/src/click/core.py index f58bf26..77a536a 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -1,17 +1,20 @@ +import enum import errno -import inspect import os import sys +import typing +import typing as t +from collections import abc from contextlib import contextmanager +from contextlib import ExitStack +from functools import partial from functools import update_wrapper +from gettext import gettext as _ +from gettext import ngettext from itertools import repeat -from ._compat import isidentifier -from ._compat import iteritems -from ._compat import PY2 -from ._compat import string_types -from ._unicodefun import _check_for_unicode_literals -from ._unicodefun import _verify_python3_env +from . import types +from ._unicodefun import _verify_python_env from .exceptions import Abort from .exceptions import BadParameter from .exceptions import ClickException @@ -22,58 +25,49 @@ from .formatting import HelpFormatter from .formatting import join_options from .globals import pop_context from .globals import push_context +from .parser import _flag_needs_value from .parser import OptionParser from .parser import split_opt from .termui import confirm from .termui import prompt from .termui import style -from .types import BOOL -from .types import convert_type -from .types import IntRange +from .utils import _detect_program_name +from .utils import _expand_args from .utils import echo -from .utils import get_os_args from .utils import make_default_short_help from .utils import make_str from .utils import PacifyFlushWrapper -_missing = object() +if t.TYPE_CHECKING: + import typing_extensions as te + from .shell_completion import CompletionItem -SUBCOMMAND_METAVAR = "COMMAND [ARGS]..." -SUBCOMMANDS_METAVAR = "COMMAND1 [ARGS]... [COMMAND2 [ARGS]...]..." - -DEPRECATED_HELP_NOTICE = " (DEPRECATED)" -DEPRECATED_INVOKE_NOTICE = "DeprecationWarning: The command %(name)s is deprecated." +F = t.TypeVar("F", bound=t.Callable[..., t.Any]) +V = t.TypeVar("V") -def _maybe_show_deprecated_notice(cmd): - if cmd.deprecated: - echo(style(DEPRECATED_INVOKE_NOTICE % {"name": cmd.name}, fg="red"), err=True) +def _complete_visible_commands( + ctx: "Context", incomplete: str +) -> t.Iterator[t.Tuple[str, "Command"]]: + """List all the subcommands of a group that start with the + incomplete value and aren't hidden. - -def fast_exit(code): - """Exit without garbage collection, this speeds up exit by about 10ms for - things like bash completion. + :param ctx: Invocation context for the group. + :param incomplete: Value being completed. May be empty. """ - sys.stdout.flush() - sys.stderr.flush() - os._exit(code) + multi = t.cast(MultiCommand, ctx.command) + + for name in multi.list_commands(ctx): + if name.startswith(incomplete): + command = multi.get_command(ctx, name) + + if command is not None and not command.hidden: + yield name, command -def _bashcomplete(cmd, prog_name, complete_var=None): - """Internal handler for the bash completion support.""" - if complete_var is None: - complete_var = "_{}_COMPLETE".format(prog_name.replace("-", "_").upper()) - complete_instr = os.environ.get(complete_var) - if not complete_instr: - return - - from ._bashcomplete import bashcomplete - - if bashcomplete(cmd, prog_name, complete_var, complete_instr): - fast_exit(1) - - -def _check_multicommand(base_command, cmd_name, cmd, register=False): +def _check_multicommand( + base_command: "MultiCommand", cmd_name: str, cmd: "Command", register: bool = False +) -> None: if not base_command.chain or not isinstance(cmd, MultiCommand): return if register: @@ -87,44 +81,22 @@ def _check_multicommand(base_command, cmd_name, cmd, register=False): " that is in chain mode. This is not supported." ) raise RuntimeError( - "{}. Command '{}' is set to chain and '{}' was added as" - " subcommand but it in itself is a multi command. ('{}' is a {}" - " within a chained {} named '{}').".format( - hint, - base_command.name, - cmd_name, - cmd_name, - cmd.__class__.__name__, - base_command.__class__.__name__, - base_command.name, - ) + f"{hint}. Command {base_command.name!r} is set to chain and" + f" {cmd_name!r} was added as a subcommand but it in itself is a" + f" multi command. ({cmd_name!r} is a {type(cmd).__name__}" + f" within a chained {type(base_command).__name__} named" + f" {base_command.name!r})." ) -def batch(iterable, batch_size): +def batch(iterable: t.Iterable[V], batch_size: int) -> t.List[t.Tuple[V, ...]]: return list(zip(*repeat(iter(iterable), batch_size))) -def invoke_param_callback(callback, ctx, param, value): - code = getattr(callback, "__code__", None) - args = getattr(code, "co_argcount", 3) - - if args < 3: - from warnings import warn - - warn( - "Parameter callbacks take 3 args, (ctx, param, value). The" - " 2-arg style is deprecated and will be removed in 8.0.".format(callback), - DeprecationWarning, - stacklevel=3, - ) - return callback(ctx, value) - - return callback(ctx, param, value) - - @contextmanager -def augment_usage_errors(ctx, param=None): +def augment_usage_errors( + ctx: "Context", param: t.Optional["Parameter"] = None +) -> t.Iterator[None]: """Context manager that attaches extra information to exceptions.""" try: yield @@ -140,23 +112,53 @@ def augment_usage_errors(ctx, param=None): raise -def iter_params_for_processing(invocation_order, declaration_order): +def iter_params_for_processing( + invocation_order: t.Sequence["Parameter"], + declaration_order: t.Sequence["Parameter"], +) -> t.List["Parameter"]: """Given a sequence of parameters in the order as should be considered for processing and an iterable of parameters that exist, this returns a list in the correct order as they should be processed. """ - def sort_key(item): + def sort_key(item: "Parameter") -> t.Tuple[bool, float]: try: - idx = invocation_order.index(item) + idx: float = invocation_order.index(item) except ValueError: idx = float("inf") - return (not item.is_eager, idx) + + return not item.is_eager, idx return sorted(declaration_order, key=sort_key) -class Context(object): +class ParameterSource(enum.Enum): + """This is an :class:`~enum.Enum` that indicates the source of a + parameter's value. + + Use :meth:`click.Context.get_parameter_source` to get the + source for a parameter by name. + + .. versionchanged:: 8.0 + Use :class:`~enum.Enum` and drop the ``validate`` method. + + .. versionchanged:: 8.0 + Added the ``PROMPT`` value. + """ + + COMMANDLINE = enum.auto() + """The value was provided by the command line args.""" + ENVIRONMENT = enum.auto() + """The value was provided with an environment variable.""" + DEFAULT = enum.auto() + """Used the default specified by the parameter.""" + DEFAULT_MAP = enum.auto() + """Used a default provided by :attr:`Context.default_map`.""" + PROMPT = enum.auto() + """Used a prompt to confirm a default or provide a value.""" + + +class Context: """The context is a special internal object that holds state relevant for the script execution at every single level. It's normally invisible to commands unless they opt-in to getting access to it. @@ -168,21 +170,6 @@ class Context(object): A context can be used as context manager in which case it will call :meth:`close` on teardown. - .. versionadded:: 2.0 - Added the `resilient_parsing`, `help_option_names`, - `token_normalize_func` parameters. - - .. versionadded:: 3.0 - Added the `allow_extra_args` and `allow_interspersed_args` - parameters. - - .. versionadded:: 4.0 - Added the `color`, `ignore_unknown_options`, and - `max_content_width` parameters. - - .. versionadded:: 7.1 - Added the `show_default` parameter. - :param command: the command class for this context. :param parent: the parent context. :param info_name: the info name for this invocation. Generally this @@ -237,60 +224,88 @@ class Context(object): codes are used in texts that Click prints which is by default not the case. This for instance would affect help output. - :param show_default: if True, shows defaults for all options. - Even if an option is later created with show_default=False, - this command-level setting overrides it. + :param show_default: Show defaults for all options. If not set, + defaults to the value from a parent context. Overrides an + option's ``show_default`` argument. + + .. versionchanged:: 8.0 + The ``show_default`` parameter defaults to the value from the + parent context. + + .. versionchanged:: 7.1 + Added the ``show_default`` parameter. + + .. versionchanged:: 4.0 + Added the ``color``, ``ignore_unknown_options``, and + ``max_content_width`` parameters. + + .. versionchanged:: 3.0 + Added the ``allow_extra_args`` and ``allow_interspersed_args`` + parameters. + + .. versionchanged:: 2.0 + Added the ``resilient_parsing``, ``help_option_names``, and + ``token_normalize_func`` parameters. """ + #: The formatter class to create with :meth:`make_formatter`. + #: + #: .. versionadded:: 8.0 + formatter_class: t.Type["HelpFormatter"] = HelpFormatter + def __init__( self, - command, - parent=None, - info_name=None, - obj=None, - auto_envvar_prefix=None, - default_map=None, - terminal_width=None, - max_content_width=None, - resilient_parsing=False, - allow_extra_args=None, - allow_interspersed_args=None, - ignore_unknown_options=None, - help_option_names=None, - token_normalize_func=None, - color=None, - show_default=None, - ): + command: "Command", + parent: t.Optional["Context"] = None, + info_name: t.Optional[str] = None, + obj: t.Optional[t.Any] = None, + auto_envvar_prefix: t.Optional[str] = None, + default_map: t.Optional[t.Dict[str, t.Any]] = None, + terminal_width: t.Optional[int] = None, + max_content_width: t.Optional[int] = None, + resilient_parsing: bool = False, + allow_extra_args: t.Optional[bool] = None, + allow_interspersed_args: t.Optional[bool] = None, + ignore_unknown_options: t.Optional[bool] = None, + help_option_names: t.Optional[t.List[str]] = None, + token_normalize_func: t.Optional[t.Callable[[str], str]] = None, + color: t.Optional[bool] = None, + show_default: t.Optional[bool] = None, + ) -> None: #: the parent context or `None` if none exists. self.parent = parent #: the :class:`Command` for this context. self.command = command #: the descriptive information name self.info_name = info_name - #: the parsed parameters except if the value is hidden in which - #: case it's not remembered. - self.params = {} + #: Map of parameter names to their parsed values. Parameters + #: with ``expose_value=False`` are not stored. + self.params: t.Dict[str, t.Any] = {} #: the leftover arguments. - self.args = [] + self.args: t.List[str] = [] #: protected arguments. These are arguments that are prepended #: to `args` when certain parsing scenarios are encountered but #: must be never propagated to another arguments. This is used #: to implement nested parsing. - self.protected_args = [] + self.protected_args: t.List[str] = [] + if obj is None and parent is not None: obj = parent.obj + #: the user object stored. - self.obj = obj - self._meta = getattr(parent, "meta", {}) + self.obj: t.Any = obj + self._meta: t.Dict[str, t.Any] = getattr(parent, "meta", {}) #: A dictionary (-like object) with defaults for parameters. if ( default_map is None + and info_name is not None and parent is not None and parent.default_map is not None ): default_map = parent.default_map.get(info_name) - self.default_map = default_map + + self.default_map: t.Optional[t.Dict[str, t.Any]] = default_map #: This flag indicates if a subcommand is going to be executed. A #: group callback can use this information to figure out if it's @@ -301,22 +316,25 @@ class Context(object): #: If chaining is enabled this will be set to ``'*'`` in case #: any commands are executed. It is however not possible to #: figure out which ones. If you require this knowledge you - #: should use a :func:`resultcallback`. - self.invoked_subcommand = None + #: should use a :func:`result_callback`. + self.invoked_subcommand: t.Optional[str] = None if terminal_width is None and parent is not None: terminal_width = parent.terminal_width + #: The width of the terminal (None is autodetection). - self.terminal_width = terminal_width + self.terminal_width: t.Optional[int] = terminal_width if max_content_width is None and parent is not None: max_content_width = parent.max_content_width + #: The maximum width of formatted content (None implies a sensible #: default which is 80 for most things). - self.max_content_width = max_content_width + self.max_content_width: t.Optional[int] = max_content_width if allow_extra_args is None: allow_extra_args = command.allow_extra_args + #: Indicates if the context allows extra args or if it should #: fail on parsing. #: @@ -325,14 +343,16 @@ class Context(object): if allow_interspersed_args is None: allow_interspersed_args = command.allow_interspersed_args + #: Indicates if the context allows mixing of arguments and #: options or not. #: #: .. versionadded:: 3.0 - self.allow_interspersed_args = allow_interspersed_args + self.allow_interspersed_args: bool = allow_interspersed_args if ignore_unknown_options is None: ignore_unknown_options = command.ignore_unknown_options + #: Instructs click to ignore options that a command does not #: understand and will store it on the context for later #: processing. This is primarily useful for situations where you @@ -341,7 +361,7 @@ class Context(object): #: forward all arguments. #: #: .. versionadded:: 4.0 - self.ignore_unknown_options = ignore_unknown_options + self.ignore_unknown_options: bool = ignore_unknown_options if help_option_names is None: if parent is not None: @@ -350,19 +370,21 @@ class Context(object): help_option_names = ["--help"] #: The names for the help options. - self.help_option_names = help_option_names + self.help_option_names: t.List[str] = help_option_names if token_normalize_func is None and parent is not None: token_normalize_func = parent.token_normalize_func #: An optional normalization function for tokens. This is #: options, choices, commands etc. - self.token_normalize_func = token_normalize_func + self.token_normalize_func: t.Optional[ + t.Callable[[str], str] + ] = token_normalize_func #: Indicates if resilient parsing is enabled. In that case Click #: will do its best to not cause any failures and default values #: will be ignored. Useful for completion. - self.resilient_parsing = resilient_parsing + self.resilient_parsing: bool = resilient_parsing # If there is no envvar prefix yet, but the parent has one and # the command on this level has a name, we can expand the envvar @@ -373,39 +395,68 @@ class Context(object): and parent.auto_envvar_prefix is not None and self.info_name is not None ): - auto_envvar_prefix = "{}_{}".format( - parent.auto_envvar_prefix, self.info_name.upper() + auto_envvar_prefix = ( + f"{parent.auto_envvar_prefix}_{self.info_name.upper()}" ) else: auto_envvar_prefix = auto_envvar_prefix.upper() + if auto_envvar_prefix is not None: auto_envvar_prefix = auto_envvar_prefix.replace("-", "_") - self.auto_envvar_prefix = auto_envvar_prefix + + self.auto_envvar_prefix: t.Optional[str] = auto_envvar_prefix if color is None and parent is not None: color = parent.color #: Controls if styling output is wanted or not. - self.color = color + self.color: t.Optional[bool] = color - self.show_default = show_default + if show_default is None and parent is not None: + show_default = parent.show_default - self._close_callbacks = [] + #: Show option default values when formatting help text. + self.show_default: t.Optional[bool] = show_default + + self._close_callbacks: t.List[t.Callable[[], t.Any]] = [] self._depth = 0 + self._parameter_source: t.Dict[str, ParameterSource] = {} + self._exit_stack = ExitStack() - def __enter__(self): + def to_info_dict(self) -> t.Dict[str, t.Any]: + """Gather information that could be useful for a tool generating + user-facing documentation. This traverses the entire CLI + structure. + + .. code-block:: python + + with Context(cli) as ctx: + info = ctx.to_info_dict() + + .. versionadded:: 8.0 + """ + return { + "command": self.command.to_info_dict(self), + "info_name": self.info_name, + "allow_extra_args": self.allow_extra_args, + "allow_interspersed_args": self.allow_interspersed_args, + "ignore_unknown_options": self.ignore_unknown_options, + "auto_envvar_prefix": self.auto_envvar_prefix, + } + + def __enter__(self) -> "Context": self._depth += 1 push_context(self) return self - def __exit__(self, exc_type, exc_value, tb): + def __exit__(self, exc_type, exc_value, tb): # type: ignore self._depth -= 1 if self._depth == 0: self.close() pop_context() @contextmanager - def scope(self, cleanup=True): + def scope(self, cleanup: bool = True) -> t.Iterator["Context"]: """This helper method can be used with the context object to promote it to the current thread local (see :func:`get_current_context`). The default behavior of this is to invoke the cleanup functions which @@ -443,7 +494,7 @@ class Context(object): self._depth -= 1 @property - def meta(self): + def meta(self) -> t.Dict[str, t.Any]: """This is a dictionary which is shared with all the contexts that are nested. It exists so that click utilities can store some state here if they need to. It is however the responsibility of @@ -470,32 +521,72 @@ class Context(object): """ return self._meta - def make_formatter(self): - """Creates the formatter for the help and usage output.""" - return HelpFormatter( + def make_formatter(self) -> HelpFormatter: + """Creates the :class:`~click.HelpFormatter` for the help and + usage output. + + To quickly customize the formatter class used without overriding + this method, set the :attr:`formatter_class` attribute. + + .. versionchanged:: 8.0 + Added the :attr:`formatter_class` attribute. + """ + return self.formatter_class( width=self.terminal_width, max_width=self.max_content_width ) - def call_on_close(self, f): - """This decorator remembers a function as callback that should be - executed when the context tears down. This is most useful to bind - resource handling to the script execution. For instance, file objects - opened by the :class:`File` type will register their close callbacks - here. + def with_resource(self, context_manager: t.ContextManager[V]) -> V: + """Register a resource as if it were used in a ``with`` + statement. The resource will be cleaned up when the context is + popped. - :param f: the function to execute on teardown. + Uses :meth:`contextlib.ExitStack.enter_context`. It calls the + resource's ``__enter__()`` method and returns the result. When + the context is popped, it closes the stack, which calls the + resource's ``__exit__()`` method. + + To register a cleanup function for something that isn't a + context manager, use :meth:`call_on_close`. Or use something + from :mod:`contextlib` to turn it into a context manager first. + + .. code-block:: python + + @click.group() + @click.option("--name") + @click.pass_context + def cli(ctx): + ctx.obj = ctx.with_resource(connect_db(name)) + + :param context_manager: The context manager to enter. + :return: Whatever ``context_manager.__enter__()`` returns. + + .. versionadded:: 8.0 """ - self._close_callbacks.append(f) - return f + return self._exit_stack.enter_context(context_manager) - def close(self): - """Invokes all close callbacks.""" - for cb in self._close_callbacks: - cb() - self._close_callbacks = [] + def call_on_close(self, f: t.Callable[..., t.Any]) -> t.Callable[..., t.Any]: + """Register a function to be called when the context tears down. + + This can be used to close resources opened during the script + execution. Resources that support Python's context manager + protocol which would be used in a ``with`` statement should be + registered with :meth:`with_resource` instead. + + :param f: The function to execute on teardown. + """ + return self._exit_stack.callback(f) + + def close(self) -> None: + """Invoke all close callbacks registered with + :meth:`call_on_close`, and exit all context managers entered + with :meth:`with_resource`. + """ + self._exit_stack.close() + # In case the context is reused, create a new exit stack. + self._exit_stack = ExitStack() @property - def command_path(self): + def command_path(self) -> str: """The computed command path. This is used for the ``usage`` information on the help page. It's automatically created by combining the info names of the chain of contexts to the root. @@ -504,25 +595,35 @@ class Context(object): if self.info_name is not None: rv = self.info_name if self.parent is not None: - rv = "{} {}".format(self.parent.command_path, rv) + parent_command_path = [self.parent.command_path] + + if isinstance(self.parent.command, Command): + for param in self.parent.command.get_params(self): + parent_command_path.extend(param.get_usage_pieces(self)) + + rv = f"{' '.join(parent_command_path)} {rv}" return rv.lstrip() - def find_root(self): + def find_root(self) -> "Context": """Finds the outermost context.""" node = self while node.parent is not None: node = node.parent return node - def find_object(self, object_type): + def find_object(self, object_type: t.Type[V]) -> t.Optional[V]: """Finds the closest object of a given type.""" - node = self + node: t.Optional["Context"] = self + while node is not None: if isinstance(node.obj, object_type): return node.obj + node = node.parent - def ensure_object(self, object_type): + return None + + def ensure_object(self, object_type: t.Type[V]) -> V: """Like :meth:`find_object` but sets the innermost object to a new instance of `object_type` if it does not exist. """ @@ -531,17 +632,39 @@ class Context(object): self.obj = rv = object_type() return rv - def lookup_default(self, name): - """Looks up the default for a parameter name. This by default - looks into the :attr:`default_map` if available. + @typing.overload + def lookup_default( + self, name: str, call: "te.Literal[True]" = True + ) -> t.Optional[t.Any]: + ... + + @typing.overload + def lookup_default( + self, name: str, call: "te.Literal[False]" = ... + ) -> t.Optional[t.Union[t.Any, t.Callable[[], t.Any]]]: + ... + + def lookup_default(self, name: str, call: bool = True) -> t.Optional[t.Any]: + """Get the default for a parameter from :attr:`default_map`. + + :param name: Name of the parameter. + :param call: If the default is a callable, call it. Disable to + return the callable instead. + + .. versionchanged:: 8.0 + Added the ``call`` parameter. """ if self.default_map is not None: - rv = self.default_map.get(name) - if callable(rv): - rv = rv() - return rv + value = self.default_map.get(name) - def fail(self, message): + if call and callable(value): + return value() + + return value + + return None + + def fail(self, message: str) -> "te.NoReturn": """Aborts the execution of the program with a specific error message. @@ -549,27 +672,40 @@ class Context(object): """ raise UsageError(message, self) - def abort(self): + def abort(self) -> "te.NoReturn": """Aborts the script.""" raise Abort() - def exit(self, code=0): + def exit(self, code: int = 0) -> "te.NoReturn": """Exits the application with a given exit code.""" raise Exit(code) - def get_usage(self): + def get_usage(self) -> str: """Helper method to get formatted usage string for the current context and command. """ return self.command.get_usage(self) - def get_help(self): + def get_help(self) -> str: """Helper method to get formatted help page for the current context and command. """ return self.command.get_help(self) - def invoke(*args, **kwargs): # noqa: B902 + def _make_sub_context(self, command: "Command") -> "Context": + """Create a new context of the same type as this context, but + for a new command. + + :meta private: + """ + return type(self)(command, info_name=command.name, parent=self) + + def invoke( + __self, # noqa: B902 + __callback: t.Union["Command", t.Callable[..., t.Any]], + *args: t.Any, + **kwargs: t.Any, + ) -> t.Any: """Invokes a command callback in exactly the way it expects. There are two ways to invoke this method: @@ -584,51 +720,87 @@ class Context(object): in against the intention of this code and no context was created. For more information about this change and why it was done in a bugfix release see :ref:`upgrade-to-3.2`. - """ - self, callback = args[:2] - ctx = self - # It's also possible to invoke another command which might or - # might not have a callback. In that case we also fill - # in defaults and make a new context for this command. - if isinstance(callback, Command): - other_cmd = callback - callback = other_cmd.callback - ctx = Context(other_cmd, info_name=other_cmd.name, parent=self) - if callback is None: + .. versionchanged:: 8.0 + All ``kwargs`` are tracked in :attr:`params` so they will be + passed if :meth:`forward` is called at multiple levels. + """ + if isinstance(__callback, Command): + other_cmd = __callback + + if other_cmd.callback is None: raise TypeError( "The given command does not have a callback that can be invoked." ) + else: + __callback = other_cmd.callback + + ctx = __self._make_sub_context(other_cmd) for param in other_cmd.params: if param.name not in kwargs and param.expose_value: - kwargs[param.name] = param.get_default(ctx) + kwargs[param.name] = param.get_default(ctx) # type: ignore - args = args[2:] - with augment_usage_errors(self): + # Track all kwargs as params, so that forward() will pass + # them on in subsequent calls. + ctx.params.update(kwargs) + else: + ctx = __self + + with augment_usage_errors(__self): with ctx: - return callback(*args, **kwargs) + return __callback(*args, **kwargs) - def forward(*args, **kwargs): # noqa: B902 + def forward( + __self, __cmd: "Command", *args: t.Any, **kwargs: t.Any # noqa: B902 + ) -> t.Any: """Similar to :meth:`invoke` but fills in default keyword arguments from the current context if the other command expects it. This cannot invoke callbacks directly, only other commands. - """ - self, cmd = args[:2] - # It's also possible to invoke another command which might or - # might not have a callback. - if not isinstance(cmd, Command): + .. versionchanged:: 8.0 + All ``kwargs`` are tracked in :attr:`params` so they will be + passed if ``forward`` is called at multiple levels. + """ + # Can only forward to other commands, not direct callbacks. + if not isinstance(__cmd, Command): raise TypeError("Callback is not a command.") - for param in self.params: + for param in __self.params: if param not in kwargs: - kwargs[param] = self.params[param] + kwargs[param] = __self.params[param] - return self.invoke(cmd, **kwargs) + return __self.invoke(__cmd, *args, **kwargs) + + def set_parameter_source(self, name: str, source: ParameterSource) -> None: + """Set the source of a parameter. This indicates the location + from which the value of the parameter was obtained. + + :param name: The name of the parameter. + :param source: A member of :class:`~click.core.ParameterSource`. + """ + self._parameter_source[name] = source + + def get_parameter_source(self, name: str) -> t.Optional[ParameterSource]: + """Get the source of a parameter. This indicates the location + from which the value of the parameter was obtained. + + This can be useful for determining when a user specified a value + on the command line that is the same as the default value. It + will be :attr:`~click.core.ParameterSource.DEFAULT` only if the + value was actually taken from the default. + + :param name: The name of the parameter. + :rtype: ParameterSource + + .. versionchanged:: 8.0 + Returns ``None`` if the parameter was not provided from any + source. + """ + return self._parameter_source.get(name) -class BaseCommand(object): +class BaseCommand: """The base command implements the minimal API contract of commands. Most code will never use this as it does not implement a lot of useful functionality but it can act as the direct subclass of alternative @@ -650,6 +822,10 @@ class BaseCommand(object): passed to the context object. """ + #: The context class to create with :meth:`make_context`. + #: + #: .. versionadded:: 8.0 + context_class: t.Type[Context] = Context #: the default for the :attr:`Context.allow_extra_args` flag. allow_extra_args = False #: the default for the :attr:`Context.allow_interspersed_args` flag. @@ -657,70 +833,158 @@ class BaseCommand(object): #: the default for the :attr:`Context.ignore_unknown_options` flag. ignore_unknown_options = False - def __init__(self, name, context_settings=None): + def __init__( + self, + name: t.Optional[str], + context_settings: t.Optional[t.Dict[str, t.Any]] = None, + ) -> None: #: the name the command thinks it has. Upon registering a command #: on a :class:`Group` the group will default the command name #: with this information. You should instead use the #: :class:`Context`\'s :attr:`~Context.info_name` attribute. self.name = name + if context_settings is None: context_settings = {} + #: an optional dictionary with defaults passed to the context. - self.context_settings = context_settings + self.context_settings: t.Dict[str, t.Any] = context_settings - def __repr__(self): - return "<{} {}>".format(self.__class__.__name__, self.name) + def to_info_dict(self, ctx: Context) -> t.Dict[str, t.Any]: + """Gather information that could be useful for a tool generating + user-facing documentation. This traverses the entire structure + below this command. - def get_usage(self, ctx): + Use :meth:`click.Context.to_info_dict` to traverse the entire + CLI structure. + + :param ctx: A :class:`Context` representing this command. + + .. versionadded:: 8.0 + """ + return {"name": self.name} + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} {self.name}>" + + def get_usage(self, ctx: Context) -> str: raise NotImplementedError("Base commands cannot get usage") - def get_help(self, ctx): + def get_help(self, ctx: Context) -> str: raise NotImplementedError("Base commands cannot get help") - def make_context(self, info_name, args, parent=None, **extra): + def make_context( + self, + info_name: t.Optional[str], + args: t.List[str], + parent: t.Optional[Context] = None, + **extra: t.Any, + ) -> Context: """This function when given an info name and arguments will kick off the parsing and create a new :class:`Context`. It does not invoke the actual command callback though. - :param info_name: the info name for this invokation. Generally this + To quickly customize the context class used without overriding + this method, set the :attr:`context_class` attribute. + + :param info_name: the info name for this invocation. Generally this is the most descriptive name for the script or command. For the toplevel script it's usually the name of the script, for commands below it it's - the name of the script. + the name of the command. :param args: the arguments to parse as list of strings. :param parent: the parent context if available. :param extra: extra keyword arguments forwarded to the context constructor. + + .. versionchanged:: 8.0 + Added the :attr:`context_class` attribute. """ - for key, value in iteritems(self.context_settings): + for key, value in self.context_settings.items(): if key not in extra: extra[key] = value - ctx = Context(self, info_name=info_name, parent=parent, **extra) + + ctx = self.context_class( + self, info_name=info_name, parent=parent, **extra # type: ignore + ) + with ctx.scope(cleanup=False): self.parse_args(ctx, args) return ctx - def parse_args(self, ctx, args): + def parse_args(self, ctx: Context, args: t.List[str]) -> t.List[str]: """Given a context and a list of arguments this creates the parser and parses the arguments, then modifies the context as necessary. This is automatically invoked by :meth:`make_context`. """ raise NotImplementedError("Base commands do not know how to parse arguments.") - def invoke(self, ctx): + def invoke(self, ctx: Context) -> t.Any: """Given a context, this invokes the command. The default implementation is raising a not implemented error. """ raise NotImplementedError("Base commands are not invokable by default") + def shell_complete(self, ctx: Context, incomplete: str) -> t.List["CompletionItem"]: + """Return a list of completions for the incomplete value. Looks + at the names of chained multi-commands. + + Any command could be part of a chained multi-command, so sibling + commands are valid at any point during command completion. Other + command classes will return more completions. + + :param ctx: Invocation context for this command. + :param incomplete: Value being completed. May be empty. + + .. versionadded:: 8.0 + """ + from click.shell_completion import CompletionItem + + results: t.List["CompletionItem"] = [] + + while ctx.parent is not None: + ctx = ctx.parent + + if isinstance(ctx.command, MultiCommand) and ctx.command.chain: + results.extend( + CompletionItem(name, help=command.get_short_help_str()) + for name, command in _complete_visible_commands(ctx, incomplete) + if name not in ctx.protected_args + ) + + return results + + @typing.overload def main( self, - args=None, - prog_name=None, - complete_var=None, - standalone_mode=True, - **extra - ): + args: t.Optional[t.Sequence[str]] = None, + prog_name: t.Optional[str] = None, + complete_var: t.Optional[str] = None, + standalone_mode: "te.Literal[True]" = True, + **extra: t.Any, + ) -> "te.NoReturn": + ... + + @typing.overload + def main( + self, + args: t.Optional[t.Sequence[str]] = None, + prog_name: t.Optional[str] = None, + complete_var: t.Optional[str] = None, + standalone_mode: bool = ..., + **extra: t.Any, + ) -> t.Any: + ... + + def main( + self, + args: t.Optional[t.Sequence[str]] = None, + prog_name: t.Optional[str] = None, + complete_var: t.Optional[str] = None, + standalone_mode: bool = True, + windows_expand_args: bool = True, + **extra: t.Any, + ) -> t.Any: """This is the way to invoke a script with all the bells and whistles as a command line application. This will always terminate the application after a call. If this is not wanted, ``SystemExit`` @@ -729,9 +993,6 @@ class BaseCommand(object): This method is also available by directly calling the instance of a :class:`Command`. - .. versionadded:: 3.0 - Added the `standalone_mode` flag to control the standalone mode. - :param args: the arguments that should be used for parsing. If not provided, ``sys.argv[1:]`` is used. :param prog_name: the program name that should be used. By default @@ -750,31 +1011,39 @@ class BaseCommand(object): propagated to the caller and the return value of this function is the return value of :meth:`invoke`. + :param windows_expand_args: Expand glob patterns, user dir, and + env vars in command line args on Windows. :param extra: extra keyword arguments are forwarded to the context constructor. See :class:`Context` for more information. + + .. versionchanged:: 8.0.1 + Added the ``windows_expand_args`` parameter to allow + disabling command line arg expansion on Windows. + + .. versionchanged:: 8.0 + When taking arguments from ``sys.argv`` on Windows, glob + patterns, user dir, and env vars are expanded. + + .. versionchanged:: 3.0 + Added the ``standalone_mode`` parameter. """ - # If we are in Python 3, we will verify that the environment is - # sane at this point or reject further execution to avoid a - # broken script. - if not PY2: - _verify_python3_env() - else: - _check_for_unicode_literals() + # Verify that the environment is configured correctly, or reject + # further execution to avoid a broken script. + _verify_python_env() if args is None: - args = get_os_args() + args = sys.argv[1:] + + if os.name == "nt" and windows_expand_args: + args = _expand_args(args) else: args = list(args) if prog_name is None: - prog_name = make_str( - os.path.basename(sys.argv[0] if sys.argv else __file__) - ) + prog_name = _detect_program_name() - # Hook for the Bash completion. This only activates if the Bash - # completion is actually enabled, otherwise this is quite a fast - # noop. - _bashcomplete(self, prog_name, complete_var) + # Process shell completion requests and exit early. + self._main_shell_completion(extra, prog_name, complete_var) try: try: @@ -792,16 +1061,16 @@ class BaseCommand(object): ctx.exit() except (EOFError, KeyboardInterrupt): echo(file=sys.stderr) - raise Abort() + raise Abort() from None except ClickException as e: if not standalone_mode: raise e.show() sys.exit(e.exit_code) - except IOError as e: + except OSError as e: if e.errno == errno.EPIPE: - sys.stdout = PacifyFlushWrapper(sys.stdout) - sys.stderr = PacifyFlushWrapper(sys.stderr) + sys.stdout = t.cast(t.TextIO, PacifyFlushWrapper(sys.stdout)) + sys.stderr = t.cast(t.TextIO, PacifyFlushWrapper(sys.stderr)) sys.exit(1) else: raise @@ -821,10 +1090,38 @@ class BaseCommand(object): except Abort: if not standalone_mode: raise - echo("Aborted!", file=sys.stderr) + echo(_("Aborted!"), file=sys.stderr) sys.exit(1) - def __call__(self, *args, **kwargs): + def _main_shell_completion( + self, + ctx_args: t.Dict[str, t.Any], + prog_name: str, + complete_var: t.Optional[str] = None, + ) -> None: + """Check if the shell is asking for tab completion, process + that, then exit early. Called from :meth:`main` before the + program is invoked. + + :param prog_name: Name of the executable in the shell. + :param complete_var: Name of the environment variable that holds + the completion instruction. Defaults to + ``_{PROG_NAME}_COMPLETE``. + """ + if complete_var is None: + complete_var = f"_{prog_name}_COMPLETE".replace("-", "_").upper() + + instruction = os.environ.get(complete_var) + + if not instruction: + return + + from .shell_completion import shell_complete + + rv = shell_complete(self, ctx_args, prog_name, complete_var, instruction) + sys.exit(rv) + + def __call__(self, *args: t.Any, **kwargs: t.Any) -> t.Any: """Alias for :meth:`main`.""" return self.main(*args, **kwargs) @@ -836,6 +1133,8 @@ class Command(BaseCommand): .. versionchanged:: 2.0 Added the `context_settings` parameter. + .. versionchanged:: 8.0 + Added repr showing the command name .. versionchanged:: 7.1 Added the `no_args_is_help` parameter. @@ -864,31 +1163,33 @@ class Command(BaseCommand): def __init__( self, - name, - context_settings=None, - callback=None, - params=None, - help=None, - epilog=None, - short_help=None, - options_metavar="[OPTIONS]", - add_help_option=True, - no_args_is_help=False, - hidden=False, - deprecated=False, - ): - BaseCommand.__init__(self, name, context_settings) + name: t.Optional[str], + context_settings: t.Optional[t.Dict[str, t.Any]] = None, + callback: t.Optional[t.Callable[..., t.Any]] = None, + params: t.Optional[t.List["Parameter"]] = None, + help: t.Optional[str] = None, + epilog: t.Optional[str] = None, + short_help: t.Optional[str] = None, + options_metavar: t.Optional[str] = "[OPTIONS]", + add_help_option: bool = True, + no_args_is_help: bool = False, + hidden: bool = False, + deprecated: bool = False, + ) -> None: + super().__init__(name, context_settings) #: the callback to execute when the command fires. This might be #: `None` in which case nothing happens. self.callback = callback #: the list of parameters for this command in the order they #: should show up in the help page and execute. Eager parameters #: will automatically be handled before non eager ones. - self.params = params or [] + self.params: t.List["Parameter"] = params or [] + # if a form feed (page break) is found in the help text, truncate help # text to the content preceding the first form feed if help and "\f" in help: help = help.split("\f", 1)[0] + self.help = help self.epilog = epilog self.options_metavar = options_metavar @@ -898,7 +1199,19 @@ class Command(BaseCommand): self.hidden = hidden self.deprecated = deprecated - def get_usage(self, ctx): + def to_info_dict(self, ctx: Context) -> t.Dict[str, t.Any]: + info_dict = super().to_info_dict(ctx) + info_dict.update( + params=[param.to_info_dict() for param in self.get_params(ctx)], + help=self.help, + epilog=self.epilog, + short_help=self.short_help, + hidden=self.hidden, + deprecated=self.deprecated, + ) + return info_dict + + def get_usage(self, ctx: Context) -> str: """Formats the usage line into a string and returns it. Calls :meth:`format_usage` internally. @@ -907,14 +1220,16 @@ class Command(BaseCommand): self.format_usage(ctx, formatter) return formatter.getvalue().rstrip("\n") - def get_params(self, ctx): + def get_params(self, ctx: Context) -> t.List["Parameter"]: rv = self.params help_option = self.get_help_option(ctx) + if help_option is not None: - rv = rv + [help_option] + rv = [*rv, help_option] + return rv - def format_usage(self, ctx, formatter): + def format_usage(self, ctx: Context, formatter: HelpFormatter) -> None: """Writes the usage line into the formatter. This is a low-level method called by :meth:`get_usage`. @@ -922,30 +1237,33 @@ class Command(BaseCommand): pieces = self.collect_usage_pieces(ctx) formatter.write_usage(ctx.command_path, " ".join(pieces)) - def collect_usage_pieces(self, ctx): + def collect_usage_pieces(self, ctx: Context) -> t.List[str]: """Returns all the pieces that go into the usage line and returns it as a list of strings. """ - rv = [self.options_metavar] + rv = [self.options_metavar] if self.options_metavar else [] + for param in self.get_params(ctx): rv.extend(param.get_usage_pieces(ctx)) + return rv - def get_help_option_names(self, ctx): + def get_help_option_names(self, ctx: Context) -> t.List[str]: """Returns the names for the help option.""" all_names = set(ctx.help_option_names) for param in self.params: all_names.difference_update(param.opts) all_names.difference_update(param.secondary_opts) - return all_names + return list(all_names) - def get_help_option(self, ctx): + def get_help_option(self, ctx: Context) -> t.Optional["Option"]: """Returns the help option object.""" help_options = self.get_help_option_names(ctx) - if not help_options or not self.add_help_option: - return - def show_help(ctx, param, value): + if not help_options or not self.add_help_option: + return None + + def show_help(ctx: Context, param: "Parameter", value: str) -> None: if value and not ctx.resilient_parsing: echo(ctx.get_help(), color=ctx.color) ctx.exit() @@ -956,17 +1274,17 @@ class Command(BaseCommand): is_eager=True, expose_value=False, callback=show_help, - help="Show this message and exit.", + help=_("Show this message and exit."), ) - def make_parser(self, ctx): + def make_parser(self, ctx: Context) -> OptionParser: """Creates the underlying option parser for this command.""" parser = OptionParser(ctx) for param in self.get_params(ctx): param.add_to_parser(parser, ctx) return parser - def get_help(self, ctx): + def get_help(self, ctx: Context) -> str: """Formats the help into a string and returns it. Calls :meth:`format_help` internally. @@ -975,18 +1293,21 @@ class Command(BaseCommand): self.format_help(ctx, formatter) return formatter.getvalue().rstrip("\n") - def get_short_help_str(self, limit=45): + def get_short_help_str(self, limit: int = 45) -> str: """Gets short help for the command or makes it by shortening the long help string. """ - return ( - self.short_help - or self.help - and make_default_short_help(self.help, limit) - or "" - ) + text = self.short_help or "" - def format_help(self, ctx, formatter): + if not text and self.help: + text = make_default_short_help(self.help, limit) + + if self.deprecated: + text = _("(Deprecated) {text}").format(text=text) + + return text.strip() + + def format_help(self, ctx: Context, formatter: HelpFormatter) -> None: """Writes the help into the formatter if it exists. This is a low-level method called by :meth:`get_help`. @@ -1003,21 +1324,20 @@ class Command(BaseCommand): self.format_options(ctx, formatter) self.format_epilog(ctx, formatter) - def format_help_text(self, ctx, formatter): + def format_help_text(self, ctx: Context, formatter: HelpFormatter) -> None: """Writes the help text to the formatter if it exists.""" - if self.help: - formatter.write_paragraph() - with formatter.indentation(): - help_text = self.help - if self.deprecated: - help_text += DEPRECATED_HELP_NOTICE - formatter.write_text(help_text) - elif self.deprecated: - formatter.write_paragraph() - with formatter.indentation(): - formatter.write_text(DEPRECATED_HELP_NOTICE) + text = self.help or "" - def format_options(self, ctx, formatter): + if self.deprecated: + text = _("(Deprecated) {text}").format(text=text) + + if text: + formatter.write_paragraph() + + with formatter.indentation(): + formatter.write_text(text) + + def format_options(self, ctx: Context, formatter: HelpFormatter) -> None: """Writes all the options into the formatter if they exist.""" opts = [] for param in self.get_params(ctx): @@ -1026,17 +1346,17 @@ class Command(BaseCommand): opts.append(rv) if opts: - with formatter.section("Options"): + with formatter.section(_("Options")): formatter.write_dl(opts) - def format_epilog(self, ctx, formatter): + def format_epilog(self, ctx: Context, formatter: HelpFormatter) -> None: """Writes the epilog into the formatter if it exists.""" if self.epilog: formatter.write_paragraph() with formatter.indentation(): formatter.write_text(self.epilog) - def parse_args(self, ctx, args): + def parse_args(self, ctx: Context, args: t.List[str]) -> t.List[str]: if not args and self.no_args_is_help and not ctx.resilient_parsing: echo(ctx.get_help(), color=ctx.color) ctx.exit() @@ -1049,22 +1369,64 @@ class Command(BaseCommand): if args and not ctx.allow_extra_args and not ctx.resilient_parsing: ctx.fail( - "Got unexpected extra argument{} ({})".format( - "s" if len(args) != 1 else "", " ".join(map(make_str, args)) - ) + ngettext( + "Got unexpected extra argument ({args})", + "Got unexpected extra arguments ({args})", + len(args), + ).format(args=" ".join(map(str, args))) ) ctx.args = args return args - def invoke(self, ctx): + def invoke(self, ctx: Context) -> t.Any: """Given a context, this invokes the attached callback (if it exists) in the right way. """ - _maybe_show_deprecated_notice(self) + if self.deprecated: + message = _( + "DeprecationWarning: The command {name!r} is deprecated." + ).format(name=self.name) + echo(style(message, fg="red"), err=True) + if self.callback is not None: return ctx.invoke(self.callback, **ctx.params) + def shell_complete(self, ctx: Context, incomplete: str) -> t.List["CompletionItem"]: + """Return a list of completions for the incomplete value. Looks + at the names of options and chained multi-commands. + + :param ctx: Invocation context for this command. + :param incomplete: Value being completed. May be empty. + + .. versionadded:: 8.0 + """ + from click.shell_completion import CompletionItem + + results: t.List["CompletionItem"] = [] + + if incomplete and not incomplete[0].isalnum(): + for param in self.get_params(ctx): + if ( + not isinstance(param, Option) + or param.hidden + or ( + not param.multiple + and ctx.get_parameter_source(param.name) # type: ignore + is ParameterSource.COMMANDLINE + ) + ): + continue + + results.extend( + CompletionItem(name, help=param.help) + for name in [*param.opts, *param.secondary_opts] + if name.startswith(incomplete) + ) + + results.extend(super().shell_complete(ctx, incomplete)) + return results + class MultiCommand(Command): """A multi command is the basic implementation of a command that @@ -1086,8 +1448,9 @@ class MultiCommand(Command): is enabled. This restricts the form of commands in that they cannot have optional arguments but it allows multiple commands to be chained together. - :param result_callback: the result callback to attach to this multi - command. + :param result_callback: The result callback to attach to this multi + command. This can be set or changed later with the + :meth:`result_callback` decorator. """ allow_extra_args = True @@ -1095,29 +1458,33 @@ class MultiCommand(Command): def __init__( self, - name=None, - invoke_without_command=False, - no_args_is_help=None, - subcommand_metavar=None, - chain=False, - result_callback=None, - **attrs - ): - Command.__init__(self, name, **attrs) + name: t.Optional[str] = None, + invoke_without_command: bool = False, + no_args_is_help: t.Optional[bool] = None, + subcommand_metavar: t.Optional[str] = None, + chain: bool = False, + result_callback: t.Optional[t.Callable[..., t.Any]] = None, + **attrs: t.Any, + ) -> None: + super().__init__(name, **attrs) + if no_args_is_help is None: no_args_is_help = not invoke_without_command + self.no_args_is_help = no_args_is_help self.invoke_without_command = invoke_without_command + if subcommand_metavar is None: if chain: - subcommand_metavar = SUBCOMMANDS_METAVAR + subcommand_metavar = "COMMAND1 [ARGS]... [COMMAND2 [ARGS]...]..." else: - subcommand_metavar = SUBCOMMAND_METAVAR + subcommand_metavar = "COMMAND [ARGS]..." + self.subcommand_metavar = subcommand_metavar self.chain = chain - #: The result callback that is stored. This can be set or - #: overridden with the :func:`resultcallback` decorator. - self.result_callback = result_callback + # The result callback that is stored. This can be set or + # overridden with the :func:`result_callback` decorator. + self._result_callback = result_callback if self.chain: for param in self.params: @@ -1127,17 +1494,35 @@ class MultiCommand(Command): " optional arguments." ) - def collect_usage_pieces(self, ctx): - rv = Command.collect_usage_pieces(self, ctx) + def to_info_dict(self, ctx: Context) -> t.Dict[str, t.Any]: + info_dict = super().to_info_dict(ctx) + commands = {} + + for name in self.list_commands(ctx): + command = self.get_command(ctx, name) + + if command is None: + continue + + sub_ctx = ctx._make_sub_context(command) + + with sub_ctx.scope(cleanup=False): + commands[name] = command.to_info_dict(sub_ctx) + + info_dict.update(commands=commands, chain=self.chain) + return info_dict + + def collect_usage_pieces(self, ctx: Context) -> t.List[str]: + rv = super().collect_usage_pieces(ctx) rv.append(self.subcommand_metavar) return rv - def format_options(self, ctx, formatter): - Command.format_options(self, ctx, formatter) + def format_options(self, ctx: Context, formatter: HelpFormatter) -> None: + super().format_options(ctx, formatter) self.format_commands(ctx, formatter) - def resultcallback(self, replace=False): - """Adds a result callback to the chain command. By default if a + def result_callback(self, replace: bool = False) -> t.Callable[[F], F]: + """Adds a result callback to the command. By default if a result callback is already registered this will chain them but this can be disabled with the `replace` parameter. The result callback is invoked with the return value of the subcommand @@ -1152,31 +1537,47 @@ class MultiCommand(Command): def cli(input): return 42 - @cli.resultcallback() + @cli.result_callback() def process_result(result, input): return result + input - .. versionadded:: 3.0 - :param replace: if set to `True` an already existing result callback will be removed. + + .. versionchanged:: 8.0 + Renamed from ``resultcallback``. + + .. versionadded:: 3.0 """ - def decorator(f): - old_callback = self.result_callback + def decorator(f: F) -> F: + old_callback = self._result_callback + if old_callback is None or replace: - self.result_callback = f + self._result_callback = f return f - def function(__value, *args, **kwargs): - return f(old_callback(__value, *args, **kwargs), *args, **kwargs) + def function(__value, *args, **kwargs): # type: ignore + inner = old_callback(__value, *args, **kwargs) # type: ignore + return f(inner, *args, **kwargs) - self.result_callback = rv = update_wrapper(function, f) + self._result_callback = rv = update_wrapper(t.cast(F, function), f) return rv return decorator - def format_commands(self, ctx, formatter): + def resultcallback(self, replace: bool = False) -> t.Callable[[F], F]: + import warnings + + warnings.warn( + "'resultcallback' has been renamed to 'result_callback'." + " The old name will be removed in Click 8.1.", + DeprecationWarning, + stacklevel=2, + ) + return self.result_callback(replace=replace) + + def format_commands(self, ctx: Context, formatter: HelpFormatter) -> None: """Extra format methods for multi methods that adds all the commands after the options. """ @@ -1201,15 +1602,16 @@ class MultiCommand(Command): rows.append((subcommand, help)) if rows: - with formatter.section("Commands"): + with formatter.section(_("Commands")): formatter.write_dl(rows) - def parse_args(self, ctx, args): + def parse_args(self, ctx: Context, args: t.List[str]) -> t.List[str]: if not args and self.no_args_is_help and not ctx.resilient_parsing: echo(ctx.get_help(), color=ctx.color) ctx.exit() - rest = Command.parse_args(self, ctx, args) + rest = super().parse_args(ctx, args) + if self.chain: ctx.protected_args = rest ctx.args = [] @@ -1218,29 +1620,24 @@ class MultiCommand(Command): return ctx.args - def invoke(self, ctx): - def _process_result(value): - if self.result_callback is not None: - value = ctx.invoke(self.result_callback, value, **ctx.params) + def invoke(self, ctx: Context) -> t.Any: + def _process_result(value: t.Any) -> t.Any: + if self._result_callback is not None: + value = ctx.invoke(self._result_callback, value, **ctx.params) return value if not ctx.protected_args: - # If we are invoked without command the chain flag controls - # how this happens. If we are not in chain mode, the return - # value here is the return value of the command. - # If however we are in chain mode, the return value is the - # return value of the result processor invoked with an empty - # list (which means that no subcommand actually was executed). if self.invoke_without_command: - if not self.chain: - return Command.invoke(self, ctx) + # No subcommand was invoked, so the result callback is + # invoked with None for regular groups, or an empty list + # for chained groups. with ctx: - Command.invoke(self, ctx) - return _process_result([]) - ctx.fail("Missing command.") + super().invoke(ctx) + return _process_result([] if self.chain else None) + ctx.fail(_("Missing command.")) # Fetch args back out - args = ctx.protected_args + ctx.args + args = [*ctx.protected_args, *ctx.args] ctx.args = [] ctx.protected_args = [] @@ -1252,8 +1649,9 @@ class MultiCommand(Command): # resources until the result processor has worked. with ctx: cmd_name, cmd, args = self.resolve_command(ctx, args) + assert cmd is not None ctx.invoked_subcommand = cmd_name - Command.invoke(self, ctx) + super().invoke(ctx) sub_ctx = cmd.make_context(cmd_name, args, parent=ctx) with sub_ctx: return _process_result(sub_ctx.command.invoke(sub_ctx)) @@ -1265,7 +1663,7 @@ class MultiCommand(Command): # but nothing else. with ctx: ctx.invoked_subcommand = "*" if args else None - Command.invoke(self, ctx) + super().invoke(ctx) # Otherwise we make every single context and invoke them in a # chain. In that case the return value to the result processor @@ -1273,6 +1671,7 @@ class MultiCommand(Command): contexts = [] while args: cmd_name, cmd, args = self.resolve_command(ctx, args) + assert cmd is not None sub_ctx = cmd.make_context( cmd_name, args, @@ -1289,7 +1688,9 @@ class MultiCommand(Command): rv.append(sub_ctx.command.invoke(sub_ctx)) return _process_result(rv) - def resolve_command(self, ctx, args): + def resolve_command( + self, ctx: Context, args: t.List[str] + ) -> t.Tuple[t.Optional[str], t.Optional[Command], t.List[str]]: cmd_name = make_str(args[0]) original_cmd_name = cmd_name @@ -1311,36 +1712,94 @@ class MultiCommand(Command): if cmd is None and not ctx.resilient_parsing: if split_opt(cmd_name)[0]: self.parse_args(ctx, ctx.args) - ctx.fail("No such command '{}'.".format(original_cmd_name)) + ctx.fail(_("No such command {name!r}.").format(name=original_cmd_name)) + return cmd_name if cmd else None, cmd, args[1:] - return cmd_name, cmd, args[1:] - - def get_command(self, ctx, cmd_name): + def get_command(self, ctx: Context, cmd_name: str) -> t.Optional[Command]: """Given a context and a command name, this returns a :class:`Command` object if it exists or returns `None`. """ - raise NotImplementedError() + raise NotImplementedError - def list_commands(self, ctx): + def list_commands(self, ctx: Context) -> t.List[str]: """Returns a list of subcommand names in the order they should appear. """ return [] + def shell_complete(self, ctx: Context, incomplete: str) -> t.List["CompletionItem"]: + """Return a list of completions for the incomplete value. Looks + at the names of options, subcommands, and chained + multi-commands. + + :param ctx: Invocation context for this command. + :param incomplete: Value being completed. May be empty. + + .. versionadded:: 8.0 + """ + from click.shell_completion import CompletionItem + + results = [ + CompletionItem(name, help=command.get_short_help_str()) + for name, command in _complete_visible_commands(ctx, incomplete) + ] + results.extend(super().shell_complete(ctx, incomplete)) + return results + class Group(MultiCommand): - """A group allows a command to have subcommands attached. This is the - most common way to implement nesting in Click. + """A group allows a command to have subcommands attached. This is + the most common way to implement nesting in Click. - :param commands: a dictionary of commands. + :param name: The name of the group command. + :param commands: A dict mapping names to :class:`Command` objects. + Can also be a list of :class:`Command`, which will use + :attr:`Command.name` to create the dict. + :param attrs: Other command arguments described in + :class:`MultiCommand`, :class:`Command`, and + :class:`BaseCommand`. + + .. versionchanged:: 8.0 + The ``commmands`` argument can be a list of command objects. """ - def __init__(self, name=None, commands=None, **attrs): - MultiCommand.__init__(self, name, **attrs) - #: the registered subcommands by their exported names. - self.commands = commands or {} + #: If set, this is used by the group's :meth:`command` decorator + #: as the default :class:`Command` class. This is useful to make all + #: subcommands use a custom command class. + #: + #: .. versionadded:: 8.0 + command_class: t.Optional[t.Type[Command]] = None - def add_command(self, cmd, name=None): + #: If set, this is used by the group's :meth:`group` decorator + #: as the default :class:`Group` class. This is useful to make all + #: subgroups use a custom group class. + #: + #: If set to the special value :class:`type` (literally + #: ``group_class = type``), this group's class will be used as the + #: default class. This makes a custom group class continue to make + #: custom groups. + #: + #: .. versionadded:: 8.0 + group_class: t.Optional[t.Union[t.Type["Group"], t.Type[type]]] = None + # Literal[type] isn't valid, so use Type[type] + + def __init__( + self, + name: t.Optional[str] = None, + commands: t.Optional[t.Union[t.Dict[str, Command], t.Sequence[Command]]] = None, + **attrs: t.Any, + ) -> None: + super().__init__(name, **attrs) + + if commands is None: + commands = {} + elif isinstance(commands, abc.Sequence): + commands = {c.name: c for c in commands if c.name is not None} + + #: The registered subcommands by their exported names. + self.commands: t.Dict[str, Command] = commands + + def add_command(self, cmd: Command, name: t.Optional[str] = None) -> None: """Registers another :class:`Command` with this group. If the name is not provided, the name of the command is used. """ @@ -1350,40 +1809,65 @@ class Group(MultiCommand): _check_multicommand(self, name, cmd, register=True) self.commands[name] = cmd - def command(self, *args, **kwargs): + def command( + self, *args: t.Any, **kwargs: t.Any + ) -> t.Callable[[t.Callable[..., t.Any]], Command]: """A shortcut decorator for declaring and attaching a command to - the group. This takes the same arguments as :func:`command` but - immediately registers the created command with this instance by - calling into :meth:`add_command`. + the group. This takes the same arguments as :func:`command` and + immediately registers the created command with this group by + calling :meth:`add_command`. + + To customize the command class used, set the + :attr:`command_class` attribute. + + .. versionchanged:: 8.0 + Added the :attr:`command_class` attribute. """ from .decorators import command - def decorator(f): + if self.command_class is not None and "cls" not in kwargs: + kwargs["cls"] = self.command_class + + def decorator(f: t.Callable[..., t.Any]) -> Command: cmd = command(*args, **kwargs)(f) self.add_command(cmd) return cmd return decorator - def group(self, *args, **kwargs): + def group( + self, *args: t.Any, **kwargs: t.Any + ) -> t.Callable[[t.Callable[..., t.Any]], "Group"]: """A shortcut decorator for declaring and attaching a group to - the group. This takes the same arguments as :func:`group` but - immediately registers the created command with this instance by - calling into :meth:`add_command`. + the group. This takes the same arguments as :func:`group` and + immediately registers the created group with this group by + calling :meth:`add_command`. + + To customize the group class used, set the :attr:`group_class` + attribute. + + .. versionchanged:: 8.0 + Added the :attr:`group_class` attribute. """ from .decorators import group - def decorator(f): + if self.group_class is not None and "cls" not in kwargs: + if self.group_class is type: + kwargs["cls"] = type(self) + else: + kwargs["cls"] = self.group_class + + def decorator(f: t.Callable[..., t.Any]) -> "Group": cmd = group(*args, **kwargs)(f) self.add_command(cmd) return cmd return decorator - def get_command(self, ctx, cmd_name): + def get_command(self, ctx: Context, cmd_name: str) -> t.Optional[Command]: return self.commands.get(cmd_name) - def list_commands(self, ctx): + def list_commands(self, ctx: Context) -> t.List[str]: return sorted(self.commands) @@ -1394,31 +1878,52 @@ class CommandCollection(MultiCommand): provides all the commands for each of them. """ - def __init__(self, name=None, sources=None, **attrs): - MultiCommand.__init__(self, name, **attrs) + def __init__( + self, + name: t.Optional[str] = None, + sources: t.Optional[t.List[MultiCommand]] = None, + **attrs: t.Any, + ) -> None: + super().__init__(name, **attrs) #: The list of registered multi commands. - self.sources = sources or [] + self.sources: t.List[MultiCommand] = sources or [] - def add_source(self, multi_cmd): + def add_source(self, multi_cmd: MultiCommand) -> None: """Adds a new multi command to the chain dispatcher.""" self.sources.append(multi_cmd) - def get_command(self, ctx, cmd_name): + def get_command(self, ctx: Context, cmd_name: str) -> t.Optional[Command]: for source in self.sources: rv = source.get_command(ctx, cmd_name) + if rv is not None: if self.chain: _check_multicommand(self, cmd_name, rv) + return rv - def list_commands(self, ctx): - rv = set() + return None + + def list_commands(self, ctx: Context) -> t.List[str]: + rv: t.Set[str] = set() + for source in self.sources: rv.update(source.list_commands(ctx)) + return sorted(rv) -class Parameter(object): +def _check_iter(value: t.Any) -> t.Iterator[t.Any]: + """Check if the value is iterable but not a string. Raises a type + error, or return an iterator over the value. + """ + if isinstance(value, str): + raise TypeError + + return iter(value) + + +class Parameter: r"""A parameter to a command comes in two versions: they are either :class:`Option`\s or :class:`Argument`\s. Other subclasses are currently not supported by design as some of the internals for parsing are @@ -1436,13 +1941,15 @@ class Parameter(object): :param default: the default value if omitted. This can also be a callable, in which case it's invoked when the default is needed without any arguments. - :param callback: a callback that should be executed after the parameter - was matched. This is called as ``fn(ctx, param, - value)`` and needs to return the value. + :param callback: A function to further process or validate the value + after type conversion. It is called as ``f(ctx, param, value)`` + and must return the value. It is called for all sources, + including prompts. :param nargs: the number of arguments to match. If not ``1`` the return value is a tuple instead of single value. The default for nargs is ``1`` (except if the type is a tuple, then it's - the arity of the tuple). + the arity of the tuple). If ``nargs=-1``, all remaining + parameters are collected. :param metavar: how the value is represented in the help page. :param expose_value: if this is `True` then the value is passed onwards to the command callback and stored on the context, @@ -1452,6 +1959,32 @@ class Parameter(object): order of processing. :param envvar: a string or list of strings that are environment variables that should be checked. + :param shell_complete: A function that returns custom shell + completions. Used instead of the param's type completion if + given. Takes ``ctx, param, incomplete`` and must return a list + of :class:`~click.shell_completion.CompletionItem` or a list of + strings. + + .. versionchanged:: 8.0 + ``process_value`` validates required parameters and bounded + ``nargs``, and invokes the parameter callback before returning + the value. This allows the callback to validate prompts. + ``full_process_value`` is removed. + + .. versionchanged:: 8.0 + ``autocompletion`` is renamed to ``shell_complete`` and has new + semantics described above. The old name is deprecated and will + be removed in 8.1, until then it will be wrapped to match the + new requirements. + + .. versionchanged:: 8.0 + For ``multiple=True, nargs>1``, the default must be a list of + tuples. + + .. versionchanged:: 8.0 + Setting a default is no longer required for ``nargs>1``, it will + default to ``None``. ``multiple=True`` or ``nargs=-1`` will + default to ``()``. .. versionchanged:: 7.1 Empty environment variables are ignored rather than taking the @@ -1463,27 +1996,38 @@ class Parameter(object): parameter. The old callback format will still work, but it will raise a warning to give you a chance to migrate the code easier. """ + param_type_name = "parameter" def __init__( self, - param_decls=None, - type=None, - required=False, - default=None, - callback=None, - nargs=None, - metavar=None, - expose_value=True, - is_eager=False, - envvar=None, - autocompletion=None, - ): + param_decls: t.Optional[t.Sequence[str]] = None, + type: t.Optional[t.Union[types.ParamType, t.Any]] = None, + required: bool = False, + default: t.Optional[t.Union[t.Any, t.Callable[[], t.Any]]] = None, + callback: t.Optional[t.Callable[[Context, "Parameter", t.Any], t.Any]] = None, + nargs: t.Optional[int] = None, + multiple: bool = False, + metavar: t.Optional[str] = None, + expose_value: bool = True, + is_eager: bool = False, + envvar: t.Optional[t.Union[str, t.Sequence[str]]] = None, + shell_complete: t.Optional[ + t.Callable[ + [Context, "Parameter", str], + t.Union[t.List["CompletionItem"], t.List[str]], + ] + ] = None, + autocompletion: t.Optional[ + t.Callable[ + [Context, t.List[str], str], t.List[t.Union[t.Tuple[str, str], str]] + ] + ] = None, + ) -> None: self.name, self.opts, self.secondary_opts = self._parse_decls( param_decls or (), expose_value ) - - self.type = convert_type(type, default) + self.type = types.convert_type(type, default) # Default nargs to what the type tells us if we have that # information available. @@ -1496,158 +2040,355 @@ class Parameter(object): self.required = required self.callback = callback self.nargs = nargs - self.multiple = False + self.multiple = multiple self.expose_value = expose_value self.default = default self.is_eager = is_eager self.metavar = metavar self.envvar = envvar - self.autocompletion = autocompletion - def __repr__(self): - return "<{} {}>".format(self.__class__.__name__, self.name) + if autocompletion is not None: + import warnings + + warnings.warn( + "'autocompletion' is renamed to 'shell_complete'. The old name is" + " deprecated and will be removed in Click 8.1. See the docs about" + " 'Parameter' for information about new behavior.", + DeprecationWarning, + stacklevel=2, + ) + + def shell_complete( + ctx: Context, param: "Parameter", incomplete: str + ) -> t.List["CompletionItem"]: + from click.shell_completion import CompletionItem + + out = [] + + for c in autocompletion(ctx, [], incomplete): # type: ignore + if isinstance(c, tuple): + c = CompletionItem(c[0], help=c[1]) + elif isinstance(c, str): + c = CompletionItem(c) + + if c.value.startswith(incomplete): + out.append(c) + + return out + + self._custom_shell_complete = shell_complete + + if __debug__: + if self.type.is_composite and nargs != self.type.arity: + raise ValueError( + f"'nargs' must be {self.type.arity} (or None) for" + f" type {self.type!r}, but it was {nargs}." + ) + + # Skip no default or callable default. + check_default = default if not callable(default) else None + + if check_default is not None: + if multiple: + try: + # Only check the first value against nargs. + check_default = next(_check_iter(check_default), None) + except TypeError: + raise ValueError( + "'default' must be a list when 'multiple' is true." + ) from None + + # Can be None for multiple with empty default. + if nargs != 1 and check_default is not None: + try: + _check_iter(check_default) + except TypeError: + if multiple: + message = ( + "'default' must be a list of lists when 'multiple' is" + " true and 'nargs' != 1." + ) + else: + message = "'default' must be a list when 'nargs' != 1." + + raise ValueError(message) from None + + if nargs > 1 and len(check_default) != nargs: + subject = "item length" if multiple else "length" + raise ValueError( + f"'default' {subject} must match nargs={nargs}." + ) + + def to_info_dict(self) -> t.Dict[str, t.Any]: + """Gather information that could be useful for a tool generating + user-facing documentation. + + Use :meth:`click.Context.to_info_dict` to traverse the entire + CLI structure. + + .. versionadded:: 8.0 + """ + return { + "name": self.name, + "param_type_name": self.param_type_name, + "opts": self.opts, + "secondary_opts": self.secondary_opts, + "type": self.type.to_info_dict(), + "required": self.required, + "nargs": self.nargs, + "multiple": self.multiple, + "default": self.default, + "envvar": self.envvar, + } + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} {self.name}>" + + def _parse_decls( + self, decls: t.Sequence[str], expose_value: bool + ) -> t.Tuple[t.Optional[str], t.List[str], t.List[str]]: + raise NotImplementedError() @property - def human_readable_name(self): + def human_readable_name(self) -> str: """Returns the human readable name of this parameter. This is the same as the name for options, but the metavar for arguments. """ - return self.name + return self.name # type: ignore - def make_metavar(self): + def make_metavar(self) -> str: if self.metavar is not None: return self.metavar + metavar = self.type.get_metavar(self) + if metavar is None: metavar = self.type.name.upper() + if self.nargs != 1: metavar += "..." + return metavar - def get_default(self, ctx): - """Given a context variable this calculates the default value.""" - # Otherwise go with the regular default. - if callable(self.default): - rv = self.default() - else: - rv = self.default - return self.type_cast_value(ctx, rv) + @typing.overload + def get_default( + self, ctx: Context, call: "te.Literal[True]" = True + ) -> t.Optional[t.Any]: + ... - def add_to_parser(self, parser, ctx): - pass + @typing.overload + def get_default( + self, ctx: Context, call: bool = ... + ) -> t.Optional[t.Union[t.Any, t.Callable[[], t.Any]]]: + ... + + def get_default( + self, ctx: Context, call: bool = True + ) -> t.Optional[t.Union[t.Any, t.Callable[[], t.Any]]]: + """Get the default for the parameter. Tries + :meth:`Context.lookup_default` first, then the local default. + + :param ctx: Current context. + :param call: If the default is a callable, call it. Disable to + return the callable instead. + + .. versionchanged:: 8.0.2 + Type casting is no longer performed when getting a default. + + .. versionchanged:: 8.0.1 + Type casting can fail in resilient parsing mode. Invalid + defaults will not prevent showing help text. + + .. versionchanged:: 8.0 + Looks at ``ctx.default_map`` first. + + .. versionchanged:: 8.0 + Added the ``call`` parameter. + """ + value = ctx.lookup_default(self.name, call=False) # type: ignore - def consume_value(self, ctx, opts): - value = opts.get(self.name) if value is None: - value = self.value_from_envvar(ctx) - if value is None: - value = ctx.lookup_default(self.name) + value = self.default + + if call and callable(value): + value = value() + return value - def type_cast_value(self, ctx, value): - """Given a value this runs it properly through the type system. - This automatically handles things like `nargs` and `multiple` as - well as composite types. + def add_to_parser(self, parser: OptionParser, ctx: Context) -> None: + raise NotImplementedError() + + def consume_value( + self, ctx: Context, opts: t.Mapping[str, t.Any] + ) -> t.Tuple[t.Any, ParameterSource]: + value = opts.get(self.name) # type: ignore + source = ParameterSource.COMMANDLINE + + if value is None: + value = self.value_from_envvar(ctx) + source = ParameterSource.ENVIRONMENT + + if value is None: + value = ctx.lookup_default(self.name) # type: ignore + source = ParameterSource.DEFAULT_MAP + + if value is None: + value = self.get_default(ctx) + source = ParameterSource.DEFAULT + + return value, source + + def type_cast_value(self, ctx: Context, value: t.Any) -> t.Any: + """Convert and validate a value against the option's + :attr:`type`, :attr:`multiple`, and :attr:`nargs`. """ - if self.type.is_composite: - if self.nargs <= 1: - raise TypeError( - "Attempted to invoke composite type but nargs has" - " been set to {}. This is not supported; nargs" - " needs to be set to a fixed value > 1.".format(self.nargs) - ) - if self.multiple: - return tuple(self.type(x or (), self, ctx) for x in value or ()) - return self.type(value or (), self, ctx) + if value is None: + return () if self.multiple or self.nargs == -1 else None - def _convert(value, level): - if level == 0: - return self.type(value, self, ctx) - return tuple(_convert(x, level - 1) for x in value or ()) + def check_iter(value: t.Any) -> t.Iterator: + try: + return _check_iter(value) + except TypeError: + # This should only happen when passing in args manually, + # the parser should construct an iterable when parsing + # the command line. + raise BadParameter( + _("Value must be an iterable."), ctx=ctx, param=self + ) from None - return _convert(value, (self.nargs != 1) + bool(self.multiple)) + if self.nargs == 1 or self.type.is_composite: + convert: t.Callable[[t.Any], t.Any] = partial( + self.type, param=self, ctx=ctx + ) + elif self.nargs == -1: - def process_value(self, ctx, value): - """Given a value and context this runs the logic to convert the - value as necessary. - """ - # If the value we were given is None we do nothing. This way - # code that calls this can easily figure out if something was - # not provided. Otherwise it would be converted into an empty - # tuple for multiple invocations which is inconvenient. - if value is not None: - return self.type_cast_value(ctx, value) + def convert(value: t.Any) -> t.Tuple: + return tuple(self.type(x, self, ctx) for x in check_iter(value)) - def value_is_missing(self, value): + else: # nargs > 1 + + def convert(value: t.Any) -> t.Tuple: + value = tuple(check_iter(value)) + + if len(value) != self.nargs: + raise BadParameter( + ngettext( + "Takes {nargs} values but 1 was given.", + "Takes {nargs} values but {len} were given.", + len(value), + ).format(nargs=self.nargs, len=len(value)), + ctx=ctx, + param=self, + ) + + return tuple(self.type(x, self, ctx) for x in value) + + if self.multiple: + return tuple(convert(x) for x in check_iter(value)) + + return convert(value) + + def value_is_missing(self, value: t.Any) -> bool: if value is None: return True + if (self.nargs != 1 or self.multiple) and value == (): return True + return False - def full_process_value(self, ctx, value): - value = self.process_value(ctx, value) - - if value is None and not ctx.resilient_parsing: - value = self.get_default(ctx) + def process_value(self, ctx: Context, value: t.Any) -> t.Any: + value = self.type_cast_value(ctx, value) if self.required and self.value_is_missing(value): raise MissingParameter(ctx=ctx, param=self) + if self.callback is not None: + value = self.callback(ctx, self, value) + return value - def resolve_envvar_value(self, ctx): + def resolve_envvar_value(self, ctx: Context) -> t.Optional[str]: if self.envvar is None: - return - if isinstance(self.envvar, (tuple, list)): - for envvar in self.envvar: - rv = os.environ.get(envvar) - if rv is not None: - return rv - else: + return None + + if isinstance(self.envvar, str): rv = os.environ.get(self.envvar) - if rv != "": + if rv: return rv + else: + for envvar in self.envvar: + rv = os.environ.get(envvar) + + if rv: + return rv + + return None + + def value_from_envvar(self, ctx: Context) -> t.Optional[t.Any]: + rv: t.Optional[t.Any] = self.resolve_envvar_value(ctx) - def value_from_envvar(self, ctx): - rv = self.resolve_envvar_value(ctx) if rv is not None and self.nargs != 1: rv = self.type.split_envvar_value(rv) + return rv - def handle_parse_result(self, ctx, opts, args): + def handle_parse_result( + self, ctx: Context, opts: t.Mapping[str, t.Any], args: t.List[str] + ) -> t.Tuple[t.Any, t.List[str]]: with augment_usage_errors(ctx, param=self): - value = self.consume_value(ctx, opts) + value, source = self.consume_value(ctx, opts) + ctx.set_parameter_source(self.name, source) # type: ignore + try: - value = self.full_process_value(ctx, value) + value = self.process_value(ctx, value) except Exception: if not ctx.resilient_parsing: raise + value = None - if self.callback is not None: - try: - value = invoke_param_callback(self.callback, ctx, self, value) - except Exception: - if not ctx.resilient_parsing: - raise if self.expose_value: - ctx.params[self.name] = value + ctx.params[self.name] = value # type: ignore + return value, args - def get_help_record(self, ctx): + def get_help_record(self, ctx: Context) -> t.Optional[t.Tuple[str, str]]: pass - def get_usage_pieces(self, ctx): + def get_usage_pieces(self, ctx: Context) -> t.List[str]: return [] - def get_error_hint(self, ctx): + def get_error_hint(self, ctx: Context) -> str: """Get a stringified version of the param for use in error messages to indicate which param caused the error. """ hint_list = self.opts or [self.human_readable_name] - return " / ".join(repr(x) for x in hint_list) + return " / ".join(f"'{x}'" for x in hint_list) + + def shell_complete(self, ctx: Context, incomplete: str) -> t.List["CompletionItem"]: + """Return a list of completions for the incomplete value. If a + ``shell_complete`` function was given during init, it is used. + Otherwise, the :attr:`type` + :meth:`~click.types.ParamType.shell_complete` function is used. + + :param ctx: Invocation context for this command. + :param incomplete: Value being completed. May be empty. + + .. versionadded:: 8.0 + """ + if self._custom_shell_complete is not None: + results = self._custom_shell_complete(ctx, self, incomplete) + + if results and isinstance(results[0], str): + from click.shell_completion import CompletionItem + + results = [CompletionItem(c) for c in results] + + return t.cast(t.List["CompletionItem"], results) + + return self.type.shell_complete(ctx, self, incomplete) class Option(Parameter): @@ -1666,8 +2407,12 @@ class Option(Parameter): :param prompt: if set to `True` or a non empty string then the user will be prompted for input. If set to `True` the prompt will be the option name capitalized. - :param confirmation_prompt: if set then the value will need to be confirmed - if it was prompted for. + :param confirmation_prompt: Prompt a second time to confirm the + value if it was prompted for. Can be set to a string instead of + ``True`` to customize the message. + :param prompt_required: If set to ``False``, the user will be + prompted for input only when the option was specified as a flag + without a value. :param hide_input: if this is `True` then the input on the prompt will be hidden from the user. This is useful for password input. @@ -1687,106 +2432,146 @@ class Option(Parameter): context. :param help: the help string. :param hidden: hide this option from help outputs. + + .. versionchanged:: 8.0.1 + ``type`` is detected from ``flag_value`` if given. """ param_type_name = "option" def __init__( self, - param_decls=None, - show_default=False, - prompt=False, - confirmation_prompt=False, - hide_input=False, - is_flag=None, - flag_value=None, - multiple=False, - count=False, - allow_from_autoenv=True, - type=None, - help=None, - hidden=False, - show_choices=True, - show_envvar=False, - **attrs - ): - default_is_missing = attrs.get("default", _missing) is _missing - Parameter.__init__(self, param_decls, type=type, **attrs) + param_decls: t.Optional[t.Sequence[str]] = None, + show_default: t.Union[bool, str] = False, + prompt: t.Union[bool, str] = False, + confirmation_prompt: t.Union[bool, str] = False, + prompt_required: bool = True, + hide_input: bool = False, + is_flag: t.Optional[bool] = None, + flag_value: t.Optional[t.Any] = None, + multiple: bool = False, + count: bool = False, + allow_from_autoenv: bool = True, + type: t.Optional[t.Union[types.ParamType, t.Any]] = None, + help: t.Optional[str] = None, + hidden: bool = False, + show_choices: bool = True, + show_envvar: bool = False, + **attrs: t.Any, + ) -> None: + default_is_missing = "default" not in attrs + super().__init__(param_decls, type=type, multiple=multiple, **attrs) if prompt is True: - prompt_text = self.name.replace("_", " ").capitalize() + if self.name is None: + raise TypeError("'name' is required with 'prompt=True'.") + + prompt_text: t.Optional[str] = self.name.replace("_", " ").capitalize() elif prompt is False: prompt_text = None else: - prompt_text = prompt + prompt_text = t.cast(str, prompt) + self.prompt = prompt_text self.confirmation_prompt = confirmation_prompt + self.prompt_required = prompt_required self.hide_input = hide_input self.hidden = hidden - # Flags + # If prompt is enabled but not required, then the option can be + # used as a flag to indicate using prompt or flag_value. + self._flag_needs_value = self.prompt is not None and not self.prompt_required + if is_flag is None: if flag_value is not None: + # Implicitly a flag because flag_value was set. is_flag = True + elif self._flag_needs_value: + # Not a flag, but when used as a flag it shows a prompt. + is_flag = False else: + # Implicitly a flag because flag options were given. is_flag = bool(self.secondary_opts) + elif is_flag is False and not self._flag_needs_value: + # Not a flag, and prompt is not enabled, can be used as a + # flag if flag_value is set. + self._flag_needs_value = flag_value is not None + if is_flag and default_is_missing: - self.default = False + self.default: t.Union[t.Any, t.Callable[[], t.Any]] = False + if flag_value is None: flag_value = not self.default - self.is_flag = is_flag - self.flag_value = flag_value - if self.is_flag and isinstance(self.flag_value, bool) and type in [None, bool]: - self.type = BOOL - self.is_bool_flag = True - else: - self.is_bool_flag = False + + if is_flag and type is None: + # Re-guess the type from the flag value instead of the + # default. + self.type = types.convert_type(None, flag_value) + + self.is_flag: bool = is_flag + self.is_bool_flag = is_flag and isinstance(self.type, types.BoolParamType) + self.flag_value: t.Any = flag_value # Counting self.count = count if count: if type is None: - self.type = IntRange(min=0) + self.type = types.IntRange(min=0) if default_is_missing: self.default = 0 - self.multiple = multiple self.allow_from_autoenv = allow_from_autoenv self.help = help self.show_default = show_default self.show_choices = show_choices self.show_envvar = show_envvar - # Sanity check for stuff we don't support if __debug__: - if self.nargs < 0: - raise TypeError("Options cannot have nargs < 0") + if self.nargs == -1: + raise TypeError("nargs=-1 is not supported for options.") + if self.prompt and self.is_flag and not self.is_bool_flag: - raise TypeError("Cannot prompt for flags that are not bools.") + raise TypeError("'prompt' is not valid for non-boolean flag.") + if not self.is_bool_flag and self.secondary_opts: - raise TypeError("Got secondary option for non boolean flag.") + raise TypeError("Secondary flag is not valid for non-boolean flag.") + if self.is_bool_flag and self.hide_input and self.prompt is not None: - raise TypeError("Hidden input does not work with boolean flag prompts.") + raise TypeError( + "'prompt' with 'hide_input' is not valid for boolean flag." + ) + if self.count: if self.multiple: - raise TypeError( - "Options cannot be multiple and count at the same time." - ) - elif self.is_flag: - raise TypeError( - "Options cannot be count and flags at the same time." - ) + raise TypeError("'count' is not valid with 'multiple'.") - def _parse_decls(self, decls, expose_value): + if self.is_flag: + raise TypeError("'count' is not valid with 'is_flag'.") + + def to_info_dict(self) -> t.Dict[str, t.Any]: + info_dict = super().to_info_dict() + info_dict.update( + help=self.help, + prompt=self.prompt, + is_flag=self.is_flag, + flag_value=self.flag_value, + count=self.count, + hidden=self.hidden, + ) + return info_dict + + def _parse_decls( + self, decls: t.Sequence[str], expose_value: bool + ) -> t.Tuple[t.Optional[str], t.List[str], t.List[str]]: opts = [] secondary_opts = [] name = None possible_names = [] for decl in decls: - if isidentifier(decl): + if decl.isidentifier(): if name is not None: - raise TypeError("Name defined twice") + raise TypeError(f"Name '{name}' defined twice") name = decl else: split_char = ";" if decl[:1] == "/" else "/" @@ -1799,6 +2584,11 @@ class Option(Parameter): second = second.lstrip() if second: secondary_opts.append(second.lstrip()) + if first == second: + raise ValueError( + f"Boolean option {decl!r} cannot use the" + " same flag for true/false." + ) else: possible_names.append(split_opt(decl)) opts.append(decl) @@ -1806,7 +2596,7 @@ class Option(Parameter): if name is None and possible_names: possible_names.sort(key=lambda x: -len(x[0])) # group long options first name = possible_names[0][1].replace("-", "_").lower() - if not isidentifier(name): + if not name.isidentifier(): name = None if name is None: @@ -1816,19 +2606,14 @@ class Option(Parameter): if not opts and not secondary_opts: raise TypeError( - "No options defined but a name was passed ({}). Did you" - " mean to declare an argument instead of an option?".format(name) + f"No options defined but a name was passed ({name})." + " Did you mean to declare an argument instead? Did" + f" you mean to pass '--{name}'?" ) return name, opts, secondary_opts - def add_to_parser(self, parser, ctx): - kwargs = { - "dest": self.name, - "nargs": self.nargs, - "obj": self, - } - + def add_to_parser(self, parser: OptionParser, ctx: Context) -> None: if self.multiple: action = "append" elif self.count: @@ -1837,74 +2622,150 @@ class Option(Parameter): action = "store" if self.is_flag: - kwargs.pop("nargs", None) - action_const = "{}_const".format(action) + action = f"{action}_const" + if self.is_bool_flag and self.secondary_opts: - parser.add_option(self.opts, action=action_const, const=True, **kwargs) parser.add_option( - self.secondary_opts, action=action_const, const=False, **kwargs + obj=self, opts=self.opts, dest=self.name, action=action, const=True + ) + parser.add_option( + obj=self, + opts=self.secondary_opts, + dest=self.name, + action=action, + const=False, ) else: parser.add_option( - self.opts, action=action_const, const=self.flag_value, **kwargs + obj=self, + opts=self.opts, + dest=self.name, + action=action, + const=self.flag_value, ) else: - kwargs["action"] = action - parser.add_option(self.opts, **kwargs) + parser.add_option( + obj=self, + opts=self.opts, + dest=self.name, + action=action, + nargs=self.nargs, + ) - def get_help_record(self, ctx): + def get_help_record(self, ctx: Context) -> t.Optional[t.Tuple[str, str]]: if self.hidden: - return - any_prefix_is_slash = [] + return None + + any_prefix_is_slash = False + + def _write_opts(opts: t.Sequence[str]) -> str: + nonlocal any_prefix_is_slash - def _write_opts(opts): rv, any_slashes = join_options(opts) + if any_slashes: - any_prefix_is_slash[:] = [True] + any_prefix_is_slash = True + if not self.is_flag and not self.count: - rv += " {}".format(self.make_metavar()) + rv += f" {self.make_metavar()}" + return rv rv = [_write_opts(self.opts)] + if self.secondary_opts: rv.append(_write_opts(self.secondary_opts)) help = self.help or "" extra = [] + if self.show_envvar: envvar = self.envvar + if envvar is None: - if self.allow_from_autoenv and ctx.auto_envvar_prefix is not None: - envvar = "{}_{}".format(ctx.auto_envvar_prefix, self.name.upper()) + if ( + self.allow_from_autoenv + and ctx.auto_envvar_prefix is not None + and self.name is not None + ): + envvar = f"{ctx.auto_envvar_prefix}_{self.name.upper()}" + if envvar is not None: - extra.append( - "env var: {}".format( - ", ".join(str(d) for d in envvar) - if isinstance(envvar, (list, tuple)) - else envvar - ) + var_str = ( + envvar + if isinstance(envvar, str) + else ", ".join(str(d) for d in envvar) ) - if self.default is not None and (self.show_default or ctx.show_default): - if isinstance(self.show_default, string_types): - default_string = "({})".format(self.show_default) - elif isinstance(self.default, (list, tuple)): - default_string = ", ".join(str(d) for d in self.default) - elif inspect.isfunction(self.default): - default_string = "(dynamic)" + extra.append(_("env var: {var}").format(var=var_str)) + + # Temporarily enable resilient parsing to avoid type casting + # failing for the default. Might be possible to extend this to + # help formatting in general. + resilient = ctx.resilient_parsing + ctx.resilient_parsing = True + + try: + default_value = self.get_default(ctx, call=False) + finally: + ctx.resilient_parsing = resilient + + show_default_is_str = isinstance(self.show_default, str) + + if show_default_is_str or ( + default_value is not None and (self.show_default or ctx.show_default) + ): + if show_default_is_str: + default_string = f"({self.show_default})" + elif isinstance(default_value, (list, tuple)): + default_string = ", ".join(str(d) for d in default_value) + elif callable(default_value): + default_string = _("(dynamic)") + elif self.is_bool_flag and self.secondary_opts: + # For boolean flags that have distinct True/False opts, + # use the opt without prefix instead of the value. + default_string = split_opt( + (self.opts if self.default else self.secondary_opts)[0] + )[1] else: - default_string = self.default - extra.append("default: {}".format(default_string)) + default_string = str(default_value) + + if default_string: + extra.append(_("default: {default}").format(default=default_string)) + + if ( + isinstance(self.type, types._NumberRangeBase) + # skip count with default range type + and not (self.count and self.type.min == 0 and self.type.max is None) + ): + range_str = self.type._describe_range() + + if range_str: + extra.append(range_str) if self.required: - extra.append("required") + extra.append(_("required")) + if extra: - help = "{}[{}]".format( - "{} ".format(help) if help else "", "; ".join(extra) - ) + extra_str = "; ".join(extra) + help = f"{help} [{extra_str}]" if help else f"[{extra_str}]" return ("; " if any_prefix_is_slash else " / ").join(rv), help - def get_default(self, ctx): + @typing.overload + def get_default( + self, ctx: Context, call: "te.Literal[True]" = True + ) -> t.Optional[t.Any]: + ... + + @typing.overload + def get_default( + self, ctx: Context, call: bool = ... + ) -> t.Optional[t.Union[t.Any, t.Callable[[], t.Any]]]: + ... + + def get_default( + self, ctx: Context, call: bool = True + ) -> t.Optional[t.Union[t.Any, t.Callable[[], t.Any]]]: # If we're a non boolean flag our default is more complex because # we need to look at all flags in the same group to figure out # if we're the the default one in which case we return the flag @@ -1912,16 +2773,20 @@ class Option(Parameter): if self.is_flag and not self.is_bool_flag: for param in ctx.command.params: if param.name == self.name and param.default: - return param.flag_value - return None - return Parameter.get_default(self, ctx) + return param.flag_value # type: ignore - def prompt_for_value(self, ctx): + return None + + return super().get_default(ctx, call=call) + + def prompt_for_value(self, ctx: Context) -> t.Any: """This is an alternative flow that can be activated in the full value processing if a value does not exist. It will prompt the user until a valid value exists and then returns the processed value as result. """ + assert self.prompt is not None + # Calculate the default before prompting anything to be stable. default = self.get_default(ctx) @@ -1940,29 +2805,74 @@ class Option(Parameter): value_proc=lambda x: self.process_value(ctx, x), ) - def resolve_envvar_value(self, ctx): - rv = Parameter.resolve_envvar_value(self, ctx) + def resolve_envvar_value(self, ctx: Context) -> t.Optional[str]: + rv = super().resolve_envvar_value(ctx) + if rv is not None: return rv - if self.allow_from_autoenv and ctx.auto_envvar_prefix is not None: - envvar = "{}_{}".format(ctx.auto_envvar_prefix, self.name.upper()) - return os.environ.get(envvar) - def value_from_envvar(self, ctx): - rv = self.resolve_envvar_value(ctx) - if rv is None: - return None - value_depth = (self.nargs != 1) + bool(self.multiple) - if value_depth > 0 and rv is not None: - rv = self.type.split_envvar_value(rv) - if self.multiple and self.nargs != 1: - rv = batch(rv, self.nargs) + if ( + self.allow_from_autoenv + and ctx.auto_envvar_prefix is not None + and self.name is not None + ): + envvar = f"{ctx.auto_envvar_prefix}_{self.name.upper()}" + rv = os.environ.get(envvar) + return rv - def full_process_value(self, ctx, value): - if value is None and self.prompt is not None and not ctx.resilient_parsing: - return self.prompt_for_value(ctx) - return Parameter.full_process_value(self, ctx, value) + def value_from_envvar(self, ctx: Context) -> t.Optional[t.Any]: + rv: t.Optional[t.Any] = self.resolve_envvar_value(ctx) + + if rv is None: + return None + + value_depth = (self.nargs != 1) + bool(self.multiple) + + if value_depth > 0: + rv = self.type.split_envvar_value(rv) + + if self.multiple and self.nargs != 1: + rv = batch(rv, self.nargs) + + return rv + + def consume_value( + self, ctx: Context, opts: t.Mapping[str, "Parameter"] + ) -> t.Tuple[t.Any, ParameterSource]: + value, source = super().consume_value(ctx, opts) + + # The parser will emit a sentinel value if the option can be + # given as a flag without a value. This is different from None + # to distinguish from the flag not being given at all. + if value is _flag_needs_value: + if self.prompt is not None and not ctx.resilient_parsing: + value = self.prompt_for_value(ctx) + source = ParameterSource.PROMPT + else: + value = self.flag_value + source = ParameterSource.COMMANDLINE + + elif ( + self.multiple + and value is not None + and any(v is _flag_needs_value for v in value) + ): + value = [self.flag_value if v is _flag_needs_value else v for v in value] + source = ParameterSource.COMMANDLINE + + # The value wasn't set, or used the param's default, prompt if + # prompting is enabled. + elif ( + source in {None, ParameterSource.DEFAULT} + and self.prompt is not None + and (self.required or self.prompt_required) + and not ctx.resilient_parsing + ): + value = self.prompt_for_value(ctx) + source = ParameterSource.PROMPT + + return value, source class Argument(Parameter): @@ -1975,37 +2885,48 @@ class Argument(Parameter): param_type_name = "argument" - def __init__(self, param_decls, required=None, **attrs): + def __init__( + self, + param_decls: t.Sequence[str], + required: t.Optional[bool] = None, + **attrs: t.Any, + ) -> None: if required is None: if attrs.get("default") is not None: required = False else: required = attrs.get("nargs", 1) > 0 - Parameter.__init__(self, param_decls, required=required, **attrs) - if self.default is not None and self.nargs < 0: - raise TypeError( - "nargs=-1 in combination with a default value is not supported." - ) + + if "multiple" in attrs: + raise TypeError("__init__() got an unexpected keyword argument 'multiple'.") + + super().__init__(param_decls, required=required, **attrs) + + if __debug__: + if self.default is not None and self.nargs == -1: + raise TypeError("'default' is not supported for nargs=-1.") @property - def human_readable_name(self): + def human_readable_name(self) -> str: if self.metavar is not None: return self.metavar - return self.name.upper() + return self.name.upper() # type: ignore - def make_metavar(self): + def make_metavar(self) -> str: if self.metavar is not None: return self.metavar var = self.type.get_metavar(self) if not var: - var = self.name.upper() + var = self.name.upper() # type: ignore if not self.required: - var = "[{}]".format(var) + var = f"[{var}]" if self.nargs != 1: var += "..." return var - def _parse_decls(self, decls, expose_value): + def _parse_decls( + self, decls: t.Sequence[str], expose_value: bool + ) -> t.Tuple[t.Optional[str], t.List[str], t.List[str]]: if not decls: if not expose_value: return None, [], [] @@ -2016,15 +2937,15 @@ class Argument(Parameter): else: raise TypeError( "Arguments take exactly one parameter declaration, got" - " {}".format(len(decls)) + f" {len(decls)}." ) return name, [arg], [] - def get_usage_pieces(self, ctx): + def get_usage_pieces(self, ctx: Context) -> t.List[str]: return [self.make_metavar()] - def get_error_hint(self, ctx): - return repr(self.make_metavar()) + def get_error_hint(self, ctx: Context) -> str: + return f"'{self.make_metavar()}'" - def add_to_parser(self, parser, ctx): + def add_to_parser(self, parser: OptionParser, ctx: Context) -> None: parser.add_argument(dest=self.name, nargs=self.nargs, obj=self) diff --git a/src/click/decorators.py b/src/click/decorators.py index c7b5af6..f1cc005 100644 --- a/src/click/decorators.py +++ b/src/click/decorators.py @@ -1,41 +1,48 @@ import inspect -import sys +import types +import typing as t from functools import update_wrapper +from gettext import gettext as _ -from ._compat import iteritems -from ._unicodefun import _check_for_unicode_literals from .core import Argument from .core import Command +from .core import Context from .core import Group from .core import Option +from .core import Parameter from .globals import get_current_context from .utils import echo +F = t.TypeVar("F", bound=t.Callable[..., t.Any]) +FC = t.TypeVar("FC", t.Callable[..., t.Any], Command) -def pass_context(f): + +def pass_context(f: F) -> F: """Marks a callback as wanting to receive the current context object as first argument. """ - def new_func(*args, **kwargs): + def new_func(*args, **kwargs): # type: ignore return f(get_current_context(), *args, **kwargs) - return update_wrapper(new_func, f) + return update_wrapper(t.cast(F, new_func), f) -def pass_obj(f): +def pass_obj(f: F) -> F: """Similar to :func:`pass_context`, but only pass the object on the context onwards (:attr:`Context.obj`). This is useful if that object represents the state of a nested system. """ - def new_func(*args, **kwargs): + def new_func(*args, **kwargs): # type: ignore return f(get_current_context().obj, *args, **kwargs) - return update_wrapper(new_func, f) + return update_wrapper(t.cast(F, new_func), f) -def make_pass_decorator(object_type, ensure=False): +def make_pass_decorator( + object_type: t.Type, ensure: bool = False +) -> "t.Callable[[F], F]": """Given an object type this creates a decorator that will work similar to :func:`pass_obj` but instead of passing the object of the current context, it will find the innermost context of type @@ -58,52 +65,99 @@ def make_pass_decorator(object_type, ensure=False): remembered on the context if it's not there yet. """ - def decorator(f): - def new_func(*args, **kwargs): + def decorator(f: F) -> F: + def new_func(*args, **kwargs): # type: ignore ctx = get_current_context() + if ensure: obj = ctx.ensure_object(object_type) else: obj = ctx.find_object(object_type) + if obj is None: raise RuntimeError( "Managed to invoke callback without a context" - " object of type '{}' existing".format(object_type.__name__) + f" object of type {object_type.__name__!r}" + " existing." ) + return ctx.invoke(f, obj, *args, **kwargs) - return update_wrapper(new_func, f) + return update_wrapper(t.cast(F, new_func), f) return decorator -def _make_command(f, name, attrs, cls): +def pass_meta_key( + key: str, *, doc_description: t.Optional[str] = None +) -> "t.Callable[[F], F]": + """Create a decorator that passes a key from + :attr:`click.Context.meta` as the first argument to the decorated + function. + + :param key: Key in ``Context.meta`` to pass. + :param doc_description: Description of the object being passed, + inserted into the decorator's docstring. Defaults to "the 'key' + key from Context.meta". + + .. versionadded:: 8.0 + """ + + def decorator(f: F) -> F: + def new_func(*args, **kwargs): # type: ignore + ctx = get_current_context() + obj = ctx.meta[key] + return ctx.invoke(f, obj, *args, **kwargs) + + return update_wrapper(t.cast(F, new_func), f) + + if doc_description is None: + doc_description = f"the {key!r} key from :attr:`click.Context.meta`" + + decorator.__doc__ = ( + f"Decorator that passes {doc_description} as the first argument" + " to the decorated function." + ) + return decorator + + +def _make_command( + f: F, + name: t.Optional[str], + attrs: t.MutableMapping[str, t.Any], + cls: t.Type[Command], +) -> Command: if isinstance(f, Command): raise TypeError("Attempted to convert a callback into a command twice.") + try: - params = f.__click_params__ + params = f.__click_params__ # type: ignore params.reverse() - del f.__click_params__ + del f.__click_params__ # type: ignore except AttributeError: params = [] + help = attrs.get("help") + if help is None: help = inspect.getdoc(f) - if isinstance(help, bytes): - help = help.decode("utf-8") else: help = inspect.cleandoc(help) + attrs["help"] = help - _check_for_unicode_literals() return cls( name=name or f.__name__.lower().replace("_", "-"), callback=f, params=params, - **attrs + **attrs, ) -def command(name=None, cls=None, **attrs): +def command( + name: t.Optional[str] = None, + cls: t.Optional[t.Type[Command]] = None, + **attrs: t.Any, +) -> t.Callable[[F], Command]: r"""Creates a new :class:`Command` and uses the decorated function as callback. This will also automatically attach all decorated :func:`option`\s and :func:`argument`\s as parameters to the command. @@ -126,33 +180,34 @@ def command(name=None, cls=None, **attrs): if cls is None: cls = Command - def decorator(f): - cmd = _make_command(f, name, attrs, cls) + def decorator(f: t.Callable[..., t.Any]) -> Command: + cmd = _make_command(f, name, attrs, cls) # type: ignore cmd.__doc__ = f.__doc__ return cmd return decorator -def group(name=None, **attrs): +def group(name: t.Optional[str] = None, **attrs: t.Any) -> t.Callable[[F], Group]: """Creates a new :class:`Group` with a function as callback. This works otherwise the same as :func:`command` just that the `cls` parameter is set to :class:`Group`. """ attrs.setdefault("cls", Group) - return command(name, **attrs) + return t.cast(Group, command(name, **attrs)) -def _param_memo(f, param): +def _param_memo(f: FC, param: Parameter) -> None: if isinstance(f, Command): f.params.append(param) else: if not hasattr(f, "__click_params__"): - f.__click_params__ = [] - f.__click_params__.append(param) + f.__click_params__ = [] # type: ignore + + f.__click_params__.append(param) # type: ignore -def argument(*param_decls, **attrs): +def argument(*param_decls: str, **attrs: t.Any) -> t.Callable[[FC], FC]: """Attaches an argument to the command. All positional arguments are passed as parameter declarations to :class:`Argument`; all keyword arguments are forwarded unchanged (except ``cls``). @@ -163,7 +218,7 @@ def argument(*param_decls, **attrs): :class:`Argument`. """ - def decorator(f): + def decorator(f: FC) -> FC: ArgumentClass = attrs.pop("cls", Argument) _param_memo(f, ArgumentClass(param_decls, **attrs)) return f @@ -171,7 +226,7 @@ def argument(*param_decls, **attrs): return decorator -def option(*param_decls, **attrs): +def option(*param_decls: str, **attrs: t.Any) -> t.Callable[[FC], FC]: """Attaches an option to the command. All positional arguments are passed as parameter declarations to :class:`Option`; all keyword arguments are forwarded unchanged (except ``cls``). @@ -182,7 +237,7 @@ def option(*param_decls, **attrs): :class:`Option`. """ - def decorator(f): + def decorator(f: FC) -> FC: # Issue 926, copy attrs, so pre-defined options can re-use the same cls= option_attrs = attrs.copy() @@ -195,139 +250,187 @@ def option(*param_decls, **attrs): return decorator -def confirmation_option(*param_decls, **attrs): - """Shortcut for confirmation prompts that can be ignored by passing - ``--yes`` as parameter. +def confirmation_option(*param_decls: str, **kwargs: t.Any) -> t.Callable[[FC], FC]: + """Add a ``--yes`` option which shows a prompt before continuing if + not passed. If the prompt is declined, the program will exit. - This is equivalent to decorating a function with :func:`option` with - the following parameters:: - - def callback(ctx, param, value): - if not value: - ctx.abort() - - @click.command() - @click.option('--yes', is_flag=True, callback=callback, - expose_value=False, prompt='Do you want to continue?') - def dropdb(): - pass + :param param_decls: One or more option names. Defaults to the single + value ``"--yes"``. + :param kwargs: Extra arguments are passed to :func:`option`. """ - def decorator(f): - def callback(ctx, param, value): - if not value: - ctx.abort() + def callback(ctx: Context, param: Parameter, value: bool) -> None: + if not value: + ctx.abort() - attrs.setdefault("is_flag", True) - attrs.setdefault("callback", callback) - attrs.setdefault("expose_value", False) - attrs.setdefault("prompt", "Do you want to continue?") - attrs.setdefault("help", "Confirm the action without prompting.") - return option(*(param_decls or ("--yes",)), **attrs)(f) + if not param_decls: + param_decls = ("--yes",) - return decorator + kwargs.setdefault("is_flag", True) + kwargs.setdefault("callback", callback) + kwargs.setdefault("expose_value", False) + kwargs.setdefault("prompt", "Do you want to continue?") + kwargs.setdefault("help", "Confirm the action without prompting.") + return option(*param_decls, **kwargs) -def password_option(*param_decls, **attrs): - """Shortcut for password prompts. +def password_option(*param_decls: str, **kwargs: t.Any) -> t.Callable[[FC], FC]: + """Add a ``--password`` option which prompts for a password, hiding + input and asking to enter the value again for confirmation. - This is equivalent to decorating a function with :func:`option` with - the following parameters:: + :param param_decls: One or more option names. Defaults to the single + value ``"--password"``. + :param kwargs: Extra arguments are passed to :func:`option`. + """ + if not param_decls: + param_decls = ("--password",) - @click.command() - @click.option('--password', prompt=True, confirmation_prompt=True, - hide_input=True) - def changeadmin(password): - pass + kwargs.setdefault("prompt", True) + kwargs.setdefault("confirmation_prompt", True) + kwargs.setdefault("hide_input", True) + return option(*param_decls, **kwargs) + + +def version_option( + version: t.Optional[str] = None, + *param_decls: str, + package_name: t.Optional[str] = None, + prog_name: t.Optional[str] = None, + message: t.Optional[str] = None, + **kwargs: t.Any, +) -> t.Callable[[FC], FC]: + """Add a ``--version`` option which immediately prints the version + number and exits the program. + + If ``version`` is not provided, Click will try to detect it using + :func:`importlib.metadata.version` to get the version for the + ``package_name``. On Python < 3.8, the ``importlib_metadata`` + backport must be installed. + + If ``package_name`` is not provided, Click will try to detect it by + inspecting the stack frames. This will be used to detect the + version, so it must match the name of the installed package. + + :param version: The version number to show. If not provided, Click + will try to detect it. + :param param_decls: One or more option names. Defaults to the single + value ``"--version"``. + :param package_name: The package name to detect the version from. If + not provided, Click will try to detect it. + :param prog_name: The name of the CLI to show in the message. If not + provided, it will be detected from the command. + :param message: The message to show. The values ``%(prog)s``, + ``%(package)s``, and ``%(version)s`` are available. Defaults to + ``"%(prog)s, version %(version)s"``. + :param kwargs: Extra arguments are passed to :func:`option`. + :raise RuntimeError: ``version`` could not be detected. + + .. versionchanged:: 8.0 + Add the ``package_name`` parameter, and the ``%(package)s`` + value for messages. + + .. versionchanged:: 8.0 + Use :mod:`importlib.metadata` instead of ``pkg_resources``. The + version is detected based on the package name, not the entry + point name. The Python package name must match the installed + package name, or be passed with ``package_name=``. + """ + if message is None: + message = _("%(prog)s, version %(version)s") + + if version is None and package_name is None: + frame = inspect.currentframe() + f_back = frame.f_back if frame is not None else None + f_globals = f_back.f_globals if f_back is not None else None + # break reference cycle + # https://docs.python.org/3/library/inspect.html#the-interpreter-stack + del frame + + if f_globals is not None: + package_name = f_globals.get("__name__") + + if package_name == "__main__": + package_name = f_globals.get("__package__") + + if package_name: + package_name = package_name.partition(".")[0] + + def callback(ctx: Context, param: Parameter, value: bool) -> None: + if not value or ctx.resilient_parsing: + return + + nonlocal prog_name + nonlocal version + + if prog_name is None: + prog_name = ctx.find_root().info_name + + if version is None and package_name is not None: + metadata: t.Optional[types.ModuleType] + + try: + from importlib import metadata # type: ignore + except ImportError: + # Python < 3.8 + import importlib_metadata as metadata # type: ignore + + try: + version = metadata.version(package_name) # type: ignore + except metadata.PackageNotFoundError: # type: ignore + raise RuntimeError( + f"{package_name!r} is not installed. Try passing" + " 'package_name' instead." + ) from None + + if version is None: + raise RuntimeError( + f"Could not determine the version for {package_name!r} automatically." + ) + + echo( + t.cast(str, message) + % {"prog": prog_name, "package": package_name, "version": version}, + color=ctx.color, + ) + ctx.exit() + + if not param_decls: + param_decls = ("--version",) + + kwargs.setdefault("is_flag", True) + kwargs.setdefault("expose_value", False) + kwargs.setdefault("is_eager", True) + kwargs.setdefault("help", _("Show the version and exit.")) + kwargs["callback"] = callback + return option(*param_decls, **kwargs) + + +def help_option(*param_decls: str, **kwargs: t.Any) -> t.Callable[[FC], FC]: + """Add a ``--help`` option which immediately prints the help page + and exits the program. + + This is usually unnecessary, as the ``--help`` option is added to + each command automatically unless ``add_help_option=False`` is + passed. + + :param param_decls: One or more option names. Defaults to the single + value ``"--help"``. + :param kwargs: Extra arguments are passed to :func:`option`. """ - def decorator(f): - attrs.setdefault("prompt", True) - attrs.setdefault("confirmation_prompt", True) - attrs.setdefault("hide_input", True) - return option(*(param_decls or ("--password",)), **attrs)(f) + def callback(ctx: Context, param: Parameter, value: bool) -> None: + if not value or ctx.resilient_parsing: + return - return decorator + echo(ctx.get_help(), color=ctx.color) + ctx.exit() + if not param_decls: + param_decls = ("--help",) -def version_option(version=None, *param_decls, **attrs): - """Adds a ``--version`` option which immediately ends the program - printing out the version number. This is implemented as an eager - option that prints the version and exits the program in the callback. - - :param version: the version number to show. If not provided Click - attempts an auto discovery via setuptools. - :param prog_name: the name of the program (defaults to autodetection) - :param message: custom message to show instead of the default - (``'%(prog)s, version %(version)s'``) - :param others: everything else is forwarded to :func:`option`. - """ - if version is None: - if hasattr(sys, "_getframe"): - module = sys._getframe(1).f_globals.get("__name__") - else: - module = "" - - def decorator(f): - prog_name = attrs.pop("prog_name", None) - message = attrs.pop("message", "%(prog)s, version %(version)s") - - def callback(ctx, param, value): - if not value or ctx.resilient_parsing: - return - prog = prog_name - if prog is None: - prog = ctx.find_root().info_name - ver = version - if ver is None: - try: - import pkg_resources - except ImportError: - pass - else: - for dist in pkg_resources.working_set: - scripts = dist.get_entry_map().get("console_scripts") or {} - for _, entry_point in iteritems(scripts): - if entry_point.module_name == module: - ver = dist.version - break - if ver is None: - raise RuntimeError("Could not determine version") - echo(message % {"prog": prog, "version": ver}, color=ctx.color) - ctx.exit() - - attrs.setdefault("is_flag", True) - attrs.setdefault("expose_value", False) - attrs.setdefault("is_eager", True) - attrs.setdefault("help", "Show the version and exit.") - attrs["callback"] = callback - return option(*(param_decls or ("--version",)), **attrs)(f) - - return decorator - - -def help_option(*param_decls, **attrs): - """Adds a ``--help`` option which immediately ends the program - printing out the help page. This is usually unnecessary to add as - this is added by default to all commands unless suppressed. - - Like :func:`version_option`, this is implemented as eager option that - prints in the callback and exits. - - All arguments are forwarded to :func:`option`. - """ - - def decorator(f): - def callback(ctx, param, value): - if value and not ctx.resilient_parsing: - echo(ctx.get_help(), color=ctx.color) - ctx.exit() - - attrs.setdefault("is_flag", True) - attrs.setdefault("expose_value", False) - attrs.setdefault("help", "Show this message and exit.") - attrs.setdefault("is_eager", True) - attrs["callback"] = callback - return option(*(param_decls or ("--help",)), **attrs)(f) - - return decorator + kwargs.setdefault("is_flag", True) + kwargs.setdefault("expose_value", False) + kwargs.setdefault("is_eager", True) + kwargs.setdefault("help", _("Show this message and exit.")) + kwargs["callback"] = callback + return option(*param_decls, **kwargs) diff --git a/src/click/exceptions.py b/src/click/exceptions.py index 592ee38..9e20b3e 100644 --- a/src/click/exceptions.py +++ b/src/click/exceptions.py @@ -1,45 +1,46 @@ -from ._compat import filename_to_ui +import os +import typing as t +from gettext import gettext as _ +from gettext import ngettext + from ._compat import get_text_stderr -from ._compat import PY2 from .utils import echo +if t.TYPE_CHECKING: + from .core import Context + from .core import Parameter -def _join_param_hints(param_hint): - if isinstance(param_hint, (tuple, list)): + +def _join_param_hints( + param_hint: t.Optional[t.Union[t.Sequence[str], str]] +) -> t.Optional[str]: + if param_hint is not None and not isinstance(param_hint, str): return " / ".join(repr(x) for x in param_hint) + return param_hint class ClickException(Exception): """An exception that Click can handle and show to the user.""" - #: The exit code for this exception + #: The exit code for this exception. exit_code = 1 - def __init__(self, message): - ctor_msg = message - if PY2: - if ctor_msg is not None: - ctor_msg = ctor_msg.encode("utf-8") - Exception.__init__(self, ctor_msg) + def __init__(self, message: str) -> None: + super().__init__(message) self.message = message - def format_message(self): + def format_message(self) -> str: return self.message - def __str__(self): + def __str__(self) -> str: return self.message - if PY2: - __unicode__ = __str__ - - def __str__(self): - return self.message.encode("utf-8") - - def show(self, file=None): + def show(self, file: t.Optional[t.IO] = None) -> None: if file is None: file = get_text_stderr() - echo("Error: {}".format(self.format_message()), file=file) + + echo(_("Error: {message}").format(message=self.format_message()), file=file) class UsageError(ClickException): @@ -53,24 +54,32 @@ class UsageError(ClickException): exit_code = 2 - def __init__(self, message, ctx=None): - ClickException.__init__(self, message) + def __init__(self, message: str, ctx: t.Optional["Context"] = None) -> None: + super().__init__(message) self.ctx = ctx self.cmd = self.ctx.command if self.ctx else None - def show(self, file=None): + def show(self, file: t.Optional[t.IO] = None) -> None: if file is None: file = get_text_stderr() color = None hint = "" - if self.cmd is not None and self.cmd.get_help_option(self.ctx) is not None: - hint = "Try '{} {}' for help.\n".format( - self.ctx.command_path, self.ctx.help_option_names[0] + if ( + self.ctx is not None + and self.ctx.command.get_help_option(self.ctx) is not None + ): + hint = _("Try '{command} {option}' for help.").format( + command=self.ctx.command_path, option=self.ctx.help_option_names[0] ) + hint = f"{hint}\n" if self.ctx is not None: color = self.ctx.color - echo("{}\n{}".format(self.ctx.get_usage(), hint), file=file, color=color) - echo("Error: {}".format(self.format_message()), file=file, color=color) + echo(f"{self.ctx.get_usage()}\n{hint}", file=file, color=color) + echo( + _("Error: {message}").format(message=self.format_message()), + file=file, + color=color, + ) class BadParameter(UsageError): @@ -91,21 +100,28 @@ class BadParameter(UsageError): each item is quoted and separated. """ - def __init__(self, message, ctx=None, param=None, param_hint=None): - UsageError.__init__(self, message, ctx) + def __init__( + self, + message: str, + ctx: t.Optional["Context"] = None, + param: t.Optional["Parameter"] = None, + param_hint: t.Optional[str] = None, + ) -> None: + super().__init__(message, ctx) self.param = param self.param_hint = param_hint - def format_message(self): + def format_message(self) -> str: if self.param_hint is not None: param_hint = self.param_hint elif self.param is not None: - param_hint = self.param.get_error_hint(self.ctx) + param_hint = self.param.get_error_hint(self.ctx) # type: ignore else: - return "Invalid value: {}".format(self.message) - param_hint = _join_param_hints(param_hint) + return _("Invalid value: {message}").format(message=self.message) - return "Invalid value for {}: {}".format(param_hint, self.message) + return _("Invalid value for {param_hint}: {message}").format( + param_hint=_join_param_hints(param_hint), message=self.message + ) class MissingParameter(BadParameter): @@ -121,19 +137,26 @@ class MissingParameter(BadParameter): """ def __init__( - self, message=None, ctx=None, param=None, param_hint=None, param_type=None - ): - BadParameter.__init__(self, message, ctx, param, param_hint) + self, + message: t.Optional[str] = None, + ctx: t.Optional["Context"] = None, + param: t.Optional["Parameter"] = None, + param_hint: t.Optional[str] = None, + param_type: t.Optional[str] = None, + ) -> None: + super().__init__(message or "", ctx, param, param_hint) self.param_type = param_type - def format_message(self): + def format_message(self) -> str: if self.param_hint is not None: - param_hint = self.param_hint + param_hint: t.Optional[str] = self.param_hint elif self.param is not None: - param_hint = self.param.get_error_hint(self.ctx) + param_hint = self.param.get_error_hint(self.ctx) # type: ignore else: param_hint = None + param_hint = _join_param_hints(param_hint) + param_hint = f" {param_hint}" if param_hint else "" param_type = self.param_type if param_type is None and self.param is not None: @@ -144,30 +167,31 @@ class MissingParameter(BadParameter): msg_extra = self.param.type.get_missing_message(self.param) if msg_extra: if msg: - msg += ". {}".format(msg_extra) + msg += f". {msg_extra}" else: msg = msg_extra - return "Missing {}{}{}{}".format( - param_type, - " {}".format(param_hint) if param_hint else "", - ". " if msg else ".", - msg or "", - ) + msg = f" {msg}" if msg else "" - def __str__(self): - if self.message is None: + # Translate param_type for known types. + if param_type == "argument": + missing = _("Missing argument") + elif param_type == "option": + missing = _("Missing option") + elif param_type == "parameter": + missing = _("Missing parameter") + else: + missing = _("Missing {param_type}").format(param_type=param_type) + + return f"{missing}{param_hint}.{msg}" + + def __str__(self) -> str: + if not self.message: param_name = self.param.name if self.param else None - return "missing parameter: {}".format(param_name) + return _("Missing parameter: {param_name}").format(param_name=param_name) else: return self.message - if PY2: - __unicode__ = __str__ - - def __str__(self): - return self.__unicode__().encode("utf-8") - class NoSuchOption(UsageError): """Raised if click attempted to handle an option that does not @@ -176,22 +200,31 @@ class NoSuchOption(UsageError): .. versionadded:: 4.0 """ - def __init__(self, option_name, message=None, possibilities=None, ctx=None): + def __init__( + self, + option_name: str, + message: t.Optional[str] = None, + possibilities: t.Optional[t.Sequence[str]] = None, + ctx: t.Optional["Context"] = None, + ) -> None: if message is None: - message = "no such option: {}".format(option_name) - UsageError.__init__(self, message, ctx) + message = _("No such option: {name}").format(name=option_name) + + super().__init__(message, ctx) self.option_name = option_name self.possibilities = possibilities - def format_message(self): - bits = [self.message] - if self.possibilities: - if len(self.possibilities) == 1: - bits.append("Did you mean {}?".format(self.possibilities[0])) - else: - possibilities = sorted(self.possibilities) - bits.append("(Possible options: {})".format(", ".join(possibilities))) - return " ".join(bits) + def format_message(self) -> str: + if not self.possibilities: + return self.message + + possibility_str = ", ".join(sorted(self.possibilities)) + suggest = ngettext( + "Did you mean {possibility}?", + "(Possible options: {possibilities})", + len(self.possibilities), + ).format(possibility=possibility_str, possibilities=possibility_str) + return f"{self.message} {suggest}" class BadOptionUsage(UsageError): @@ -204,8 +237,10 @@ class BadOptionUsage(UsageError): :param option_name: the name of the option being used incorrectly. """ - def __init__(self, option_name, message, ctx=None): - UsageError.__init__(self, message, ctx) + def __init__( + self, option_name: str, message: str, ctx: t.Optional["Context"] = None + ) -> None: + super().__init__(message, ctx) self.option_name = option_name @@ -217,23 +252,22 @@ class BadArgumentUsage(UsageError): .. versionadded:: 6.0 """ - def __init__(self, message, ctx=None): - UsageError.__init__(self, message, ctx) - class FileError(ClickException): """Raised if a file cannot be opened.""" - def __init__(self, filename, hint=None): - ui_filename = filename_to_ui(filename) + def __init__(self, filename: str, hint: t.Optional[str] = None) -> None: if hint is None: - hint = "unknown error" - ClickException.__init__(self, hint) - self.ui_filename = ui_filename + hint = _("unknown error") + + super().__init__(hint) + self.ui_filename = os.fsdecode(filename) self.filename = filename - def format_message(self): - return "Could not open file {}: {}".format(self.ui_filename, self.message) + def format_message(self) -> str: + return _("Could not open file {filename!r}: {message}").format( + filename=self.ui_filename, message=self.message + ) class Abort(RuntimeError): @@ -249,5 +283,5 @@ class Exit(RuntimeError): __slots__ = ("exit_code",) - def __init__(self, code=0): + def __init__(self, code: int = 0) -> None: self.exit_code = code diff --git a/src/click/formatting.py b/src/click/formatting.py index 319c7f6..ddd2a2f 100644 --- a/src/click/formatting.py +++ b/src/click/formatting.py @@ -1,30 +1,38 @@ +import typing as t from contextlib import contextmanager +from gettext import gettext as _ from ._compat import term_len from .parser import split_opt -from .termui import get_terminal_size # Can force a width. This is used by the test system -FORCED_WIDTH = None +FORCED_WIDTH: t.Optional[int] = None -def measure_table(rows): - widths = {} +def measure_table(rows: t.Iterable[t.Tuple[str, str]]) -> t.Tuple[int, ...]: + widths: t.Dict[int, int] = {} + for row in rows: for idx, col in enumerate(row): widths[idx] = max(widths.get(idx, 0), term_len(col)) + return tuple(y for x, y in sorted(widths.items())) -def iter_rows(rows, col_count): +def iter_rows( + rows: t.Iterable[t.Tuple[str, str]], col_count: int +) -> t.Iterator[t.Tuple[str, ...]]: for row in rows: - row = tuple(row) yield row + ("",) * (col_count - len(row)) def wrap_text( - text, width=78, initial_indent="", subsequent_indent="", preserve_paragraphs=False -): + text: str, + width: int = 78, + initial_indent: str = "", + subsequent_indent: str = "", + preserve_paragraphs: bool = False, +) -> str: """A helper function that intelligently wraps text. By default, it assumes that it operates on a single paragraph of text but if the `preserve_paragraphs` parameter is provided it will intelligently @@ -55,11 +63,11 @@ def wrap_text( if not preserve_paragraphs: return wrapper.fill(text) - p = [] - buf = [] + p: t.List[t.Tuple[int, bool, str]] = [] + buf: t.List[str] = [] indent = None - def _flush_par(): + def _flush_par() -> None: if not buf: return if buf[0].strip() == "\b": @@ -91,7 +99,7 @@ def wrap_text( return "\n\n".join(rv) -class HelpFormatter(object): +class HelpFormatter: """This class helps with formatting text-based help pages. It's usually just needed for very special internal cases, but it's also exposed so that developers can write their own fancy outputs. @@ -103,38 +111,51 @@ class HelpFormatter(object): width clamped to a maximum of 78. """ - def __init__(self, indent_increment=2, width=None, max_width=None): + def __init__( + self, + indent_increment: int = 2, + width: t.Optional[int] = None, + max_width: t.Optional[int] = None, + ) -> None: + import shutil + self.indent_increment = indent_increment if max_width is None: max_width = 80 if width is None: width = FORCED_WIDTH if width is None: - width = max(min(get_terminal_size()[0], max_width) - 2, 50) + width = max(min(shutil.get_terminal_size().columns, max_width) - 2, 50) self.width = width self.current_indent = 0 - self.buffer = [] + self.buffer: t.List[str] = [] - def write(self, string): + def write(self, string: str) -> None: """Writes a unicode string into the internal buffer.""" self.buffer.append(string) - def indent(self): + def indent(self) -> None: """Increases the indentation.""" self.current_indent += self.indent_increment - def dedent(self): + def dedent(self) -> None: """Decreases the indentation.""" self.current_indent -= self.indent_increment - def write_usage(self, prog, args="", prefix="Usage: "): + def write_usage( + self, prog: str, args: str = "", prefix: t.Optional[str] = None + ) -> None: """Writes a usage line into the buffer. :param prog: the program name. :param args: whitespace separated list of arguments. - :param prefix: the prefix for the first line. + :param prefix: The prefix for the first line. Defaults to + ``"Usage: "``. """ - usage_prefix = "{:>{w}}{} ".format(prefix, prog, w=self.current_indent) + if prefix is None: + prefix = f"{_('Usage:')} " + + usage_prefix = f"{prefix:>{self.current_indent}}{prog} " text_width = self.width - self.current_indent if text_width >= (term_len(usage_prefix) + 20): @@ -161,25 +182,24 @@ class HelpFormatter(object): self.write("\n") - def write_heading(self, heading): + def write_heading(self, heading: str) -> None: """Writes a heading into the buffer.""" - self.write("{:>{w}}{}:\n".format("", heading, w=self.current_indent)) + self.write(f"{'':>{self.current_indent}}{heading}:\n") - def write_paragraph(self): + def write_paragraph(self) -> None: """Writes a paragraph into the buffer.""" if self.buffer: self.write("\n") - def write_text(self, text): + def write_text(self, text: str) -> None: """Writes re-indented text into the buffer. This rewraps and preserves paragraphs. """ - text_width = max(self.width - self.current_indent, 11) indent = " " * self.current_indent self.write( wrap_text( text, - text_width, + self.width, initial_indent=indent, subsequent_indent=indent, preserve_paragraphs=True, @@ -187,7 +207,12 @@ class HelpFormatter(object): ) self.write("\n") - def write_dl(self, rows, col_max=30, col_spacing=2): + def write_dl( + self, + rows: t.Sequence[t.Tuple[str, str]], + col_max: int = 30, + col_spacing: int = 2, + ) -> None: """Writes a definition list into the buffer. This is how options and commands are usually formatted. @@ -204,7 +229,7 @@ class HelpFormatter(object): first_col = min(widths[0], col_max) + col_spacing for first, second in iter_rows(rows, len(widths)): - self.write("{:>{w}}{}".format("", first, w=self.current_indent)) + self.write(f"{'':>{self.current_indent}}{first}") if not second: self.write("\n") continue @@ -219,23 +244,15 @@ class HelpFormatter(object): lines = wrapped_text.splitlines() if lines: - self.write("{}\n".format(lines[0])) + self.write(f"{lines[0]}\n") for line in lines[1:]: - self.write( - "{:>{w}}{}\n".format( - "", line, w=first_col + self.current_indent - ) - ) - - if len(lines) > 1: - # separate long help from next option - self.write("\n") + self.write(f"{'':>{first_col + self.current_indent}}{line}\n") else: self.write("\n") @contextmanager - def section(self, name): + def section(self, name: str) -> t.Iterator[None]: """Helpful context manager that writes a paragraph, a heading, and the indents. @@ -250,7 +267,7 @@ class HelpFormatter(object): self.dedent() @contextmanager - def indentation(self): + def indentation(self) -> t.Iterator[None]: """A context manager that increases the indentation.""" self.indent() try: @@ -258,12 +275,12 @@ class HelpFormatter(object): finally: self.dedent() - def getvalue(self): + def getvalue(self) -> str: """Returns the buffer contents.""" return "".join(self.buffer) -def join_options(options): +def join_options(options: t.Sequence[str]) -> t.Tuple[str, bool]: """Given a list of option strings this joins them in the most appropriate way and returns them in the form ``(formatted_string, any_prefix_is_slash)`` where the second item in the tuple is a flag that @@ -271,13 +288,14 @@ def join_options(options): """ rv = [] any_prefix_is_slash = False + for opt in options: prefix = split_opt(opt)[0] + if prefix == "/": any_prefix_is_slash = True + rv.append((len(prefix), opt)) rv.sort(key=lambda x: x[0]) - - rv = ", ".join(x[1] for x in rv) - return rv, any_prefix_is_slash + return ", ".join(x[1] for x in rv), any_prefix_is_slash diff --git a/src/click/globals.py b/src/click/globals.py index 1649f9a..a7b0c93 100644 --- a/src/click/globals.py +++ b/src/click/globals.py @@ -1,9 +1,25 @@ +import typing +import typing as t from threading import local +if t.TYPE_CHECKING: + import typing_extensions as te + from .core import Context + _local = local() -def get_current_context(silent=False): +@typing.overload +def get_current_context(silent: "te.Literal[False]" = False) -> "Context": + ... + + +@typing.overload +def get_current_context(silent: bool = ...) -> t.Optional["Context"]: + ... + + +def get_current_context(silent: bool = False) -> t.Optional["Context"]: """Returns the current click context. This can be used as a way to access the current context object from anywhere. This is a more implicit alternative to the :func:`pass_context` decorator. This function is @@ -19,29 +35,35 @@ def get_current_context(silent=False): :exc:`RuntimeError`. """ try: - return _local.stack[-1] - except (AttributeError, IndexError): + return t.cast("Context", _local.stack[-1]) + except (AttributeError, IndexError) as e: if not silent: - raise RuntimeError("There is no active click context.") + raise RuntimeError("There is no active click context.") from e + + return None -def push_context(ctx): +def push_context(ctx: "Context") -> None: """Pushes a new context to the current stack.""" _local.__dict__.setdefault("stack", []).append(ctx) -def pop_context(): +def pop_context() -> None: """Removes the top level from the stack.""" _local.stack.pop() -def resolve_color_default(color=None): - """"Internal helper to get the default value of the color flag. If a +def resolve_color_default(color: t.Optional[bool] = None) -> t.Optional[bool]: + """Internal helper to get the default value of the color flag. If a value is passed it's returned unchanged, otherwise it's looked up from the current context. """ if color is not None: return color + ctx = get_current_context(silent=True) + if ctx is not None: return ctx.color + + return None diff --git a/src/click/parser.py b/src/click/parser.py index f43ebfe..2d5a2ed 100644 --- a/src/click/parser.py +++ b/src/click/parser.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ This module started out as largely a copy paste from the stdlib's optparse module with the features removed that we do not need from @@ -18,16 +17,38 @@ by the Python Software Foundation. This is limited to code in parser.py. Copyright 2001-2006 Gregory P. Ward. All rights reserved. Copyright 2002-2006 Python Software Foundation. All rights reserved. """ -import re +# This code uses parts of optparse written by Gregory P. Ward and +# maintained by the Python Software Foundation. +# Copyright 2001-2006 Gregory P. Ward +# Copyright 2002-2006 Python Software Foundation +import typing as t from collections import deque +from gettext import gettext as _ +from gettext import ngettext from .exceptions import BadArgumentUsage from .exceptions import BadOptionUsage from .exceptions import NoSuchOption from .exceptions import UsageError +if t.TYPE_CHECKING: + import typing_extensions as te + from .core import Argument as CoreArgument + from .core import Context + from .core import Option as CoreOption + from .core import Parameter as CoreParameter -def _unpack_args(args, nargs_spec): +V = t.TypeVar("V") + +# Sentinel value that indicates an option was passed as a flag without a +# value but is not a flag option. Option.consume_value uses this to +# prompt or use the flag_value. +_flag_needs_value = object() + + +def _unpack_args( + args: t.Sequence[str], nargs_spec: t.Sequence[int] +) -> t.Tuple[t.Sequence[t.Union[str, t.Sequence[t.Optional[str]], None]], t.List[str]]: """Given an iterable of arguments and an iterable of nargs specifications, it returns a tuple with all the unpacked arguments at the first index and all remaining arguments as the second. @@ -39,10 +60,10 @@ def _unpack_args(args, nargs_spec): """ args = deque(args) nargs_spec = deque(nargs_spec) - rv = [] - spos = None + rv: t.List[t.Union[str, t.Tuple[t.Optional[str], ...], None]] = [] + spos: t.Optional[int] = None - def _fetch(c): + def _fetch(c: "te.Deque[V]") -> t.Optional[V]: try: if spos is None: return c.popleft() @@ -53,18 +74,25 @@ def _unpack_args(args, nargs_spec): while nargs_spec: nargs = _fetch(nargs_spec) + + if nargs is None: + continue + if nargs == 1: rv.append(_fetch(args)) elif nargs > 1: x = [_fetch(args) for _ in range(nargs)] + # If we're reversed, we're pulling in the arguments in reverse, # so we need to turn them around. if spos is not None: x.reverse() + rv.append(tuple(x)) elif nargs < 0: if spos is not None: raise TypeError("Cannot have two nargs < 0") + spos = len(rv) rv.append(None) @@ -78,13 +106,7 @@ def _unpack_args(args, nargs_spec): return tuple(rv), list(args) -def _error_opt_args(nargs, opt): - if nargs == 1: - raise BadOptionUsage(opt, "{} option requires an argument".format(opt)) - raise BadOptionUsage(opt, "{} option requires {} arguments".format(opt, nargs)) - - -def split_opt(opt): +def split_opt(opt: str) -> t.Tuple[str, str]: first = opt[:1] if first.isalnum(): return "", opt @@ -93,34 +115,57 @@ def split_opt(opt): return first, opt[1:] -def normalize_opt(opt, ctx): +def normalize_opt(opt: str, ctx: t.Optional["Context"]) -> str: if ctx is None or ctx.token_normalize_func is None: return opt prefix, opt = split_opt(opt) - return prefix + ctx.token_normalize_func(opt) + return f"{prefix}{ctx.token_normalize_func(opt)}" -def split_arg_string(string): - """Given an argument string this attempts to split it into small parts.""" - rv = [] - for match in re.finditer( - r"('([^'\\]*(?:\\.[^'\\]*)*)'|\"([^\"\\]*(?:\\.[^\"\\]*)*)\"|\S+)\s*", - string, - re.S, +def split_arg_string(string: str) -> t.List[str]: + """Split an argument string as with :func:`shlex.split`, but don't + fail if the string is incomplete. Ignores a missing closing quote or + incomplete escape sequence and uses the partial token as-is. + + .. code-block:: python + + split_arg_string("example 'my file") + ["example", "my file"] + + split_arg_string("example my\\") + ["example", "my"] + + :param string: String to split. + """ + import shlex + + lex = shlex.shlex(string, posix=True) + lex.whitespace_split = True + lex.commenters = "" + out = [] + + try: + for token in lex: + out.append(token) + except ValueError: + # Raised when end-of-string is reached in an invalid state. Use + # the partial token as-is. The quote or escape character is in + # lex.state, not lex.token. + out.append(lex.token) + + return out + + +class Option: + def __init__( + self, + obj: "CoreOption", + opts: t.Sequence[str], + dest: t.Optional[str], + action: t.Optional[str] = None, + nargs: int = 1, + const: t.Optional[t.Any] = None, ): - arg = match.group().strip() - if arg[:1] == arg[-1:] and arg[:1] in "\"'": - arg = arg[1:-1].encode("ascii", "backslashreplace").decode("unicode-escape") - try: - arg = type(string)(arg) - except UnicodeError: - pass - rv.append(arg) - return rv - - -class Option(object): - def __init__(self, opts, dest, action=None, nargs=1, const=None, obj=None): self._short_opts = [] self._long_opts = [] self.prefixes = set() @@ -128,7 +173,7 @@ class Option(object): for opt in opts: prefix, value = split_opt(opt) if not prefix: - raise ValueError("Invalid start character for option ({})".format(opt)) + raise ValueError(f"Invalid start character for option ({opt})") self.prefixes.add(prefix[0]) if len(prefix) == 1 and len(value) == 1: self._short_opts.append(opt) @@ -146,53 +191,66 @@ class Option(object): self.obj = obj @property - def takes_value(self): + def takes_value(self) -> bool: return self.action in ("store", "append") - def process(self, value, state): + def process(self, value: str, state: "ParsingState") -> None: if self.action == "store": - state.opts[self.dest] = value + state.opts[self.dest] = value # type: ignore elif self.action == "store_const": - state.opts[self.dest] = self.const + state.opts[self.dest] = self.const # type: ignore elif self.action == "append": - state.opts.setdefault(self.dest, []).append(value) + state.opts.setdefault(self.dest, []).append(value) # type: ignore elif self.action == "append_const": - state.opts.setdefault(self.dest, []).append(self.const) + state.opts.setdefault(self.dest, []).append(self.const) # type: ignore elif self.action == "count": - state.opts[self.dest] = state.opts.get(self.dest, 0) + 1 + state.opts[self.dest] = state.opts.get(self.dest, 0) + 1 # type: ignore else: - raise ValueError("unknown action '{}'".format(self.action)) + raise ValueError(f"unknown action '{self.action}'") state.order.append(self.obj) -class Argument(object): - def __init__(self, dest, nargs=1, obj=None): +class Argument: + def __init__(self, obj: "CoreArgument", dest: t.Optional[str], nargs: int = 1): self.dest = dest self.nargs = nargs self.obj = obj - def process(self, value, state): + def process( + self, + value: t.Union[t.Optional[str], t.Sequence[t.Optional[str]]], + state: "ParsingState", + ) -> None: if self.nargs > 1: + assert value is not None holes = sum(1 for x in value if x is None) if holes == len(value): value = None elif holes != 0: raise BadArgumentUsage( - "argument {} takes {} values".format(self.dest, self.nargs) + _("Argument {name!r} takes {nargs} values.").format( + name=self.dest, nargs=self.nargs + ) ) - state.opts[self.dest] = value + + if self.nargs == -1 and self.obj.envvar is not None and value == (): + # Replace empty tuple with None so that a value from the + # environment may be tried. + value = None + + state.opts[self.dest] = value # type: ignore state.order.append(self.obj) -class ParsingState(object): - def __init__(self, rargs): - self.opts = {} - self.largs = [] +class ParsingState: + def __init__(self, rargs: t.List[str]) -> None: + self.opts: t.Dict[str, t.Any] = {} + self.largs: t.List[str] = [] self.rargs = rargs - self.order = [] + self.order: t.List["CoreParameter"] = [] -class OptionParser(object): +class OptionParser: """The option parser is an internal class that is ultimately used to parse options and arguments. It's modelled after optparse and brings a similar but vastly simplified API. It should generally not be used @@ -206,7 +264,7 @@ class OptionParser(object): should go with. """ - def __init__(self, ctx=None): + def __init__(self, ctx: t.Optional["Context"] = None) -> None: #: The :class:`~click.Context` for this parser. This might be #: `None` for some advanced use cases. self.ctx = ctx @@ -220,44 +278,54 @@ class OptionParser(object): #: second mode where it will ignore it and continue processing #: after shifting all the unknown options into the resulting args. self.ignore_unknown_options = False + if ctx is not None: self.allow_interspersed_args = ctx.allow_interspersed_args self.ignore_unknown_options = ctx.ignore_unknown_options - self._short_opt = {} - self._long_opt = {} - self._opt_prefixes = {"-", "--"} - self._args = [] - def add_option(self, opts, dest, action=None, nargs=1, const=None, obj=None): + self._short_opt: t.Dict[str, Option] = {} + self._long_opt: t.Dict[str, Option] = {} + self._opt_prefixes = {"-", "--"} + self._args: t.List[Argument] = [] + + def add_option( + self, + obj: "CoreOption", + opts: t.Sequence[str], + dest: t.Optional[str], + action: t.Optional[str] = None, + nargs: int = 1, + const: t.Optional[t.Any] = None, + ) -> None: """Adds a new option named `dest` to the parser. The destination is not inferred (unlike with optparse) and needs to be explicitly provided. Action can be any of ``store``, ``store_const``, - ``append``, ``appnd_const`` or ``count``. + ``append``, ``append_const`` or ``count``. The `obj` can be used to identify the option in the order list that is returned from the parser. """ - if obj is None: - obj = dest opts = [normalize_opt(opt, self.ctx) for opt in opts] - option = Option(opts, dest, action=action, nargs=nargs, const=const, obj=obj) + option = Option(obj, opts, dest, action=action, nargs=nargs, const=const) self._opt_prefixes.update(option.prefixes) for opt in option._short_opts: self._short_opt[opt] = option for opt in option._long_opts: self._long_opt[opt] = option - def add_argument(self, dest, nargs=1, obj=None): + def add_argument( + self, obj: "CoreArgument", dest: t.Optional[str], nargs: int = 1 + ) -> None: """Adds a positional argument named `dest` to the parser. The `obj` can be used to identify the option in the order list that is returned from the parser. """ - if obj is None: - obj = dest - self._args.append(Argument(dest=dest, nargs=nargs, obj=obj)) + self._args.append(Argument(obj, dest=dest, nargs=nargs)) - def parse_args(self, args): + def parse_args( + self, args: t.List[str] + ) -> t.Tuple[t.Dict[str, t.Any], t.List[str], t.List["CoreParameter"]]: """Parses positional arguments and returns ``(values, args, order)`` for the parsed options and arguments as well as the leftover arguments if there are any. The order is a list of objects as they @@ -273,7 +341,7 @@ class OptionParser(object): raise return state.opts, state.largs, state.order - def _process_args_for_args(self, state): + def _process_args_for_args(self, state: ParsingState) -> None: pargs, args = _unpack_args( state.largs + state.rargs, [x.nargs for x in self._args] ) @@ -284,7 +352,7 @@ class OptionParser(object): state.largs = args state.rargs = [] - def _process_args_for_options(self, state): + def _process_args_for_options(self, state: ParsingState) -> None: while state.rargs: arg = state.rargs.pop(0) arglen = len(arg) @@ -320,9 +388,13 @@ class OptionParser(object): # *empty* -- still a subset of [arg0, ..., arg(i-1)], but # not a very interesting subset! - def _match_long_opt(self, opt, explicit_value, state): + def _match_long_opt( + self, opt: str, explicit_value: t.Optional[str], state: ParsingState + ) -> None: if opt not in self._long_opt: - possibilities = [word for word in self._long_opt if word.startswith(opt)] + from difflib import get_close_matches + + possibilities = get_close_matches(opt, self._long_opt) raise NoSuchOption(opt, possibilities=possibilities, ctx=self.ctx) option = self._long_opt[opt] @@ -334,31 +406,26 @@ class OptionParser(object): if explicit_value is not None: state.rargs.insert(0, explicit_value) - nargs = option.nargs - if len(state.rargs) < nargs: - _error_opt_args(nargs, opt) - elif nargs == 1: - value = state.rargs.pop(0) - else: - value = tuple(state.rargs[:nargs]) - del state.rargs[:nargs] + value = self._get_value_from_state(opt, option, state) elif explicit_value is not None: - raise BadOptionUsage(opt, "{} option does not take a value".format(opt)) + raise BadOptionUsage( + opt, _("Option {name!r} does not take a value.").format(name=opt) + ) else: value = None option.process(value, state) - def _match_short_opt(self, arg, state): + def _match_short_opt(self, arg: str, state: ParsingState) -> None: stop = False i = 1 prefix = arg[0] unknown_options = [] for ch in arg[1:]: - opt = normalize_opt(prefix + ch, self.ctx) + opt = normalize_opt(f"{prefix}{ch}", self.ctx) option = self._short_opt.get(opt) i += 1 @@ -374,14 +441,7 @@ class OptionParser(object): state.rargs.insert(0, arg[i:]) stop = True - nargs = option.nargs - if len(state.rargs) < nargs: - _error_opt_args(nargs, opt) - elif nargs == 1: - value = state.rargs.pop(0) - else: - value = tuple(state.rargs[:nargs]) - del state.rargs[:nargs] + value = self._get_value_from_state(opt, option, state) else: value = None @@ -396,9 +456,47 @@ class OptionParser(object): # to the state as new larg. This way there is basic combinatorics # that can be achieved while still ignoring unknown arguments. if self.ignore_unknown_options and unknown_options: - state.largs.append("{}{}".format(prefix, "".join(unknown_options))) + state.largs.append(f"{prefix}{''.join(unknown_options)}") - def _process_opts(self, arg, state): + def _get_value_from_state( + self, option_name: str, option: Option, state: ParsingState + ) -> t.Any: + nargs = option.nargs + + if len(state.rargs) < nargs: + if option.obj._flag_needs_value: + # Option allows omitting the value. + value = _flag_needs_value + else: + raise BadOptionUsage( + option_name, + ngettext( + "Option {name!r} requires an argument.", + "Option {name!r} requires {nargs} arguments.", + nargs, + ).format(name=option_name, nargs=nargs), + ) + elif nargs == 1: + next_rarg = state.rargs[0] + + if ( + option.obj._flag_needs_value + and isinstance(next_rarg, str) + and next_rarg[:1] in self._opt_prefixes + and len(next_rarg) > 1 + ): + # The next arg looks like the start of an option, don't + # use it as the value if omitting the value is allowed. + value = _flag_needs_value + else: + value = state.rargs.pop(0) + else: + value = tuple(state.rargs[:nargs]) + del state.rargs[:nargs] + + return value + + def _process_opts(self, arg: str, state: ParsingState) -> None: explicit_value = None # Long option handling happens in two parts. The first part is # supporting explicitly attached values. In any case, we will try @@ -422,7 +520,10 @@ class OptionParser(object): # short option code and will instead raise the no option # error. if arg[:2] not in self._opt_prefixes: - return self._match_short_opt(arg, state) + self._match_short_opt(arg, state) + return + if not self.ignore_unknown_options: raise + state.largs.append(arg) diff --git a/src/click/py.typed b/src/click/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/click/shell_completion.py b/src/click/shell_completion.py new file mode 100644 index 0000000..cad080d --- /dev/null +++ b/src/click/shell_completion.py @@ -0,0 +1,581 @@ +import os +import re +import typing as t +from gettext import gettext as _ + +from .core import Argument +from .core import BaseCommand +from .core import Context +from .core import MultiCommand +from .core import Option +from .core import Parameter +from .core import ParameterSource +from .parser import split_arg_string +from .utils import echo + + +def shell_complete( + cli: BaseCommand, + ctx_args: t.Dict[str, t.Any], + prog_name: str, + complete_var: str, + instruction: str, +) -> int: + """Perform shell completion for the given CLI program. + + :param cli: Command being called. + :param ctx_args: Extra arguments to pass to + ``cli.make_context``. + :param prog_name: Name of the executable in the shell. + :param complete_var: Name of the environment variable that holds + the completion instruction. + :param instruction: Value of ``complete_var`` with the completion + instruction and shell, in the form ``instruction_shell``. + :return: Status code to exit with. + """ + shell, _, instruction = instruction.partition("_") + comp_cls = get_completion_class(shell) + + if comp_cls is None: + return 1 + + comp = comp_cls(cli, ctx_args, prog_name, complete_var) + + if instruction == "source": + echo(comp.source()) + return 0 + + if instruction == "complete": + echo(comp.complete()) + return 0 + + return 1 + + +class CompletionItem: + """Represents a completion value and metadata about the value. The + default metadata is ``type`` to indicate special shell handling, + and ``help`` if a shell supports showing a help string next to the + value. + + Arbitrary parameters can be passed when creating the object, and + accessed using ``item.attr``. If an attribute wasn't passed, + accessing it returns ``None``. + + :param value: The completion suggestion. + :param type: Tells the shell script to provide special completion + support for the type. Click uses ``"dir"`` and ``"file"``. + :param help: String shown next to the value if supported. + :param kwargs: Arbitrary metadata. The built-in implementations + don't use this, but custom type completions paired with custom + shell support could use it. + """ + + __slots__ = ("value", "type", "help", "_info") + + def __init__( + self, + value: t.Any, + type: str = "plain", + help: t.Optional[str] = None, + **kwargs: t.Any, + ) -> None: + self.value = value + self.type = type + self.help = help + self._info = kwargs + + def __getattr__(self, name: str) -> t.Any: + return self._info.get(name) + + +# Only Bash >= 4.4 has the nosort option. +_SOURCE_BASH = """\ +%(complete_func)s() { + local IFS=$'\\n' + local response + + response=$(env COMP_WORDS="${COMP_WORDS[*]}" COMP_CWORD=$COMP_CWORD \ +%(complete_var)s=bash_complete $1) + + for completion in $response; do + IFS=',' read type value <<< "$completion" + + if [[ $type == 'dir' ]]; then + COMREPLY=() + compopt -o dirnames + elif [[ $type == 'file' ]]; then + COMREPLY=() + compopt -o default + elif [[ $type == 'plain' ]]; then + COMPREPLY+=($value) + fi + done + + return 0 +} + +%(complete_func)s_setup() { + complete -o nosort -F %(complete_func)s %(prog_name)s +} + +%(complete_func)s_setup; +""" + +_SOURCE_ZSH = """\ +#compdef %(prog_name)s + +%(complete_func)s() { + local -a completions + local -a completions_with_descriptions + local -a response + (( ! $+commands[%(prog_name)s] )) && return 1 + + response=("${(@f)$(env COMP_WORDS="${words[*]}" COMP_CWORD=$((CURRENT-1)) \ +%(complete_var)s=zsh_complete %(prog_name)s)}") + + for type key descr in ${response}; do + if [[ "$type" == "plain" ]]; then + if [[ "$descr" == "_" ]]; then + completions+=("$key") + else + completions_with_descriptions+=("$key":"$descr") + fi + elif [[ "$type" == "dir" ]]; then + _path_files -/ + elif [[ "$type" == "file" ]]; then + _path_files -f + fi + done + + if [ -n "$completions_with_descriptions" ]; then + _describe -V unsorted completions_with_descriptions -U + fi + + if [ -n "$completions" ]; then + compadd -U -V unsorted -a completions + fi +} + +compdef %(complete_func)s %(prog_name)s; +""" + +_SOURCE_FISH = """\ +function %(complete_func)s; + set -l response; + + for value in (env %(complete_var)s=fish_complete COMP_WORDS=(commandline -cp) \ +COMP_CWORD=(commandline -t) %(prog_name)s); + set response $response $value; + end; + + for completion in $response; + set -l metadata (string split "," $completion); + + if test $metadata[1] = "dir"; + __fish_complete_directories $metadata[2]; + else if test $metadata[1] = "file"; + __fish_complete_path $metadata[2]; + else if test $metadata[1] = "plain"; + echo $metadata[2]; + end; + end; +end; + +complete --no-files --command %(prog_name)s --arguments \ +"(%(complete_func)s)"; +""" + + +class ShellComplete: + """Base class for providing shell completion support. A subclass for + a given shell will override attributes and methods to implement the + completion instructions (``source`` and ``complete``). + + :param cli: Command being called. + :param prog_name: Name of the executable in the shell. + :param complete_var: Name of the environment variable that holds + the completion instruction. + + .. versionadded:: 8.0 + """ + + name: t.ClassVar[str] + """Name to register the shell as with :func:`add_completion_class`. + This is used in completion instructions (``{name}_source`` and + ``{name}_complete``). + """ + + source_template: t.ClassVar[str] + """Completion script template formatted by :meth:`source`. This must + be provided by subclasses. + """ + + def __init__( + self, + cli: BaseCommand, + ctx_args: t.Dict[str, t.Any], + prog_name: str, + complete_var: str, + ) -> None: + self.cli = cli + self.ctx_args = ctx_args + self.prog_name = prog_name + self.complete_var = complete_var + + @property + def func_name(self) -> str: + """The name of the shell function defined by the completion + script. + """ + safe_name = re.sub(r"\W*", "", self.prog_name.replace("-", "_"), re.ASCII) + return f"_{safe_name}_completion" + + def source_vars(self) -> t.Dict[str, t.Any]: + """Vars for formatting :attr:`source_template`. + + By default this provides ``complete_func``, ``complete_var``, + and ``prog_name``. + """ + return { + "complete_func": self.func_name, + "complete_var": self.complete_var, + "prog_name": self.prog_name, + } + + def source(self) -> str: + """Produce the shell script that defines the completion + function. By default this ``%``-style formats + :attr:`source_template` with the dict returned by + :meth:`source_vars`. + """ + return self.source_template % self.source_vars() + + def get_completion_args(self) -> t.Tuple[t.List[str], str]: + """Use the env vars defined by the shell script to return a + tuple of ``args, incomplete``. This must be implemented by + subclasses. + """ + raise NotImplementedError + + def get_completions( + self, args: t.List[str], incomplete: str + ) -> t.List[CompletionItem]: + """Determine the context and last complete command or parameter + from the complete args. Call that object's ``shell_complete`` + method to get the completions for the incomplete value. + + :param args: List of complete args before the incomplete value. + :param incomplete: Value being completed. May be empty. + """ + ctx = _resolve_context(self.cli, self.ctx_args, self.prog_name, args) + obj, incomplete = _resolve_incomplete(ctx, args, incomplete) + return obj.shell_complete(ctx, incomplete) + + def format_completion(self, item: CompletionItem) -> str: + """Format a completion item into the form recognized by the + shell script. This must be implemented by subclasses. + + :param item: Completion item to format. + """ + raise NotImplementedError + + def complete(self) -> str: + """Produce the completion data to send back to the shell. + + By default this calls :meth:`get_completion_args`, gets the + completions, then calls :meth:`format_completion` for each + completion. + """ + args, incomplete = self.get_completion_args() + completions = self.get_completions(args, incomplete) + out = [self.format_completion(item) for item in completions] + return "\n".join(out) + + +class BashComplete(ShellComplete): + """Shell completion for Bash.""" + + name = "bash" + source_template = _SOURCE_BASH + + def _check_version(self) -> None: + import subprocess + + output = subprocess.run( + ["bash", "-c", "echo ${BASH_VERSION}"], stdout=subprocess.PIPE + ) + match = re.search(r"^(\d+)\.(\d+)\.\d+", output.stdout.decode()) + + if match is not None: + major, minor = match.groups() + + if major < "4" or major == "4" and minor < "4": + raise RuntimeError( + _( + "Shell completion is not supported for Bash" + " versions older than 4.4." + ) + ) + else: + raise RuntimeError( + _("Couldn't detect Bash version, shell completion is not supported.") + ) + + def source(self) -> str: + self._check_version() + return super().source() + + def get_completion_args(self) -> t.Tuple[t.List[str], str]: + cwords = split_arg_string(os.environ["COMP_WORDS"]) + cword = int(os.environ["COMP_CWORD"]) + args = cwords[1:cword] + + try: + incomplete = cwords[cword] + except IndexError: + incomplete = "" + + return args, incomplete + + def format_completion(self, item: CompletionItem) -> str: + return f"{item.type},{item.value}" + + +class ZshComplete(ShellComplete): + """Shell completion for Zsh.""" + + name = "zsh" + source_template = _SOURCE_ZSH + + def get_completion_args(self) -> t.Tuple[t.List[str], str]: + cwords = split_arg_string(os.environ["COMP_WORDS"]) + cword = int(os.environ["COMP_CWORD"]) + args = cwords[1:cword] + + try: + incomplete = cwords[cword] + except IndexError: + incomplete = "" + + return args, incomplete + + def format_completion(self, item: CompletionItem) -> str: + return f"{item.type}\n{item.value}\n{item.help if item.help else '_'}" + + +class FishComplete(ShellComplete): + """Shell completion for Fish.""" + + name = "fish" + source_template = _SOURCE_FISH + + def get_completion_args(self) -> t.Tuple[t.List[str], str]: + cwords = split_arg_string(os.environ["COMP_WORDS"]) + incomplete = os.environ["COMP_CWORD"] + args = cwords[1:] + + # Fish stores the partial word in both COMP_WORDS and + # COMP_CWORD, remove it from complete args. + if incomplete and args and args[-1] == incomplete: + args.pop() + + return args, incomplete + + def format_completion(self, item: CompletionItem) -> str: + if item.help: + return f"{item.type},{item.value}\t{item.help}" + + return f"{item.type},{item.value}" + + +_available_shells: t.Dict[str, t.Type[ShellComplete]] = { + "bash": BashComplete, + "fish": FishComplete, + "zsh": ZshComplete, +} + + +def add_completion_class( + cls: t.Type[ShellComplete], name: t.Optional[str] = None +) -> None: + """Register a :class:`ShellComplete` subclass under the given name. + The name will be provided by the completion instruction environment + variable during completion. + + :param cls: The completion class that will handle completion for the + shell. + :param name: Name to register the class under. Defaults to the + class's ``name`` attribute. + """ + if name is None: + name = cls.name + + _available_shells[name] = cls + + +def get_completion_class(shell: str) -> t.Optional[t.Type[ShellComplete]]: + """Look up a registered :class:`ShellComplete` subclass by the name + provided by the completion instruction environment variable. If the + name isn't registered, returns ``None``. + + :param shell: Name the class is registered under. + """ + return _available_shells.get(shell) + + +def _is_incomplete_argument(ctx: Context, param: Parameter) -> bool: + """Determine if the given parameter is an argument that can still + accept values. + + :param ctx: Invocation context for the command represented by the + parsed complete args. + :param param: Argument object being checked. + """ + if not isinstance(param, Argument): + return False + + assert param.name is not None + value = ctx.params[param.name] + return ( + param.nargs == -1 + or ctx.get_parameter_source(param.name) is not ParameterSource.COMMANDLINE + or ( + param.nargs > 1 + and isinstance(value, (tuple, list)) + and len(value) < param.nargs + ) + ) + + +def _start_of_option(value: str) -> bool: + """Check if the value looks like the start of an option.""" + if not value: + return False + + c = value[0] + # Allow "/" since that starts a path. + return not c.isalnum() and c != "/" + + +def _is_incomplete_option(args: t.List[str], param: Parameter) -> bool: + """Determine if the given parameter is an option that needs a value. + + :param args: List of complete args before the incomplete value. + :param param: Option object being checked. + """ + if not isinstance(param, Option): + return False + + if param.is_flag: + return False + + last_option = None + + for index, arg in enumerate(reversed(args)): + if index + 1 > param.nargs: + break + + if _start_of_option(arg): + last_option = arg + + return last_option is not None and last_option in param.opts + + +def _resolve_context( + cli: BaseCommand, ctx_args: t.Dict[str, t.Any], prog_name: str, args: t.List[str] +) -> Context: + """Produce the context hierarchy starting with the command and + traversing the complete arguments. This only follows the commands, + it doesn't trigger input prompts or callbacks. + + :param cli: Command being called. + :param prog_name: Name of the executable in the shell. + :param args: List of complete args before the incomplete value. + """ + ctx_args["resilient_parsing"] = True + ctx = cli.make_context(prog_name, args.copy(), **ctx_args) + args = ctx.protected_args + ctx.args + + while args: + command = ctx.command + + if isinstance(command, MultiCommand): + if not command.chain: + name, cmd, args = command.resolve_command(ctx, args) + + if cmd is None: + return ctx + + ctx = cmd.make_context(name, args, parent=ctx, resilient_parsing=True) + args = ctx.protected_args + ctx.args + else: + while args: + name, cmd, args = command.resolve_command(ctx, args) + + if cmd is None: + return ctx + + sub_ctx = cmd.make_context( + name, + args, + parent=ctx, + allow_extra_args=True, + allow_interspersed_args=False, + resilient_parsing=True, + ) + args = sub_ctx.args + + ctx = sub_ctx + args = [*sub_ctx.protected_args, *sub_ctx.args] + else: + break + + return ctx + + +def _resolve_incomplete( + ctx: Context, args: t.List[str], incomplete: str +) -> t.Tuple[t.Union[BaseCommand, Parameter], str]: + """Find the Click object that will handle the completion of the + incomplete value. Return the object and the incomplete value. + + :param ctx: Invocation context for the command represented by + the parsed complete args. + :param args: List of complete args before the incomplete value. + :param incomplete: Value being completed. May be empty. + """ + # Different shells treat an "=" between a long option name and + # value differently. Might keep the value joined, return the "=" + # as a separate item, or return the split name and value. Always + # split and discard the "=" to make completion easier. + if incomplete == "=": + incomplete = "" + elif "=" in incomplete and _start_of_option(incomplete): + name, _, incomplete = incomplete.partition("=") + args.append(name) + + # The "--" marker tells Click to stop treating values as options + # even if they start with the option character. If it hasn't been + # given and the incomplete arg looks like an option, the current + # command will provide option name completions. + if "--" not in args and _start_of_option(incomplete): + return ctx.command, incomplete + + params = ctx.command.get_params(ctx) + + # If the last complete arg is an option name with an incomplete + # value, the option will provide value completions. + for param in params: + if _is_incomplete_option(args, param): + return param, incomplete + + # It's not an option name or value. The first argument without a + # parsed value will provide value completions. + for param in params: + if _is_incomplete_argument(ctx, param): + return param, incomplete + + # There were no unparsed arguments, the command may be a group that + # will provide command name completions. + return ctx.command, incomplete diff --git a/src/click/termui.py b/src/click/termui.py index 02ef9e9..a023f42 100644 --- a/src/click/termui.py +++ b/src/click/termui.py @@ -2,29 +2,31 @@ import inspect import io import itertools import os -import struct import sys +import typing +import typing as t +from gettext import gettext as _ -from ._compat import DEFAULT_COLUMNS -from ._compat import get_winterm_size from ._compat import isatty -from ._compat import raw_input -from ._compat import string_types from ._compat import strip_ansi -from ._compat import text_type from ._compat import WIN from .exceptions import Abort from .exceptions import UsageError from .globals import resolve_color_default from .types import Choice from .types import convert_type -from .types import Path +from .types import ParamType from .utils import echo from .utils import LazyFile +if t.TYPE_CHECKING: + from ._termui_impl import ProgressBar + +V = t.TypeVar("V") + # The prompt functions to use. The doc tools currently override these # functions to customize how they work. -visible_prompt_func = raw_input +visible_prompt_func: t.Callable[[str], str] = input _ansi_colors = { "black": 30, @@ -48,63 +50,61 @@ _ansi_colors = { _ansi_reset_all = "\033[0m" -def hidden_prompt_func(prompt): +def hidden_prompt_func(prompt: str) -> str: import getpass return getpass.getpass(prompt) def _build_prompt( - text, suffix, show_default=False, default=None, show_choices=True, type=None -): + text: str, + suffix: str, + show_default: bool = False, + default: t.Optional[t.Any] = None, + show_choices: bool = True, + type: t.Optional[ParamType] = None, +) -> str: prompt = text if type is not None and show_choices and isinstance(type, Choice): - prompt += " ({})".format(", ".join(map(str, type.choices))) + prompt += f" ({', '.join(map(str, type.choices))})" if default is not None and show_default: - prompt = "{} [{}]".format(prompt, _format_default(default)) - return prompt + suffix + prompt = f"{prompt} [{_format_default(default)}]" + return f"{prompt}{suffix}" -def _format_default(default): +def _format_default(default: t.Any) -> t.Any: if isinstance(default, (io.IOBase, LazyFile)) and hasattr(default, "name"): - return default.name + return default.name # type: ignore return default def prompt( - text, - default=None, - hide_input=False, - confirmation_prompt=False, - type=None, - value_proc=None, - prompt_suffix=": ", - show_default=True, - err=False, - show_choices=True, -): + text: str, + default: t.Optional[t.Any] = None, + hide_input: bool = False, + confirmation_prompt: t.Union[bool, str] = False, + type: t.Optional[t.Union[ParamType, t.Any]] = None, + value_proc: t.Optional[t.Callable[[str], t.Any]] = None, + prompt_suffix: str = ": ", + show_default: bool = True, + err: bool = False, + show_choices: bool = True, +) -> t.Any: """Prompts a user for input. This is a convenience function that can be used to prompt a user for input later. If the user aborts the input by sending a interrupt signal, this function will catch it and raise a :exc:`Abort` exception. - .. versionadded:: 7.0 - Added the show_choices parameter. - - .. versionadded:: 6.0 - Added unicode support for cmd.exe on Windows. - - .. versionadded:: 4.0 - Added the `err` parameter. - :param text: the text to show for the prompt. :param default: the default value to use if no input happens. If this is not given it will prompt until it's aborted. :param hide_input: if this is set to true then the input value will be hidden. - :param confirmation_prompt: asks for confirmation for the value. + :param confirmation_prompt: Prompt a second time to confirm the + value. Can be set to a string instead of ``True`` to customize + the message. :param type: the type to use to check the value against. :param value_proc: if this parameter is provided it's a function that is invoked instead of the type conversion to @@ -117,23 +117,37 @@ def prompt( For example if type is a Choice of either day or week, show_choices is true and text is "Group by" then the prompt will be "Group by (day, week): ". - """ - result = None - def prompt_func(text): + .. versionadded:: 8.0 + ``confirmation_prompt`` can be a custom string. + + .. versionadded:: 7.0 + Added the ``show_choices`` parameter. + + .. versionadded:: 6.0 + Added unicode support for cmd.exe on Windows. + + .. versionadded:: 4.0 + Added the `err` parameter. + + """ + + def prompt_func(text: str) -> str: f = hidden_prompt_func if hide_input else visible_prompt_func try: # Write the prompt separately so that we get nice # coloring through colorama on Windows - echo(text, nl=False, err=err) - return f("") + echo(text.rstrip(" "), nl=False, err=err) + # Echo a space to stdout to work around an issue where + # readline causes backspace to clear the whole line. + return f(" ") except (KeyboardInterrupt, EOFError): # getpass doesn't print a newline if the user aborts input with ^C. # Allegedly this behavior is inherited from getpass(3). # A doc bug has been filed at https://bugs.python.org/issue24711 if hide_input: echo(None, err=err) - raise Abort() + raise Abort() from None if value_proc is None: value_proc = convert_type(type, default) @@ -142,72 +156,93 @@ def prompt( text, prompt_suffix, show_default, default, show_choices, type ) - while 1: - while 1: + if confirmation_prompt: + if confirmation_prompt is True: + confirmation_prompt = _("Repeat for confirmation") + + confirmation_prompt = t.cast(str, confirmation_prompt) + confirmation_prompt = _build_prompt(confirmation_prompt, prompt_suffix) + + while True: + while True: value = prompt_func(prompt) if value: break elif default is not None: - if isinstance(value_proc, Path): - # validate Path default value(exists, dir_okay etc.) - value = default - break - return default + value = default + break try: result = value_proc(value) except UsageError as e: - echo("Error: {}".format(e.message), err=err) # noqa: B306 + if hide_input: + echo(_("Error: The value you entered was invalid."), err=err) + else: + echo(_("Error: {e.message}").format(e=e), err=err) # noqa: B306 continue if not confirmation_prompt: return result - while 1: - value2 = prompt_func("Repeat for confirmation: ") + while True: + confirmation_prompt = t.cast(str, confirmation_prompt) + value2 = prompt_func(confirmation_prompt) if value2: break if value == value2: return result - echo("Error: the two entered values do not match", err=err) + echo(_("Error: The two entered values do not match."), err=err) def confirm( - text, default=False, abort=False, prompt_suffix=": ", show_default=True, err=False -): + text: str, + default: t.Optional[bool] = False, + abort: bool = False, + prompt_suffix: str = ": ", + show_default: bool = True, + err: bool = False, +) -> bool: """Prompts for confirmation (yes/no question). If the user aborts the input by sending a interrupt signal this function will catch it and raise a :exc:`Abort` exception. - .. versionadded:: 4.0 - Added the `err` parameter. - :param text: the question to ask. - :param default: the default for the prompt. + :param default: The default value to use when no input is given. If + ``None``, repeat until input is given. :param abort: if this is set to `True` a negative answer aborts the exception by raising :exc:`Abort`. :param prompt_suffix: a suffix that should be added to the prompt. :param show_default: shows or hides the default value in the prompt. :param err: if set to true the file defaults to ``stderr`` instead of ``stdout``, the same as with echo. + + .. versionchanged:: 8.0 + Repeat until input is given if ``default`` is ``None``. + + .. versionadded:: 4.0 + Added the ``err`` parameter. """ prompt = _build_prompt( - text, prompt_suffix, show_default, "Y/n" if default else "y/N" + text, + prompt_suffix, + show_default, + "y/n" if default is None else ("Y/n" if default else "y/N"), ) - while 1: + + while True: try: # Write the prompt separately so that we get nice # coloring through colorama on Windows echo(prompt, nl=False, err=err) value = visible_prompt_func("").lower().strip() except (KeyboardInterrupt, EOFError): - raise Abort() + raise Abort() from None if value in ("y", "yes"): rv = True elif value in ("n", "no"): rv = False - elif value == "": + elif default is not None and value == "": rv = default else: - echo("Error: invalid input", err=err) + echo(_("Error: invalid input"), err=err) continue break if abort and not rv: @@ -215,54 +250,30 @@ def confirm( return rv -def get_terminal_size(): +def get_terminal_size() -> os.terminal_size: """Returns the current size of the terminal as tuple in the form ``(width, height)`` in columns and rows. + + .. deprecated:: 8.0 + Will be removed in Click 8.1. Use + :func:`shutil.get_terminal_size` instead. """ - # If shutil has get_terminal_size() (Python 3.3 and later) use that - if sys.version_info >= (3, 3): - import shutil + import shutil + import warnings - shutil_get_terminal_size = getattr(shutil, "get_terminal_size", None) - if shutil_get_terminal_size: - sz = shutil_get_terminal_size() - return sz.columns, sz.lines - - # We provide a sensible default for get_winterm_size() when being invoked - # inside a subprocess. Without this, it would not provide a useful input. - if get_winterm_size is not None: - size = get_winterm_size() - if size == (0, 0): - return (79, 24) - else: - return size - - def ioctl_gwinsz(fd): - try: - import fcntl - import termios - - cr = struct.unpack("hh", fcntl.ioctl(fd, termios.TIOCGWINSZ, "1234")) - except Exception: - return - return cr - - cr = ioctl_gwinsz(0) or ioctl_gwinsz(1) or ioctl_gwinsz(2) - if not cr: - try: - fd = os.open(os.ctermid(), os.O_RDONLY) - try: - cr = ioctl_gwinsz(fd) - finally: - os.close(fd) - except Exception: - pass - if not cr or not cr[0] or not cr[1]: - cr = (os.environ.get("LINES", 25), os.environ.get("COLUMNS", DEFAULT_COLUMNS)) - return int(cr[1]), int(cr[0]) + warnings.warn( + "'click.get_terminal_size()' is deprecated and will be removed" + " in Click 8.1. Use 'shutil.get_terminal_size()' instead.", + DeprecationWarning, + stacklevel=2, + ) + return shutil.get_terminal_size() -def echo_via_pager(text_or_generator, color=None): +def echo_via_pager( + text_or_generator: t.Union[t.Iterable[str], t.Callable[[], t.Iterable[str]], str], + color: t.Optional[bool] = None, +) -> None: """This function takes a text and shows it via an environment specific pager on stdout. @@ -277,14 +288,14 @@ def echo_via_pager(text_or_generator, color=None): color = resolve_color_default(color) if inspect.isgeneratorfunction(text_or_generator): - i = text_or_generator() - elif isinstance(text_or_generator, string_types): + i = t.cast(t.Callable[[], t.Iterable[str]], text_or_generator)() + elif isinstance(text_or_generator, str): i = [text_or_generator] else: - i = iter(text_or_generator) + i = iter(t.cast(t.Iterable[str], text_or_generator)) # convert every element of i to a text type if necessary - text_generator = (el if isinstance(el, string_types) else text_type(el) for el in i) + text_generator = (el if isinstance(el, str) else str(el) for el in i) from ._termui_impl import pager @@ -292,21 +303,22 @@ def echo_via_pager(text_or_generator, color=None): def progressbar( - iterable=None, - length=None, - label=None, - show_eta=True, - show_percent=None, - show_pos=False, - item_show_func=None, - fill_char="#", - empty_char="-", - bar_template="%(label)s [%(bar)s] %(info)s", - info_sep=" ", - width=36, - file=None, - color=None, -): + iterable: t.Optional[t.Iterable[V]] = None, + length: t.Optional[int] = None, + label: t.Optional[str] = None, + show_eta: bool = True, + show_percent: t.Optional[bool] = None, + show_pos: bool = False, + item_show_func: t.Optional[t.Callable[[t.Optional[V]], t.Optional[str]]] = None, + fill_char: str = "#", + empty_char: str = "-", + bar_template: str = "%(label)s [%(bar)s] %(info)s", + info_sep: str = " ", + width: int = 36, + file: t.Optional[t.TextIO] = None, + color: t.Optional[bool] = None, + update_min_steps: int = 1, +) -> "ProgressBar[V]": """This function creates an iterable context manager that can be used to iterate over something while showing a progress bar. It will either iterate over the `iterable` or `length` items (that are counted @@ -346,11 +358,19 @@ def progressbar( process_chunk(chunk) bar.update(chunks.bytes) - .. versionadded:: 2.0 + The ``update()`` method also takes an optional value specifying the + ``current_item`` at the new position. This is useful when used + together with ``item_show_func`` to customize the output for each + manual step:: - .. versionadded:: 4.0 - Added the `color` parameter. Added a `update` method to the - progressbar object. + with click.progressbar( + length=total_size, + label='Unzipping archive', + item_show_func=lambda a: a.filename + ) as bar: + for archive in zip_file: + archive.extract() + bar.update(archive.size, archive) :param iterable: an iterable to iterate over. If not provided the length is required. @@ -369,10 +389,10 @@ def progressbar( `False` if not. :param show_pos: enables or disables the absolute position display. The default is `False`. - :param item_show_func: a function called with the current item which - can return a string to show the current item - next to the progress bar. Note that the current - item can be `None`! + :param item_show_func: A function called with the current item which + can return a string to show next to the progress bar. If the + function returns ``None`` nothing is shown. The current item can + be ``None``, such as when entering and exiting the bar. :param fill_char: the character to use to show the filled part of the progress bar. :param empty_char: the character to use to show the non-filled part of @@ -384,12 +404,33 @@ def progressbar( :param info_sep: the separator between multiple info items (eta etc.) :param width: the width of the progress bar in characters, 0 means full terminal width - :param file: the file to write to. If this is not a terminal then - only the label is printed. + :param file: The file to write to. If this is not a terminal then + only the label is printed. :param color: controls if the terminal supports ANSI colors or not. The default is autodetection. This is only needed if ANSI codes are included anywhere in the progress bar output which is not the case by default. + :param update_min_steps: Render only when this many updates have + completed. This allows tuning for very fast iterators. + + .. versionchanged:: 8.0 + Output is shown even if execution time is less than 0.5 seconds. + + .. versionchanged:: 8.0 + ``item_show_func`` shows the current item, not the previous one. + + .. versionchanged:: 8.0 + Labels are echoed if the output is not a TTY. Reverts a change + in 7.0 that removed all output. + + .. versionadded:: 8.0 + Added the ``update_min_steps`` parameter. + + .. versionchanged:: 4.0 + Added the ``color`` parameter. Added the ``update`` method to + the object. + + .. versionadded:: 2.0 """ from ._termui_impl import ProgressBar @@ -409,10 +450,11 @@ def progressbar( label=label, width=width, color=color, + update_min_steps=update_min_steps, ) -def clear(): +def clear() -> None: """Clears the terminal screen. This will have the effect of clearing the whole visible space of the terminal and moving the cursor to the top left. This does not do anything if not connected to a terminal. @@ -421,26 +463,39 @@ def clear(): """ if not isatty(sys.stdout): return - # If we're on Windows and we don't have colorama available, then we - # clear the screen by shelling out. Otherwise we can use an escape - # sequence. if WIN: os.system("cls") else: sys.stdout.write("\033[2J\033[1;1H") +def _interpret_color( + color: t.Union[int, t.Tuple[int, int, int], str], offset: int = 0 +) -> str: + if isinstance(color, int): + return f"{38 + offset};5;{color:d}" + + if isinstance(color, (tuple, list)): + r, g, b = color + return f"{38 + offset};2;{r:d};{g:d};{b:d}" + + return str(_ansi_colors[color] + offset) + + def style( - text, - fg=None, - bg=None, - bold=None, - dim=None, - underline=None, - blink=None, - reverse=None, - reset=True, -): + text: t.Any, + fg: t.Optional[t.Union[int, t.Tuple[int, int, int], str]] = None, + bg: t.Optional[t.Union[int, t.Tuple[int, int, int], str]] = None, + bold: t.Optional[bool] = None, + dim: t.Optional[bool] = None, + underline: t.Optional[bool] = None, + overline: t.Optional[bool] = None, + italic: t.Optional[bool] = None, + blink: t.Optional[bool] = None, + reverse: t.Optional[bool] = None, + strikethrough: t.Optional[bool] = None, + reset: bool = True, +) -> str: """Styles a text with ANSI styles and returns the new string. By default the styling is self contained which means that at the end of the string a reset code is issued. This can be prevented by @@ -451,6 +506,7 @@ def style( click.echo(click.style('Hello World!', fg='green')) click.echo(click.style('ATTENTION!', blink=True)) click.echo(click.style('Some things', reverse=True, fg='cyan')) + click.echo(click.style('More colors', fg=(255, 12, 128), bg=117)) Supported color names: @@ -472,10 +528,15 @@ def style( * ``bright_white`` * ``reset`` (reset the color code only) - .. versionadded:: 2.0 + If the terminal supports it, color may also be specified as: - .. versionadded:: 7.0 - Added support for bright colors. + - An integer in the interval [0, 255]. The terminal must support + 8-bit/256-color mode. + - An RGB tuple of three integers in [0, 255]. The terminal must + support 24-bit/true-color mode. + + See https://en.wikipedia.org/wiki/ANSI_color and + https://gist.github.com/XVilka/8346728 for more information. :param text: the string to style with ansi codes. :param fg: if provided this will become the foreground color. @@ -484,42 +545,73 @@ def style( :param dim: if provided this will enable or disable dim mode. This is badly supported. :param underline: if provided this will enable or disable underline. + :param overline: if provided this will enable or disable overline. + :param italic: if provided this will enable or disable italic. :param blink: if provided this will enable or disable blinking. :param reverse: if provided this will enable or disable inverse rendering (foreground becomes background and the other way round). + :param strikethrough: if provided this will enable or disable + striking through text. :param reset: by default a reset-all code is added at the end of the string which means that styles do not carry over. This can be disabled to compose styles. + + .. versionchanged:: 8.0 + A non-string ``message`` is converted to a string. + + .. versionchanged:: 8.0 + Added support for 256 and RGB color codes. + + .. versionchanged:: 8.0 + Added the ``strikethrough``, ``italic``, and ``overline`` + parameters. + + .. versionchanged:: 7.0 + Added support for bright colors. + + .. versionadded:: 2.0 """ + if not isinstance(text, str): + text = str(text) + bits = [] + if fg: try: - bits.append("\033[{}m".format(_ansi_colors[fg])) + bits.append(f"\033[{_interpret_color(fg)}m") except KeyError: - raise TypeError("Unknown color '{}'".format(fg)) + raise TypeError(f"Unknown color {fg!r}") from None + if bg: try: - bits.append("\033[{}m".format(_ansi_colors[bg] + 10)) + bits.append(f"\033[{_interpret_color(bg, 10)}m") except KeyError: - raise TypeError("Unknown color '{}'".format(bg)) + raise TypeError(f"Unknown color {bg!r}") from None + if bold is not None: - bits.append("\033[{}m".format(1 if bold else 22)) + bits.append(f"\033[{1 if bold else 22}m") if dim is not None: - bits.append("\033[{}m".format(2 if dim else 22)) + bits.append(f"\033[{2 if dim else 22}m") if underline is not None: - bits.append("\033[{}m".format(4 if underline else 24)) + bits.append(f"\033[{4 if underline else 24}m") + if overline is not None: + bits.append(f"\033[{53 if overline else 55}m") + if italic is not None: + bits.append(f"\033[{3 if italic else 23}m") if blink is not None: - bits.append("\033[{}m".format(5 if blink else 25)) + bits.append(f"\033[{5 if blink else 25}m") if reverse is not None: - bits.append("\033[{}m".format(7 if reverse else 27)) + bits.append(f"\033[{7 if reverse else 27}m") + if strikethrough is not None: + bits.append(f"\033[{9 if strikethrough else 29}m") bits.append(text) if reset: bits.append(_ansi_reset_all) return "".join(bits) -def unstyle(text): +def unstyle(text: str) -> str: """Removes ANSI styling information from a string. Usually it's not necessary to use this function as Click's echo function will automatically remove styling if necessary. @@ -531,7 +623,14 @@ def unstyle(text): return strip_ansi(text) -def secho(message=None, file=None, nl=True, err=False, color=None, **styles): +def secho( + message: t.Optional[t.Any] = None, + file: t.Optional[t.IO] = None, + nl: bool = True, + err: bool = False, + color: t.Optional[bool] = None, + **styles: t.Any, +) -> None: """This function combines :func:`echo` and :func:`style` into one call. As such the following two calls are the same:: @@ -541,16 +640,31 @@ def secho(message=None, file=None, nl=True, err=False, color=None, **styles): All keyword arguments are forwarded to the underlying functions depending on which one they go with. + Non-string types will be converted to :class:`str`. However, + :class:`bytes` are passed directly to :meth:`echo` without applying + style. If you want to style bytes that represent text, call + :meth:`bytes.decode` first. + + .. versionchanged:: 8.0 + A non-string ``message`` is converted to a string. Bytes are + passed through without style applied. + .. versionadded:: 2.0 """ - if message is not None: + if message is not None and not isinstance(message, (bytes, bytearray)): message = style(message, **styles) + return echo(message, file=file, nl=nl, err=err, color=color) def edit( - text=None, editor=None, env=None, require_save=True, extension=".txt", filename=None -): + text: t.Optional[t.AnyStr] = None, + editor: t.Optional[str] = None, + env: t.Optional[t.Mapping[str, str]] = None, + require_save: bool = True, + extension: str = ".txt", + filename: t.Optional[str] = None, +) -> t.Optional[t.AnyStr]: r"""Edits the given text in the defined editor. If an editor is given (should be the full path to the executable but the regular operating system search path is used for finding the executable) it overrides @@ -580,15 +694,16 @@ def edit( """ from ._termui_impl import Editor - editor = Editor( - editor=editor, env=env, require_save=require_save, extension=extension - ) + ed = Editor(editor=editor, env=env, require_save=require_save, extension=extension) + if filename is None: - return editor.edit(text) - editor.edit_file(filename) + return ed.edit(text) + + ed.edit_file(filename) + return None -def launch(url, wait=False, locate=False): +def launch(url: str, wait: bool = False, locate: bool = False) -> int: """This function launches the given URL (or filename) in the default viewer application for this file type. If this is an executable, it might launch the executable in a new session. The return value is @@ -603,7 +718,9 @@ def launch(url, wait=False, locate=False): .. versionadded:: 2.0 :param url: URL or filename of the thing to launch. - :param wait: waits for the program to stop. + :param wait: Wait for the program to exit before returning. This + only works if the launched program blocks. In particular, + ``xdg-open`` on Linux does not block. :param locate: if this is set to `True` then instead of launching the application associated with the URL it will attempt to launch a file manager with the file located. This @@ -617,10 +734,10 @@ def launch(url, wait=False, locate=False): # If this is provided, getchar() calls into this instead. This is used # for unittesting purposes. -_getchar = None +_getchar: t.Optional[t.Callable[[bool], str]] = None -def getchar(echo=False): +def getchar(echo: bool = False) -> str: """Fetches a single character from the terminal and returns it. This will always return a unicode character and under certain rare circumstances this might return more than one character. The @@ -640,19 +757,23 @@ def getchar(echo=False): :param echo: if set to `True`, the character read will also show up on the terminal. The default is to not show it. """ - f = _getchar - if f is None: + global _getchar + + if _getchar is None: from ._termui_impl import getchar as f - return f(echo) + + _getchar = f + + return _getchar(echo) -def raw_terminal(): +def raw_terminal() -> t.ContextManager[int]: from ._termui_impl import raw_terminal as f return f() -def pause(info="Press any key to continue ...", err=False): +def pause(info: t.Optional[str] = None, err: bool = False) -> None: """This command stops execution and waits for the user to press any key to continue. This is similar to the Windows batch "pause" command. If the program is not run through a terminal, this command @@ -663,12 +784,17 @@ def pause(info="Press any key to continue ...", err=False): .. versionadded:: 4.0 Added the `err` parameter. - :param info: the info string to print before pausing. + :param info: The message to print before pausing. Defaults to + ``"Press any key to continue..."``. :param err: if set to message goes to ``stderr`` instead of ``stdout``, the same as with echo. """ if not isatty(sys.stdin) or not isatty(sys.stdout): return + + if info is None: + info = _("Press any key to continue...") + try: if info: echo(info, nl=False, err=err) diff --git a/src/click/testing.py b/src/click/testing.py index a3dba3b..d19b850 100644 --- a/src/click/testing.py +++ b/src/click/testing.py @@ -1,77 +1,117 @@ import contextlib +import io import os import shlex import shutil import sys import tempfile +import typing as t +from types import TracebackType from . import formatting from . import termui from . import utils -from ._compat import iteritems -from ._compat import PY2 -from ._compat import string_types +from ._compat import _find_binary_reader + +if t.TYPE_CHECKING: + from .core import BaseCommand -if PY2: - from cStringIO import StringIO -else: - import io - from ._compat import _find_binary_reader - - -class EchoingStdin(object): - def __init__(self, input, output): +class EchoingStdin: + def __init__(self, input: t.BinaryIO, output: t.BinaryIO) -> None: self._input = input self._output = output + self._paused = False - def __getattr__(self, x): + def __getattr__(self, x: str) -> t.Any: return getattr(self._input, x) - def _echo(self, rv): - self._output.write(rv) + def _echo(self, rv: bytes) -> bytes: + if not self._paused: + self._output.write(rv) + return rv - def read(self, n=-1): + def read(self, n: int = -1) -> bytes: return self._echo(self._input.read(n)) - def readline(self, n=-1): + def read1(self, n: int = -1) -> bytes: + return self._echo(self._input.read1(n)) # type: ignore + + def readline(self, n: int = -1) -> bytes: return self._echo(self._input.readline(n)) - def readlines(self): + def readlines(self) -> t.List[bytes]: return [self._echo(x) for x in self._input.readlines()] - def __iter__(self): + def __iter__(self) -> t.Iterator[bytes]: return iter(self._echo(x) for x in self._input) - def __repr__(self): + def __repr__(self) -> str: return repr(self._input) -def make_input_stream(input, charset): +@contextlib.contextmanager +def _pause_echo(stream: t.Optional[EchoingStdin]) -> t.Iterator[None]: + if stream is None: + yield + else: + stream._paused = True + yield + stream._paused = False + + +class _NamedTextIOWrapper(io.TextIOWrapper): + def __init__( + self, buffer: t.BinaryIO, name: str, mode: str, **kwargs: t.Any + ) -> None: + super().__init__(buffer, **kwargs) + self._name = name + self._mode = mode + + @property + def name(self) -> str: + return self._name + + @property + def mode(self) -> str: + return self._mode + + +def make_input_stream( + input: t.Optional[t.Union[str, bytes, t.IO]], charset: str +) -> t.BinaryIO: # Is already an input stream. if hasattr(input, "read"): - if PY2: - return input - rv = _find_binary_reader(input) + rv = _find_binary_reader(t.cast(t.IO, input)) + if rv is not None: return rv + raise TypeError("Could not find binary reader for input stream.") if input is None: input = b"" - elif not isinstance(input, bytes): + elif isinstance(input, str): input = input.encode(charset) - if PY2: - return StringIO(input) - return io.BytesIO(input) + + return io.BytesIO(t.cast(bytes, input)) -class Result(object): +class Result: """Holds the captured result of an invoked CLI script.""" def __init__( - self, runner, stdout_bytes, stderr_bytes, exit_code, exception, exc_info=None + self, + runner: "CliRunner", + stdout_bytes: bytes, + stderr_bytes: t.Optional[bytes], + return_value: t.Any, + exit_code: int, + exception: t.Optional[BaseException], + exc_info: t.Optional[ + t.Tuple[t.Type[BaseException], BaseException, TracebackType] + ] = None, ): #: The runner that created the result self.runner = runner @@ -79,6 +119,10 @@ class Result(object): self.stdout_bytes = stdout_bytes #: The standard error as bytes, or None if not available self.stderr_bytes = stderr_bytes + #: The value returned from the invoked command. + #: + #: .. versionadded:: 8.0 + self.return_value = return_value #: The exit code as integer. self.exit_code = exit_code #: The exception that happened if one did. @@ -87,19 +131,19 @@ class Result(object): self.exc_info = exc_info @property - def output(self): + def output(self) -> str: """The (standard) output as unicode string.""" return self.stdout @property - def stdout(self): + def stdout(self) -> str: """The standard output as unicode string.""" return self.stdout_bytes.decode(self.runner.charset, "replace").replace( "\r\n", "\n" ) @property - def stderr(self): + def stderr(self) -> str: """The standard error as unicode string.""" if self.stderr_bytes is None: raise ValueError("stderr not separately captured") @@ -107,21 +151,18 @@ class Result(object): "\r\n", "\n" ) - def __repr__(self): - return "<{} {}>".format( - type(self).__name__, repr(self.exception) if self.exception else "okay" - ) + def __repr__(self) -> str: + exc_str = repr(self.exception) if self.exception else "okay" + return f"<{type(self).__name__} {exc_str}>" -class CliRunner(object): +class CliRunner: """The CLI runner provides functionality to invoke a Click command line script for unittesting purposes in a isolated environment. This only works in single-threaded systems without any concurrency as it changes the global interpreter state. - :param charset: the character set for the input and output data. This is - UTF-8 by default and should not be changed currently as - the reporting to Click only works in Python 2 properly. + :param charset: the character set for the input and output data. :param env: a dictionary with environment variables for overriding. :param echo_stdin: if this is set to `True`, then reading from stdin writes to stdout. This is useful for showing examples in @@ -134,22 +175,28 @@ class CliRunner(object): independently """ - def __init__(self, charset=None, env=None, echo_stdin=False, mix_stderr=True): - if charset is None: - charset = "utf-8" + def __init__( + self, + charset: str = "utf-8", + env: t.Optional[t.Mapping[str, t.Optional[str]]] = None, + echo_stdin: bool = False, + mix_stderr: bool = True, + ) -> None: self.charset = charset self.env = env or {} self.echo_stdin = echo_stdin self.mix_stderr = mix_stderr - def get_default_prog_name(self, cli): + def get_default_prog_name(self, cli: "BaseCommand") -> str: """Given a command object it will return the default program name for it. The default is the `name` attribute or ``"root"`` if not set. """ return cli.name or "root" - def make_env(self, overrides=None): + def make_env( + self, overrides: t.Optional[t.Mapping[str, t.Optional[str]]] = None + ) -> t.Mapping[str, t.Optional[str]]: """Returns the environment overrides for invoking a script.""" rv = dict(self.env) if overrides: @@ -157,7 +204,12 @@ class CliRunner(object): return rv @contextlib.contextmanager - def isolation(self, input=None, env=None, color=False): + def isolation( + self, + input: t.Optional[t.Union[str, bytes, t.IO]] = None, + env: t.Optional[t.Mapping[str, t.Optional[str]]] = None, + color: bool = False, + ) -> t.Iterator[t.Tuple[io.BytesIO, t.Optional[io.BytesIO]]]: """A context manager that sets up the isolation for invoking of a command line tool. This sets up stdin with the given input data and `os.environ` with the overrides from the given dictionary. @@ -166,15 +218,20 @@ class CliRunner(object): This is automatically done in the :meth:`invoke` method. - .. versionadded:: 4.0 - The ``color`` parameter was added. - :param input: the input stream to put into sys.stdin. :param env: the environment overrides as dictionary. :param color: whether the output should contain color codes. The application can still override this explicitly. + + .. versionchanged:: 8.0 + ``stderr`` is opened with ``errors="backslashreplace"`` + instead of the default ``"strict"``. + + .. versionchanged:: 4.0 + Added the ``color`` parameter. """ - input = make_input_stream(input, self.charset) + bytes_input = make_input_stream(input, self.charset) + echo_input = None old_stdin = sys.stdin old_stdout = sys.stdout @@ -184,51 +241,68 @@ class CliRunner(object): env = self.make_env(env) - if PY2: - bytes_output = StringIO() - if self.echo_stdin: - input = EchoingStdin(input, bytes_output) - sys.stdout = bytes_output - if not self.mix_stderr: - bytes_error = StringIO() - sys.stderr = bytes_error - else: - bytes_output = io.BytesIO() - if self.echo_stdin: - input = EchoingStdin(input, bytes_output) - input = io.TextIOWrapper(input, encoding=self.charset) - sys.stdout = io.TextIOWrapper(bytes_output, encoding=self.charset) - if not self.mix_stderr: - bytes_error = io.BytesIO() - sys.stderr = io.TextIOWrapper(bytes_error, encoding=self.charset) + bytes_output = io.BytesIO() + if self.echo_stdin: + bytes_input = echo_input = t.cast( + t.BinaryIO, EchoingStdin(bytes_input, bytes_output) + ) + + sys.stdin = text_input = _NamedTextIOWrapper( + bytes_input, encoding=self.charset, name="", mode="r" + ) + + if self.echo_stdin: + # Force unbuffered reads, otherwise TextIOWrapper reads a + # large chunk which is echoed early. + text_input._CHUNK_SIZE = 1 # type: ignore + + sys.stdout = _NamedTextIOWrapper( + bytes_output, encoding=self.charset, name="", mode="w" + ) + + bytes_error = None if self.mix_stderr: sys.stderr = sys.stdout + else: + bytes_error = io.BytesIO() + sys.stderr = _NamedTextIOWrapper( + bytes_error, + encoding=self.charset, + name="", + mode="w", + errors="backslashreplace", + ) - sys.stdin = input - - def visible_input(prompt=None): + @_pause_echo(echo_input) # type: ignore + def visible_input(prompt: t.Optional[str] = None) -> str: sys.stdout.write(prompt or "") - val = input.readline().rstrip("\r\n") - sys.stdout.write("{}\n".format(val)) + val = text_input.readline().rstrip("\r\n") + sys.stdout.write(f"{val}\n") sys.stdout.flush() return val - def hidden_input(prompt=None): - sys.stdout.write("{}\n".format(prompt or "")) + @_pause_echo(echo_input) # type: ignore + def hidden_input(prompt: t.Optional[str] = None) -> str: + sys.stdout.write(f"{prompt or ''}\n") sys.stdout.flush() - return input.readline().rstrip("\r\n") + return text_input.readline().rstrip("\r\n") - def _getchar(echo): + @_pause_echo(echo_input) # type: ignore + def _getchar(echo: bool) -> str: char = sys.stdin.read(1) + if echo: sys.stdout.write(char) - sys.stdout.flush() + + sys.stdout.flush() return char default_color = color - def should_strip_ansi(stream=None, color=None): + def should_strip_ansi( + stream: t.Optional[t.IO] = None, color: t.Optional[bool] = None + ) -> bool: if color is None: return not default_color return not color @@ -236,15 +310,15 @@ class CliRunner(object): old_visible_prompt_func = termui.visible_prompt_func old_hidden_prompt_func = termui.hidden_prompt_func old__getchar_func = termui._getchar - old_should_strip_ansi = utils.should_strip_ansi + old_should_strip_ansi = utils.should_strip_ansi # type: ignore termui.visible_prompt_func = visible_input termui.hidden_prompt_func = hidden_input termui._getchar = _getchar - utils.should_strip_ansi = should_strip_ansi + utils.should_strip_ansi = should_strip_ansi # type: ignore old_env = {} try: - for key, value in iteritems(env): + for key, value in env.items(): old_env[key] = os.environ.get(key) if value is None: try: @@ -253,9 +327,9 @@ class CliRunner(object): pass else: os.environ[key] = value - yield (bytes_output, not self.mix_stderr and bytes_error) + yield (bytes_output, bytes_error) finally: - for key, value in iteritems(old_env): + for key, value in old_env.items(): if value is None: try: del os.environ[key] @@ -269,19 +343,19 @@ class CliRunner(object): termui.visible_prompt_func = old_visible_prompt_func termui.hidden_prompt_func = old_hidden_prompt_func termui._getchar = old__getchar_func - utils.should_strip_ansi = old_should_strip_ansi + utils.should_strip_ansi = old_should_strip_ansi # type: ignore formatting.FORCED_WIDTH = old_forced_width def invoke( self, - cli, - args=None, - input=None, - env=None, - catch_exceptions=True, - color=False, - **extra - ): + cli: "BaseCommand", + args: t.Optional[t.Union[str, t.Sequence[str]]] = None, + input: t.Optional[t.Union[str, bytes, t.IO]] = None, + env: t.Optional[t.Mapping[str, t.Optional[str]]] = None, + catch_exceptions: bool = True, + color: bool = False, + **extra: t.Any, + ) -> Result: """Invokes a command in an isolated environment. The arguments are forwarded directly to the command line script, the `extra` keyword arguments are passed to the :meth:`~clickpkg.Command.main` function of @@ -289,16 +363,6 @@ class CliRunner(object): This returns a :class:`Result` object. - .. versionadded:: 3.0 - The ``catch_exceptions`` parameter was added. - - .. versionchanged:: 3.0 - The result object now has an `exc_info` attribute with the - traceback if available. - - .. versionadded:: 4.0 - The ``color`` parameter was added. - :param cli: the command to invoke :param args: the arguments to invoke. It may be given as an iterable or a string. When given as string it will be interpreted @@ -311,13 +375,28 @@ class CliRunner(object): :param extra: the keyword arguments to pass to :meth:`main`. :param color: whether the output should contain color codes. The application can still override this explicitly. + + .. versionchanged:: 8.0 + The result object has the ``return_value`` attribute with + the value returned from the invoked command. + + .. versionchanged:: 4.0 + Added the ``color`` parameter. + + .. versionchanged:: 3.0 + Added the ``catch_exceptions`` parameter. + + .. versionchanged:: 3.0 + The result object has the ``exc_info`` attribute with the + traceback if available. """ exc_info = None with self.isolation(input=input, env=env, color=color) as outstreams: - exception = None + return_value = None + exception: t.Optional[BaseException] = None exit_code = 0 - if isinstance(args, string_types): + if isinstance(args, str): args = shlex.split(args) try: @@ -326,20 +405,23 @@ class CliRunner(object): prog_name = self.get_default_prog_name(cli) try: - cli.main(args=args or (), prog_name=prog_name, **extra) + return_value = cli.main(args=args or (), prog_name=prog_name, **extra) except SystemExit as e: exc_info = sys.exc_info() - exit_code = e.code - if exit_code is None: - exit_code = 0 + e_code = t.cast(t.Optional[t.Union[int, t.Any]], e.code) - if exit_code != 0: + if e_code is None: + e_code = 0 + + if e_code != 0: exception = e - if not isinstance(exit_code, int): - sys.stdout.write(str(exit_code)) + if not isinstance(e_code, int): + sys.stdout.write(str(e_code)) sys.stdout.write("\n") - exit_code = 1 + e_code = 1 + + exit_code = e_code except Exception as e: if not catch_exceptions: @@ -353,30 +435,45 @@ class CliRunner(object): if self.mix_stderr: stderr = None else: - stderr = outstreams[1].getvalue() + stderr = outstreams[1].getvalue() # type: ignore return Result( runner=self, stdout_bytes=stdout, stderr_bytes=stderr, + return_value=return_value, exit_code=exit_code, exception=exception, - exc_info=exc_info, + exc_info=exc_info, # type: ignore ) @contextlib.contextmanager - def isolated_filesystem(self): - """A context manager that creates a temporary folder and changes - the current working directory to it for isolated filesystem tests. + def isolated_filesystem( + self, temp_dir: t.Optional[t.Union[str, os.PathLike]] = None + ) -> t.Iterator[str]: + """A context manager that creates a temporary directory and + changes the current working directory to it. This isolates tests + that affect the contents of the CWD to prevent them from + interfering with each other. + + :param temp_dir: Create the temporary directory under this + directory. If given, the created directory is not removed + when exiting. + + .. versionchanged:: 8.0 + Added the ``temp_dir`` parameter. """ cwd = os.getcwd() - t = tempfile.mkdtemp() + t = tempfile.mkdtemp(dir=temp_dir) os.chdir(t) + try: yield t finally: os.chdir(cwd) - try: - shutil.rmtree(t) - except (OSError, IOError): # noqa: B014 - pass + + if temp_dir is None: + try: + shutil.rmtree(t) + except OSError: # noqa: B014 + pass diff --git a/src/click/types.py b/src/click/types.py index 505c39f..a7de43b 100644 --- a/src/click/types.py +++ b/src/click/types.py @@ -1,37 +1,47 @@ import os import stat +import typing as t from datetime import datetime +from gettext import gettext as _ +from gettext import ngettext from ._compat import _get_argv_encoding -from ._compat import filename_to_ui from ._compat import get_filesystem_encoding -from ._compat import get_streerror from ._compat import open_stream -from ._compat import PY2 -from ._compat import text_type from .exceptions import BadParameter from .utils import LazyFile from .utils import safecall +if t.TYPE_CHECKING: + import typing_extensions as te + from .core import Context + from .core import Parameter + from .shell_completion import CompletionItem -class ParamType(object): - """Helper for converting values through types. The following is - necessary for a valid type: - * it needs a name - * it needs to pass through None unchanged - * it needs to convert from a string - * it needs to convert its result type through unchanged - (eg: needs to be idempotent) - * it needs to be able to deal with param and context being `None`. - This can be the case when the object is used with prompt - inputs. +class ParamType: + """Represents the type of a parameter. Validates and converts values + from the command line or Python into the correct type. + + To implement a custom type, subclass and implement at least the + following: + + - The :attr:`name` class attribute must be set. + - Calling an instance of the type with ``None`` must return + ``None``. This is already implemented by default. + - :meth:`convert` must convert string values to the correct type. + - :meth:`convert` must accept values that are already the correct + type. + - It must be able to convert a value if the ``ctx`` and ``param`` + arguments are ``None``. This can occur when converting prompt + input. """ - is_composite = False + is_composite: t.ClassVar[bool] = False + arity: t.ClassVar[int] = 1 #: the descriptive name of this type - name = None + name: str #: if a list of this type is expected and the value is pulled from a #: string environment variable, this is what splits it up. `None` @@ -39,29 +49,66 @@ class ParamType(object): #: whitespace splits them up. The exception are paths and files which #: are split by ``os.path.pathsep`` by default (":" on Unix and ";" on #: Windows). - envvar_list_splitter = None + envvar_list_splitter: t.ClassVar[t.Optional[str]] = None - def __call__(self, value, param=None, ctx=None): + def to_info_dict(self) -> t.Dict[str, t.Any]: + """Gather information that could be useful for a tool generating + user-facing documentation. + + Use :meth:`click.Context.to_info_dict` to traverse the entire + CLI structure. + + .. versionadded:: 8.0 + """ + # The class name without the "ParamType" suffix. + param_type = type(self).__name__.partition("ParamType")[0] + param_type = param_type.partition("ParameterType")[0] + return {"param_type": param_type, "name": self.name} + + def __call__( + self, + value: t.Any, + param: t.Optional["Parameter"] = None, + ctx: t.Optional["Context"] = None, + ) -> t.Any: if value is not None: return self.convert(value, param, ctx) - def get_metavar(self, param): + def get_metavar(self, param: "Parameter") -> t.Optional[str]: """Returns the metavar default for this param if it provides one.""" - def get_missing_message(self, param): + def get_missing_message(self, param: "Parameter") -> t.Optional[str]: """Optionally might return extra information about a missing parameter. .. versionadded:: 2.0 """ - def convert(self, value, param, ctx): - """Converts the value. This is not invoked for values that are - `None` (the missing value). + def convert( + self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] + ) -> t.Any: + """Convert the value to the correct type. This is not called if + the value is ``None`` (the missing value). + + This must accept string values from the command line, as well as + values that are already the correct type. It may also convert + other compatible types. + + The ``param`` and ``ctx`` arguments may be ``None`` in certain + situations, such as when converting prompt input. + + If the value cannot be converted, call :meth:`fail` with a + descriptive message. + + :param value: The value to convert. + :param param: The parameter that is using this type to convert + its value. May be ``None``. + :param ctx: The current context that arrived at this value. May + be ``None``. """ return value - def split_envvar_value(self, rv): + def split_envvar_value(self, rv: str) -> t.Sequence[str]: """Given a value from an environment variable this splits it up into small chunks depending on the defined envvar list splitter. @@ -71,49 +118,83 @@ class ParamType(object): """ return (rv or "").split(self.envvar_list_splitter) - def fail(self, message, param=None, ctx=None): + def fail( + self, + message: str, + param: t.Optional["Parameter"] = None, + ctx: t.Optional["Context"] = None, + ) -> "t.NoReturn": """Helper method to fail with an invalid value message.""" raise BadParameter(message, ctx=ctx, param=param) + def shell_complete( + self, ctx: "Context", param: "Parameter", incomplete: str + ) -> t.List["CompletionItem"]: + """Return a list of + :class:`~click.shell_completion.CompletionItem` objects for the + incomplete value. Most types do not provide completions, but + some do, and this allows custom types to provide custom + completions as well. + + :param ctx: Invocation context for this command. + :param param: The parameter that is requesting completion. + :param incomplete: Value being completed. May be empty. + + .. versionadded:: 8.0 + """ + return [] + class CompositeParamType(ParamType): is_composite = True @property - def arity(self): + def arity(self) -> int: # type: ignore raise NotImplementedError() class FuncParamType(ParamType): - def __init__(self, func): + def __init__(self, func: t.Callable[[t.Any], t.Any]) -> None: self.name = func.__name__ self.func = func - def convert(self, value, param, ctx): + def to_info_dict(self) -> t.Dict[str, t.Any]: + info_dict = super().to_info_dict() + info_dict["func"] = self.func + return info_dict + + def convert( + self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] + ) -> t.Any: try: return self.func(value) except ValueError: try: - value = text_type(value) + value = str(value) except UnicodeError: - value = str(value).decode("utf-8", "replace") + value = value.decode("utf-8", "replace") + self.fail(value, param, ctx) class UnprocessedParamType(ParamType): name = "text" - def convert(self, value, param, ctx): + def convert( + self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] + ) -> t.Any: return value - def __repr__(self): + def __repr__(self) -> str: return "UNPROCESSED" class StringParamType(ParamType): name = "text" - def convert(self, value, param, ctx): + def convert( + self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] + ) -> t.Any: if isinstance(value, bytes): enc = _get_argv_encoding() try: @@ -128,9 +209,9 @@ class StringParamType(ParamType): else: value = value.decode("utf-8", "replace") return value - return value + return str(value) - def __repr__(self): + def __repr__(self) -> str: return "STRING" @@ -153,17 +234,32 @@ class Choice(ParamType): name = "choice" - def __init__(self, choices, case_sensitive=True): + def __init__(self, choices: t.Sequence[str], case_sensitive: bool = True) -> None: self.choices = choices self.case_sensitive = case_sensitive - def get_metavar(self, param): - return "[{}]".format("|".join(self.choices)) + def to_info_dict(self) -> t.Dict[str, t.Any]: + info_dict = super().to_info_dict() + info_dict["choices"] = self.choices + info_dict["case_sensitive"] = self.case_sensitive + return info_dict - def get_missing_message(self, param): - return "Choose from:\n\t{}.".format(",\n\t".join(self.choices)) + def get_metavar(self, param: "Parameter") -> str: + choices_str = "|".join(self.choices) - def convert(self, value, param, ctx): + # Use curly braces to indicate a required argument. + if param.required and param.param_type_name == "argument": + return f"{{{choices_str}}}" + + # Use square braces to indicate an option or optional argument. + return f"[{choices_str}]" + + def get_missing_message(self, param: "Parameter") -> str: + return _("Choose from:\n\t{choices}").format(choices=",\n\t".join(self.choices)) + + def convert( + self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] + ) -> t.Any: # Match through normalization and case sensitivity # first do token_normalize_func, then lowercase # preserve original `value` to produce an accurate message in @@ -179,30 +275,51 @@ class Choice(ParamType): } if not self.case_sensitive: - if PY2: - lower = str.lower - else: - lower = str.casefold - - normed_value = lower(normed_value) + normed_value = normed_value.casefold() normed_choices = { - lower(normed_choice): original + normed_choice.casefold(): original for normed_choice, original in normed_choices.items() } if normed_value in normed_choices: return normed_choices[normed_value] + choices_str = ", ".join(map(repr, self.choices)) self.fail( - "invalid choice: {}. (choose from {})".format( - value, ", ".join(self.choices) - ), + ngettext( + "{value!r} is not {choice}.", + "{value!r} is not one of {choices}.", + len(self.choices), + ).format(value=value, choice=choices_str, choices=choices_str), param, ctx, ) - def __repr__(self): - return "Choice('{}')".format(list(self.choices)) + def __repr__(self) -> str: + return f"Choice({list(self.choices)})" + + def shell_complete( + self, ctx: "Context", param: "Parameter", incomplete: str + ) -> t.List["CompletionItem"]: + """Complete choices that start with the incomplete value. + + :param ctx: Invocation context for this command. + :param param: The parameter that is requesting completion. + :param incomplete: Value being completed. May be empty. + + .. versionadded:: 8.0 + """ + from click.shell_completion import CompletionItem + + str_choices = map(str, self.choices) + + if self.case_sensitive: + matched = (c for c in str_choices if c.startswith(incomplete)) + else: + incomplete = incomplete.lower() + matched = (c for c in str_choices if c.lower().startswith(incomplete)) + + return [CompletionItem(c) for c in matched] class DateTime(ParamType): @@ -228,212 +345,285 @@ class DateTime(ParamType): name = "datetime" - def __init__(self, formats=None): + def __init__(self, formats: t.Optional[t.Sequence[str]] = None): self.formats = formats or ["%Y-%m-%d", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S"] - def get_metavar(self, param): - return "[{}]".format("|".join(self.formats)) + def to_info_dict(self) -> t.Dict[str, t.Any]: + info_dict = super().to_info_dict() + info_dict["formats"] = self.formats + return info_dict - def _try_to_convert_date(self, value, format): + def get_metavar(self, param: "Parameter") -> str: + return f"[{'|'.join(self.formats)}]" + + def _try_to_convert_date(self, value: t.Any, format: str) -> t.Optional[datetime]: try: return datetime.strptime(value, format) except ValueError: return None - def convert(self, value, param, ctx): - # Exact match - for format in self.formats: - dtime = self._try_to_convert_date(value, format) - if dtime: - return dtime + def convert( + self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] + ) -> t.Any: + if isinstance(value, datetime): + return value + for format in self.formats: + converted = self._try_to_convert_date(value, format) + + if converted is not None: + return converted + + formats_str = ", ".join(map(repr, self.formats)) self.fail( - "invalid datetime format: {}. (choose from {})".format( - value, ", ".join(self.formats) - ) + ngettext( + "{value!r} does not match the format {format}.", + "{value!r} does not match the formats {formats}.", + len(self.formats), + ).format(value=value, format=formats_str, formats=formats_str), + param, + ctx, ) - def __repr__(self): + def __repr__(self) -> str: return "DateTime" -class IntParamType(ParamType): - name = "integer" +class _NumberParamTypeBase(ParamType): + _number_class: t.ClassVar[t.Type] - def convert(self, value, param, ctx): + def convert( + self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] + ) -> t.Any: try: - return int(value) + return self._number_class(value) except ValueError: - self.fail("{} is not a valid integer".format(value), param, ctx) + self.fail( + _("{value!r} is not a valid {number_type}.").format( + value=value, number_type=self.name + ), + param, + ctx, + ) - def __repr__(self): + +class _NumberRangeBase(_NumberParamTypeBase): + def __init__( + self, + min: t.Optional[float] = None, + max: t.Optional[float] = None, + min_open: bool = False, + max_open: bool = False, + clamp: bool = False, + ) -> None: + self.min = min + self.max = max + self.min_open = min_open + self.max_open = max_open + self.clamp = clamp + + def to_info_dict(self) -> t.Dict[str, t.Any]: + info_dict = super().to_info_dict() + info_dict.update( + min=self.min, + max=self.max, + min_open=self.min_open, + max_open=self.max_open, + clamp=self.clamp, + ) + return info_dict + + def convert( + self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] + ) -> t.Any: + import operator + + rv = super().convert(value, param, ctx) + lt_min: bool = self.min is not None and ( + operator.le if self.min_open else operator.lt + )(rv, self.min) + gt_max: bool = self.max is not None and ( + operator.ge if self.max_open else operator.gt + )(rv, self.max) + + if self.clamp: + if lt_min: + return self._clamp(self.min, 1, self.min_open) # type: ignore + + if gt_max: + return self._clamp(self.max, -1, self.max_open) # type: ignore + + if lt_min or gt_max: + self.fail( + _("{value} is not in the range {range}.").format( + value=rv, range=self._describe_range() + ), + param, + ctx, + ) + + return rv + + def _clamp(self, bound: float, dir: "te.Literal[1, -1]", open: bool) -> float: + """Find the valid value to clamp to bound in the given + direction. + + :param bound: The boundary value. + :param dir: 1 or -1 indicating the direction to move. + :param open: If true, the range does not include the bound. + """ + raise NotImplementedError + + def _describe_range(self) -> str: + """Describe the range for use in help text.""" + if self.min is None: + op = "<" if self.max_open else "<=" + return f"x{op}{self.max}" + + if self.max is None: + op = ">" if self.min_open else ">=" + return f"x{op}{self.min}" + + lop = "<" if self.min_open else "<=" + rop = "<" if self.max_open else "<=" + return f"{self.min}{lop}x{rop}{self.max}" + + def __repr__(self) -> str: + clamp = " clamped" if self.clamp else "" + return f"<{type(self).__name__} {self._describe_range()}{clamp}>" + + +class IntParamType(_NumberParamTypeBase): + name = "integer" + _number_class = int + + def __repr__(self) -> str: return "INT" -class IntRange(IntParamType): - """A parameter that works similar to :data:`click.INT` but restricts - the value to fit into a range. The default behavior is to fail if the - value falls outside the range, but it can also be silently clamped - between the two edges. +class IntRange(_NumberRangeBase, IntParamType): + """Restrict an :data:`click.INT` value to a range of accepted + values. See :ref:`ranges`. - See :ref:`ranges` for an example. + If ``min`` or ``max`` are not passed, any value is accepted in that + direction. If ``min_open`` or ``max_open`` are enabled, the + corresponding boundary is not included in the range. + + If ``clamp`` is enabled, a value outside the range is clamped to the + boundary instead of failing. + + .. versionchanged:: 8.0 + Added the ``min_open`` and ``max_open`` parameters. """ name = "integer range" - def __init__(self, min=None, max=None, clamp=False): - self.min = min - self.max = max - self.clamp = clamp + def _clamp( # type: ignore + self, bound: int, dir: "te.Literal[1, -1]", open: bool + ) -> int: + if not open: + return bound - def convert(self, value, param, ctx): - rv = IntParamType.convert(self, value, param, ctx) - if self.clamp: - if self.min is not None and rv < self.min: - return self.min - if self.max is not None and rv > self.max: - return self.max - if ( - self.min is not None - and rv < self.min - or self.max is not None - and rv > self.max - ): - if self.min is None: - self.fail( - "{} is bigger than the maximum valid value {}.".format( - rv, self.max - ), - param, - ctx, - ) - elif self.max is None: - self.fail( - "{} is smaller than the minimum valid value {}.".format( - rv, self.min - ), - param, - ctx, - ) - else: - self.fail( - "{} is not in the valid range of {} to {}.".format( - rv, self.min, self.max - ), - param, - ctx, - ) - return rv - - def __repr__(self): - return "IntRange({}, {})".format(self.min, self.max) + return bound + dir -class FloatParamType(ParamType): +class FloatParamType(_NumberParamTypeBase): name = "float" + _number_class = float - def convert(self, value, param, ctx): - try: - return float(value) - except ValueError: - self.fail( - "{} is not a valid floating point value".format(value), param, ctx - ) - - def __repr__(self): + def __repr__(self) -> str: return "FLOAT" -class FloatRange(FloatParamType): - """A parameter that works similar to :data:`click.FLOAT` but restricts - the value to fit into a range. The default behavior is to fail if the - value falls outside the range, but it can also be silently clamped - between the two edges. +class FloatRange(_NumberRangeBase, FloatParamType): + """Restrict a :data:`click.FLOAT` value to a range of accepted + values. See :ref:`ranges`. - See :ref:`ranges` for an example. + If ``min`` or ``max`` are not passed, any value is accepted in that + direction. If ``min_open`` or ``max_open`` are enabled, the + corresponding boundary is not included in the range. + + If ``clamp`` is enabled, a value outside the range is clamped to the + boundary instead of failing. This is not supported if either + boundary is marked ``open``. + + .. versionchanged:: 8.0 + Added the ``min_open`` and ``max_open`` parameters. """ name = "float range" - def __init__(self, min=None, max=None, clamp=False): - self.min = min - self.max = max - self.clamp = clamp + def __init__( + self, + min: t.Optional[float] = None, + max: t.Optional[float] = None, + min_open: bool = False, + max_open: bool = False, + clamp: bool = False, + ) -> None: + super().__init__( + min=min, max=max, min_open=min_open, max_open=max_open, clamp=clamp + ) - def convert(self, value, param, ctx): - rv = FloatParamType.convert(self, value, param, ctx) - if self.clamp: - if self.min is not None and rv < self.min: - return self.min - if self.max is not None and rv > self.max: - return self.max - if ( - self.min is not None - and rv < self.min - or self.max is not None - and rv > self.max - ): - if self.min is None: - self.fail( - "{} is bigger than the maximum valid value {}.".format( - rv, self.max - ), - param, - ctx, - ) - elif self.max is None: - self.fail( - "{} is smaller than the minimum valid value {}.".format( - rv, self.min - ), - param, - ctx, - ) - else: - self.fail( - "{} is not in the valid range of {} to {}.".format( - rv, self.min, self.max - ), - param, - ctx, - ) - return rv + if (min_open or max_open) and clamp: + raise TypeError("Clamping is not supported for open bounds.") - def __repr__(self): - return "FloatRange({}, {})".format(self.min, self.max) + def _clamp(self, bound: float, dir: "te.Literal[1, -1]", open: bool) -> float: + if not open: + return bound + + # Could use Python 3.9's math.nextafter here, but clamping an + # open float range doesn't seem to be particularly useful. It's + # left up to the user to write a callback to do it if needed. + raise RuntimeError("Clamping is not supported for open bounds.") class BoolParamType(ParamType): name = "boolean" - def convert(self, value, param, ctx): - if isinstance(value, bool): + def convert( + self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] + ) -> t.Any: + if value in {False, True}: return bool(value) - value = value.lower() - if value in ("true", "t", "1", "yes", "y"): - return True - elif value in ("false", "f", "0", "no", "n"): - return False - self.fail("{} is not a valid boolean".format(value), param, ctx) - def __repr__(self): + norm = value.strip().lower() + + if norm in {"1", "true", "t", "yes", "y", "on"}: + return True + + if norm in {"0", "false", "f", "no", "n", "off"}: + return False + + self.fail( + _("{value!r} is not a valid boolean.").format(value=value), param, ctx + ) + + def __repr__(self) -> str: return "BOOL" class UUIDParameterType(ParamType): name = "uuid" - def convert(self, value, param, ctx): + def convert( + self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] + ) -> t.Any: import uuid + if isinstance(value, uuid.UUID): + return value + + value = value.strip() + try: - if PY2 and isinstance(value, text_type): - value = value.encode("ascii") return uuid.UUID(value) except ValueError: - self.fail("{} is not a valid UUID value".format(value), param, ctx) + self.fail( + _("{value!r} is not a valid UUID.").format(value=value), param, ctx + ) - def __repr__(self): + def __repr__(self) -> str: return "UUID" @@ -468,15 +658,25 @@ class File(ParamType): envvar_list_splitter = os.path.pathsep def __init__( - self, mode="r", encoding=None, errors="strict", lazy=None, atomic=False - ): + self, + mode: str = "r", + encoding: t.Optional[str] = None, + errors: t.Optional[str] = "strict", + lazy: t.Optional[bool] = None, + atomic: bool = False, + ) -> None: self.mode = mode self.encoding = encoding self.errors = errors self.lazy = lazy self.atomic = atomic - def resolve_lazy_flag(self, value): + def to_info_dict(self) -> t.Dict[str, t.Any]: + info_dict = super().to_info_dict() + info_dict.update(mode=self.mode, encoding=self.encoding) + return info_dict + + def resolve_lazy_flag(self, value: t.Any) -> bool: if self.lazy is not None: return self.lazy if value == "-": @@ -485,7 +685,9 @@ class File(ParamType): return True return False - def convert(self, value, param, ctx): + def convert( + self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] + ) -> t.Any: try: if hasattr(value, "read") or hasattr(value, "write"): return value @@ -493,16 +695,22 @@ class File(ParamType): lazy = self.resolve_lazy_flag(value) if lazy: - f = LazyFile( - value, self.mode, self.encoding, self.errors, atomic=self.atomic + f: t.IO = t.cast( + t.IO, + LazyFile( + value, self.mode, self.encoding, self.errors, atomic=self.atomic + ), ) + if ctx is not None: - ctx.call_on_close(f.close_intelligently) + ctx.call_on_close(f.close_intelligently) # type: ignore + return f f, should_close = open_stream( value, self.mode, self.encoding, self.errors, atomic=self.atomic ) + # If a context is provided, we automatically close the file # at the end of the context execution (or flush out). If a # context does not exist, it's the caller's responsibility to @@ -513,15 +721,26 @@ class File(ParamType): ctx.call_on_close(safecall(f.close)) else: ctx.call_on_close(safecall(f.flush)) + return f - except (IOError, OSError) as e: # noqa: B014 - self.fail( - "Could not open file: {}: {}".format( - filename_to_ui(value), get_streerror(e) - ), - param, - ctx, - ) + except OSError as e: # noqa: B014 + self.fail(f"{os.fsdecode(value)!r}: {e.strerror}", param, ctx) + + def shell_complete( + self, ctx: "Context", param: "Parameter", incomplete: str + ) -> t.List["CompletionItem"]: + """Return a special completion marker that tells the completion + system to use the shell to provide file path completions. + + :param ctx: Invocation context for this command. + :param param: The parameter that is requesting completion. + :param incomplete: Value being completed. May be empty. + + .. versionadded:: 8.0 + """ + from click.shell_completion import CompletionItem + + return [CompletionItem(incomplete, type="file")] class Path(ParamType): @@ -530,9 +749,6 @@ class Path(ParamType): handle it returns just the filename. Secondly, it can perform various basic checks about what the file or directory should be. - .. versionchanged:: 6.0 - `allow_dash` was added. - :param exists: if set to true, the file or directory needs to exist for this value to be valid. If this is not required and a file does indeed not exist, then all further checks are @@ -548,25 +764,29 @@ class Path(ParamType): supposed to be done by the shell only. :param allow_dash: If this is set to `True`, a single dash to indicate standard streams is permitted. - :param path_type: optionally a string type that should be used to - represent the path. The default is `None` which - means the return value will be either bytes or - unicode depending on what makes most sense given the - input data Click deals with. + :param path_type: Convert the incoming path value to this type. If + ``None``, keep Python's default, which is ``str``. Useful to + convert to :class:`pathlib.Path`. + + .. versionchanged:: 8.0 + Allow passing ``type=pathlib.Path``. + + .. versionchanged:: 6.0 + Added the ``allow_dash`` parameter. """ envvar_list_splitter = os.path.pathsep def __init__( self, - exists=False, - file_okay=True, - dir_okay=True, - writable=False, - readable=True, - resolve_path=False, - allow_dash=False, - path_type=None, + exists: bool = False, + file_okay: bool = True, + dir_okay: bool = True, + writable: bool = False, + readable: bool = True, + resolve_path: bool = False, + allow_dash: bool = False, + path_type: t.Optional[t.Type] = None, ): self.exists = exists self.file_okay = file_okay @@ -578,31 +798,58 @@ class Path(ParamType): self.type = path_type if self.file_okay and not self.dir_okay: - self.name = "file" - self.path_type = "File" + self.name = _("file") elif self.dir_okay and not self.file_okay: - self.name = "directory" - self.path_type = "Directory" + self.name = _("directory") else: - self.name = "path" - self.path_type = "Path" + self.name = _("path") - def coerce_path_result(self, rv): + def to_info_dict(self) -> t.Dict[str, t.Any]: + info_dict = super().to_info_dict() + info_dict.update( + exists=self.exists, + file_okay=self.file_okay, + dir_okay=self.dir_okay, + writable=self.writable, + readable=self.readable, + allow_dash=self.allow_dash, + ) + return info_dict + + def coerce_path_result(self, rv: t.Any) -> t.Any: if self.type is not None and not isinstance(rv, self.type): - if self.type is text_type: - rv = rv.decode(get_filesystem_encoding()) + if self.type is str: + rv = os.fsdecode(rv) + elif self.type is bytes: + rv = os.fsencode(rv) else: - rv = rv.encode(get_filesystem_encoding()) + rv = self.type(rv) + return rv - def convert(self, value, param, ctx): + def convert( + self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] + ) -> t.Any: rv = value is_dash = self.file_okay and self.allow_dash and rv in (b"-", "-") if not is_dash: if self.resolve_path: - rv = os.path.realpath(rv) + # Get the absolute directory containing the path. + dir_ = os.path.dirname(os.path.abspath(rv)) + + # Resolve a symlink. realpath on Windows Python < 3.9 + # doesn't resolve symlinks. This might return a relative + # path even if the path to the link is absolute. + if os.path.islink(rv): + rv = os.readlink(rv) + + # Join dir_ with the resolved symlink if the resolved + # path is relative. This will make it relative to the + # original containing directory. + if not os.path.isabs(rv): + rv = os.path.join(dir_, rv) try: st = os.stat(rv) @@ -610,8 +857,8 @@ class Path(ParamType): if not self.exists: return self.coerce_path_result(rv) self.fail( - "{} '{}' does not exist.".format( - self.path_type, filename_to_ui(value) + _("{name} {filename!r} does not exist.").format( + name=self.name.title(), filename=os.fsdecode(value) ), param, ctx, @@ -619,30 +866,32 @@ class Path(ParamType): if not self.file_okay and stat.S_ISREG(st.st_mode): self.fail( - "{} '{}' is a file.".format(self.path_type, filename_to_ui(value)), + _("{name} {filename!r} is a file.").format( + name=self.name.title(), filename=os.fsdecode(value) + ), param, ctx, ) if not self.dir_okay and stat.S_ISDIR(st.st_mode): self.fail( - "{} '{}' is a directory.".format( - self.path_type, filename_to_ui(value) + _("{name} {filename!r} is a directory.").format( + name=self.name.title(), filename=os.fsdecode(value) ), param, ctx, ) - if self.writable and not os.access(value, os.W_OK): + if self.writable and not os.access(rv, os.W_OK): self.fail( - "{} '{}' is not writable.".format( - self.path_type, filename_to_ui(value) + _("{name} {filename!r} is not writable.").format( + name=self.name.title(), filename=os.fsdecode(value) ), param, ctx, ) - if self.readable and not os.access(value, os.R_OK): + if self.readable and not os.access(rv, os.R_OK): self.fail( - "{} '{}' is not readable.".format( - self.path_type, filename_to_ui(value) + _("{name} {filename!r} is not readable.").format( + name=self.name.title(), filename=os.fsdecode(value) ), param, ctx, @@ -650,6 +899,24 @@ class Path(ParamType): return self.coerce_path_result(rv) + def shell_complete( + self, ctx: "Context", param: "Parameter", incomplete: str + ) -> t.List["CompletionItem"]: + """Return a special completion marker that tells the completion + system to use the shell to provide path completions for only + directories or any paths. + + :param ctx: Invocation context for this command. + :param param: The parameter that is requesting completion. + :param incomplete: Value being completed. May be empty. + + .. versionadded:: 8.0 + """ + from click.shell_completion import CompletionItem + + type = "dir" if self.dir_okay and not self.file_okay else "file" + return [CompletionItem(incomplete, type=type)] + class Tuple(CompositeParamType): """The default behavior of Click is to apply a type on a value directly. @@ -665,75 +932,107 @@ class Tuple(CompositeParamType): :param types: a list of types that should be used for the tuple items. """ - def __init__(self, types): + def __init__(self, types: t.Sequence[t.Union[t.Type, ParamType]]) -> None: self.types = [convert_type(ty) for ty in types] - @property - def name(self): - return "<{}>".format(" ".join(ty.name for ty in self.types)) + def to_info_dict(self) -> t.Dict[str, t.Any]: + info_dict = super().to_info_dict() + info_dict["types"] = [t.to_info_dict() for t in self.types] + return info_dict @property - def arity(self): + def name(self) -> str: # type: ignore + return f"<{' '.join(ty.name for ty in self.types)}>" + + @property + def arity(self) -> int: # type: ignore return len(self.types) - def convert(self, value, param, ctx): - if len(value) != len(self.types): - raise TypeError( - "It would appear that nargs is set to conflict with the" - " composite type arity." + def convert( + self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] + ) -> t.Any: + len_type = len(self.types) + len_value = len(value) + + if len_value != len_type: + self.fail( + ngettext( + "{len_type} values are required, but {len_value} was given.", + "{len_type} values are required, but {len_value} were given.", + len_value, + ).format(len_type=len_type, len_value=len_value), + param=param, + ctx=ctx, ) + return tuple(ty(x, param, ctx) for ty, x in zip(self.types, value)) -def convert_type(ty, default=None): - """Converts a callable or python type into the most appropriate - param type. +def convert_type(ty: t.Optional[t.Any], default: t.Optional[t.Any] = None) -> ParamType: + """Find the most appropriate :class:`ParamType` for the given Python + type. If the type isn't provided, it can be inferred from a default + value. """ guessed_type = False + if ty is None and default is not None: - if isinstance(default, tuple): - ty = tuple(map(type, default)) + if isinstance(default, (tuple, list)): + # If the default is empty, ty will remain None and will + # return STRING. + if default: + item = default[0] + + # A tuple of tuples needs to detect the inner types. + # Can't call convert recursively because that would + # incorrectly unwind the tuple to a single type. + if isinstance(item, (tuple, list)): + ty = tuple(map(type, item)) + else: + ty = type(item) else: ty = type(default) + guessed_type = True if isinstance(ty, tuple): return Tuple(ty) + if isinstance(ty, ParamType): return ty - if ty is text_type or ty is str or ty is None: + + if ty is str or ty is None: return STRING + if ty is int: return INT - # Booleans are only okay if not guessed. This is done because for - # flags the default value is actually a bit of a lie in that it - # indicates which of the flags is the one we want. See get_default() - # for more information. - if ty is bool and not guessed_type: - return BOOL + if ty is float: return FLOAT + + if ty is bool: + return BOOL + if guessed_type: return STRING - # Catch a common mistake if __debug__: try: if issubclass(ty, ParamType): raise AssertionError( - "Attempted to use an uninstantiated parameter type ({}).".format(ty) + f"Attempted to use an uninstantiated parameter type ({ty})." ) except TypeError: + # ty is an instance (correct), so issubclass fails. pass + return FuncParamType(ty) #: A dummy parameter type that just does nothing. From a user's -#: perspective this appears to just be the same as `STRING` but internally -#: no string conversion takes place. This is necessary to achieve the -#: same bytes/unicode behavior on Python 2/3 in situations where you want -#: to not convert argument types. This is usually useful when working -#: with file paths as they can appear in bytes and unicode. +#: perspective this appears to just be the same as `STRING` but +#: internally no string conversion takes place if the input was bytes. +#: This is usually useful when working with file paths as they can +#: appear in bytes and unicode. #: #: For path related uses the :class:`Path` type is a better choice but #: there are situations where an unprocessed type is useful which is why diff --git a/src/click/utils.py b/src/click/utils.py index 79265e7..16033d6 100644 --- a/src/click/utils.py +++ b/src/click/utils.py @@ -1,86 +1,105 @@ import os import sys +import typing as t +from functools import update_wrapper +from types import ModuleType from ._compat import _default_text_stderr from ._compat import _default_text_stdout +from ._compat import _find_binary_writer from ._compat import auto_wrap_for_ansi from ._compat import binary_streams -from ._compat import filename_to_ui from ._compat import get_filesystem_encoding -from ._compat import get_streerror -from ._compat import is_bytes from ._compat import open_stream -from ._compat import PY2 from ._compat import should_strip_ansi -from ._compat import string_types from ._compat import strip_ansi from ._compat import text_streams -from ._compat import text_type from ._compat import WIN from .globals import resolve_color_default -if not PY2: - from ._compat import _find_binary_writer -elif WIN: - from ._winconsole import _get_windows_argv - from ._winconsole import _hash_py_argv - from ._winconsole import _initial_argv_hash +if t.TYPE_CHECKING: + import typing_extensions as te -echo_native_types = string_types + (bytes, bytearray) +F = t.TypeVar("F", bound=t.Callable[..., t.Any]) -def _posixify(name): +def _posixify(name: str) -> str: return "-".join(name.split()).lower() -def safecall(func): +def safecall(func: F) -> F: """Wraps a function so that it swallows exceptions.""" - def wrapper(*args, **kwargs): + def wrapper(*args, **kwargs): # type: ignore try: return func(*args, **kwargs) except Exception: pass - return wrapper + return update_wrapper(t.cast(F, wrapper), func) -def make_str(value): +def make_str(value: t.Any) -> str: """Converts a value into a valid string.""" if isinstance(value, bytes): try: return value.decode(get_filesystem_encoding()) except UnicodeError: return value.decode("utf-8", "replace") - return text_type(value) + return str(value) -def make_default_short_help(help, max_length=45): - """Return a condensed version of help string.""" +def make_default_short_help(help: str, max_length: int = 45) -> str: + """Returns a condensed version of help string.""" + # Consider only the first paragraph. + paragraph_end = help.find("\n\n") + + if paragraph_end != -1: + help = help[:paragraph_end] + + # Collapse newlines, tabs, and spaces. words = help.split() + + if not words: + return "" + + # The first paragraph started with a "no rewrap" marker, ignore it. + if words[0] == "\b": + words = words[1:] + total_length = 0 - result = [] - done = False + last_index = len(words) - 1 - for word in words: - if word[-1:] == ".": - done = True - new_length = 1 + len(word) if result else len(word) - if total_length + new_length > max_length: - result.append("...") - done = True - else: - if result: - result.append(" ") - result.append(word) - if done: + for i, word in enumerate(words): + total_length += len(word) + (i > 0) + + if total_length > max_length: # too long, truncate break - total_length += new_length - return "".join(result) + if word[-1] == ".": # sentence end, truncate without "..." + return " ".join(words[: i + 1]) + + if total_length == max_length and i != last_index: + break # not at sentence end, truncate with "..." + else: + return " ".join(words) # no truncation needed + + # Account for the length of the suffix. + total_length += len("...") + + # remove words until the length is short enough + while i > 0: + total_length -= len(words[i]) + (i > 0) + + if total_length <= max_length: + break + + i -= 1 + + return " ".join(words[:i]) + "..." -class LazyFile(object): +class LazyFile: """A lazy file works like a regular file but it does not fully open the file but it does perform some basic checks early to see if the filename parameter does make sense. This is useful for safely opening @@ -88,13 +107,19 @@ class LazyFile(object): """ def __init__( - self, filename, mode="r", encoding=None, errors="strict", atomic=False + self, + filename: str, + mode: str = "r", + encoding: t.Optional[str] = None, + errors: t.Optional[str] = "strict", + atomic: bool = False, ): self.name = filename self.mode = mode self.encoding = encoding self.errors = errors self.atomic = atomic + self._f: t.Optional[t.IO] if filename == "-": self._f, self.should_close = open_stream(filename, mode, encoding, errors) @@ -107,15 +132,15 @@ class LazyFile(object): self._f = None self.should_close = True - def __getattr__(self, name): + def __getattr__(self, name: str) -> t.Any: return getattr(self.open(), name) - def __repr__(self): + def __repr__(self) -> str: if self._f is not None: return repr(self._f) - return "".format(self.name, self.mode) + return f"" - def open(self): + def open(self) -> t.IO: """Opens the file if it's not yet open. This call might fail with a :exc:`FileError`. Not handling this error will produce an error that Click shows. @@ -126,102 +151,100 @@ class LazyFile(object): rv, self.should_close = open_stream( self.name, self.mode, self.encoding, self.errors, atomic=self.atomic ) - except (IOError, OSError) as e: # noqa: E402 + except OSError as e: # noqa: E402 from .exceptions import FileError - raise FileError(self.name, hint=get_streerror(e)) + raise FileError(self.name, hint=e.strerror) from e self._f = rv return rv - def close(self): + def close(self) -> None: """Closes the underlying file, no matter what.""" if self._f is not None: self._f.close() - def close_intelligently(self): + def close_intelligently(self) -> None: """This function only closes the file if it was opened by the lazy file wrapper. For instance this will never close stdin. """ if self.should_close: self.close() - def __enter__(self): + def __enter__(self) -> "LazyFile": return self - def __exit__(self, exc_type, exc_value, tb): + def __exit__(self, exc_type, exc_value, tb): # type: ignore self.close_intelligently() - def __iter__(self): + def __iter__(self) -> t.Iterator[t.AnyStr]: self.open() - return iter(self._f) + return iter(self._f) # type: ignore -class KeepOpenFile(object): - def __init__(self, file): +class KeepOpenFile: + def __init__(self, file: t.IO) -> None: self._file = file - def __getattr__(self, name): + def __getattr__(self, name: str) -> t.Any: return getattr(self._file, name) - def __enter__(self): + def __enter__(self) -> "KeepOpenFile": return self - def __exit__(self, exc_type, exc_value, tb): + def __exit__(self, exc_type, exc_value, tb): # type: ignore pass - def __repr__(self): + def __repr__(self) -> str: return repr(self._file) - def __iter__(self): + def __iter__(self) -> t.Iterator[t.AnyStr]: return iter(self._file) -def echo(message=None, file=None, nl=True, err=False, color=None): - """Prints a message plus a newline to the given file or stdout. On - first sight, this looks like the print function, but it has improved - support for handling Unicode and binary data that does not fail no - matter how badly configured the system is. +def echo( + message: t.Optional[t.Any] = None, + file: t.Optional[t.IO] = None, + nl: bool = True, + err: bool = False, + color: t.Optional[bool] = None, +) -> None: + """Print a message and newline to stdout or a file. This should be + used instead of :func:`print` because it provides better support + for different data, files, and environments. - Primarily it means that you can print binary data as well as Unicode - data on both 2.x and 3.x to the given file in the most appropriate way - possible. This is a very carefree function in that it will try its - best to not fail. As of Click 6.0 this includes support for unicode - output on the Windows console. + Compared to :func:`print`, this does the following: - In addition to that, if `colorama`_ is installed, the echo function will - also support clever handling of ANSI codes. Essentially it will then - do the following: + - Ensures that the output encoding is not misconfigured on Linux. + - Supports Unicode in the Windows console. + - Supports writing to binary outputs, and supports writing bytes + to text outputs. + - Supports colors and styles on Windows. + - Removes ANSI color and style codes if the output does not look + like an interactive terminal. + - Always flushes the output. - - add transparent handling of ANSI color codes on Windows. - - hide ANSI codes automatically if the destination file is not a - terminal. - - .. _colorama: https://pypi.org/project/colorama/ + :param message: The string or bytes to output. Other objects are + converted to strings. + :param file: The file to write to. Defaults to ``stdout``. + :param err: Write to ``stderr`` instead of ``stdout``. + :param nl: Print a newline after the message. Enabled by default. + :param color: Force showing or hiding colors and other styles. By + default Click will remove color if the output does not look like + an interactive terminal. .. versionchanged:: 6.0 - As of Click 6.0 the echo function will properly support unicode - output on the windows console. Not that click does not modify - the interpreter in any way which means that `sys.stdout` or the - print statement or function will still not provide unicode support. - - .. versionchanged:: 2.0 - Starting with version 2.0 of Click, the echo function will work - with colorama if it's installed. - - .. versionadded:: 3.0 - The `err` parameter was added. + Support Unicode output on the Windows console. Click does not + modify ``sys.stdout``, so ``sys.stdout.write()`` and ``print()`` + will still not support Unicode. .. versionchanged:: 4.0 - Added the `color` flag. + Added the ``color`` parameter. - :param message: the message to print - :param file: the file to write to (defaults to ``stdout``) - :param err: if set to true the file defaults to ``stderr`` instead of - ``stdout``. This is faster and easier than calling - :func:`get_text_stderr` yourself. - :param nl: if set to `True` (the default) a newline is printed afterwards. - :param color: controls if the terminal supports ANSI colors or not. The - default is autodetection. + .. versionadded:: 3.0 + Added the ``err`` parameter. + + .. versionchanged:: 2.0 + Support colors on Windows if colorama is installed. """ if file is None: if err: @@ -230,70 +253,73 @@ def echo(message=None, file=None, nl=True, err=False, color=None): file = _default_text_stdout() # Convert non bytes/text into the native string type. - if message is not None and not isinstance(message, echo_native_types): - message = text_type(message) + if message is not None and not isinstance(message, (str, bytes, bytearray)): + out: t.Optional[t.Union[str, bytes]] = str(message) + else: + out = message if nl: - message = message or u"" - if isinstance(message, text_type): - message += u"\n" + out = out or "" + if isinstance(out, str): + out += "\n" else: - message += b"\n" + out += b"\n" - # If there is a message, and we're in Python 3, and the value looks - # like bytes, we manually need to find the binary stream and write the - # message in there. This is done separately so that most stream - # types will work as you would expect. Eg: you can write to StringIO - # for other cases. - if message and not PY2 and is_bytes(message): + if not out: + file.flush() + return + + # If there is a message and the value looks like bytes, we manually + # need to find the binary stream and write the message in there. + # This is done separately so that most stream types will work as you + # would expect. Eg: you can write to StringIO for other cases. + if isinstance(out, (bytes, bytearray)): binary_file = _find_binary_writer(file) + if binary_file is not None: file.flush() - binary_file.write(message) + binary_file.write(out) binary_file.flush() return - # ANSI-style support. If there is no message or we are dealing with - # bytes nothing is happening. If we are connected to a file we want - # to strip colors. If we are on windows we either wrap the stream - # to strip the color or we use the colorama support to translate the - # ansi codes to API calls. - if message and not is_bytes(message): + # ANSI style code support. For no message or bytes, nothing happens. + # When outputting to a file instead of a terminal, strip codes. + else: color = resolve_color_default(color) + if should_strip_ansi(file, color): - message = strip_ansi(message) + out = strip_ansi(out) elif WIN: if auto_wrap_for_ansi is not None: - file = auto_wrap_for_ansi(file) + file = auto_wrap_for_ansi(file) # type: ignore elif not color: - message = strip_ansi(message) + out = strip_ansi(out) - if message: - file.write(message) + file.write(out) # type: ignore file.flush() -def get_binary_stream(name): - """Returns a system stream for byte processing. This essentially - returns the stream from the sys module with the given name but it - solves some compatibility issues between different Python versions. - Primarily this function is necessary for getting binary streams on - Python 3. +def get_binary_stream(name: "te.Literal['stdin', 'stdout', 'stderr']") -> t.BinaryIO: + """Returns a system stream for byte processing. :param name: the name of the stream to open. Valid names are ``'stdin'``, ``'stdout'`` and ``'stderr'`` """ opener = binary_streams.get(name) if opener is None: - raise TypeError("Unknown standard stream '{}'".format(name)) + raise TypeError(f"Unknown standard stream '{name}'") return opener() -def get_text_stream(name, encoding=None, errors="strict"): +def get_text_stream( + name: "te.Literal['stdin', 'stdout', 'stderr']", + encoding: t.Optional[str] = None, + errors: t.Optional[str] = "strict", +) -> t.TextIO: """Returns a system stream for text processing. This usually returns a wrapped stream around a binary stream returned from - :func:`get_binary_stream` but it also can take shortcuts on Python 3 - for already correctly configured streams. + :func:`get_binary_stream` but it also can take shortcuts for already + correctly configured streams. :param name: the name of the stream to open. Valid names are ``'stdin'``, ``'stdout'`` and ``'stderr'`` @@ -302,13 +328,18 @@ def get_text_stream(name, encoding=None, errors="strict"): """ opener = text_streams.get(name) if opener is None: - raise TypeError("Unknown standard stream '{}'".format(name)) + raise TypeError(f"Unknown standard stream '{name}'") return opener(encoding, errors) def open_file( - filename, mode="r", encoding=None, errors="strict", lazy=False, atomic=False -): + filename: str, + mode: str = "r", + encoding: t.Optional[str] = None, + errors: t.Optional[str] = "strict", + lazy: bool = False, + atomic: bool = False, +) -> t.IO: """This is similar to how the :class:`File` works but for manual usage. Files are opened non lazy by default. This can open regular files as well as stdin/stdout if ``'-'`` is passed. @@ -332,35 +363,35 @@ def open_file( moved on close. """ if lazy: - return LazyFile(filename, mode, encoding, errors, atomic=atomic) + return t.cast(t.IO, LazyFile(filename, mode, encoding, errors, atomic=atomic)) f, should_close = open_stream(filename, mode, encoding, errors, atomic=atomic) if not should_close: - f = KeepOpenFile(f) + f = t.cast(t.IO, KeepOpenFile(f)) return f -def get_os_args(): - """This returns the argument part of sys.argv in the most appropriate - form for processing. What this means is that this return value is in - a format that works for Click to process but does not necessarily - correspond well to what's actually standard for the interpreter. +def get_os_args() -> t.Sequence[str]: + """Returns the argument part of ``sys.argv``, removing the first + value which is the name of the script. - On most environments the return value is ``sys.argv[:1]`` unchanged. - However if you are on Windows and running Python 2 the return value - will actually be a list of unicode strings instead because the - default behavior on that platform otherwise will not be able to - carry all possible values that sys.argv can have. - - .. versionadded:: 6.0 + .. deprecated:: 8.0 + Will be removed in Click 8.1. Access ``sys.argv[1:]`` directly + instead. """ - # We can only extract the unicode argv if sys.argv has not been - # changed since the startup of the application. - if PY2 and WIN and _initial_argv_hash == _hash_py_argv(): - return _get_windows_argv() + import warnings + + warnings.warn( + "'get_os_args' is deprecated and will be removed in Click 8.1." + " Access 'sys.argv[1:]' directly instead.", + DeprecationWarning, + stacklevel=2, + ) return sys.argv[1:] -def format_filename(filename, shorten=False): +def format_filename( + filename: t.Union[str, bytes, os.PathLike], shorten: bool = False +) -> str: """Formats a filename for user display. The main purpose of this function is to ensure that the filename can be displayed at all. This will decode the filename to unicode if necessary in a way that it will @@ -374,10 +405,11 @@ def format_filename(filename, shorten=False): """ if shorten: filename = os.path.basename(filename) - return filename_to_ui(filename) + + return os.fsdecode(filename) -def get_app_dir(app_name, roaming=True, force_posix=False): +def get_app_dir(app_name: str, roaming: bool = True, force_posix: bool = False) -> str: r"""Returns the config folder for the application. The default behavior is to return whatever is most appropriate for the operating system. @@ -392,13 +424,9 @@ def get_app_dir(app_name, roaming=True, force_posix=False): ``~/.config/foo-bar`` Unix (POSIX): ``~/.foo-bar`` - Win XP (roaming): - ``C:\Documents and Settings\\Local Settings\Application Data\Foo Bar`` - Win XP (not roaming): - ``C:\Documents and Settings\\Application Data\Foo Bar`` - Win 7 (roaming): + Windows (roaming): ``C:\Users\\AppData\Roaming\Foo Bar`` - Win 7 (not roaming): + Windows (not roaming): ``C:\Users\\AppData\Local\Foo Bar`` .. versionadded:: 2.0 @@ -419,7 +447,7 @@ def get_app_dir(app_name, roaming=True, force_posix=False): folder = os.path.expanduser("~") return os.path.join(folder, app_name) if force_posix: - return os.path.join(os.path.expanduser("~/.{}".format(_posixify(app_name)))) + return os.path.join(os.path.expanduser(f"~/.{_posixify(app_name)}")) if sys.platform == "darwin": return os.path.join( os.path.expanduser("~/Library/Application Support"), app_name @@ -430,7 +458,7 @@ def get_app_dir(app_name, roaming=True, force_posix=False): ) -class PacifyFlushWrapper(object): +class PacifyFlushWrapper: """This wrapper is used to catch and suppress BrokenPipeErrors resulting from ``.flush()`` being called on broken pipe during the shutdown/final-GC of the Python interpreter. Notably ``.flush()`` is always called on @@ -439,17 +467,113 @@ class PacifyFlushWrapper(object): pipe, all calls and attributes are proxied. """ - def __init__(self, wrapped): + def __init__(self, wrapped: t.IO) -> None: self.wrapped = wrapped - def flush(self): + def flush(self) -> None: try: self.wrapped.flush() - except IOError as e: + except OSError as e: import errno if e.errno != errno.EPIPE: raise - def __getattr__(self, attr): + def __getattr__(self, attr: str) -> t.Any: return getattr(self.wrapped, attr) + + +def _detect_program_name( + path: t.Optional[str] = None, _main: ModuleType = sys.modules["__main__"] +) -> str: + """Determine the command used to run the program, for use in help + text. If a file or entry point was executed, the file name is + returned. If ``python -m`` was used to execute a module or package, + ``python -m name`` is returned. + + This doesn't try to be too precise, the goal is to give a concise + name for help text. Files are only shown as their name without the + path. ``python`` is only shown for modules, and the full path to + ``sys.executable`` is not shown. + + :param path: The Python file being executed. Python puts this in + ``sys.argv[0]``, which is used by default. + :param _main: The ``__main__`` module. This should only be passed + during internal testing. + + .. versionadded:: 8.0 + Based on command args detection in the Werkzeug reloader. + + :meta private: + """ + if not path: + path = sys.argv[0] + + # The value of __package__ indicates how Python was called. It may + # not exist if a setuptools script is installed as an egg. It may be + # set incorrectly for entry points created with pip on Windows. + if getattr(_main, "__package__", None) is None or ( + os.name == "nt" + and _main.__package__ == "" + and not os.path.exists(path) + and os.path.exists(f"{path}.exe") + ): + # Executed a file, like "python app.py". + return os.path.basename(path) + + # Executed a module, like "python -m example". + # Rewritten by Python from "-m script" to "/path/to/script.py". + # Need to look at main module to determine how it was executed. + py_module = t.cast(str, _main.__package__) + name = os.path.splitext(os.path.basename(path))[0] + + # A submodule like "example.cli". + if name != "__main__": + py_module = f"{py_module}.{name}" + + return f"python -m {py_module.lstrip('.')}" + + +def _expand_args( + args: t.Iterable[str], + *, + user: bool = True, + env: bool = True, + glob_recursive: bool = True, +) -> t.List[str]: + """Simulate Unix shell expansion with Python functions. + + See :func:`glob.glob`, :func:`os.path.expanduser`, and + :func:`os.path.expandvars`. + + This intended for use on Windows, where the shell does not do any + expansion. It may not exactly match what a Unix shell would do. + + :param args: List of command line arguments to expand. + :param user: Expand user home directory. + :param env: Expand environment variables. + :param glob_recursive: ``**`` matches directories recursively. + + .. versionadded:: 8.0 + + :meta private: + """ + from glob import glob + + out = [] + + for arg in args: + if user: + arg = os.path.expanduser(arg) + + if env: + arg = os.path.expandvars(arg) + + matches = glob(arg, recursive=glob_recursive) + + if not matches: + out.append(arg) + else: + out.extend(matches) + + return out diff --git a/tests/conftest.py b/tests/conftest.py index 9440804..d33df11 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,7 @@ +import os +import shutil +import tempfile + import pytest from click.testing import CliRunner @@ -6,3 +10,22 @@ from click.testing import CliRunner @pytest.fixture(scope="function") def runner(request): return CliRunner() + + +def check_symlink_impl(): + """This function checks if using symlinks is allowed + on the host machine""" + tempdir = tempfile.mkdtemp(prefix="click-") + test_pth = os.path.join(tempdir, "check_sym_impl") + sym_pth = os.path.join(tempdir, "link") + open(test_pth, "w").close() + rv = True + try: + os.symlink(test_pth, sym_pth) + except (NotImplementedError, OSError): + # Creating symlinks on Windows require elevated access. + # OSError is thrown if the function is called without it. + rv = False + finally: + shutil.rmtree(tempdir, ignore_errors=True) + return rv diff --git a/tests/test_arguments.py b/tests/test_arguments.py index 0b510c0..f4d7afd 100644 --- a/tests/test_arguments.py +++ b/tests/test_arguments.py @@ -1,11 +1,8 @@ -# -*- coding: utf-8 -*- import sys import pytest import click -from click._compat import PY2 -from click._compat import text_type def test_nargs_star(runner): @@ -13,19 +10,19 @@ def test_nargs_star(runner): @click.argument("src", nargs=-1) @click.argument("dst") def copy(src, dst): - click.echo("src={}".format("|".join(src))) - click.echo("dst={}".format(dst)) + click.echo(f"src={'|'.join(src)}") + click.echo(f"dst={dst}") result = runner.invoke(copy, ["foo.txt", "bar.txt", "dir"]) assert not result.exception assert result.output.splitlines() == ["src=foo.txt|bar.txt", "dst=dir"] -def test_nargs_default(runner): +def test_argument_unbounded_nargs_cant_have_default(runner): with pytest.raises(TypeError, match="nargs=-1"): @click.command() - @click.argument("src", nargs=-1, default=42) + @click.argument("src", nargs=-1, default=["42"]) def copy(src): pass @@ -35,8 +32,9 @@ def test_nargs_tup(runner): @click.argument("name", nargs=1) @click.argument("point", nargs=2, type=click.INT) def copy(name, point): - click.echo("name={}".format(name)) - click.echo("point={0[0]}/{0[1]}".format(point)) + click.echo(f"name={name}") + x, y = point + click.echo(f"point={x}/{y}") result = runner.invoke(copy, ["peter", "1", "2"]) assert not result.exception @@ -56,7 +54,8 @@ def test_nargs_tup_composite(runner): @click.command() @click.argument("item", **opts) def copy(item): - click.echo("name={0[0]} id={0[1]:d}".format(item)) + name, id = item + click.echo(f"name={name} id={id:d}") result = runner.invoke(copy, ["peter", "1"]) assert not result.exception @@ -83,22 +82,17 @@ def test_bytes_args(runner, monkeypatch): @click.argument("arg") def from_bytes(arg): assert isinstance( - arg, text_type + arg, str ), "UTF-8 encoded argument should be implicitly converted to Unicode" # Simulate empty locale environment variables - if PY2: - monkeypatch.setattr(sys.stdin, "encoding", "ANSI_X3.4-1968") - monkeypatch.setattr(sys, "getfilesystemencoding", lambda: "ANSI_X3.4-1968") - monkeypatch.setattr(sys, "getdefaultencoding", lambda: "ascii") - else: - monkeypatch.setattr(sys.stdin, "encoding", "utf-8") - monkeypatch.setattr(sys, "getfilesystemencoding", lambda: "utf-8") - monkeypatch.setattr(sys, "getdefaultencoding", lambda: "utf-8") + monkeypatch.setattr(sys.stdin, "encoding", "utf-8") + monkeypatch.setattr(sys, "getfilesystemencoding", lambda: "utf-8") + monkeypatch.setattr(sys, "getdefaultencoding", lambda: "utf-8") runner.invoke( from_bytes, - [u"Something outside of ASCII range: 林".encode("UTF-8")], + ["Something outside of ASCII range: 林".encode()], catch_exceptions=False, ) @@ -169,33 +163,55 @@ def test_stdout_default(runner): assert result.output == "Foo bar baz\n" -def test_nargs_envvar(runner): - @click.command() - @click.option("--arg", nargs=2) - def cmd(arg): - click.echo("|".join(arg)) - - result = runner.invoke( - cmd, [], auto_envvar_prefix="TEST", env={"TEST_ARG": "foo bar"} - ) - assert not result.exception - assert result.output == "foo|bar\n" +@pytest.mark.parametrize( + ("nargs", "value", "expect"), + [ + (2, "", None), + (2, "a", "Takes 2 values but 1 was given."), + (2, "a b", ("a", "b")), + (2, "a b c", "Takes 2 values but 3 were given."), + (-1, "a b c", ("a", "b", "c")), + (-1, "", ()), + ], +) +def test_nargs_envvar(runner, nargs, value, expect): + if nargs == -1: + param = click.argument("arg", envvar="X", nargs=nargs) + else: + param = click.option("--arg", envvar="X", nargs=nargs) @click.command() - @click.option("--arg", envvar="X", nargs=2) + @param def cmd(arg): - click.echo("|".join(arg)) + return arg - result = runner.invoke(cmd, [], env={"X": "foo bar"}) - assert not result.exception - assert result.output == "foo|bar\n" + result = runner.invoke(cmd, env={"X": value}, standalone_mode=False) + + if isinstance(expect, str): + assert isinstance(result.exception, click.BadParameter) + assert expect in result.exception.format_message() + else: + assert result.return_value == expect + + +def test_nargs_envvar_only_if_values_empty(runner): + @click.command() + @click.argument("arg", envvar="X", nargs=-1) + def cli(arg): + return arg + + result = runner.invoke(cli, ["a", "b"], standalone_mode=False) + assert result.return_value == ("a", "b") + + result = runner.invoke(cli, env={"X": "a"}, standalone_mode=False) + assert result.return_value == ("a",) def test_empty_nargs(runner): @click.command() @click.argument("arg", nargs=-1) def cmd(arg): - click.echo("arg:{}".format("|".join(arg))) + click.echo(f"arg:{'|'.join(arg)}") result = runner.invoke(cmd, []) assert result.exit_code == 0 @@ -204,7 +220,7 @@ def test_empty_nargs(runner): @click.command() @click.argument("arg", nargs=-1, required=True) def cmd2(arg): - click.echo("arg:{}".format("|".join(arg))) + click.echo(f"arg:{'|'.join(arg)}") result = runner.invoke(cmd2, []) assert result.exit_code == 2 @@ -215,7 +231,7 @@ def test_missing_arg(runner): @click.command() @click.argument("arg") def cmd(arg): - click.echo("arg:{}".format(arg)) + click.echo(f"arg:{arg}") result = runner.invoke(cmd, []) assert result.exit_code == 2 @@ -226,9 +242,9 @@ def test_missing_argument_string_cast(): ctx = click.Context(click.Command("")) with pytest.raises(click.MissingParameter) as excinfo: - click.Argument(["a"], required=True).full_process_value(ctx, None) + click.Argument(["a"], required=True).process_value(ctx, None) - assert str(excinfo.value) == "missing parameter: a" + assert str(excinfo.value) == "Missing parameter: a" def test_implicit_non_required(runner): @@ -268,7 +284,7 @@ def test_nargs_star_ordering(runner): click.echo(arg) result = runner.invoke(cmd, ["a", "b", "c"]) - assert result.output.splitlines() == ["(u'a',)" if PY2 else "('a',)", "b", "c"] + assert result.output.splitlines() == ["('a',)", "b", "c"] def test_nargs_specified_plus_star_ordering(runner): @@ -281,11 +297,7 @@ def test_nargs_specified_plus_star_ordering(runner): click.echo(arg) result = runner.invoke(cmd, ["a", "b", "c", "d", "e", "f"]) - assert result.output.splitlines() == [ - "(u'a', u'b', u'c')" if PY2 else "('a', 'b', 'c')", - "d", - "(u'e', u'f')" if PY2 else "('e', 'f')", - ] + assert result.output.splitlines() == ["('a', 'b', 'c')", "d", "('e', 'f')"] def test_defaults_for_nargs(runner): @@ -303,7 +315,7 @@ def test_defaults_for_nargs(runner): result = runner.invoke(cmd, ["3"]) assert result.exception is not None - assert "argument a takes 2 values" in result.output + assert "Argument 'a' takes 2 values." in result.output def test_multiple_param_decls_not_allowed(runner): @@ -313,3 +325,55 @@ def test_multiple_param_decls_not_allowed(runner): @click.argument("x", click.Choice(["a", "b"])) def copy(x): click.echo(x) + + +def test_multiple_not_allowed(): + with pytest.raises(TypeError, match="multiple"): + click.Argument(["a"], multiple=True) + + +@pytest.mark.parametrize("value", [(), ("a",), ("a", "b", "c")]) +def test_nargs_bad_default(runner, value): + with pytest.raises(ValueError, match="nargs=2"): + click.Argument(["a"], nargs=2, default=value) + + +def test_subcommand_help(runner): + @click.group() + @click.argument("name") + @click.argument("val") + @click.option("--opt") + @click.pass_context + def cli(ctx, name, val, opt): + ctx.obj = dict(name=name, val=val) + + @cli.command() + @click.pass_obj + def cmd(obj): + click.echo(f"CMD for {obj['name']} with value {obj['val']}") + + result = runner.invoke(cli, ["foo", "bar", "cmd", "--help"]) + assert not result.exception + assert "Usage: cli NAME VAL cmd [OPTIONS]" in result.output + + +def test_nested_subcommand_help(runner): + @click.group() + @click.argument("arg1") + @click.option("--opt1") + def cli(arg1, opt1): + pass + + @cli.group() + @click.argument("arg2") + @click.option("--opt2") + def cmd(arg2, opt2): + pass + + @cmd.command() + def subcmd(): + click.echo("subcommand") + + result = runner.invoke(cli, ["arg1", "cmd", "arg2", "subcmd", "--help"]) + assert not result.exception + assert "Usage: cli ARG1 cmd ARG2 subcmd [OPTIONS]" in result.output diff --git a/tests/test_bashcomplete.py b/tests/test_bashcomplete.py deleted file mode 100644 index df4ef28..0000000 --- a/tests/test_bashcomplete.py +++ /dev/null @@ -1,500 +0,0 @@ -# -*- coding: utf-8 -*- -import pytest - -import click -from click._bashcomplete import get_choices - - -def choices_without_help(cli, args, incomplete): - completions = get_choices(cli, "dummy", args, incomplete) - return [c[0] for c in completions] - - -def choices_with_help(cli, args, incomplete): - return list(get_choices(cli, "dummy", args, incomplete)) - - -def test_single_command(): - @click.command() - @click.option("--local-opt") - def cli(local_opt): - pass - - assert choices_without_help(cli, [], "-") == ["--local-opt"] - assert choices_without_help(cli, [], "") == [] - - -def test_boolean_flag(): - @click.command() - @click.option("--shout/--no-shout", default=False) - def cli(local_opt): - pass - - assert choices_without_help(cli, [], "-") == ["--shout", "--no-shout"] - - -def test_multi_value_option(): - @click.group() - @click.option("--pos", nargs=2, type=float) - def cli(local_opt): - pass - - @cli.command() - @click.option("--local-opt") - def sub(local_opt): - pass - - assert choices_without_help(cli, [], "-") == ["--pos"] - assert choices_without_help(cli, ["--pos"], "") == [] - assert choices_without_help(cli, ["--pos", "1.0"], "") == [] - assert choices_without_help(cli, ["--pos", "1.0", "1.0"], "") == ["sub"] - - -def test_multi_option(): - @click.command() - @click.option("--message", "-m", multiple=True) - def cli(local_opt): - pass - - assert choices_without_help(cli, [], "-") == ["--message", "-m"] - assert choices_without_help(cli, ["-m"], "") == [] - - -def test_small_chain(): - @click.group() - @click.option("--global-opt") - def cli(global_opt): - pass - - @cli.command() - @click.option("--local-opt") - def sub(local_opt): - pass - - assert choices_without_help(cli, [], "") == ["sub"] - assert choices_without_help(cli, [], "-") == ["--global-opt"] - assert choices_without_help(cli, ["sub"], "") == [] - assert choices_without_help(cli, ["sub"], "-") == ["--local-opt"] - - -def test_long_chain(): - @click.group("cli") - @click.option("--cli-opt") - def cli(cli_opt): - pass - - @cli.group("asub") - @click.option("--asub-opt") - def asub(asub_opt): - pass - - @asub.group("bsub") - @click.option("--bsub-opt") - def bsub(bsub_opt): - pass - - COLORS = ["red", "green", "blue"] - - def get_colors(ctx, args, incomplete): - for c in COLORS: - if c.startswith(incomplete): - yield c - - def search_colors(ctx, args, incomplete): - for c in COLORS: - if incomplete in c: - yield c - - CSUB_OPT_CHOICES = ["foo", "bar"] - CSUB_CHOICES = ["bar", "baz"] - - @bsub.command("csub") - @click.option("--csub-opt", type=click.Choice(CSUB_OPT_CHOICES)) - @click.option("--csub", type=click.Choice(CSUB_CHOICES)) - @click.option("--search-color", autocompletion=search_colors) - @click.argument("color", autocompletion=get_colors) - def csub(csub_opt, color): - pass - - assert choices_without_help(cli, [], "-") == ["--cli-opt"] - assert choices_without_help(cli, [], "") == ["asub"] - assert choices_without_help(cli, ["asub"], "-") == ["--asub-opt"] - assert choices_without_help(cli, ["asub"], "") == ["bsub"] - assert choices_without_help(cli, ["asub", "bsub"], "-") == ["--bsub-opt"] - assert choices_without_help(cli, ["asub", "bsub"], "") == ["csub"] - assert choices_without_help(cli, ["asub", "bsub", "csub"], "-") == [ - "--csub-opt", - "--csub", - "--search-color", - ] - assert ( - choices_without_help(cli, ["asub", "bsub", "csub", "--csub-opt"], "") - == CSUB_OPT_CHOICES - ) - assert choices_without_help(cli, ["asub", "bsub", "csub"], "--csub") == [ - "--csub-opt", - "--csub", - ] - assert ( - choices_without_help(cli, ["asub", "bsub", "csub", "--csub"], "") - == CSUB_CHOICES - ) - assert choices_without_help(cli, ["asub", "bsub", "csub", "--csub-opt"], "f") == [ - "foo" - ] - assert choices_without_help(cli, ["asub", "bsub", "csub"], "") == COLORS - assert choices_without_help(cli, ["asub", "bsub", "csub"], "b") == ["blue"] - assert choices_without_help( - cli, ["asub", "bsub", "csub", "--search-color"], "een" - ) == ["green"] - - -def test_chaining(): - @click.group("cli", chain=True) - @click.option("--cli-opt") - @click.argument("arg", type=click.Choice(["cliarg1", "cliarg2"])) - def cli(cli_opt, arg): - pass - - @cli.command() - @click.option("--asub-opt") - def asub(asub_opt): - pass - - @cli.command(help="bsub help") - @click.option("--bsub-opt") - @click.argument("arg", type=click.Choice(["arg1", "arg2"])) - def bsub(bsub_opt, arg): - pass - - @cli.command() - @click.option("--csub-opt") - @click.argument("arg", type=click.Choice(["carg1", "carg2"]), default="carg1") - def csub(csub_opt, arg): - pass - - assert choices_without_help(cli, [], "-") == ["--cli-opt"] - assert choices_without_help(cli, [], "") == ["cliarg1", "cliarg2"] - assert choices_without_help(cli, ["cliarg1", "asub"], "-") == ["--asub-opt"] - assert choices_without_help(cli, ["cliarg1", "asub"], "") == ["bsub", "csub"] - assert choices_without_help(cli, ["cliarg1", "bsub"], "") == ["arg1", "arg2"] - assert choices_without_help(cli, ["cliarg1", "asub", "--asub-opt"], "") == [] - assert choices_without_help( - cli, ["cliarg1", "asub", "--asub-opt", "5", "bsub"], "-" - ) == ["--bsub-opt"] - assert choices_without_help(cli, ["cliarg1", "asub", "bsub"], "-") == ["--bsub-opt"] - assert choices_without_help(cli, ["cliarg1", "asub", "csub"], "") == [ - "carg1", - "carg2", - ] - assert choices_without_help(cli, ["cliarg1", "bsub", "arg1", "csub"], "") == [ - "carg1", - "carg2", - ] - assert choices_without_help(cli, ["cliarg1", "asub", "csub"], "-") == ["--csub-opt"] - assert choices_with_help(cli, ["cliarg1", "asub"], "b") == [("bsub", "bsub help")] - - -def test_argument_choice(): - @click.command() - @click.argument("arg1", required=True, type=click.Choice(["arg11", "arg12"])) - @click.argument("arg2", type=click.Choice(["arg21", "arg22"]), default="arg21") - @click.argument("arg3", type=click.Choice(["arg", "argument"]), default="arg") - def cli(): - pass - - assert choices_without_help(cli, [], "") == ["arg11", "arg12"] - assert choices_without_help(cli, [], "arg") == ["arg11", "arg12"] - assert choices_without_help(cli, ["arg11"], "") == ["arg21", "arg22"] - assert choices_without_help(cli, ["arg12", "arg21"], "") == ["arg", "argument"] - assert choices_without_help(cli, ["arg12", "arg21"], "argu") == ["argument"] - - -def test_option_choice(): - @click.command() - @click.option("--opt1", type=click.Choice(["opt11", "opt12"]), help="opt1 help") - @click.option("--opt2", type=click.Choice(["opt21", "opt22"]), default="opt21") - @click.option("--opt3", type=click.Choice(["opt", "option"])) - def cli(): - pass - - assert choices_with_help(cli, [], "-") == [ - ("--opt1", "opt1 help"), - ("--opt2", None), - ("--opt3", None), - ] - assert choices_without_help(cli, [], "--opt") == ["--opt1", "--opt2", "--opt3"] - assert choices_without_help(cli, [], "--opt1=") == ["opt11", "opt12"] - assert choices_without_help(cli, [], "--opt2=") == ["opt21", "opt22"] - assert choices_without_help(cli, ["--opt2"], "=") == ["opt21", "opt22"] - assert choices_without_help(cli, ["--opt2", "="], "opt") == ["opt21", "opt22"] - assert choices_without_help(cli, ["--opt1"], "") == ["opt11", "opt12"] - assert choices_without_help(cli, ["--opt2"], "") == ["opt21", "opt22"] - assert choices_without_help(cli, ["--opt1", "opt11", "--opt2"], "") == [ - "opt21", - "opt22", - ] - assert choices_without_help(cli, ["--opt2", "opt21"], "-") == ["--opt1", "--opt3"] - assert choices_without_help(cli, ["--opt1", "opt11"], "-") == ["--opt2", "--opt3"] - assert choices_without_help(cli, ["--opt1"], "opt") == ["opt11", "opt12"] - assert choices_without_help(cli, ["--opt3"], "opti") == ["option"] - - assert choices_without_help(cli, ["--opt1", "invalid_opt"], "-") == [ - "--opt2", - "--opt3", - ] - - -def test_option_and_arg_choice(): - @click.command() - @click.option("--opt1", type=click.Choice(["opt11", "opt12"])) - @click.argument("arg1", required=False, type=click.Choice(["arg11", "arg12"])) - @click.option("--opt2", type=click.Choice(["opt21", "opt22"])) - def cli(): - pass - - assert choices_without_help(cli, ["--opt1"], "") == ["opt11", "opt12"] - assert choices_without_help(cli, [""], "--opt1=") == ["opt11", "opt12"] - assert choices_without_help(cli, [], "") == ["arg11", "arg12"] - assert choices_without_help(cli, ["--opt2"], "") == ["opt21", "opt22"] - assert choices_without_help(cli, ["arg11"], "--opt") == ["--opt1", "--opt2"] - assert choices_without_help(cli, [], "--opt") == ["--opt1", "--opt2"] - - -def test_boolean_flag_choice(): - @click.command() - @click.option("--shout/--no-shout", default=False) - @click.argument("arg", required=False, type=click.Choice(["arg1", "arg2"])) - def cli(local_opt): - pass - - assert choices_without_help(cli, [], "-") == ["--shout", "--no-shout"] - assert choices_without_help(cli, ["--shout"], "") == ["arg1", "arg2"] - - -def test_multi_value_option_choice(): - @click.command() - @click.option("--pos", nargs=2, type=click.Choice(["pos1", "pos2"])) - @click.argument("arg", required=False, type=click.Choice(["arg1", "arg2"])) - def cli(local_opt): - pass - - assert choices_without_help(cli, ["--pos"], "") == ["pos1", "pos2"] - assert choices_without_help(cli, ["--pos", "pos1"], "") == ["pos1", "pos2"] - assert choices_without_help(cli, ["--pos", "pos1", "pos2"], "") == ["arg1", "arg2"] - assert choices_without_help(cli, ["--pos", "pos1", "pos2", "arg1"], "") == [] - - -def test_multi_option_choice(): - @click.command() - @click.option("--message", "-m", multiple=True, type=click.Choice(["m1", "m2"])) - @click.argument("arg", required=False, type=click.Choice(["arg1", "arg2"])) - def cli(local_opt): - pass - - assert choices_without_help(cli, ["-m"], "") == ["m1", "m2"] - assert choices_without_help(cli, ["-m", "m1", "-m"], "") == ["m1", "m2"] - assert choices_without_help(cli, ["-m", "m1"], "") == ["arg1", "arg2"] - - -def test_variadic_argument_choice(): - @click.command() - @click.option("--opt", type=click.Choice(["opt1", "opt2"])) - @click.argument("src", nargs=-1, type=click.Choice(["src1", "src2"])) - def cli(local_opt): - pass - - assert choices_without_help(cli, ["src1", "src2"], "") == ["src1", "src2"] - assert choices_without_help(cli, ["src1", "src2"], "--o") == ["--opt"] - assert choices_without_help(cli, ["src1", "src2", "--opt"], "") == ["opt1", "opt2"] - assert choices_without_help(cli, ["src1", "src2"], "") == ["src1", "src2"] - - -def test_variadic_argument_complete(): - def _complete(ctx, args, incomplete): - return ["abc", "def", "ghi", "jkl", "mno", "pqr", "stu", "vwx", "yz"] - - @click.group() - def entrypoint(): - pass - - @click.command() - @click.option("--opt", autocompletion=_complete) - @click.argument("arg", nargs=-1) - def subcommand(opt, arg): - pass - - entrypoint.add_command(subcommand) - - assert choices_without_help(entrypoint, ["subcommand", "--opt"], "") == _complete( - 0, 0, 0 - ) - assert choices_without_help( - entrypoint, ["subcommand", "whatever", "--opt"], "" - ) == _complete(0, 0, 0) - assert ( - choices_without_help(entrypoint, ["subcommand", "whatever", "--opt", "abc"], "") - == [] - ) - - -def test_long_chain_choice(): - @click.group() - def cli(): - pass - - @cli.group() - @click.option("--sub-opt", type=click.Choice(["subopt1", "subopt2"])) - @click.argument( - "sub-arg", required=False, type=click.Choice(["subarg1", "subarg2"]) - ) - def sub(sub_opt, sub_arg): - pass - - @sub.command(short_help="bsub help") - @click.option("--bsub-opt", type=click.Choice(["bsubopt1", "bsubopt2"])) - @click.argument( - "bsub-arg1", required=False, type=click.Choice(["bsubarg1", "bsubarg2"]) - ) - @click.argument( - "bbsub-arg2", required=False, type=click.Choice(["bbsubarg1", "bbsubarg2"]) - ) - def bsub(bsub_opt): - pass - - @sub.group("csub") - def csub(): - pass - - @csub.command() - def dsub(): - pass - - assert choices_with_help(cli, ["sub", "subarg1"], "") == [ - ("bsub", "bsub help"), - ("csub", ""), - ] - assert choices_without_help(cli, ["sub"], "") == ["subarg1", "subarg2"] - assert choices_without_help(cli, ["sub", "--sub-opt"], "") == ["subopt1", "subopt2"] - assert choices_without_help(cli, ["sub", "--sub-opt", "subopt1"], "") == [ - "subarg1", - "subarg2", - ] - assert choices_without_help( - cli, ["sub", "--sub-opt", "subopt1", "subarg1", "bsub"], "-" - ) == ["--bsub-opt"] - assert choices_without_help( - cli, ["sub", "--sub-opt", "subopt1", "subarg1", "bsub"], "" - ) == ["bsubarg1", "bsubarg2"] - assert choices_without_help( - cli, ["sub", "--sub-opt", "subopt1", "subarg1", "bsub", "--bsub-opt"], "" - ) == ["bsubopt1", "bsubopt2"] - assert choices_without_help( - cli, - [ - "sub", - "--sub-opt", - "subopt1", - "subarg1", - "bsub", - "--bsub-opt", - "bsubopt1", - "bsubarg1", - ], - "", - ) == ["bbsubarg1", "bbsubarg2"] - assert choices_without_help( - cli, ["sub", "--sub-opt", "subopt1", "subarg1", "csub"], "" - ) == ["dsub"] - - -def test_chained_multi(): - @click.group() - def cli(): - pass - - @cli.group() - def sub(): - pass - - @sub.group() - def bsub(): - pass - - @sub.group(chain=True) - def csub(): - pass - - @csub.command() - def dsub(): - pass - - @csub.command() - def esub(): - pass - - assert choices_without_help(cli, ["sub"], "") == ["bsub", "csub"] - assert choices_without_help(cli, ["sub"], "c") == ["csub"] - assert choices_without_help(cli, ["sub", "csub"], "") == ["dsub", "esub"] - assert choices_without_help(cli, ["sub", "csub", "dsub"], "") == ["esub"] - - -def test_hidden(): - @click.group() - @click.option("--name", hidden=True) - @click.option("--choices", type=click.Choice([1, 2]), hidden=True) - def cli(name): - pass - - @cli.group(hidden=True) - def hgroup(): - pass - - @hgroup.group() - def hgroupsub(): - pass - - @cli.command() - def asub(): - pass - - @cli.command(hidden=True) - @click.option("--hname") - def hsub(): - pass - - assert choices_without_help(cli, [], "--n") == [] - assert choices_without_help(cli, [], "--c") == [] - # If the user exactly types out the hidden param, complete its options. - assert choices_without_help(cli, ["--choices"], "") == [1, 2] - assert choices_without_help(cli, [], "") == ["asub"] - assert choices_without_help(cli, [], "") == ["asub"] - assert choices_without_help(cli, [], "h") == [] - # If the user exactly types out the hidden command, complete its subcommands. - assert choices_without_help(cli, ["hgroup"], "") == ["hgroupsub"] - assert choices_without_help(cli, ["hsub"], "--h") == ["--hname"] - - -@pytest.mark.parametrize( - ("args", "part", "expect"), - [ - ([], "-", ["--opt"]), - (["value"], "--", ["--opt"]), - ([], "-o", []), - (["--opt"], "-o", []), - (["--"], "", ["name", "-o", "--opt", "--"]), - (["--"], "--o", ["--opt"]), - ], -) -def test_args_with_double_dash_complete(args, part, expect): - def _complete(ctx, args, incomplete): - values = ["name", "-o", "--opt", "--"] - return [x for x in values if x.startswith(incomplete)] - - @click.command() - @click.option("--opt") - @click.argument("args", nargs=-1, autocompletion=_complete) - def cli(opt, args): - pass - - assert choices_without_help(cli, args, part) == expect diff --git a/tests/test_basic.py b/tests/test_basic.py index f07b6d1..c38c1af 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -1,6 +1,8 @@ -# -*- coding: utf-8 -*- import os import uuid +from itertools import chain + +import pytest import click @@ -78,11 +80,35 @@ def test_basic_group(runner): assert "SUBCOMMAND EXECUTED" in result.output +def test_group_commands_dict(runner): + """A Group can be built with a dict of commands.""" + + @click.command() + def sub(): + click.echo("sub", nl=False) + + cli = click.Group(commands={"other": sub}) + result = runner.invoke(cli, ["other"]) + assert result.output == "sub" + + +def test_group_from_list(runner): + """A Group can be built with a list of commands.""" + + @click.command() + def sub(): + click.echo("sub", nl=False) + + cli = click.Group(commands=[sub]) + result = runner.invoke(cli, ["sub"]) + assert result.output == "sub" + + def test_basic_option(runner): @click.command() @click.option("--foo", default="no value") def cli(foo): - click.echo(u"FOO:[{}]".format(foo)) + click.echo(f"FOO:[{foo}]") result = runner.invoke(cli, []) assert not result.exception @@ -94,22 +120,22 @@ def test_basic_option(runner): result = runner.invoke(cli, ["--foo"]) assert result.exception - assert "--foo option requires an argument" in result.output + assert "Option '--foo' requires an argument." in result.output result = runner.invoke(cli, ["--foo="]) assert not result.exception assert "FOO:[]" in result.output - result = runner.invoke(cli, [u"--foo=\N{SNOWMAN}"]) + result = runner.invoke(cli, ["--foo=\N{SNOWMAN}"]) assert not result.exception - assert u"FOO:[\N{SNOWMAN}]" in result.output + assert "FOO:[\N{SNOWMAN}]" in result.output def test_int_option(runner): @click.command() @click.option("--foo", default=42) def cli(foo): - click.echo("FOO:[{}]".format(foo * 2)) + click.echo(f"FOO:[{foo * 2}]") result = runner.invoke(cli, []) assert not result.exception @@ -121,7 +147,7 @@ def test_int_option(runner): result = runner.invoke(cli, ["--foo=bar"]) assert result.exception - assert "Invalid value for '--foo': bar is not a valid integer" in result.output + assert "Invalid value for '--foo': 'bar' is not a valid integer." in result.output def test_uuid_option(runner): @@ -131,7 +157,7 @@ def test_uuid_option(runner): ) def cli(u): assert type(u) is uuid.UUID - click.echo("U:[{}]".format(u)) + click.echo(f"U:[{u}]") result = runner.invoke(cli, []) assert not result.exception @@ -143,7 +169,7 @@ def test_uuid_option(runner): result = runner.invoke(cli, ["--u=bar"]) assert result.exception - assert "Invalid value for '--u': bar is not a valid UUID value" in result.output + assert "Invalid value for '--u': 'bar' is not a valid UUID." in result.output def test_float_option(runner): @@ -151,7 +177,7 @@ def test_float_option(runner): @click.option("--foo", default=42, type=click.FLOAT) def cli(foo): assert type(foo) is float - click.echo("FOO:[{}]".format(foo)) + click.echo(f"FOO:[{foo}]") result = runner.invoke(cli, []) assert not result.exception @@ -163,7 +189,7 @@ def test_float_option(runner): result = runner.invoke(cli, ["--foo=bar"]) assert result.exception - assert "Invalid value for '--foo': bar is not a valid float" in result.output + assert "Invalid value for '--foo': 'bar' is not a valid float." in result.output def test_boolean_option(runner): @@ -182,7 +208,7 @@ def test_boolean_option(runner): assert result.output == "False\n" result = runner.invoke(cli, []) assert not result.exception - assert result.output == "{}\n".format(default) + assert result.output == f"{default}\n" for default in True, False: @@ -193,33 +219,30 @@ def test_boolean_option(runner): result = runner.invoke(cli, ["--flag"]) assert not result.exception - assert result.output == "{}\n".format(not default) + assert result.output == f"{not default}\n" result = runner.invoke(cli, []) assert not result.exception - assert result.output == "{}\n".format(default) + assert result.output == f"{default}\n" -def test_boolean_conversion(runner): - for default in True, False: +@pytest.mark.parametrize( + ("value", "expect"), + chain( + ((x, "True") for x in ("1", "true", "t", "yes", "y", "on")), + ((x, "False") for x in ("0", "false", "f", "no", "n", "off")), + ), +) +def test_boolean_conversion(runner, value, expect): + @click.command() + @click.option("--flag", type=bool) + def cli(flag): + click.echo(flag, nl=False) - @click.command() - @click.option("--flag", default=default, type=bool) - def cli(flag): - click.echo(flag) + result = runner.invoke(cli, ["--flag", value]) + assert result.output == expect - for value in "true", "t", "1", "yes", "y": - result = runner.invoke(cli, ["--flag", value]) - assert not result.exception - assert result.output == "True\n" - - for value in "false", "f", "0", "no", "n": - result = runner.invoke(cli, ["--flag", value]) - assert not result.exception - assert result.output == "False\n" - - result = runner.invoke(cli, []) - assert not result.exception - assert result.output == "{}\n".format(default) + result = runner.invoke(cli, ["--flag", value.title()]) + assert result.output == expect def test_file_option(runner): @@ -280,10 +303,7 @@ def test_file_lazy_mode(runner): os.mkdir("example.txt") result_in = runner.invoke(input_non_lazy, ["--file=example.txt"]) assert result_in.exit_code == 2 - assert ( - "Invalid value for '--file': Could not open file: example.txt" - in result_in.output - ) + assert "Invalid value for '--file': 'example.txt'" in result_in.output def test_path_option(runner): @@ -308,8 +328,8 @@ def test_path_option(runner): @click.command() @click.option("-f", type=click.Path(exists=True)) def showtype(f): - click.echo("is_file={}".format(os.path.isfile(f))) - click.echo("is_dir={}".format(os.path.isdir(f))) + click.echo(f"is_file={os.path.isfile(f)}") + click.echo(f"is_dir={os.path.isdir(f)}") with runner.isolated_filesystem(): result = runner.invoke(showtype, ["-f", "xxx"]) @@ -322,7 +342,7 @@ def test_path_option(runner): @click.command() @click.option("-f", type=click.Path()) def exists(f): - click.echo("exists={}".format(os.path.exists(f))) + click.echo(f"exists={os.path.exists(f)}") with runner.isolated_filesystem(): result = runner.invoke(exists, ["-f", "xxx"]) @@ -345,14 +365,35 @@ def test_choice_option(runner): result = runner.invoke(cli, ["--method=meh"]) assert result.exit_code == 2 assert ( - "Invalid value for '--method': invalid choice: meh." - " (choose from foo, bar, baz)" in result.output + "Invalid value for '--method': 'meh' is not one of 'foo', 'bar', 'baz'." + in result.output ) result = runner.invoke(cli, ["--help"]) assert "--method [foo|bar|baz]" in result.output +def test_choice_argument(runner): + @click.command() + @click.argument("method", type=click.Choice(["foo", "bar", "baz"])) + def cli(method): + click.echo(method) + + result = runner.invoke(cli, ["foo"]) + assert not result.exception + assert result.output == "foo\n" + + result = runner.invoke(cli, ["meh"]) + assert result.exit_code == 2 + assert ( + "Invalid value for '{foo|bar|baz}': 'meh' is not one of 'foo'," + " 'bar', 'baz'." in result.output + ) + + result = runner.invoke(cli, ["--help"]) + assert "{foo|bar|baz}" in result.output + + def test_datetime_option_default(runner): @click.command() @click.option("--start_date", type=click.DateTime()) @@ -370,9 +411,8 @@ def test_datetime_option_default(runner): result = runner.invoke(cli, ["--start_date=2015-09"]) assert result.exit_code == 2 assert ( - "Invalid value for '--start_date':" - " invalid datetime format: 2015-09." - " (choose from %Y-%m-%d, %Y-%m-%dT%H:%M:%S, %Y-%m-%d %H:%M:%S)" + "Invalid value for '--start_date': '2015-09' does not match the formats" + " '%Y-%m-%d', '%Y-%m-%dT%H:%M:%S', '%Y-%m-%d %H:%M:%S'." ) in result.output result = runner.invoke(cli, ["--help"]) @@ -392,76 +432,6 @@ def test_datetime_option_custom(runner): assert result.output == "2010-06-05T00:00:00\n" -def test_int_range_option(runner): - @click.command() - @click.option("--x", type=click.IntRange(0, 5)) - def cli(x): - click.echo(x) - - result = runner.invoke(cli, ["--x=5"]) - assert not result.exception - assert result.output == "5\n" - - result = runner.invoke(cli, ["--x=6"]) - assert result.exit_code == 2 - assert ( - "Invalid value for '--x': 6 is not in the valid range of 0 to 5.\n" - in result.output - ) - - @click.command() - @click.option("--x", type=click.IntRange(0, 5, clamp=True)) - def clamp(x): - click.echo(x) - - result = runner.invoke(clamp, ["--x=5"]) - assert not result.exception - assert result.output == "5\n" - - result = runner.invoke(clamp, ["--x=6"]) - assert not result.exception - assert result.output == "5\n" - - result = runner.invoke(clamp, ["--x=-1"]) - assert not result.exception - assert result.output == "0\n" - - -def test_float_range_option(runner): - @click.command() - @click.option("--x", type=click.FloatRange(0, 5)) - def cli(x): - click.echo(x) - - result = runner.invoke(cli, ["--x=5.0"]) - assert not result.exception - assert result.output == "5.0\n" - - result = runner.invoke(cli, ["--x=6.0"]) - assert result.exit_code == 2 - assert ( - "Invalid value for '--x': 6.0 is not in the valid range of 0 to 5.\n" - in result.output - ) - - @click.command() - @click.option("--x", type=click.FloatRange(0, 5, clamp=True)) - def clamp(x): - click.echo(x) - - result = runner.invoke(clamp, ["--x=5.0"]) - assert not result.exception - assert result.output == "5.0\n" - - result = runner.invoke(clamp, ["--x=6.0"]) - assert not result.exception - assert result.output == "5\n" - - result = runner.invoke(clamp, ["--x=-1.0"]) - assert not result.exception - assert result.output == "0\n" - - def test_required_option(runner): @click.command() @click.option("--foo", required=True) @@ -558,3 +528,39 @@ def test_hidden_group(runner): assert result.exit_code == 0 assert "subgroup" not in result.output assert "nope" not in result.output + + +def test_summary_line(runner): + @click.group() + def cli(): + pass + + @cli.command() + def cmd(): + """ + Summary line without period + + Here is a sentence. And here too. + """ + pass + + result = runner.invoke(cli, ["--help"]) + assert "Summary line without period" in result.output + assert "Here is a sentence." not in result.output + + +def test_help_invalid_default(runner): + cli = click.Command( + "cli", + params=[ + click.Option( + ["-a"], + type=click.Path(exists=True), + default="not found", + show_default=True, + ), + ], + ) + result = runner.invoke(cli, ["--help"]) + assert result.exit_code == 0 + assert "default: not found" in result.output diff --git a/tests/test_chain.py b/tests/test_chain.py index c227270..23520a0 100644 --- a/tests/test_chain.py +++ b/tests/test_chain.py @@ -7,9 +7,8 @@ import click def debug(): click.echo( - "{}={}".format( - sys._getframe(1).f_code.co_name, "|".join(click.get_current_context().args) - ) + f"{sys._getframe(1).f_code.co_name}" + f"={'|'.join(click.get_current_context().args)}" ) @@ -77,18 +76,37 @@ def test_chaining_with_options(runner): @cli.command("sdist") @click.option("--format") def sdist(format): - click.echo("sdist called {}".format(format)) + click.echo(f"sdist called {format}") @cli.command("bdist") @click.option("--format") def bdist(format): - click.echo("bdist called {}".format(format)) + click.echo(f"bdist called {format}") result = runner.invoke(cli, ["bdist", "--format=1", "sdist", "--format=2"]) assert not result.exception assert result.output.splitlines() == ["bdist called 1", "sdist called 2"] +@pytest.mark.parametrize(("chain", "expect"), [(False, "None"), (True, "[]")]) +def test_no_command_result_callback(runner, chain, expect): + """When a group has ``invoke_without_command=True``, the result + callback is always invoked. A regular group invokes it with + ``None``, a chained group with ``[]``. + """ + + @click.group(invoke_without_command=True, chain=chain) + def cli(): + pass + + @cli.result_callback() + def process_result(result): + click.echo(str(result), nl=False) + + result = runner.invoke(cli, []) + assert result.output == expect + + def test_chaining_with_arguments(runner): @click.group(chain=True) def cli(): @@ -97,12 +115,12 @@ def test_chaining_with_arguments(runner): @cli.command("sdist") @click.argument("format") def sdist(format): - click.echo("sdist called {}".format(format)) + click.echo(f"sdist called {format}") @cli.command("bdist") @click.argument("format") def bdist(format): - click.echo("bdist called {}".format(format)) + click.echo(f"bdist called {format}") result = runner.invoke(cli, ["bdist", "1", "sdist", "2"]) assert not result.exception @@ -115,7 +133,7 @@ def test_pipeline(runner): def cli(input): pass - @cli.resultcallback() + @cli.result_callback() def process_pipeline(processors, input): iterator = (x.rstrip("\r\n") for x in input) for processor in processors: @@ -192,7 +210,7 @@ def test_multicommand_arg_behavior(runner): @click.group(chain=True) @click.argument("arg") def cli(arg): - click.echo("cli:{}".format(arg)) + click.echo(f"cli:{arg}") @cli.command() def a(): diff --git a/tests/test_commands.py b/tests/test_commands.py index 1d99218..9ebf612 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import re import click @@ -26,7 +25,7 @@ def test_other_command_forward(runner): @cli.command() @click.option("--count", default=1) def test(count): - click.echo("Count: {:d}".format(count)) + click.echo(f"Count: {count:d}") @cli.command() @click.option("--count", default=1) @@ -40,6 +39,28 @@ def test_other_command_forward(runner): assert result.output == "Count: 1\nCount: 42\n" +def test_forwarded_params_consistency(runner): + cli = click.Group() + + @cli.command() + @click.option("-a") + @click.pass_context + def first(ctx, **kwargs): + click.echo(f"{ctx.params}") + + @cli.command() + @click.option("-a") + @click.option("-b") + @click.pass_context + def second(ctx, **kwargs): + click.echo(f"{ctx.params}") + ctx.forward(first) + + result = runner.invoke(cli, ["second", "-a", "foo", "-b", "bar"]) + assert not result.exception + assert result.output == "{'a': 'foo', 'b': 'bar'}\n{'a': 'foo', 'b': 'bar'}\n" + + def test_auto_shorthelp(runner): @click.group() def cli(): @@ -102,7 +123,7 @@ def test_group_with_args(runner): @click.group() @click.argument("obj") def cli(obj): - click.echo("obj={}".format(obj)) + click.echo(f"obj={obj}") @cli.command() def move(): @@ -134,7 +155,7 @@ def test_base_command(runner): class OptParseCommand(click.BaseCommand): def __init__(self, name, parser, callback): - click.BaseCommand.__init__(self, name) + super().__init__(name) self.parser = parser self.callback = callback @@ -211,7 +232,7 @@ def test_object_propagation(runner): @cli.command() @click.pass_context def sync(ctx): - click.echo("Debug is {}".format("on" if ctx.obj["DEBUG"] else "off")) + click.echo(f"Debug is {'on' if ctx.obj['DEBUG'] else 'off'}") result = runner.invoke(cli, ["sync"]) assert result.exception is None @@ -259,13 +280,43 @@ def test_invoked_subcommand(runner): assert result.output == "no subcommand, use default\nin subcommand\n" +def test_aliased_command_canonical_name(runner): + class AliasedGroup(click.Group): + def get_command(self, ctx, cmd_name): + return push + + def resolve_command(self, ctx, args): + _, command, args = super().resolve_command(ctx, args) + return command.name, command, args + + cli = AliasedGroup() + + @cli.command() + def push(): + click.echo("push command") + + result = runner.invoke(cli, ["pu", "--help"]) + assert not result.exception + assert result.output.startswith("Usage: root push [OPTIONS]") + + +def test_group_add_command_name(runner): + cli = click.Group("cli") + cmd = click.Command("a", params=[click.Option(["-x"], required=True)]) + cli.add_command(cmd, "b") + # Check that the command is accessed through the registered name, + # not the original name. + result = runner.invoke(cli, ["b"], default_map={"b": {"x": 3}}) + assert result.exit_code == 0 + + def test_unprocessed_options(runner): @click.command(context_settings=dict(ignore_unknown_options=True)) @click.argument("args", nargs=-1, type=click.UNPROCESSED) @click.option("--verbose", "-v", count=True) def cli(verbose, args): - click.echo("Verbosity: {}".format(verbose)) - click.echo("Args: {}".format("|".join(args))) + click.echo(f"Verbosity: {verbose}") + click.echo(f"Args: {'|'.join(args)}") result = runner.invoke(cli, ["-foo", "-vvvvx", "--muhaha", "x", "y", "-x"]) assert not result.exception @@ -282,14 +333,14 @@ def test_deprecated_in_help_messages(runner): pass result = runner.invoke(cmd_with_help, ["--help"]) - assert "(DEPRECATED)" in result.output + assert "(Deprecated)" in result.output @click.command(deprecated=True) def cmd_without_help(): pass result = runner.invoke(cmd_without_help, ["--help"]) - assert "(DEPRECATED)" in result.output + assert "(Deprecated)" in result.output def test_deprecated_in_invocation(runner): diff --git a/tests/test_compat.py b/tests/test_compat.py index 7851ab8..0e2e424 100644 --- a/tests/test_compat.py +++ b/tests/test_compat.py @@ -1,45 +1,8 @@ -import pytest - -import click from click._compat import should_strip_ansi -from click._compat import WIN -def test_legacy_callbacks(runner): - def legacy_callback(ctx, value): - return value.upper() - - @click.command() - @click.option("--foo", callback=legacy_callback) - def cli(foo): - click.echo(foo) - - with pytest.warns(DeprecationWarning, match="2-arg style"): - result = runner.invoke(cli, ["--foo", "wat"]) - assert result.exit_code == 0 - assert "WAT" in result.output - - -def test_bash_func_name(): - from click._bashcomplete import get_completion_script - - script = get_completion_script("foo-bar baz_blah", "_COMPLETE_VAR", "bash").strip() - assert script.startswith("_foo_barbaz_blah_completion()") - assert "_COMPLETE_VAR=complete $1" in script - - -def test_zsh_func_name(): - from click._bashcomplete import get_completion_script - - script = get_completion_script("foo-bar", "_COMPLETE_VAR", "zsh").strip() - assert script.startswith("#compdef foo-bar") - assert "compdef _foo_bar_completion foo-bar;" in script - assert "(( ! $+commands[foo-bar] )) && return 1" in script - - -@pytest.mark.xfail(WIN, reason="Jupyter not tested/supported on Windows") def test_is_jupyter_kernel_output(): - class JupyterKernelFakeStream(object): + class JupyterKernelFakeStream: pass # implementation detail, aka cheapskate test diff --git a/tests/test_context.py b/tests/test_context.py index b3a20c6..98f0835 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -1,9 +1,14 @@ -# -*- coding: utf-8 -*- +from contextlib import contextmanager + +import pytest + import click +from click.core import ParameterSource +from click.decorators import pass_meta_key def test_ensure_context_objects(runner): - class Foo(object): + class Foo: def __init__(self): self.title = "default" @@ -25,7 +30,7 @@ def test_ensure_context_objects(runner): def test_get_context_objects(runner): - class Foo(object): + class Foo: def __init__(self): self.title = "default" @@ -48,7 +53,7 @@ def test_get_context_objects(runner): def test_get_context_objects_no_ensuring(runner): - class Foo(object): + class Foo: def __init__(self): self.title = "default" @@ -71,7 +76,7 @@ def test_get_context_objects_no_ensuring(runner): def test_get_context_objects_missing(runner): - class Foo(object): + class Foo: pass pass_foo = click.make_pass_decorator(Foo) @@ -129,7 +134,7 @@ def test_global_context_object(runner): def test_context_meta(runner): - LANG_KEY = "{}.lang".format(__name__) + LANG_KEY = f"{__name__}.lang" def set_language(value): click.get_current_context().meta[LANG_KEY] = value @@ -147,6 +152,28 @@ def test_context_meta(runner): runner.invoke(cli, [], catch_exceptions=False) +def test_make_pass_meta_decorator(runner): + @click.group() + @click.pass_context + def cli(ctx): + ctx.meta["value"] = "good" + + @cli.command() + @pass_meta_key("value") + def show(value): + return value + + result = runner.invoke(cli, ["show"], standalone_mode=False) + assert result.return_value == "good" + + +def test_make_pass_meta_decorator_doc(): + pass_value = pass_meta_key("value") + assert "the 'value' key from :attr:`click.Context.meta`" in pass_value.__doc__ + pass_value = pass_meta_key("value", doc_description="the test value") + assert "passes the test value" in pass_value.__doc__ + + def test_context_pushing(): rv = [] @@ -210,13 +237,29 @@ def test_close_before_pop(runner): assert called == [True] +def test_with_resource(): + @contextmanager + def manager(): + val = [1] + yield val + val[0] = 0 + + ctx = click.Context(click.Command("test")) + + with ctx.scope(): + rv = ctx.with_resource(manager()) + assert rv[0] == 1 + + assert rv == [0] + + def test_make_pass_decorator_args(runner): """ Test to check that make_pass_decorator doesn't consume arguments based on invocation order. """ - class Foo(object): + class Foo: title = "foocmd" pass_foo = click.make_pass_decorator(Foo) @@ -247,6 +290,20 @@ def test_make_pass_decorator_args(runner): assert result.output == "foocmd\n" +def test_propagate_show_default_setting(runner): + """A context's ``show_default`` setting defaults to the value from + the parent context. + """ + group = click.Group( + commands={ + "sub": click.Command("sub", params=[click.Option(["-a"], default="a")]), + }, + context_settings={"show_default": True}, + ) + result = runner.invoke(group, ["sub", "--help"]) + assert "[default: a]" in result.output + + def test_exit_not_standalone(): @click.command() @click.pass_context @@ -261,3 +318,50 @@ def test_exit_not_standalone(): ctx.exit(0) assert cli.main([], "test_exit_not_standalone", standalone_mode=False) == 0 + + +@pytest.mark.parametrize( + ("option_args", "invoke_args", "expect"), + [ + pytest.param({}, {}, ParameterSource.DEFAULT, id="default"), + pytest.param( + {}, + {"default_map": {"option": 1}}, + ParameterSource.DEFAULT_MAP, + id="default_map", + ), + pytest.param( + {}, + {"args": ["-o", "1"]}, + ParameterSource.COMMANDLINE, + id="commandline short", + ), + pytest.param( + {}, + {"args": ["--option", "1"]}, + ParameterSource.COMMANDLINE, + id="commandline long", + ), + pytest.param( + {}, + {"auto_envvar_prefix": "TEST", "env": {"TEST_OPTION": "1"}}, + ParameterSource.ENVIRONMENT, + id="environment auto", + ), + pytest.param( + {"envvar": "NAME"}, + {"env": {"NAME": "1"}}, + ParameterSource.ENVIRONMENT, + id="environment manual", + ), + ], +) +def test_parameter_source(runner, option_args, invoke_args, expect): + @click.command() + @click.pass_context + @click.option("-o", "--option", default=1, **option_args) + def cli(ctx, option): + return ctx.get_parameter_source("option") + + rv = runner.invoke(cli, standalone_mode=False, **invoke_args) + assert rv.return_value == expect diff --git a/tests/test_custom_classes.py b/tests/test_custom_classes.py new file mode 100644 index 0000000..314842b --- /dev/null +++ b/tests/test_custom_classes.py @@ -0,0 +1,112 @@ +import click + + +def test_command_context_class(): + """A command with a custom ``context_class`` should produce a + context using that type. + """ + + class CustomContext(click.Context): + pass + + class CustomCommand(click.Command): + context_class = CustomContext + + command = CustomCommand("test") + context = command.make_context("test", []) + assert isinstance(context, CustomContext) + + +def test_context_invoke_type(runner): + """A command invoked from a custom context should have a new + context with the same type. + """ + + class CustomContext(click.Context): + pass + + class CustomCommand(click.Command): + context_class = CustomContext + + @click.command() + @click.argument("first_id", type=int) + @click.pass_context + def second(ctx, first_id): + assert isinstance(ctx, CustomContext) + assert id(ctx) != first_id + + @click.command(cls=CustomCommand) + @click.pass_context + def first(ctx): + assert isinstance(ctx, CustomContext) + ctx.invoke(second, first_id=id(ctx)) + + assert not runner.invoke(first).exception + + +def test_context_formatter_class(): + """A context with a custom ``formatter_class`` should format help + using that type. + """ + + class CustomFormatter(click.HelpFormatter): + def write_heading(self, heading): + heading = click.style(heading, fg="yellow") + return super().write_heading(heading) + + class CustomContext(click.Context): + formatter_class = CustomFormatter + + context = CustomContext( + click.Command("test", params=[click.Option(["--value"])]), color=True + ) + assert "\x1b[33mOptions\x1b[0m:" in context.get_help() + + +def test_group_command_class(runner): + """A group with a custom ``command_class`` should create subcommands + of that type by default. + """ + + class CustomCommand(click.Command): + pass + + class CustomGroup(click.Group): + command_class = CustomCommand + + group = CustomGroup() + subcommand = group.command()(lambda: None) + assert type(subcommand) is CustomCommand + subcommand = group.command(cls=click.Command)(lambda: None) + assert type(subcommand) is click.Command + + +def test_group_group_class(runner): + """A group with a custom ``group_class`` should create subgroups + of that type by default. + """ + + class CustomSubGroup(click.Group): + pass + + class CustomGroup(click.Group): + group_class = CustomSubGroup + + group = CustomGroup() + subgroup = group.group()(lambda: None) + assert type(subgroup) is CustomSubGroup + subgroup = group.command(cls=click.Group)(lambda: None) + assert type(subgroup) is click.Group + + +def test_group_group_class_self(runner): + """A group with ``group_class = type`` should create subgroups of + the same type as itself. + """ + + class CustomGroup(click.Group): + group_class = type + + group = CustomGroup() + subgroup = group.group()(lambda: None) + assert type(subgroup) is CustomGroup diff --git a/tests/test_defaults.py b/tests/test_defaults.py index ce44903..0e438eb 100644 --- a/tests/test_defaults.py +++ b/tests/test_defaults.py @@ -6,7 +6,7 @@ def test_basic_defaults(runner): @click.option("--foo", default=42, type=click.FLOAT) def cli(foo): assert type(foo) is float - click.echo("FOO:[{}]".format(foo)) + click.echo(f"FOO:[{foo}]") result = runner.invoke(cli, []) assert not result.exception @@ -32,8 +32,8 @@ def test_nargs_plus_multiple(runner): "--arg", default=((1, 2), (3, 4)), nargs=2, multiple=True, type=click.INT ) def cli(arg): - for item in arg: - click.echo("<{0[0]:d}|{0[1]:d}>".format(item)) + for a, b in arg: + click.echo(f"<{a:d}|{b:d}>") result = runner.invoke(cli, []) assert not result.exception diff --git a/tests/test_formatting.py b/tests/test_formatting.py index 4fabbb2..f957e01 100644 --- a/tests/test_formatting.py +++ b/tests/test_formatting.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import click @@ -53,13 +52,11 @@ def test_basic_functionality(runner): def test_wrapping_long_options_strings(runner): @click.group() def cli(): - """Top level command - """ + """Top level command""" @cli.group() def a_very_long(): - """Second level - """ + """Second level""" @a_very_long.command() @click.argument("first") @@ -69,8 +66,7 @@ def test_wrapping_long_options_strings(runner): @click.argument("fifth") @click.argument("sixth") def command(): - """A command. - """ + """A command.""" # 54 is chosen as a length where the second line is one character # longer than the maximum length. @@ -91,13 +87,11 @@ def test_wrapping_long_options_strings(runner): def test_wrapping_long_command_name(runner): @click.group() def cli(): - """Top level command - """ + """Top level command""" @cli.group() def a_very_very_very_long(): - """Second level - """ + """Second level""" @a_very_very_very_long.command() @click.argument("first") @@ -107,8 +101,7 @@ def test_wrapping_long_command_name(runner): @click.argument("fifth") @click.argument("sixth") def command(): - """A command. - """ + """A command.""" result = runner.invoke( cli, ["a-very-very-very-long", "command", "--help"], terminal_width=54 @@ -129,9 +122,11 @@ def test_wrapping_long_command_name(runner): def test_formatting_empty_help_lines(runner): @click.command() def cli(): + # fmt: off """Top level command """ + # fmt: on result = runner.invoke(cli, ["--help"]) assert not result.exception @@ -151,7 +146,7 @@ def test_formatting_usage_error(runner): @click.command() @click.argument("arg") def cmd(arg): - click.echo("arg:{}".format(arg)) + click.echo(f"arg:{arg}") result = runner.invoke(cmd, []) assert result.exit_code == 2 @@ -196,7 +191,7 @@ def test_formatting_usage_error_metavar_bad_arg(runner): "Usage: cmd [OPTIONS] metavar", "Try 'cmd --help' for help.", "", - "Error: Invalid value for 'metavar': 3.14 is not a valid integer", + "Error: Invalid value for 'metavar': '3.14' is not a valid integer.", ] @@ -208,7 +203,7 @@ def test_formatting_usage_error_nested(runner): @cmd.command() @click.argument("bar") def foo(bar): - click.echo("foo:{}".format(bar)) + click.echo(f"foo:{bar}") result = runner.invoke(cmd, ["foo"]) assert result.exit_code == 2 @@ -224,7 +219,7 @@ def test_formatting_usage_error_no_help(runner): @click.command(add_help_option=False) @click.argument("arg") def cmd(arg): - click.echo("arg:{}".format(arg)) + click.echo(f"arg:{arg}") result = runner.invoke(cmd, []) assert result.exit_code == 2 @@ -239,7 +234,7 @@ def test_formatting_usage_custom_help(runner): @click.command(context_settings=dict(help_option_names=["--man"])) @click.argument("arg") def cmd(arg): - click.echo("arg:{}".format(arg)) + click.echo(f"arg:{arg}") result = runner.invoke(cmd, []) assert result.exit_code == 2 @@ -301,13 +296,32 @@ def test_truncating_docstring(runner): ] +def test_removing_multiline_marker(runner): + @click.group() + def cli(): + pass + + @cli.command() + def cmd1(): + """\b + This is command with a multiline help text + which should not be rewrapped. + The output of the short help text should + not contain the multiline marker. + """ + pass + + result = runner.invoke(cli, ["--help"]) + assert "\b" not in result.output + + def test_global_show_default(runner): @click.command(context_settings=dict(show_default=True)) @click.option("-f", "in_file", default="out.txt", help="Output file name") def cli(): pass - result = runner.invoke(cli, ["--help"],) + result = runner.invoke(cli, ["--help"]) assert result.output.splitlines() == [ "Usage: cli [OPTIONS]", "", @@ -317,37 +331,17 @@ def test_global_show_default(runner): ] -def test_formatting_usage_multiline_option_padding(runner): - @click.command("foo") - @click.option("--bar", help="This help message will be padded if it wraps.") - def cli(): - pass - - result = runner.invoke(cli, "--help", terminal_width=45) - assert not result.exception - assert result.output.splitlines() == [ - "Usage: foo [OPTIONS]", - "", - "Options:", - " --bar TEXT This help message will be", - " padded if it wraps.", - "", - " --help Show this message and exit.", - ] +def test_formatting_with_options_metavar_empty(runner): + cli = click.Command("cli", options_metavar="", params=[click.Argument(["var"])]) + result = runner.invoke(cli, ["--help"]) + assert "Usage: cli VAR\n" in result.output -def test_formatting_usage_no_option_padding(runner): - @click.command("foo") - @click.option("--bar", help="This help message will be padded if it wraps.") - def cli(): - pass - - result = runner.invoke(cli, "--help", terminal_width=80) - assert not result.exception - assert result.output.splitlines() == [ - "Usage: foo [OPTIONS]", - "", - "Options:", - " --bar TEXT This help message will be padded if it wraps.", - " --help Show this message and exit.", - ] +def test_help_formatter_write_text(): + text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit" + formatter = click.HelpFormatter(width=len(" Lorem ipsum dolor sit amet,")) + formatter.current_indent = 2 + formatter.write_text(text) + actual = formatter.getvalue() + expected = " Lorem ipsum dolor sit amet,\n consectetur adipiscing elit\n" + assert actual == expected diff --git a/tests/test_imports.py b/tests/test_imports.py index e309eac..ec32fca 100644 --- a/tests/test_imports.py +++ b/tests/test_imports.py @@ -6,10 +6,7 @@ from click._compat import WIN IMPORT_TEST = b"""\ -try: - import __builtin__ as builtins -except ImportError: - import builtins +import builtins found_imports = set() real_import = builtins.__import__ @@ -44,15 +41,17 @@ ALLOWED_IMPORTS = { "itertools", "io", "threading", - "colorama", "errno", "fcntl", "datetime", - "pipes", + "enum", + "typing", + "types", + "gettext", } if WIN: - ALLOWED_IMPORTS.update(["ctypes", "ctypes.wintypes", "msvcrt", "time", "zlib"]) + ALLOWED_IMPORTS.update(["ctypes", "ctypes.wintypes", "msvcrt", "time"]) def test_light_imports(): @@ -60,9 +59,7 @@ def test_light_imports(): [sys.executable, "-"], stdin=subprocess.PIPE, stdout=subprocess.PIPE ) rv = c.communicate(IMPORT_TEST)[0] - - if sys.version_info[0] != 2: - rv = rv.decode("utf-8") + rv = rv.decode("utf-8") imported = json.loads(rv) for module in imported: diff --git a/tests/test_info_dict.py b/tests/test_info_dict.py new file mode 100644 index 0000000..b58ad6e --- /dev/null +++ b/tests/test_info_dict.py @@ -0,0 +1,268 @@ +import pytest + +import click.types + +# Common (obj, expect) pairs used to construct multiple tests. +STRING_PARAM_TYPE = (click.STRING, {"param_type": "String", "name": "text"}) +INT_PARAM_TYPE = (click.INT, {"param_type": "Int", "name": "integer"}) +BOOL_PARAM_TYPE = (click.BOOL, {"param_type": "Bool", "name": "boolean"}) +HELP_OPTION = ( + None, + { + "name": "help", + "param_type_name": "option", + "opts": ["--help"], + "secondary_opts": [], + "type": BOOL_PARAM_TYPE[1], + "required": False, + "nargs": 1, + "multiple": False, + "default": False, + "envvar": None, + "help": "Show this message and exit.", + "prompt": None, + "is_flag": True, + "flag_value": True, + "count": False, + "hidden": False, + }, +) +NAME_ARGUMENT = ( + click.Argument(["name"]), + { + "name": "name", + "param_type_name": "argument", + "opts": ["name"], + "secondary_opts": [], + "type": STRING_PARAM_TYPE[1], + "required": True, + "nargs": 1, + "multiple": False, + "default": None, + "envvar": None, + }, +) +NUMBER_OPTION = ( + click.Option(["-c", "--count", "number"], default=1), + { + "name": "number", + "param_type_name": "option", + "opts": ["-c", "--count"], + "secondary_opts": [], + "type": INT_PARAM_TYPE[1], + "required": False, + "nargs": 1, + "multiple": False, + "default": 1, + "envvar": None, + "help": None, + "prompt": None, + "is_flag": False, + "flag_value": False, + "count": False, + "hidden": False, + }, +) +HELLO_COMMAND = ( + click.Command("hello", params=[NUMBER_OPTION[0]]), + { + "name": "hello", + "params": [NUMBER_OPTION[1], HELP_OPTION[1]], + "help": None, + "epilog": None, + "short_help": None, + "hidden": False, + "deprecated": False, + }, +) +HELLO_GROUP = ( + click.Group("cli", [HELLO_COMMAND[0]]), + { + "name": "cli", + "params": [HELP_OPTION[1]], + "help": None, + "epilog": None, + "short_help": None, + "hidden": False, + "deprecated": False, + "commands": {"hello": HELLO_COMMAND[1]}, + "chain": False, + }, +) + + +@pytest.mark.parametrize( + ("obj", "expect"), + [ + pytest.param( + click.types.FuncParamType(range), + {"param_type": "Func", "name": "range", "func": range}, + id="Func ParamType", + ), + pytest.param( + click.UNPROCESSED, + {"param_type": "Unprocessed", "name": "text"}, + id="UNPROCESSED ParamType", + ), + pytest.param(*STRING_PARAM_TYPE, id="STRING ParamType"), + pytest.param( + click.Choice(["a", "b"]), + { + "param_type": "Choice", + "name": "choice", + "choices": ["a", "b"], + "case_sensitive": True, + }, + id="Choice ParamType", + ), + pytest.param( + click.DateTime(["%Y-%m-%d"]), + {"param_type": "DateTime", "name": "datetime", "formats": ["%Y-%m-%d"]}, + id="DateTime ParamType", + ), + pytest.param(*INT_PARAM_TYPE, id="INT ParamType"), + pytest.param( + click.IntRange(0, 10, clamp=True), + { + "param_type": "IntRange", + "name": "integer range", + "min": 0, + "max": 10, + "min_open": False, + "max_open": False, + "clamp": True, + }, + id="IntRange ParamType", + ), + pytest.param( + click.FLOAT, {"param_type": "Float", "name": "float"}, id="FLOAT ParamType" + ), + pytest.param( + click.FloatRange(-0.5, 0.5), + { + "param_type": "FloatRange", + "name": "float range", + "min": -0.5, + "max": 0.5, + "min_open": False, + "max_open": False, + "clamp": False, + }, + id="FloatRange ParamType", + ), + pytest.param(*BOOL_PARAM_TYPE, id="Bool ParamType"), + pytest.param( + click.UUID, {"param_type": "UUID", "name": "uuid"}, id="UUID ParamType" + ), + pytest.param( + click.File(), + {"param_type": "File", "name": "filename", "mode": "r", "encoding": None}, + id="File ParamType", + ), + pytest.param( + click.Path(), + { + "param_type": "Path", + "name": "path", + "exists": False, + "file_okay": True, + "dir_okay": True, + "writable": False, + "readable": True, + "allow_dash": False, + }, + id="Path ParamType", + ), + pytest.param( + click.Tuple((click.STRING, click.INT)), + { + "param_type": "Tuple", + "name": "", + "types": [STRING_PARAM_TYPE[1], INT_PARAM_TYPE[1]], + }, + id="Tuple ParamType", + ), + pytest.param(*NUMBER_OPTION, id="Option"), + pytest.param( + click.Option(["--cache/--no-cache", "-c/-u"]), + { + "name": "cache", + "param_type_name": "option", + "opts": ["--cache", "-c"], + "secondary_opts": ["--no-cache", "-u"], + "type": BOOL_PARAM_TYPE[1], + "required": False, + "nargs": 1, + "multiple": False, + "default": False, + "envvar": None, + "help": None, + "prompt": None, + "is_flag": True, + "flag_value": True, + "count": False, + "hidden": False, + }, + id="Flag Option", + ), + pytest.param(*NAME_ARGUMENT, id="Argument"), + ], +) +def test_parameter(obj, expect): + out = obj.to_info_dict() + assert out == expect + + +@pytest.mark.parametrize( + ("obj", "expect"), + [ + pytest.param(*HELLO_COMMAND, id="Command"), + pytest.param(*HELLO_GROUP, id="Group"), + pytest.param( + click.Group( + "base", + [click.Command("test", params=[NAME_ARGUMENT[0]]), HELLO_GROUP[0]], + ), + { + "name": "base", + "params": [HELP_OPTION[1]], + "help": None, + "epilog": None, + "short_help": None, + "hidden": False, + "deprecated": False, + "commands": { + "cli": HELLO_GROUP[1], + "test": { + "name": "test", + "params": [NAME_ARGUMENT[1], HELP_OPTION[1]], + "help": None, + "epilog": None, + "short_help": None, + "hidden": False, + "deprecated": False, + }, + }, + "chain": False, + }, + id="Nested Group", + ), + ], +) +def test_command(obj, expect): + ctx = click.Context(obj) + out = obj.to_info_dict(ctx) + assert out == expect + + +def test_context(): + ctx = click.Context(HELLO_COMMAND[0]) + out = ctx.to_info_dict() + assert out == { + "command": HELLO_COMMAND[1], + "info_name": None, + "allow_extra_args": False, + "allow_interspersed_args": True, + "ignore_unknown_options": False, + "auto_envvar_prefix": None, + } diff --git a/tests/test_options.py b/tests/test_options.py index 4baa374..2e34337 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -1,11 +1,10 @@ -# -*- coding: utf-8 -*- import os import re import pytest import click -from click._compat import text_type +from click import Option def test_prefixes(runner): @@ -13,7 +12,7 @@ def test_prefixes(runner): @click.option("++foo", is_flag=True, help="das foo") @click.option("--bar", is_flag=True, help="das bar") def cli(foo, bar): - click.echo("foo={} bar={}".format(foo, bar)) + click.echo(f"foo={foo} bar={bar}") result = runner.invoke(cli, ["++foo", "--bar"]) assert not result.exception @@ -25,16 +24,17 @@ def test_prefixes(runner): def test_invalid_option(runner): - with pytest.raises(TypeError, match="name was passed"): + with pytest.raises(TypeError, match="name was passed") as exc_info: + click.Option(["foo"]) - @click.command() - @click.option("foo") - def cli(foo): - pass + message = str(exc_info.value) + assert "name was passed (foo)" in message + assert "declare an argument" in message + assert "'--foo'" in message def test_invalid_nargs(runner): - with pytest.raises(TypeError, match="nargs < 0"): + with pytest.raises(TypeError, match="nargs=-1"): @click.command() @click.option("--foo", nargs=-1) @@ -46,8 +46,8 @@ def test_nargs_tup_composite_mult(runner): @click.command() @click.option("--item", type=(str, int), multiple=True) def copy(item): - for item in item: - click.echo("name={0[0]} id={0[1]:d}".format(item)) + for name, id in item: + click.echo(f"name={name} id={id:d}") result = runner.invoke(copy, ["--item", "peter", "1", "--item", "max", "2"]) assert not result.exception @@ -58,7 +58,7 @@ def test_counting(runner): @click.command() @click.option("-v", count=True, help="Verbosity", type=click.IntRange(0, 3)) def cli(v): - click.echo("verbosity={:d}".format(v)) + click.echo(f"verbosity={v:d}") result = runner.invoke(cli, ["-vvv"]) assert not result.exception @@ -66,10 +66,7 @@ def test_counting(runner): result = runner.invoke(cli, ["-vvvv"]) assert result.exception - assert ( - "Invalid value for '-v': 4 is not in the valid range of 0 to 3." - in result.output - ) + assert "Invalid value for '-v': 4 is not in the range 0<=x<=3." in result.output result = runner.invoke(cli, []) assert not result.exception @@ -87,7 +84,23 @@ def test_unknown_options(runner, unknown_flag): result = runner.invoke(cli, [unknown_flag]) assert result.exception - assert "no such option: {}".format(unknown_flag) in result.output + assert f"No such option: {unknown_flag}" in result.output + + +@pytest.mark.parametrize( + ("value", "expect"), + [ + ("--cat", "Did you mean --count?"), + ("--bounds", "(Possible options: --bound, --count)"), + ("--bount", "(Possible options: --bound, --count)"), + ], +) +def test_suggest_possible_options(runner, value, expect): + cli = click.Command( + "cli", params=[click.Option(["--bound"]), click.Option(["--count"])] + ) + result = runner.invoke(cli, [value]) + assert expect in result.output def test_multiple_required(runner): @@ -105,11 +118,46 @@ def test_multiple_required(runner): assert "Error: Missing option '-m' / '--message'." in result.output +@pytest.mark.parametrize( + ("multiple", "nargs", "default"), + [ + (True, 1, []), + (True, 1, [1]), + # (False, -1, []), + # (False, -1, [1]), + (False, 2, [1, 2]), + # (True, -1, [[]]), + # (True, -1, []), + # (True, -1, [[1]]), + (True, 2, []), + (True, 2, [[1, 2]]), + ], +) +def test_init_good_default_list(runner, multiple, nargs, default): + click.Option(["-a"], multiple=multiple, nargs=nargs, default=default) + + +@pytest.mark.parametrize( + ("multiple", "nargs", "default"), + [ + (True, 1, 1), + # (False, -1, 1), + (False, 2, [1]), + (True, 2, [[1]]), + ], +) +def test_init_bad_default_list(runner, multiple, nargs, default): + type = (str, str) if nargs == 2 else None + + with pytest.raises(ValueError, match="default"): + click.Option(["-a"], type=type, multiple=multiple, nargs=nargs, default=default) + + def test_empty_envvar(runner): @click.command() @click.option("--mypath", type=click.Path(exists=True), envvar="MYPATH") def cli(mypath): - click.echo("mypath: {}".format(mypath)) + click.echo(f"mypath: {mypath}") result = runner.invoke(cli, [], env={"MYPATH": ""}) assert result.exit_code == 0 @@ -146,12 +194,23 @@ def test_multiple_envvar(runner): cmd, [], auto_envvar_prefix="TEST", - env={"TEST_ARG": "foo{}bar".format(os.path.pathsep)}, + env={"TEST_ARG": f"foo{os.path.pathsep}bar"}, ) assert not result.exception assert result.output == "foo|bar\n" +def test_trailing_blanks_boolean_envvar(runner): + @click.command() + @click.option("--shout/--no-shout", envvar="SHOUT") + def cli(shout): + click.echo(f"shout: {shout!r}") + + result = runner.invoke(cli, [], env={"SHOUT": " true "}) + assert result.exit_code == 0 + assert result.output == "shout: True\n" + + def test_multiple_default_help(runner): @click.command() @click.option("--arg1", multiple=True, default=("foo", "bar"), show_default=True) @@ -165,21 +224,49 @@ def test_multiple_default_help(runner): assert "1, 2" in result.output -def test_multiple_default_type(runner): +def test_show_default_default_map(runner): @click.command() - @click.option("--arg1", multiple=True, default=("foo", "bar")) - @click.option("--arg2", multiple=True, default=(1, "a")) - def cmd(arg1, arg2): - assert all(isinstance(e[0], text_type) for e in arg1) - assert all(isinstance(e[1], text_type) for e in arg1) + @click.option("--arg", default="a", show_default=True) + def cmd(arg): + click.echo(arg) - assert all(isinstance(e[0], int) for e in arg2) - assert all(isinstance(e[1], text_type) for e in arg2) + result = runner.invoke(cmd, ["--help"], default_map={"arg": "b"}) - result = runner.invoke( - cmd, "--arg1 a b --arg1 test 1 --arg2 2 two --arg2 4 four".split() - ) assert not result.exception + assert "[default: b]" in result.output + + +def test_multiple_default_type(): + opt = click.Option(["-a"], multiple=True, default=(1, 2)) + assert opt.nargs == 1 + assert opt.multiple + assert opt.type is click.INT + ctx = click.Context(click.Command("test")) + assert opt.get_default(ctx) == (1, 2) + + +def test_multiple_default_composite_type(): + opt = click.Option(["-a"], multiple=True, default=[(1, "a")]) + assert opt.nargs == 2 + assert opt.multiple + assert isinstance(opt.type, click.Tuple) + assert opt.type.types == [click.INT, click.STRING] + ctx = click.Context(click.Command("test")) + assert opt.type_cast_value(ctx, opt.get_default(ctx)) == ((1, "a"),) + + +def test_parse_multiple_default_composite_type(runner): + @click.command() + @click.option("-a", multiple=True, default=("a", "b")) + @click.option("-b", multiple=True, default=[(1, "a")]) + def cmd(a, b): + click.echo(a) + click.echo(b) + + # result = runner.invoke(cmd, "-a c -a 1 -a d -b 2 two -b 4 four".split()) + # assert result.output == "('c', '1', 'd')\n((2, 'two'), (4, 'four'))\n" + result = runner.invoke(cmd) + assert result.output == "('a', 'b')\n((1, 'a'),)\n" def test_dynamic_default_help_unset(runner): @@ -218,6 +305,45 @@ def test_dynamic_default_help_text(runner): assert "(current user)" in result.output +@pytest.mark.parametrize( + ("type", "expect"), + [ + (click.IntRange(1, 32), "1<=x<=32"), + (click.IntRange(1, 32, min_open=True, max_open=True), "1=1"), + (click.IntRange(max=32), "x<=32"), + ], +) +def test_intrange_default_help_text(type, expect): + option = click.Option(["--num"], type=type, show_default=True, default=2) + context = click.Context(click.Command("test")) + result = option.get_help_record(context)[1] + assert expect in result + + +def test_count_default_type_help(): + """A count option with the default type should not show >=0 in help.""" + option = click.Option(["--count"], count=True, help="some words") + context = click.Context(click.Command("test")) + result = option.get_help_record(context)[1] + assert result == "some words" + + +def test_file_type_help_default(): + """The default for a File type is a filename string. The string + should be displayed in help, not an open file object. + + Type casting is only applied to defaults in processing, not when + getting the default value. + """ + option = click.Option( + ["--in"], type=click.File(), default=__file__, show_default=True + ) + context = click.Context(click.Command("test")) + result = option.get_help_record(context)[1] + assert __file__ in result + + def test_toupper_envvar_prefix(runner): @click.command() @click.option("--arg") @@ -309,6 +435,22 @@ def test_custom_validation(runner): assert result.output == "42\n" +def test_callback_validates_prompt(runner, monkeypatch): + def validate(ctx, param, value): + if value < 0: + raise click.BadParameter("should be positive") + + return value + + @click.command() + @click.option("-a", type=int, callback=validate, prompt=True) + def cli(a): + click.echo(a) + + result = runner.invoke(cli, input="-12\n60\n") + assert result.output == "A: -12\nError: should be positive\nA: 60\n60\n" + + def test_winstyle_options(runner): @click.command() @click.option("/debug;/no-debug", help="Enables or disables debug mode.") @@ -342,9 +484,9 @@ def test_missing_option_string_cast(): ctx = click.Context(click.Command("")) with pytest.raises(click.MissingParameter) as excinfo: - click.Option(["-a"], required=True).full_process_value(ctx, None) + click.Option(["-a"], required=True).process_value(ctx, None) - assert str(excinfo.value) == "missing parameter: a" + assert str(excinfo.value) == "Missing parameter: a" def test_missing_choice(runner): @@ -421,20 +563,21 @@ def test_option_help_preserve_paragraphs(runner): def cmd(config): pass - result = runner.invoke(cmd, ["--help"],) + result = runner.invoke(cmd, ["--help"]) assert result.exit_code == 0 + i = " " * 21 assert ( " -C, --config PATH Configuration file to use.\n" - "{i}\n" - "{i}If not given, the environment variable CONFIG_FILE is\n" - "{i}consulted and used if set. If neither are given, a default\n" - "{i}configuration file is loaded.".format(i=" " * 21) + f"{i}\n" + f"{i}If not given, the environment variable CONFIG_FILE is\n" + f"{i}consulted and used if set. If neither are given, a default\n" + f"{i}configuration file is loaded." ) in result.output def test_argument_custom_class(runner): class CustomArgument(click.Argument): - def get_default(self, ctx): + def get_default(self, ctx, call=True): """a dumb override of a default value for testing""" return "I am a default" @@ -556,3 +699,143 @@ def test_option_names(runner, option_args, expected): if form.startswith("-"): result = runner.invoke(cmd, [form]) assert result.output == "True\n" + + +def test_flag_duplicate_names(runner): + with pytest.raises(ValueError, match="cannot use the same flag for true/false"): + click.Option(["--foo/--foo"], default=False) + + +@pytest.mark.parametrize(("default", "expect"), [(False, "no-cache"), (True, "cache")]) +def test_show_default_boolean_flag_name(runner, default, expect): + """When a boolean flag has distinct True/False opts, it should show + the default opt name instead of the default value. It should only + show one name even if multiple are declared. + """ + opt = click.Option( + ("--cache/--no-cache", "--c/--nc"), + default=default, + show_default=True, + help="Enable/Disable the cache.", + ) + ctx = click.Context(click.Command("test")) + message = opt.get_help_record(ctx)[1] + assert f"[default: {expect}]" in message + + +def test_show_default_boolean_flag_value(runner): + """When a boolean flag only has one opt, it will show the default + value, not the opt name. + """ + opt = click.Option( + ("--cache",), is_flag=True, show_default=True, help="Enable the cache." + ) + ctx = click.Context(click.Command("test")) + message = opt.get_help_record(ctx)[1] + assert "[default: False]" in message + + +def test_show_default_string(runner): + """When show_default is a string show that value as default.""" + opt = click.Option(["--limit"], show_default="unlimited") + ctx = click.Context(click.Command("cli")) + message = opt.get_help_record(ctx)[1] + assert "[default: (unlimited)]" in message + + +def test_do_not_show_no_default(runner): + """When show_default is True and no default is set do not show None.""" + opt = click.Option(["--limit"], show_default=True) + ctx = click.Context(click.Command("cli")) + message = opt.get_help_record(ctx)[1] + assert "[default: None]" not in message + + +def test_do_not_show_default_empty_multiple(): + """When show_default is True and multiple=True is set, it should not + print empty default value in --help output. + """ + opt = click.Option(["-a"], multiple=True, help="values", show_default=True) + ctx = click.Context(click.Command("cli")) + message = opt.get_help_record(ctx)[1] + assert message == "values" + + +@pytest.mark.parametrize( + ("args", "expect"), + [ + (None, (None, None, ())), + (["--opt"], ("flag", None, ())), + (["--opt", "-a", 42], ("flag", "42", ())), + (["--opt", "test", "-a", 42], ("test", "42", ())), + (["--opt=test", "-a", 42], ("test", "42", ())), + (["-o"], ("flag", None, ())), + (["-o", "-a", 42], ("flag", "42", ())), + (["-o", "test", "-a", 42], ("test", "42", ())), + (["-otest", "-a", 42], ("test", "42", ())), + (["a", "b", "c"], (None, None, ("a", "b", "c"))), + (["--opt", "a", "b", "c"], ("a", None, ("b", "c"))), + (["--opt", "test"], ("test", None, ())), + (["-otest", "a", "b", "c"], ("test", None, ("a", "b", "c"))), + (["--opt=test", "a", "b", "c"], ("test", None, ("a", "b", "c"))), + ], +) +def test_option_with_optional_value(runner, args, expect): + @click.command() + @click.option("-o", "--opt", is_flag=False, flag_value="flag") + @click.option("-a") + @click.argument("b", nargs=-1) + def cli(opt, a, b): + return opt, a, b + + result = runner.invoke(cli, args, standalone_mode=False, catch_exceptions=False) + assert result.return_value == expect + + +def test_multiple_option_with_optional_value(runner): + cli = click.Command( + "cli", + params=[ + click.Option(["-f"], is_flag=False, flag_value="flag", multiple=True), + click.Option(["-a"]), + click.Argument(["b"], nargs=-1), + ], + callback=lambda **kwargs: kwargs, + ) + result = runner.invoke( + cli, + ["-f", "-f", "other", "-f", "-a", "1", "a", "b"], + standalone_mode=False, + catch_exceptions=False, + ) + assert result.return_value == { + "f": ("flag", "other", "flag"), + "a": "1", + "b": ("a", "b"), + } + + +def test_type_from_flag_value(): + param = click.Option(["-a", "x"], default=True, flag_value=4) + assert param.type is click.INT + param = click.Option(["-b", "x"], flag_value=8) + assert param.type is click.INT + + +@pytest.mark.parametrize( + ("option", "expected"), + [ + # Not boolean flags + pytest.param(Option(["-a"], type=int), False, id="int option"), + pytest.param(Option(["-a"], type=bool), False, id="bool non-flag [None]"), + pytest.param(Option(["-a"], default=True), False, id="bool non-flag [True]"), + pytest.param(Option(["-a"], default=False), False, id="bool non-flag [False]"), + pytest.param(Option(["-a"], flag_value=1), False, id="non-bool flag_value"), + # Boolean flags + pytest.param(Option(["-a"], is_flag=True), True, id="is_flag=True"), + pytest.param(Option(["-a/-A"]), True, id="secondary option [implicit flag]"), + pytest.param(Option(["-a"], flag_value=True), True, id="bool flag_value"), + ], +) +def test_is_bool_flag_is_correctly_set(option, expected): + assert option.is_bool_flag is expected diff --git a/tests/test_parser.py b/tests/test_parser.py new file mode 100644 index 0000000..964f9c8 --- /dev/null +++ b/tests/test_parser.py @@ -0,0 +1,17 @@ +import pytest + +from click.parser import split_arg_string + + +@pytest.mark.parametrize( + ("value", "expect"), + [ + ("cli a b c", ["cli", "a", "b", "c"]), + ("cli 'my file", ["cli", "my file"]), + ("cli 'my file'", ["cli", "my file"]), + ("cli my\\", ["cli", "my"]), + ("cli my\\ file", ["cli", "my file"]), + ], +) +def test_split_arg_string(value, expect): + assert split_arg_string(value) == expect diff --git a/tests/test_shell_completion.py b/tests/test_shell_completion.py new file mode 100644 index 0000000..8338d0e --- /dev/null +++ b/tests/test_shell_completion.py @@ -0,0 +1,319 @@ +import pytest + +from click.core import Argument +from click.core import Command +from click.core import Group +from click.core import Option +from click.shell_completion import CompletionItem +from click.shell_completion import ShellComplete +from click.types import Choice +from click.types import File +from click.types import Path + + +def _get_completions(cli, args, incomplete): + comp = ShellComplete(cli, {}, cli.name, "_CLICK_COMPLETE") + return comp.get_completions(args, incomplete) + + +def _get_words(cli, args, incomplete): + return [c.value for c in _get_completions(cli, args, incomplete)] + + +def test_command(): + cli = Command("cli", params=[Option(["-t", "--test"])]) + assert _get_words(cli, [], "") == [] + assert _get_words(cli, [], "-") == ["-t", "--test", "--help"] + assert _get_words(cli, [], "--") == ["--test", "--help"] + assert _get_words(cli, [], "--t") == ["--test"] + # -t has been seen, so --test isn't suggested + assert _get_words(cli, ["-t", "a"], "-") == ["--help"] + + +def test_group(): + cli = Group("cli", params=[Option(["-a"])], commands=[Command("x"), Command("y")]) + assert _get_words(cli, [], "") == ["x", "y"] + assert _get_words(cli, [], "-") == ["-a", "--help"] + + +def test_group_command_same_option(): + cli = Group( + "cli", params=[Option(["-a"])], commands=[Command("x", params=[Option(["-a"])])] + ) + assert _get_words(cli, [], "-") == ["-a", "--help"] + assert _get_words(cli, ["-a", "a"], "-") == ["--help"] + assert _get_words(cli, ["-a", "a", "x"], "-") == ["-a", "--help"] + assert _get_words(cli, ["-a", "a", "x", "-a", "a"], "-") == ["--help"] + + +def test_chained(): + cli = Group( + "cli", + chain=True, + commands=[ + Command("set", params=[Option(["-y"])]), + Command("start"), + Group("get", commands=[Command("full")]), + ], + ) + assert _get_words(cli, [], "") == ["get", "set", "start"] + assert _get_words(cli, [], "s") == ["set", "start"] + assert _get_words(cli, ["set", "start"], "") == ["get"] + # subcommands and parent subcommands + assert _get_words(cli, ["get"], "") == ["full", "set", "start"] + assert _get_words(cli, ["get", "full"], "") == ["set", "start"] + assert _get_words(cli, ["get"], "s") == ["set", "start"] + + +def test_help_option(): + cli = Group("cli", commands=[Command("with"), Command("no", add_help_option=False)]) + assert _get_words(cli, ["with"], "--") == ["--help"] + assert _get_words(cli, ["no"], "--") == [] + + +def test_argument_order(): + cli = Command( + "cli", + params=[ + Argument(["plain"]), + Argument(["c1"], type=Choice(["a1", "a2", "b"])), + Argument(["c2"], type=Choice(["c1", "c2", "d"])), + ], + ) + # first argument has no completions + assert _get_words(cli, [], "") == [] + assert _get_words(cli, [], "a") == [] + # first argument filled, now completion can happen + assert _get_words(cli, ["x"], "a") == ["a1", "a2"] + assert _get_words(cli, ["x", "b"], "d") == ["d"] + + +def test_argument_default(): + cli = Command( + "cli", + add_help_option=False, + params=[ + Argument(["a"], type=Choice(["a"]), default="a"), + Argument(["b"], type=Choice(["b"]), default="b"), + ], + ) + assert _get_words(cli, [], "") == ["a"] + assert _get_words(cli, ["a"], "b") == ["b"] + # ignore type validation + assert _get_words(cli, ["x"], "b") == ["b"] + + +def test_type_choice(): + cli = Command("cli", params=[Option(["-c"], type=Choice(["a1", "a2", "b"]))]) + assert _get_words(cli, ["-c"], "") == ["a1", "a2", "b"] + assert _get_words(cli, ["-c"], "a") == ["a1", "a2"] + assert _get_words(cli, ["-c"], "a2") == ["a2"] + + +@pytest.mark.parametrize( + ("type", "expect"), + [(File(), "file"), (Path(), "file"), (Path(file_okay=False), "dir")], +) +def test_path_types(type, expect): + cli = Command("cli", params=[Option(["-f"], type=type)]) + out = _get_completions(cli, ["-f"], "ab") + assert len(out) == 1 + c = out[0] + assert c.value == "ab" + assert c.type == expect + + +def test_absolute_path(): + cli = Command("cli", params=[Option(["-f"], type=Path())]) + out = _get_completions(cli, ["-f"], "/ab") + assert len(out) == 1 + c = out[0] + assert c.value == "/ab" + + +def test_option_flag(): + cli = Command( + "cli", + add_help_option=False, + params=[ + Option(["--on/--off"]), + Argument(["a"], type=Choice(["a1", "a2", "b"])), + ], + ) + assert _get_words(cli, [], "--") == ["--on", "--off"] + # flag option doesn't take value, use choice argument + assert _get_words(cli, ["--on"], "a") == ["a1", "a2"] + + +def test_option_custom(): + def custom(ctx, param, incomplete): + return [incomplete.upper()] + + cli = Command( + "cli", + params=[ + Argument(["x"]), + Argument(["y"]), + Argument(["z"], shell_complete=custom), + ], + ) + assert _get_words(cli, ["a", "b"], "") == [""] + assert _get_words(cli, ["a", "b"], "c") == ["C"] + + +def test_autocompletion_deprecated(): + # old function takes args and not param, returns all values, can mix + # strings and tuples + def custom(ctx, args, incomplete): + assert isinstance(args, list) + return [("art", "x"), "bat", "cat"] + + with pytest.deprecated_call(): + cli = Command("cli", params=[Argument(["x"], autocompletion=custom)]) + + assert _get_words(cli, [], "") == ["art", "bat", "cat"] + assert _get_words(cli, [], "c") == ["cat"] + + +def test_option_multiple(): + cli = Command( + "type", + params=[Option(["-m"], type=Choice(["a", "b"]), multiple=True), Option(["-f"])], + ) + assert _get_words(cli, ["-m"], "") == ["a", "b"] + assert "-m" in _get_words(cli, ["-m", "a"], "-") + assert _get_words(cli, ["-m", "a", "-m"], "") == ["a", "b"] + # used single options aren't suggested again + assert "-c" not in _get_words(cli, ["-c", "f"], "-") + + +def test_option_nargs(): + cli = Command("cli", params=[Option(["-c"], type=Choice(["a", "b"]), nargs=2)]) + assert _get_words(cli, ["-c"], "") == ["a", "b"] + assert _get_words(cli, ["-c", "a"], "") == ["a", "b"] + assert _get_words(cli, ["-c", "a", "b"], "") == [] + + +def test_argument_nargs(): + cli = Command( + "cli", + params=[ + Argument(["x"], type=Choice(["a", "b"]), nargs=2), + Argument(["y"], type=Choice(["c", "d"]), nargs=-1), + Option(["-z"]), + ], + ) + assert _get_words(cli, [], "") == ["a", "b"] + assert _get_words(cli, ["a"], "") == ["a", "b"] + assert _get_words(cli, ["a", "b"], "") == ["c", "d"] + assert _get_words(cli, ["a", "b", "c"], "") == ["c", "d"] + assert _get_words(cli, ["a", "b", "c", "d"], "") == ["c", "d"] + assert _get_words(cli, ["a", "-z", "1"], "") == ["a", "b"] + assert _get_words(cli, ["a", "-z", "1", "b"], "") == ["c", "d"] + + +def test_double_dash(): + cli = Command( + "cli", + add_help_option=False, + params=[ + Option(["--opt"]), + Argument(["name"], type=Choice(["name", "--", "-o", "--opt"])), + ], + ) + assert _get_words(cli, [], "-") == ["--opt"] + assert _get_words(cli, ["value"], "-") == ["--opt"] + assert _get_words(cli, [], "") == ["name", "--", "-o", "--opt"] + assert _get_words(cli, ["--"], "") == ["name", "--", "-o", "--opt"] + + +def test_hidden(): + cli = Group( + "cli", + commands=[ + Command( + "hidden", + add_help_option=False, + hidden=True, + params=[ + Option(["-a"]), + Option(["-b"], type=Choice(["a", "b"]), hidden=True), + ], + ) + ], + ) + assert "hidden" not in _get_words(cli, [], "") + assert "hidden" not in _get_words(cli, [], "hidden") + assert _get_words(cli, ["hidden"], "-") == ["-a"] + assert _get_words(cli, ["hidden", "-b"], "") == ["a", "b"] + + +def test_add_different_name(): + cli = Group("cli", commands={"renamed": Command("original")}) + words = _get_words(cli, [], "") + assert "renamed" in words + assert "original" not in words + + +def test_completion_item_data(): + c = CompletionItem("test", a=1) + assert c.a == 1 + assert c.b is None + + +@pytest.fixture() +def _patch_for_completion(monkeypatch): + monkeypatch.setattr( + "click.shell_completion.BashComplete._check_version", lambda self: True + ) + + +@pytest.mark.parametrize("shell", ["bash", "zsh", "fish"]) +@pytest.mark.usefixtures("_patch_for_completion") +def test_full_source(runner, shell): + cli = Group("cli", commands=[Command("a"), Command("b")]) + result = runner.invoke(cli, env={"_CLI_COMPLETE": f"{shell}_source"}) + assert f"_CLI_COMPLETE={shell}_complete" in result.output + + +@pytest.mark.parametrize( + ("shell", "env", "expect"), + [ + ("bash", {"COMP_WORDS": "", "COMP_CWORD": "0"}, "plain,a\nplain,b\n"), + ("bash", {"COMP_WORDS": "a b", "COMP_CWORD": "1"}, "plain,b\n"), + ("zsh", {"COMP_WORDS": "", "COMP_CWORD": "0"}, "plain\na\n_\nplain\nb\nbee\n"), + ("zsh", {"COMP_WORDS": "a b", "COMP_CWORD": "1"}, "plain\nb\nbee\n"), + ("fish", {"COMP_WORDS": "", "COMP_CWORD": ""}, "plain,a\nplain,b\tbee\n"), + ("fish", {"COMP_WORDS": "a b", "COMP_CWORD": "b"}, "plain,b\tbee\n"), + ], +) +@pytest.mark.usefixtures("_patch_for_completion") +def test_full_complete(runner, shell, env, expect): + cli = Group("cli", commands=[Command("a"), Command("b", help="bee")]) + env["_CLI_COMPLETE"] = f"{shell}_complete" + result = runner.invoke(cli, env=env) + assert result.output == expect + + +@pytest.mark.usefixtures("_patch_for_completion") +def test_context_settings(runner): + def complete(ctx, param, incomplete): + return ctx.obj["choices"] + + cli = Command("cli", params=[Argument("x", shell_complete=complete)]) + result = runner.invoke( + cli, + obj={"choices": ["a", "b"]}, + env={"COMP_WORDS": "", "COMP_CWORD": "0", "_CLI_COMPLETE": "bash_complete"}, + ) + assert result.output == "plain,a\nplain,b\n" + + +@pytest.mark.parametrize(("value", "expect"), [(False, ["Au", "al"]), (True, ["al"])]) +def test_choice_case_sensitive(value, expect): + cli = Command( + "cli", + params=[Option(["-a"], type=Choice(["Au", "al", "Bc"], case_sensitive=value))], + ) + completions = _get_words(cli, ["-a"], "a") + assert completions == expect diff --git a/tests/test_termui.py b/tests/test_termui.py index 99a6a47..5e819df 100644 --- a/tests/test_termui.py +++ b/tests/test_termui.py @@ -1,4 +1,4 @@ -# -*- coding: utf-8 -*- +import platform import time import pytest @@ -7,7 +7,7 @@ import click._termui_impl from click._compat import WIN -class FakeClock(object): +class FakeClock: def __init__(self): self.now = time.time() @@ -18,31 +18,31 @@ class FakeClock(object): return self.now -def _create_progress(length=10, length_known=True, **kwargs): +def _create_progress(length=10, **kwargs): progress = click.progressbar(tuple(range(length))) for key, value in kwargs.items(): setattr(progress, key, value) - progress.length_known = length_known return progress def test_progressbar_strip_regression(runner, monkeypatch): - fake_clock = FakeClock() label = " padded line" @click.command() def cli(): with _create_progress(label=label) as progress: for _ in progress: - fake_clock.advance_time() + pass - monkeypatch.setattr(time, "time", fake_clock.time) monkeypatch.setattr(click._termui_impl, "isatty", lambda _: True) - assert label in runner.invoke(cli, []).output + assert ( + label + in runner.invoke(cli, [], standalone_mode=False, catch_exceptions=False).output + ) def test_progressbar_length_hint(runner, monkeypatch): - class Hinted(object): + class Hinted: def __init__(self, n): self.items = list(range(n)) @@ -60,33 +60,26 @@ def test_progressbar_length_hint(runner, monkeypatch): next = __next__ - fake_clock = FakeClock() - @click.command() def cli(): with click.progressbar(Hinted(10), label="test") as progress: for _ in progress: - fake_clock.advance_time() + pass - monkeypatch.setattr(time, "time", fake_clock.time) monkeypatch.setattr(click._termui_impl, "isatty", lambda _: True) result = runner.invoke(cli, []) assert result.exception is None def test_progressbar_hidden(runner, monkeypatch): - fake_clock = FakeClock() - label = "whatever" - @click.command() def cli(): - with _create_progress(label=label) as progress: + with _create_progress(label="working") as progress: for _ in progress: - fake_clock.advance_time() + pass - monkeypatch.setattr(time, "time", fake_clock.time) monkeypatch.setattr(click._termui_impl, "isatty", lambda _: False) - assert runner.invoke(cli, []).output == "" + assert runner.invoke(cli, []).output == "working\n" @pytest.mark.parametrize("avg, expected", [([], 0.0), ([1, 4], 2.5)]) @@ -120,12 +113,9 @@ def test_progressbar_format_eta(runner, eta, expected): @pytest.mark.parametrize("pos, length", [(0, 5), (-1, 1), (5, 5), (6, 5), (4, 0)]) def test_progressbar_format_pos(runner, pos, length): - with _create_progress(length, length_known=length != 0, pos=pos) as progress: + with _create_progress(length, pos=pos) as progress: result = progress.format_pos() - if progress.length_known: - assert result == "{}/{}".format(pos, length) - else: - assert result == str(pos) + assert result == f"{pos}/{length}" @pytest.mark.parametrize( @@ -133,33 +123,30 @@ def test_progressbar_format_pos(runner, pos, length): [ (8, False, 7, 0, "#######-"), (0, True, 8, 0, "########"), - (0, False, 8, 0, "--------"), - (0, False, 5, 3, "#-------"), ], ) def test_progressbar_format_bar(runner, length, finished, pos, avg, expected): with _create_progress( - length, length_known=length != 0, width=8, pos=pos, finished=finished, avg=[avg] + length, width=8, pos=pos, finished=finished, avg=[avg] ) as progress: assert progress.format_bar() == expected @pytest.mark.parametrize( - "length, length_known, show_percent, show_pos, pos, expected", + "length, show_percent, show_pos, pos, expected", [ - (0, True, True, True, 0, " [--------] 0/0 0%"), - (0, True, False, True, 0, " [--------] 0/0"), - (0, True, False, False, 0, " [--------]"), - (0, False, False, False, 0, " [--------]"), - (8, True, True, True, 8, " [########] 8/8 100%"), + (0, True, True, 0, " [--------] 0/0 0%"), + (0, False, True, 0, " [--------] 0/0"), + (0, False, False, 0, " [--------]"), + (0, False, False, 0, " [--------]"), + (8, True, True, 8, " [########] 8/8 100%"), ], ) def test_progressbar_format_progress_line( - runner, length, length_known, show_percent, show_pos, pos, expected + runner, length, show_percent, show_pos, pos, expected ): with _create_progress( length, - length_known, width=8, show_percent=show_percent, pos=pos, @@ -194,21 +181,16 @@ def test_progressbar_iter_outside_with_exceptions(runner): def test_progressbar_is_iterator(runner, monkeypatch): - fake_clock = FakeClock() - @click.command() def cli(): with click.progressbar(range(10), label="test") as progress: while True: try: next(progress) - fake_clock.advance_time() except StopIteration: break - monkeypatch.setattr(time, "time", fake_clock.time) monkeypatch.setattr(click._termui_impl, "isatty", lambda _: True) - result = runner.invoke(cli, []) assert result.exception is None @@ -248,7 +230,7 @@ def test_file_prompt_default_format(runner, file_kwargs): click.echo(f.name) result = runner.invoke(cli) - assert result.output == "file [{0}]: \n{0}\n".format(__file__) + assert result.output == f"file [{__file__}]: \n{__file__}\n" def test_secho(runner): @@ -258,6 +240,17 @@ def test_secho(runner): assert bytes == b"" +@pytest.mark.skipif(platform.system() == "Windows", reason="No style on Windows.") +@pytest.mark.parametrize( + ("value", "expect"), [(123, b"\x1b[45m123\x1b[0m"), (b"test", b"test")] +) +def test_secho_non_text(runner, value, expect): + with runner.isolation() as (out, _): + click.secho(value, nl=False, color=True, bg="magenta") + result = out.getvalue() + assert result == expect + + def test_progressbar_yields_all_items(runner): with click.progressbar(range(3)) as progress: assert len(list(progress)) == 3 @@ -279,13 +272,60 @@ def test_progressbar_update(runner, monkeypatch): lines = [line for line in output.split("\n") if "[" in line] - assert " 25% 00:00:03" in lines[0] - assert " 50% 00:00:02" in lines[1] - assert " 75% 00:00:01" in lines[2] - assert "100% " in lines[3] + assert " 0%" in lines[0] + assert " 25% 00:00:03" in lines[1] + assert " 50% 00:00:02" in lines[2] + assert " 75% 00:00:01" in lines[3] + assert "100% " in lines[4] -@pytest.mark.parametrize("key_char", (u"h", u"H", u"é", u"À", u" ", u"字", u"àH", u"àR")) +def test_progressbar_item_show_func(runner, monkeypatch): + """item_show_func should show the current item being yielded.""" + + @click.command() + def cli(): + with click.progressbar(range(3), item_show_func=lambda x: str(x)) as progress: + for item in progress: + click.echo(f" item {item}") + + monkeypatch.setattr(click._termui_impl, "isatty", lambda _: True) + lines = runner.invoke(cli).output.splitlines() + + for i, line in enumerate(x for x in lines if "item" in x): + assert f"{i} item {i}" in line + + +def test_progressbar_update_with_item_show_func(runner, monkeypatch): + @click.command() + def cli(): + with click.progressbar( + length=6, item_show_func=lambda x: f"Custom {x}" + ) as progress: + while not progress.finished: + progress.update(2, progress.pos) + click.echo() + + monkeypatch.setattr(click._termui_impl, "isatty", lambda _: True) + output = runner.invoke(cli, []).output + + lines = [line for line in output.split("\n") if "[" in line] + + assert "Custom 0" in lines[0] + assert "Custom 2" in lines[1] + assert "Custom 4" in lines[2] + + +def test_progress_bar_update_min_steps(runner): + bar = _create_progress(update_min_steps=5) + bar.update(3) + assert bar._completed_intervals == 3 + assert bar.pos == 0 + bar.update(2) + assert bar._completed_intervals == 0 + assert bar.pos == 5 + + +@pytest.mark.parametrize("key_char", ("h", "H", "é", "À", " ", "字", "àH", "àR")) @pytest.mark.parametrize("echo", [True, False]) @pytest.mark.skipif(not WIN, reason="Tests user-input using the msvcrt module.") def test_getchar_windows(runner, monkeypatch, key_char, echo): @@ -296,7 +336,7 @@ def test_getchar_windows(runner, monkeypatch, key_char, echo): @pytest.mark.parametrize( - "special_key_char, key_char", [(u"\x00", "a"), (u"\x00", "b"), (u"\xe0", "c")] + "special_key_char, key_char", [("\x00", "a"), ("\x00", "b"), ("\xe0", "c")] ) @pytest.mark.skipif( not WIN, reason="Tests special character inputs using the msvcrt module." @@ -307,11 +347,11 @@ def test_getchar_special_key_windows(runner, monkeypatch, special_key_char, key_ click._termui_impl.msvcrt, "getwch", lambda: ordered_inputs.pop() ) monkeypatch.setattr(click.termui, "_getchar", None) - assert click.getchar() == special_key_char + key_char + assert click.getchar() == f"{special_key_char}{key_char}" @pytest.mark.parametrize( - ("key_char", "exc"), [(u"\x03", KeyboardInterrupt), (u"\x1a", EOFError)], + ("key_char", "exc"), [("\x03", KeyboardInterrupt), ("\x1a", EOFError)] ) @pytest.mark.skipif(not WIN, reason="Tests user-input using the msvcrt module.") def test_getchar_windows_exceptions(runner, monkeypatch, key_char, exc): @@ -320,3 +360,84 @@ def test_getchar_windows_exceptions(runner, monkeypatch, key_char, exc): with pytest.raises(exc): click.getchar() + + +@pytest.mark.skipif(platform.system() == "Windows", reason="No sed on Windows.") +def test_fast_edit(runner): + result = click.edit("a\nb", editor="sed -i~ 's/$/Test/'") + assert result == "aTest\nbTest\n" + + +@pytest.mark.parametrize( + ("prompt_required", "required", "args", "expect"), + [ + (True, False, None, "prompt"), + (True, False, ["-v"], "Option '-v' requires an argument."), + (False, True, None, "prompt"), + (False, True, ["-v"], "prompt"), + ], +) +def test_prompt_required_with_required(runner, prompt_required, required, args, expect): + @click.command() + @click.option("-v", prompt=True, prompt_required=prompt_required, required=required) + def cli(v): + click.echo(str(v)) + + result = runner.invoke(cli, args, input="prompt") + assert expect in result.output + + +@pytest.mark.parametrize( + ("args", "expect"), + [ + # Flag not passed, don't prompt. + pytest.param(None, None, id="no flag"), + # Flag and value passed, don't prompt. + pytest.param(["-v", "value"], "value", id="short sep value"), + pytest.param(["--value", "value"], "value", id="long sep value"), + pytest.param(["-vvalue"], "value", id="short join value"), + pytest.param(["--value=value"], "value", id="long join value"), + # Flag without value passed, prompt. + pytest.param(["-v"], "prompt", id="short no value"), + pytest.param(["--value"], "prompt", id="long no value"), + # Don't use next option flag as value. + pytest.param(["-v", "-o", "42"], ("prompt", "42"), id="no value opt"), + ], +) +def test_prompt_required_false(runner, args, expect): + @click.command() + @click.option("-v", "--value", prompt=True, prompt_required=False) + @click.option("-o") + def cli(value, o): + if o is not None: + return value, o + + return value + + result = runner.invoke(cli, args=args, input="prompt", standalone_mode=False) + assert result.exception is None + assert result.return_value == expect + + +@pytest.mark.parametrize( + ("prompt", "input", "expect"), + [ + (True, "password\npassword", "password"), + ("Confirm Password", "password\npassword\n", "password"), + (False, None, None), + ], +) +def test_confirmation_prompt(runner, prompt, input, expect): + @click.command() + @click.option( + "--password", prompt=prompt, hide_input=True, confirmation_prompt=prompt + ) + def cli(password): + return password + + result = runner.invoke(cli, input=input, standalone_mode=False) + assert result.exception is None + assert result.return_value == expect + + if prompt == "Confirm Password": + assert "Confirm Password: " in result.output diff --git a/tests/test_testing.py b/tests/test_testing.py index 22a285d..9f294b3 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -1,26 +1,20 @@ import os import sys +from io import BytesIO import pytest import click -from click._compat import PY2 from click._compat import WIN from click.testing import CliRunner -# Use the most reasonable io that users would use for the python version. -if PY2: - from cStringIO import StringIO as ReasonableBytesIO -else: - from io import BytesIO as ReasonableBytesIO - def test_runner(): @click.command() def test(): i = click.get_binary_stream("stdin") o = click.get_binary_stream("stdout") - while 1: + while True: chunk = i.read(4096) if not chunk: break @@ -32,18 +26,71 @@ def test_runner(): assert not result.exception assert result.output == "Hello World!\n" + +def test_echo_stdin_stream(): + @click.command() + def test(): + i = click.get_binary_stream("stdin") + o = click.get_binary_stream("stdout") + while True: + chunk = i.read(4096) + if not chunk: + break + o.write(chunk) + o.flush() + runner = CliRunner(echo_stdin=True) result = runner.invoke(test, input="Hello World!\n") assert not result.exception assert result.output == "Hello World!\nHello World!\n" +def test_echo_stdin_prompts(): + @click.command() + def test_python_input(): + foo = input("Foo: ") + click.echo(f"foo={foo}") + + runner = CliRunner(echo_stdin=True) + result = runner.invoke(test_python_input, input="bar bar\n") + assert not result.exception + assert result.output == "Foo: bar bar\nfoo=bar bar\n" + + @click.command() + @click.option("--foo", prompt=True) + def test_prompt(foo): + click.echo(f"foo={foo}") + + result = runner.invoke(test_prompt, input="bar bar\n") + assert not result.exception + assert result.output == "Foo: bar bar\nfoo=bar bar\n" + + @click.command() + @click.option("--foo", prompt=True, hide_input=True) + def test_hidden_prompt(foo): + click.echo(f"foo={foo}") + + result = runner.invoke(test_hidden_prompt, input="bar bar\n") + assert not result.exception + assert result.output == "Foo: \nfoo=bar bar\n" + + @click.command() + @click.option("--foo", prompt=True) + @click.option("--bar", prompt=True) + def test_multiple_prompts(foo, bar): + click.echo(f"foo={foo}, bar={bar}") + + result = runner.invoke(test_multiple_prompts, input="one\ntwo\n") + assert not result.exception + assert result.output == "Foo: one\nBar: two\nfoo=one, bar=two\n" + + def test_runner_with_stream(): @click.command() def test(): i = click.get_binary_stream("stdin") o = click.get_binary_stream("stdout") - while 1: + while True: chunk = i.read(4096) if not chunk: break @@ -51,12 +98,12 @@ def test_runner_with_stream(): o.flush() runner = CliRunner() - result = runner.invoke(test, input=ReasonableBytesIO(b"Hello World!\n")) + result = runner.invoke(test, input=BytesIO(b"Hello World!\n")) assert not result.exception assert result.output == "Hello World!\n" runner = CliRunner(echo_stdin=True) - result = runner.invoke(test, input=ReasonableBytesIO(b"Hello World!\n")) + result = runner.invoke(test, input=BytesIO(b"Hello World!\n")) assert not result.exception assert result.output == "Hello World!\nHello World!\n" @@ -65,7 +112,7 @@ def test_prompts(): @click.command() @click.option("--foo", prompt=True) def test(foo): - click.echo("foo={}".format(foo)) + click.echo(f"foo={foo}") runner = CliRunner() result = runner.invoke(test, input="wau wau\n") @@ -75,7 +122,7 @@ def test_prompts(): @click.command() @click.option("--foo", prompt=True, hide_input=True) def test(foo): - click.echo("foo={}".format(foo)) + click.echo(f"foo={foo}") runner = CliRunner() result = runner.invoke(test, input="wau wau\n") @@ -93,6 +140,25 @@ def test_getchar(): assert not result.exception assert result.output == "y\n" + runner = CliRunner(echo_stdin=True) + result = runner.invoke(continue_it, input="y") + assert not result.exception + assert result.output == "y\n" + + @click.command() + def getchar_echo(): + click.echo(click.getchar(echo=True)) + + runner = CliRunner() + result = runner.invoke(getchar_echo, input="y") + assert not result.exception + assert result.output == "yy\n" + + runner = CliRunner(echo_stdin=True) + result = runner.invoke(getchar_echo, input="y") + assert not result.exception + assert result.output == "yy\n" + def test_catch_exceptions(): class CustomError(Exception): @@ -131,7 +197,7 @@ def test_with_color(): assert not result.exception result = runner.invoke(cli, color=True) - assert result.output == "{}\n".format(click.style("hello world", fg="blue")) + assert result.output == f"{click.style('hello world', fg='blue')}\n" assert not result.exception @@ -219,7 +285,7 @@ def test_exit_code_and_output_from_sys_exit(): def test_env(): @click.command() def cli_env(): - click.echo("ENV={}".format(os.environ["TEST_CLICK_ENV"])) + click.echo(f"ENV={os.environ['TEST_CLICK_ENV']}") runner = CliRunner() @@ -301,3 +367,54 @@ def test_setting_prog_name_in_extra(): result = runner.invoke(cli, prog_name="foobar") assert not result.exception assert result.output == "ok\n" + + +def test_command_standalone_mode_returns_value(): + @click.command() + def cli(): + click.echo("ok") + return "Hello, World!" + + runner = CliRunner() + result = runner.invoke(cli, standalone_mode=False) + assert result.output == "ok\n" + assert result.return_value == "Hello, World!" + assert result.exit_code == 0 + + +def test_file_stdin_attrs(runner): + @click.command() + @click.argument("f", type=click.File()) + def cli(f): + click.echo(f.name) + click.echo(f.mode, nl=False) + + result = runner.invoke(cli, ["-"]) + assert result.output == "\nr" + + +def test_isolated_runner(runner): + with runner.isolated_filesystem() as d: + assert os.path.exists(d) + + assert not os.path.exists(d) + + +def test_isolated_runner_custom_tempdir(runner, tmp_path): + with runner.isolated_filesystem(temp_dir=tmp_path) as d: + assert os.path.exists(d) + + assert os.path.exists(d) + os.rmdir(d) + + +def test_isolation_stderr_errors(): + """Writing to stderr should escape invalid characters instead of + raising a UnicodeEncodeError. + """ + runner = CliRunner(mix_stderr=False) + + with runner.isolation() as (_, err): + click.echo("\udce2", err=True, nl=False) + + assert err.getvalue() == b"\\udce2" diff --git a/tests/test_types.py b/tests/test_types.py new file mode 100644 index 0000000..dff8172 --- /dev/null +++ b/tests/test_types.py @@ -0,0 +1,140 @@ +import os.path +import pathlib + +import pytest +from conftest import check_symlink_impl + +import click + + +@pytest.mark.parametrize( + ("type", "value", "expect"), + [ + (click.IntRange(0, 5), "3", 3), + (click.IntRange(5), "5", 5), + (click.IntRange(5), "100", 100), + (click.IntRange(max=5), "5", 5), + (click.IntRange(max=5), "-100", -100), + (click.IntRange(0, clamp=True), "-1", 0), + (click.IntRange(max=5, clamp=True), "6", 5), + (click.IntRange(0, min_open=True, clamp=True), "0", 1), + (click.IntRange(max=5, max_open=True, clamp=True), "5", 4), + (click.FloatRange(0.5, 1.5), "1.2", 1.2), + (click.FloatRange(0.5, min_open=True), "0.51", 0.51), + (click.FloatRange(max=1.5, max_open=True), "1.49", 1.49), + (click.FloatRange(0.5, clamp=True), "-0.0", 0.5), + (click.FloatRange(max=1.5, clamp=True), "inf", 1.5), + ], +) +def test_range(type, value, expect): + assert type.convert(value, None, None) == expect + + +@pytest.mark.parametrize( + ("type", "value", "expect"), + [ + (click.IntRange(0, 5), "6", "6 is not in the range 0<=x<=5."), + (click.IntRange(5), "4", "4 is not in the range x>=5."), + (click.IntRange(max=5), "6", "6 is not in the range x<=5."), + (click.IntRange(0, 5, min_open=True), 0, "00.5"), + (click.FloatRange(max=1.5, max_open=True), 1.5, "x<1.5"), + ], +) +def test_range_fail(type, value, expect): + with pytest.raises(click.BadParameter) as exc_info: + type.convert(value, None, None) + + assert expect in exc_info.value.message + + +def test_float_range_no_clamp_open(): + with pytest.raises(TypeError): + click.FloatRange(0, 1, max_open=True, clamp=True) + + sneaky = click.FloatRange(0, 1, max_open=True) + sneaky.clamp = True + + with pytest.raises(RuntimeError): + sneaky.convert("1.5", None, None) + + +@pytest.mark.parametrize( + ("nargs", "multiple", "default", "expect"), + [ + (2, False, None, None), + (2, False, (None, None), (None, None)), + (None, True, None, ()), + (None, True, (None, None), (None, None)), + (2, True, None, ()), + (2, True, [(None, None)], ((None, None),)), + (-1, None, None, ()), + ], +) +def test_cast_multi_default(runner, nargs, multiple, default, expect): + if nargs == -1: + param = click.Argument(["a"], nargs=nargs, default=default) + else: + param = click.Option(["-a"], nargs=nargs, multiple=multiple, default=default) + + cli = click.Command("cli", params=[param], callback=lambda a: a) + result = runner.invoke(cli, standalone_mode=False) + assert result.exception is None + assert result.return_value == expect + + +@pytest.mark.parametrize( + ("cls", "expect"), + [ + (None, "a/b/c.txt"), + (str, "a/b/c.txt"), + (bytes, b"a/b/c.txt"), + (pathlib.Path, pathlib.Path("a", "b", "c.txt")), + ], +) +def test_path_type(runner, cls, expect): + cli = click.Command( + "cli", + params=[click.Argument(["p"], type=click.Path(path_type=cls))], + callback=lambda p: p, + ) + result = runner.invoke(cli, ["a/b/c.txt"], standalone_mode=False) + assert result.exception is None + assert result.return_value == expect + + +@pytest.mark.skipif(not check_symlink_impl(), reason="symlink not allowed on device") +@pytest.mark.parametrize( + ("sym_file", "abs_fun"), + [ + (("relative_symlink",), os.path.basename), + (("test", "absolute_symlink"), lambda x: x), + ], +) +def test_symlink_resolution(tmpdir, sym_file, abs_fun): + """This test ensures symlinks are properly resolved by click""" + tempdir = str(tmpdir) + real_path = os.path.join(tempdir, "test_file") + sym_path = os.path.join(tempdir, *sym_file) + + # create dirs and files + os.makedirs(os.path.join(tempdir, "test"), exist_ok=True) + open(real_path, "w").close() + os.symlink(abs_fun(real_path), sym_path) + + # test + ctx = click.Context(click.Command("do_stuff")) + rv = click.Path(resolve_path=True).convert(sym_path, None, ctx) + + # os.readlink prepends path prefixes to absolute + # links in windows. + # https://docs.microsoft.com/en-us/windows/win32/ + # ... fileio/naming-a-file#win32-file-namespaces + # + # Here we strip win32 path prefix from the resolved path + rv_drive, rv_path = os.path.splitdrive(rv) + stripped_rv_drive = rv_drive.split(os.path.sep)[-1] + rv = os.path.join(stripped_rv_drive, rv_path) + + assert rv == real_path diff --git a/tests/test_utils.py b/tests/test_utils.py index d86f59b..d21b246 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,6 +1,8 @@ import os +import pathlib import stat import sys +from io import StringIO import pytest @@ -11,7 +13,7 @@ from click._compat import WIN def test_echo(runner): with runner.isolation() as outstreams: - click.echo(u"\N{SNOWMAN}") + click.echo("\N{SNOWMAN}") click.echo(b"\x44\x44") click.echo(42, nl=False) click.echo(b"a", nl=False) @@ -19,19 +21,7 @@ def test_echo(runner): bytes = outstreams[0].getvalue().replace(b"\r\n", b"\n") assert bytes == b"\xe2\x98\x83\nDD\n42ax" - # If we are in Python 2, we expect that writing bytes into a string io - # does not do anything crazy. In Python 3 - if sys.version_info[0] == 2: - import StringIO - - sys.stdout = x = StringIO.StringIO() - try: - click.echo("\xf6") - finally: - sys.stdout = sys.__stdout__ - assert x.getvalue() == "\xf6\n" - - # And in any case, if wrapped, we expect bytes to survive. + # if wrapped, we expect bytes to survive. @click.command() def cli(): click.echo(b"\xf6") @@ -49,8 +39,8 @@ def test_echo_custom_file(): import io f = io.StringIO() - click.echo(u"hello", file=f) - assert f.getvalue() == u"hello\n" + click.echo("hello", file=f) + assert f.getvalue() == "hello\n" @pytest.mark.parametrize( @@ -72,10 +62,25 @@ def test_echo_custom_file(): ({"bg": "magenta"}, "\x1b[45mx y\x1b[0m"), ({"bg": "cyan"}, "\x1b[46mx y\x1b[0m"), ({"bg": "white"}, "\x1b[47mx y\x1b[0m"), - ({"blink": True}, "\x1b[5mx y\x1b[0m"), - ({"underline": True}, "\x1b[4mx y\x1b[0m"), + ({"bg": 91}, "\x1b[48;5;91mx y\x1b[0m"), + ({"bg": (135, 0, 175)}, "\x1b[48;2;135;0;175mx y\x1b[0m"), ({"bold": True}, "\x1b[1mx y\x1b[0m"), ({"dim": True}, "\x1b[2mx y\x1b[0m"), + ({"underline": True}, "\x1b[4mx y\x1b[0m"), + ({"overline": True}, "\x1b[53mx y\x1b[0m"), + ({"italic": True}, "\x1b[3mx y\x1b[0m"), + ({"blink": True}, "\x1b[5mx y\x1b[0m"), + ({"reverse": True}, "\x1b[7mx y\x1b[0m"), + ({"strikethrough": True}, "\x1b[9mx y\x1b[0m"), + ({"bold": False}, "\x1b[22mx y\x1b[0m"), + ({"dim": False}, "\x1b[22mx y\x1b[0m"), + ({"underline": False}, "\x1b[24mx y\x1b[0m"), + ({"overline": False}, "\x1b[55mx y\x1b[0m"), + ({"italic": False}, "\x1b[23mx y\x1b[0m"), + ({"blink": False}, "\x1b[25mx y\x1b[0m"), + ({"reverse": False}, "\x1b[27mx y\x1b[0m"), + ({"strikethrough": False}, "\x1b[29mx y\x1b[0m"), + ({"fg": "black", "reset": False}, "\x1b[30mx y"), ], ) def test_styling(styles, ref): @@ -91,14 +96,12 @@ def test_unstyle_other_ansi(text, expect): def test_filename_formatting(): assert click.format_filename(b"foo.txt") == "foo.txt" assert click.format_filename(b"/x/foo.txt") == "/x/foo.txt" - assert click.format_filename(u"/x/foo.txt") == "/x/foo.txt" - assert click.format_filename(u"/x/foo.txt", shorten=True) == "foo.txt" + assert click.format_filename("/x/foo.txt") == "/x/foo.txt" + assert click.format_filename("/x/foo.txt", shorten=True) == "foo.txt" # filesystem encoding on windows permits this. if not WIN: - assert ( - click.format_filename(b"/x/foo\xff.txt", shorten=True) == u"foo\ufffd.txt" - ) + assert click.format_filename(b"/x/foo\xff.txt", shorten=True) == "foo\udcff.txt" def test_prompts(runner): @@ -141,6 +144,14 @@ def test_prompts(runner): assert result.output == "Foo [Y/n]: n\nno :(\n" +def test_confirm_repeat(runner): + cli = click.Command( + "cli", params=[click.Option(["--a/--no-a"], default=None, prompt=True)] + ) + result = runner.invoke(cli, input="\ny\n") + assert result.output == "A [y/n]: \nError: invalid input\nA [y/n]: y\n" + + @pytest.mark.skipif(WIN, reason="Different behavior on windows.") def test_prompts_abort(monkeypatch, capsys): def f(_): @@ -151,10 +162,10 @@ def test_prompts_abort(monkeypatch, capsys): try: click.prompt("Password", hide_input=True) except click.Abort: - click.echo("Screw you.") + click.echo("interrupted") out, err = capsys.readouterr() - assert out == "Password: \nScrew you.\n" + assert out == "Password:\ninterrupted\n" def _test_gen_func(): @@ -203,31 +214,34 @@ def test_echo_color_flag(monkeypatch, capfd): click.echo(styled_text, color=False) out, err = capfd.readouterr() - assert out == "{}\n".format(text) + assert out == f"{text}\n" click.echo(styled_text, color=True) out, err = capfd.readouterr() - assert out == "{}\n".format(styled_text) + assert out == f"{styled_text}\n" isatty = True click.echo(styled_text) out, err = capfd.readouterr() - assert out == "{}\n".format(styled_text) + assert out == f"{styled_text}\n" isatty = False click.echo(styled_text) out, err = capfd.readouterr() - assert out == "{}\n".format(text) + assert out == f"{text}\n" + + +def test_prompt_cast_default(capfd, monkeypatch): + monkeypatch.setattr(sys, "stdin", StringIO("\n")) + value = click.prompt("value", default="100", type=int) + capfd.readouterr() + assert type(value) is int @pytest.mark.skipif(WIN, reason="Test too complex to make work windows.") def test_echo_writing_to_standard_error(capfd, monkeypatch): def emulate_input(text): """Emulate keyboard input.""" - if sys.version_info[0] == 2: - from StringIO import StringIO - else: - from io import StringIO monkeypatch.setattr(sys, "stdin", StringIO(text)) click.echo("Echo to standard output") @@ -249,8 +263,8 @@ def test_echo_writing_to_standard_error(capfd, monkeypatch): emulate_input("asdlkj\n") click.prompt("Prompt to stderr", err=True) out, err = capfd.readouterr() - assert out == "" - assert err == "Prompt to stderr: " + assert out == " " + assert err == "Prompt to stderr:" emulate_input("y\n") click.confirm("Prompt to stdin") @@ -278,6 +292,12 @@ def test_echo_writing_to_standard_error(capfd, monkeypatch): assert err == "Pause to stderr\n" +def test_echo_with_capsys(capsys): + click.echo("Capture me.") + out, err = capsys.readouterr() + assert out == "Capture me.\n" + + def test_open_file(runner): @click.command() @click.argument("filename") @@ -393,3 +413,78 @@ def test_iter_lazyfile(tmpdir): with click.utils.LazyFile(f.name) as lf: for e_line, a_line in zip(expected, lf): assert e_line == a_line.strip() + + +class MockMain: + __slots__ = "__package__" + + def __init__(self, package_name): + self.__package__ = package_name + + +@pytest.mark.parametrize( + ("path", "main", "expected"), + [ + ("example.py", None, "example.py"), + (str(pathlib.Path("/foo/bar/example.py")), None, "example.py"), + ("example", None, "example"), + ( + str(pathlib.Path("example/__main__.py")), + MockMain(".example"), + "python -m example", + ), + ( + str(pathlib.Path("example/cli.py")), + MockMain(".example"), + "python -m example.cli", + ), + ], +) +def test_detect_program_name(path, main, expected): + assert click.utils._detect_program_name(path, _main=main) == expected + + +def test_expand_args(monkeypatch): + user = os.path.expanduser("~") + assert user in click.utils._expand_args(["~"]) + monkeypatch.setenv("CLICK_TEST", "hello") + assert "hello" in click.utils._expand_args(["$CLICK_TEST"]) + assert "setup.cfg" in click.utils._expand_args(["*.cfg"]) + assert os.path.join("docs", "conf.py") in click.utils._expand_args(["**/conf.py"]) + assert "*.not-found" in click.utils._expand_args(["*.not-found"]) + + +@pytest.mark.parametrize( + ("value", "max_length", "expect"), + [ + pytest.param("", 10, "", id="empty"), + pytest.param("123 567 90", 10, "123 567 90", id="equal length, no dot"), + pytest.param("123 567 9. aaaa bbb", 10, "123 567 9.", id="sentence < max"), + pytest.param("123 567\n\n 9. aaaa bbb", 10, "123 567", id="paragraph < max"), + pytest.param("123 567 90123.", 10, "123 567...", id="truncate"), + pytest.param("123 5678 xxxxxx", 10, "123...", id="length includes suffix"), + pytest.param( + "token in ~/.netrc ciao ciao", + 20, + "token in ~/.netrc...", + id="ignore dot in word", + ), + ], +) +@pytest.mark.parametrize( + "alter", + [ + pytest.param(None, id=""), + pytest.param( + lambda text: "\n\b\n" + " ".join(text.split(" ")) + "\n", id="no-wrap mark" + ), + ], +) +def test_make_default_short_help(value, max_length, alter, expect): + assert len(expect) <= max_length + + if alter: + value = alter(value) + + out = click.utils.make_default_short_help(value, max_length) + assert out == expect diff --git a/tox.ini b/tox.ini index beed51d..de68730 100644 --- a/tox.ini +++ b/tox.ini @@ -1,21 +1,24 @@ [tox] envlist = - py{38,37,36,35,27,py3,py} + py{39,38,37,36,py3} style + typing docs skip_missing_interpreters = true [testenv] -deps = - pytest - colorama -commands = pytest --tb=short --basetemp={envtmpdir} {posargs} +deps = -r requirements/tests.txt +commands = pytest -v --tb=short --basetemp={envtmpdir} {posargs} [testenv:style] deps = pre-commit skip_install = true commands = pre-commit run --all-files --show-diff-on-failure +[testenv:typing] +deps = -r requirements/typing.txt +commands = mypy + [testenv:docs] -deps = -r docs/requirements.txt +deps = -r requirements/docs.txt commands = sphinx-build -W -b html -d {envtmpdir}/doctrees docs {envtmpdir}/html