Repository: pallets/click
Pull request #2873 (2025-03-26 11:15:52)

Merge `stable` into `main`; Release 8.2.0
In order to test the upcoming 8.2.0 release, I'm working on this PR that is trying to reconcile `stable` branch with the `main` branch.

Both branches have diverged a lot, and cannot be merged naively. This PR is fixing the conflicting changes which happened in both branches.

This PR particularly:
- [x] fix missing changes in documentation updates
- [x] update some links in documentation 
- [x] fix running of `typing` job
- [x] fix running of `pre-commit` workflow
- [x] reconcile my main objective:
  - #2811
  - #2840
- [x] reconcile pager issues mentioned in #2830:
  - #1477 
  - #2775 

Relation to other proposals:
- Supersedes #2830 and #2844
- Addresses #2789


Patch:
diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml
index 1e15010..b8c896d 100644
--- a/.github/workflows/publish.yaml
+++ b/.github/workflows/publish.yaml
@@ -64,10 +64,6 @@ jobs:
       id-token: write
     steps:
       - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
-      - uses: pypa/gh-action-pypi-publish@f7600683efdcb7656dec5b29656edb7bc586e597 # v1.10.3
-        with:
-          repository-url: https://test.pypi.org/legacy/
-          packages-dir: artifact/
-      - uses: pypa/gh-action-pypi-publish@f7600683efdcb7656dec5b29656edb7bc586e597 # v1.10.3
+      - uses: pypa/gh-action-pypi-publish@15c56dba361d8335944d31a2ecd17d700fc7bcbc # v1.12.2
         with:
           packages-dir: artifact/
diff --git a/CHANGES.rst b/CHANGES.rst
index 062596b..b537d76 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -3,7 +3,7 @@
 Version 8.2.0
 -------------
 
-Unreleased
+Released 2025-03-26
 
 -   Drop support for Python 3.7. :pr:`2588`
 -   Use modern packaging metadata with ``pyproject.toml`` instead of ``setup.cfg``.
@@ -104,7 +104,7 @@ Unreleased
 Version 8.1.8
 -------------
 
-Unreleased
+Released 2024-12-19
 
 -   Fix an issue with type hints for ``click.open_file()``. :issue:`2717`
 -   Fix issue where error message for invalid ``click.Path`` displays on
@@ -117,6 +117,19 @@ Unreleased
     :issue:`2632`
 -   Fix ``click.echo(color=...)`` passing ``color`` to coloroma so it can be
     forced on Windows. :issue:`2606`.
+-   More robust bash version check, fixing problem on Windows with git-bash.
+    :issue:`2638`
+-   Cache the help option generated by the ``help_option_names`` setting to
+    respect its eagerness. :pr:`2811`
+-   Replace uses of ``os.system`` with ``subprocess.Popen``. :issue:`1476`
+-   Exceptions generated during a command will use the context's ``color``
+    setting when being displayed. :issue:`2193`
+-   Error message when defining option with invalid name is more descriptive.
+    :issue:`2452`
+-   Refactor code generating default ``--help`` option to deduplicate code.
+    :pr:`2563`
+-   Test ``CLIRunner`` resets patched ``_compat.should_strip_ansi``.
+    :issue:`2732`
 
 
 Version 8.1.7
diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst
deleted file mode 100644
index 5a9106a..0000000
--- a/CONTRIBUTING.rst
+++ /dev/null
@@ -1,216 +0,0 @@
-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
-
--   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.1.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 <https://coverage.readthedocs.io>`__.
-
-
-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 <https://www.sphinx-doc.org/en/stable/>`__.
diff --git a/README.md b/README.md
index 1aa055d..cee84bf 100644
--- a/README.md
+++ b/README.md
@@ -50,3 +50,11 @@ allow the maintainers to devote more time to the projects, [please
 donate today][].
 
 [please donate today]: https://palletsprojects.com/donate
+
+## Contributing
+
+See our [detailed contributing documentation][contrib] for many ways to
+contribute, including reporting issues, requesting features, asking or answering
+questions, and making PRs.
+
+[contrib]: https://palletsprojects.com/contributing/
diff --git a/docs/arguments.rst b/docs/arguments.rst
index 5b877d8..ae2991b 100644
--- a/docs/arguments.rst
+++ b/docs/arguments.rst
@@ -5,18 +5,21 @@ Arguments
 
 .. currentmodule:: click
 
-Arguments work similarly to :ref:`options <options>` but are positional.
-They also only support a subset of the features of options due to their
-syntactical nature. Click will also not attempt to document arguments for
-you and wants you to :ref:`document them manually <documenting-arguments>`
-in order to avoid ugly help pages.
+Arguments are:
+
+*   Are positional in nature.
+*   Similar to a limited version of :ref:`options <options>` that can take an arbitrary number of inputs
+*   :ref:`Documented manually <documenting-arguments>`.
+
+Useful and often used kwargs are:
+
+*   ``default``: Passes a default.
+*   ``nargs``: Sets the number of arguments. Set to -1 to take an arbitrary number.
 
 Basic Arguments
 ---------------
 
-The most basic option is a simple string argument of one value.  If no
-type is provided, the type of the default value is used, and if no default
-value is provided, the type is assumed to be :data:`STRING`.
+A minimal :class:`click.Argument` solely takes one string argument: the name of the argument. This will assume the argument is required, has no default, and is of the type ``str``.
 
 Example:
 
@@ -24,164 +27,86 @@ Example:
 
     @click.command()
     @click.argument('filename')
-    def touch(filename):
+    def touch(filename: str):
         """Print FILENAME."""
         click.echo(filename)
 
-And what it looks like:
+And from the command line:
 
 .. click:run::
 
     invoke(touch, args=['foo.txt'])
 
-Variadic Arguments
-------------------
 
-The second most common version is variadic arguments where a specific (or
-unlimited) number of arguments is accepted.  This can be controlled with
-the ``nargs`` parameter.  If it is set to ``-1``, then an unlimited number
-of arguments is accepted.
+An argument may be assigned a :ref:`parameter type <parameter-types>`. If no type is provided, the type of the default value is used. If no default value is provided, the type is assumed to be :data:`STRING`.
 
-The value is then passed as a tuple.  Note that only one argument can be
-set to ``nargs=-1``, as it will eat up all arguments.
+.. admonition:: Note on Required Arguments
 
-Example:
+   It is possible to make an argument required by setting ``required=True``.  It is not recommended since we think command line tools should gracefully degrade into becoming no ops.  We think this because command line tools are often invoked with wildcard inputs and they should not error out if the wildcard is empty.
+
+Multiple Arguments
+-----------------------------------
+
+To set the number of argument use the ``nargs`` kwarg. It can be set to any positive integer and -1. Setting it to -1, makes the number of arguments arbitrary (which is called variadic) and can only be used once. The arguments are then packed as a tuple and passed to the function.
 
 .. click:example::
 
     @click.command()
-    @click.argument('src', nargs=-1)
-    @click.argument('dst', nargs=1)
-    def copy(src, dst):
+    @click.argument('src', nargs=1)
+    @click.argument('dsts', nargs=-1)
+    def copy(src: str, dsts: tuple[str, ...]):
         """Move file SRC to DST."""
-        for fn in src:
-            click.echo(f"move {fn} to folder {dst}")
+        for destination in dsts:
+            click.echo(f"Copy {src} to folder {destination}")
 
-And what it looks like:
+And from the command line:
 
 .. click:run::
 
-    invoke(copy, args=['foo.txt', 'bar.txt', 'my_folder'])
-
-Note that this is not how you would write this application.  The reason
-for this is that in this particular example the arguments are defined as
-strings.  Filenames, however, are not strings!  They might be on certain
-operating systems, but not necessarily on all.  For better ways to write
-this, see the next sections.
+    invoke(copy, args=['foo.txt', 'usr/david/foo.txt', 'usr/mitsuko/foo.txt'])
 
-.. admonition:: Note on Non-Empty Variadic Arguments
+.. admonition:: Note on Handling Files
 
-   If you come from ``argparse``, you might be missing support for setting
-   ``nargs`` to ``+`` to indicate that at least one argument is required.
+    This is not how you should handle files and files paths. This merely used as a simple example. See :ref:`handling-files` to learn more about how to handle files in parameters.
 
-   This is supported by setting ``required=True``.  However, this should
-   not be used if you can avoid it as we believe scripts should gracefully
-   degrade into becoming noops if a variadic argument is empty.  The
-   reason for this is that very often, scripts are invoked with wildcard
-   inputs from the command line and they should not error out if the
-   wildcard is empty.
+Argument Escape Sequences
+---------------------------
 
-.. _file-args:
+If you want to process arguments that look like options, like a file named ``-foo.txt`` or ``--foo.txt`` , you must pass the ``--`` separator first. After you pass the ``--``, you may only pass arguments. This is a common feature for POSIX command line tools.
 
-File Arguments
---------------
-
-Since all the examples have already worked with filenames, it makes sense
-to explain how to deal with files properly.  Command line tools are more
-fun if they work with files the Unix way, which is to accept ``-`` as a
-special file that refers to stdin/stdout.
-
-Click supports this through the :class:`click.File` type which
-intelligently handles files for you.  It also deals with Unicode and bytes
-correctly for all versions of Python so your script stays very portable.
-
-Example:
+Example usage:
 
 .. click:example::
 
     @click.command()
-    @click.argument('input', type=click.File('rb'))
-    @click.argument('output', type=click.File('wb'))
-    def inout(input, output):
-        """Copy contents of INPUT to OUTPUT."""
-        while True:
-            chunk = input.read(1024)
-            if not chunk:
-                break
-            output.write(chunk)
-
-And what it does:
-
-.. click:run::
+    @click.argument('files', nargs=-1, type=click.Path())
+    def touch(files):
+        """Print all FILES file names."""
+        for filename in files:
+            click.echo(filename)
 
-    with isolated_filesystem():
-        invoke(inout, args=['-', 'hello.txt'], input=['hello'],
-               terminate_input=True)
-        invoke(inout, args=['hello.txt', '-'])
+And from the command line:
 
-File Path Arguments
--------------------
+.. click:run::
 
-In the previous example, the files were opened immediately.  But what if
-we just want the filename?  The naïve way is to use the default string
-argument type. The :class:`Path` type has several checks available which raise nice
-errors if they fail, such as existence. Filenames in these error messages are formatted
-with :func:`format_filename`, so any undecodable bytes will be printed nicely.
+    invoke(touch, ['--', '-foo.txt', 'bar.txt'])
 
-Example:
+If you don't like the ``--`` marker, you can set ignore_unknown_options to True to avoid checking unknown options:
 
 .. click:example::
 
-    @click.command()
-    @click.argument('filename', type=click.Path(exists=True))
-    def touch(filename):
-        """Print FILENAME if the file exists."""
-        click.echo(click.format_filename(filename))
+    @click.command(context_settings={"ignore_unknown_options": True})
+    @click.argument('files', nargs=-1, type=click.Path())
+    def touch(files):
+        """Print all FILES file names."""
+        for filename in files:
+            click.echo(filename)
 
-And what it does:
+And from the command line:
 
 .. click:run::
 
-    with isolated_filesystem():
-        with open('hello.txt', 'w') as f:
-            f.write('Hello World!\n')
-        invoke(touch, args=['hello.txt'])
-        println()
-        invoke(touch, args=['missing.txt'])
-
-
-File Opening Safety
--------------------
-
-The :class:`FileType` type has one problem it needs to deal with, and that
-is to decide when to open a file.  The default behavior is to be
-"intelligent" about it.  What this means is that it will open stdin/stdout
-and files opened for reading immediately.  This will give the user direct
-feedback when a file cannot be opened, but it will only open files
-for writing the first time an IO operation is performed by automatically
-wrapping the file in a special wrapper.
-
-This behavior can be forced by passing ``lazy=True`` or ``lazy=False`` to
-the constructor.  If the file is opened lazily, it will fail its first IO
-operation by raising an :exc:`FileError`.
-
-Since files opened for writing will typically immediately empty the file,
-the lazy mode should only be disabled if the developer is absolutely sure
-that this is intended behavior.
-
-Forcing lazy mode is also very useful to avoid resource handling
-confusion.  If a file is opened in lazy mode, it will receive a
-``close_intelligently`` method that can help figure out if the file
-needs closing or not.  This is not needed for parameters, but is
-necessary for manually prompting with the :func:`prompt` function as you
-do not know if a stream like stdout was opened (which was already open
-before) or a real file that needs closing.
-
-Starting with Click 2.0, it is also possible to open files in atomic mode by
-passing ``atomic=True``.  In atomic mode, all writes go into a separate
-file in the same folder, and upon completion, the file will be moved over to
-the original location.  This is useful if a file regularly read by other
-users is modified.
+    invoke(touch, ['-foo.txt', 'bar.txt'])
 
 
 .. _environment-variables:
@@ -189,11 +114,9 @@ users is modified.
 Environment Variables
 ---------------------
 
-Like options, arguments can also grab values from an environment variable.
-Unlike options, however, this is only supported for explicitly named
-environment variables.
+Arguments can use environment variables. To do so, pass the name(s) of the environment variable(s) via `envvar` in ``click.argument``.
 
-Example usage:
+Checking one environment variable:
 
 .. click:example::
 
@@ -208,59 +131,28 @@ And from the command line:
 .. click:run::
 
     with isolated_filesystem():
+        # Writing the file in the filesystem.
         with open('hello.txt', 'w') as f:
             f.write('Hello World!')
         invoke(echo, env={'SRC': 'hello.txt'})
 
-In that case, it can also be a list of different environment variables
-where the first one is picked.
-
-Generally, this feature is not recommended because it can cause the user
-a lot of confusion.
-
-Option-Like Arguments
----------------------
 
-Sometimes, you want to process arguments that look like options.  For
-instance, imagine you have a file named ``-foo.txt``.  If you pass this as
-an argument in this manner, Click will treat it as an option.
-
-To solve this, Click does what any POSIX style command line script does,
-and that is to accept the string ``--`` as a separator for options and
-arguments.  After the ``--`` marker, all further parameters are accepted as
-arguments.
-
-Example usage:
+Checking multiple environment variables:
 
 .. click:example::
 
     @click.command()
-    @click.argument('files', nargs=-1, type=click.Path())
-    def touch(files):
-        """Print all FILES file names."""
-        for filename in files:
-            click.echo(filename)
-
-And from the command line:
-
-.. click:run::
-
-    invoke(touch, ['--', '-foo.txt', 'bar.txt'])
-
-If you don't like the ``--`` marker, you can set ignore_unknown_options to
-True to avoid checking unknown options:
-
-.. click:example::
-
-    @click.command(context_settings={"ignore_unknown_options": True})
-    @click.argument('files', nargs=-1, type=click.Path())
-    def touch(files):
-        """Print all FILES file names."""
-        for filename in files:
-            click.echo(filename)
+    @click.argument('src', envvar=['SRC', 'SRC_2'], type=click.File('r'))
+    def echo(src):
+        """Print value of SRC environment variable."""
+        click.echo(src.read())
 
 And from the command line:
 
 .. click:run::
 
-    invoke(touch, ['-foo.txt', 'bar.txt'])
+    with isolated_filesystem():
+        # Writing the file in the filesystem.
+        with open('hello.txt', 'w') as f:
+            f.write('Hello World from second variable!')
+        invoke(echo, env={'SRC_2': 'hello.txt'})
diff --git a/docs/contrib.rst b/docs/contrib.rst
index 287862c..628edd7 100644
--- a/docs/contrib.rst
+++ b/docs/contrib.rst
@@ -18,4 +18,41 @@ Please note that the quality and stability of those packages may be different
 than Click itself. While published under a common organization, they are still
 separate from Click and the Pallets maintainers.
 
+
+Third-party projects
+--------------------
+
+Other projects that extend Click's features are available outside of the
+click-contrib_ organization.
+
+Some of the most popular and actively maintained are listed below:
+
+==========================================================  ===========================================================================================  =================================================================================================  ======================================================================================================
+Project                                                     Description                                                                                  Popularity                                                                                         Activity
+==========================================================  ===========================================================================================  =================================================================================================  ======================================================================================================
+`Typer <https://github.com/fastapi/typer>`_                 Use Python type hints to create CLI apps.                                                    .. image:: https://img.shields.io/github/stars/fastapi/typer?label=%20&style=flat-square           .. image:: https://img.shields.io/github/last-commit/fastapi/typer?label=%20&style=flat-square
+                                                                                                                                                            :alt: GitHub stars                                                                                 :alt: Last commit
+`rich-click <https://github.com/ewels/rich-click>`_         Format help outputwith Rich.                                                                 .. image:: https://img.shields.io/github/stars/ewels/rich-click?label=%20&style=flat-square        .. image:: https://img.shields.io/github/last-commit/ewels/rich-click?label=%20&style=flat-square
+                                                                                                                                                            :alt: GitHub stars                                                                                 :alt: Last commit
+`click-app <https://github.com/simonw/click-app>`_          Cookiecutter template for creating new CLIs.                                                 .. image:: https://img.shields.io/github/stars/simonw/click-app?label=%20&style=flat-square        .. image:: https://img.shields.io/github/last-commit/simonw/click-app?label=%20&style=flat-square
+                                                                                                                                                            :alt: GitHub stars                                                                                 :alt: Last commit
+`Cloup <https://github.com/janluke/cloup>`_                 Adds option groups, constraints, command aliases, help themes, suggestions and more.         .. image:: https://img.shields.io/github/stars/janluke/cloup?label=%20&style=flat-square           .. image:: https://img.shields.io/github/last-commit/janluke/cloup?label=%20&style=flat-square
+                                                                                                                                                            :alt: GitHub stars                                                                                 :alt: Last commit
+`Click Extra <https://github.com/kdeldycke/click-extra>`_   Cloup + colorful ``--help``, ``--config``, ``--show-params``, ``--verbosity`` options, etc.  .. image:: https://img.shields.io/github/stars/kdeldycke/click-extra?label=%20&style=flat-square   .. image:: https://img.shields.io/github/last-commit/kdeldycke/click-extra?label=%20&style=flat-square
+                                                                                                                                                            :alt: GitHub stars                                                                                 :alt: Last commit
+==========================================================  ===========================================================================================  =================================================================================================  ======================================================================================================
+
+.. note::
+
+    To make it into the list above, a project:
+
+    - must be actively maintained (at least one commit in the last year)
+    - must have a reasonable number of stars (at least 20)
+
+    If you have a project that meets these criteria, please open a pull request
+    to add it to the list.
+
+    If a project is no longer maintained or does not meet the criteria above,
+    please open a pull request to remove it from the list.
+
 .. _click-contrib: https://github.com/click-contrib/
diff --git a/docs/handling-files.rst b/docs/handling-files.rst
new file mode 100644
index 0000000..0a61966
--- /dev/null
+++ b/docs/handling-files.rst
@@ -0,0 +1,90 @@
+.. _handling-files:
+
+Handling Files
+================
+
+.. currentmodule:: click
+
+Click has built in features to support file and file path handling. The examples use arguments but the same principle applies to options as well.
+
+.. _file-args:
+
+File Arguments
+-----------------
+
+Click supports working with files with the :class:`File` type. Some notable features are:
+
+*   Support for   ``-`` to mean a special file that refers to stdin when used for reading, and stdout when used for writing. This is a common pattern for POSIX command line utilities.
+*   Deals with ``str`` and ``bytes`` correctly for all versions of Python.
+
+Example:
+
+.. click:example::
+
+    @click.command()
+    @click.argument('input', type=click.File('rb'))
+    @click.argument('output', type=click.File('wb'))
+    def inout(input, output):
+        """Copy contents of INPUT to OUTPUT."""
+        while True:
+            chunk = input.read(1024)
+            if not chunk:
+                break
+            output.write(chunk)
+
+And from the command line:
+
+.. click:run::
+
+    with isolated_filesystem():
+        invoke(inout, args=['-', 'hello.txt'], input=['hello'],
+               terminate_input=True)
+        invoke(inout, args=['hello.txt', '-'])
+
+File Path Arguments
+----------------------
+
+For handling paths, the :class:`Path` type is better than a ``str``. Some notable features are:
+
+*   The ``exists`` argument will verify whether the path exists.
+*	``readable``, ``writable``, and ``executable`` can perform permission checks.
+*	``file_okay`` and ``dir_okay`` allow specifying whether files/directories are accepted.
+*   Error messages are nicely formatted using :func:`format_filename` so any undecodable bytes will be printed nicely.
+
+See :class:`Path` for all features.
+
+Example:
+
+.. click:example::
+
+    @click.command()
+    @click.argument('filename', type=click.Path(exists=True))
+    def touch(filename):
+        """Print FILENAME if the file exists."""
+        click.echo(click.format_filename(filename))
+
+And from the command line:
+
+.. click:run::
+
+    with isolated_filesystem():
+        with open('hello.txt', 'w') as f:
+            f.write('Hello World!\n')
+        invoke(touch, args=['hello.txt'])
+        println()
+        invoke(touch, args=['missing.txt'])
+
+
+File Opening Behaviors
+-----------------------------
+
+The :class:`File` type attempts to be "intelligent" about when to open a file. Stdin/stdout and files opened for reading will be opened immediately. This will give the user direct feedback when a file cannot be opened. Files opened for writing will only be open on the first IO operation. This is done by automatically wrapping the file in a special wrapper.
+
+File open behavior can be controlled by the boolean kwarg ``lazy``. If a file is opened lazily:
+
+*   A failure at first IO operation will happen by raising an :exc:`FileError`.
+*   It can help minimize resource handling confusion.  If a file is opened in lazy mode, it will call :meth:`LazyFile.close_intelligently` to help figure out if the file needs closing or not. This is not needed for parameters, but is necessary for manually prompting. For manual prompts with the :func:`prompt` function you do not know if a stream like stdout was opened (which was already open before) or a real file was opened (that needs closing).
+
+Since files opened for writing will typically empty the file, the lazy mode should only be disabled if the developer is absolutely sure that this is intended behavior.
+
+It is also possible to open files in atomic mode by passing ``atomic=True``.  In atomic mode, all writes go into a separate file in the same folder, and upon completion, the file will be moved over to the original location.  This is useful if a file regularly read by other users is modified.
diff --git a/docs/index.rst b/docs/index.rst
index 36917a6..8305a49 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -80,6 +80,7 @@ usage patterns.
    commands
    documentation
    prompts
+   handling-files
    complex
    advanced
    testing
diff --git a/pyproject.toml b/pyproject.toml
index a8f3655..b58a391 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [project]
 name = "click"
-version = "8.2.0.dev"
+version = "8.2.0"
 description = "Composable command line interface toolkit"
 readme = "README.md"
 license = {file = "LICENSE.txt"}
diff --git a/requirements/build.txt b/requirements/build.txt
index 1b13b05..9d6dd10 100644
--- a/requirements/build.txt
+++ b/requirements/build.txt
@@ -6,7 +6,7 @@
 #
 build==1.2.2.post1
     # via -r build.in
-packaging==24.1
+packaging==24.2
     # via build
 pyproject-hooks==1.2.0
     # via build
diff --git a/requirements/dev.txt b/requirements/dev.txt
index bca2031..803b084 100644
--- a/requirements/dev.txt
+++ b/requirements/dev.txt
@@ -86,7 +86,7 @@ nodeenv==1.9.1
     #   -r typing.txt
     #   pre-commit
     #   pyright
-packaging==24.1
+packaging==24.2
     # via
     #   -r docs.txt
     #   -r tests.txt
@@ -115,9 +115,9 @@ pygments==2.18.0
     #   sphinx-tabs
 pyproject-api==1.8.0
     # via tox
-pyright==1.1.386
+pyright==1.1.390
     # via -r typing.txt
-pytest==8.3.3
+pytest==8.3.4
     # via -r tests.txt
 pyyaml==6.0.2
     # via pre-commit
diff --git a/requirements/docs.txt b/requirements/docs.txt
index 831d377..6367c30 100644
--- a/requirements/docs.txt
+++ b/requirements/docs.txt
@@ -35,7 +35,7 @@ mdurl==0.1.2
     # via markdown-it-py
 myst-parser==4.0.1
     # via -r docs.in
-packaging==24.1
+packaging==24.2
     # via
     #   pallets-sphinx-themes
     #   sphinx
diff --git a/requirements/typing.txt b/requirements/typing.txt
index 127bc9e..f9402d6 100644
--- a/requirements/typing.txt
+++ b/requirements/typing.txt
@@ -10,7 +10,7 @@ mypy-extensions==1.0.0
     # via mypy
 nodeenv==1.9.1
     # via pyright
-pyright==1.1.386
+pyright==1.1.390
     # via -r typing.in
 typing-extensions==4.12.2
     # via
diff --git a/src/click/_termui_impl.py b/src/click/_termui_impl.py
index 833b9fe..bbf2386 100644
--- a/src/click/_termui_impl.py
+++ b/src/click/_termui_impl.py
@@ -10,11 +10,14 @@ import collections.abc as cabc
 import contextlib
 import math
 import os
+import shlex
 import sys
 import time
 import typing as t
 from gettext import gettext as _
 from io import StringIO
+from pathlib import Path
+from shutil import which
 from types import TracebackType
 
 from ._compat import _default_text_stdout
@@ -375,58 +378,89 @@ def pager(generator: cabc.Iterable[str], color: bool | None = None) -> None:
 
     if not isatty(sys.stdin) or not isatty(stdout):
         return _nullpager(stdout, generator, color)
-    pager_cmd = (os.environ.get("PAGER", None) or "").strip()
-    if pager_cmd:
+
+    # Split and normalize the pager command into parts.
+    pager_cmd_parts = shlex.split(os.environ.get("PAGER", ""), posix=False)
+    if pager_cmd_parts:
         if WIN:
-            return _tempfilepager(generator, pager_cmd, color)
-        return _pipepager(generator, pager_cmd, color)
+            if _tempfilepager(generator, pager_cmd_parts, color):
+                return
+        elif _pipepager(generator, pager_cmd_parts, color):
+            return
+
     if os.environ.get("TERM") in ("dumb", "emacs"):
         return _nullpager(stdout, generator, color)
-    if WIN or sys.platform.startswith("os2"):
-        return _tempfilepager(generator, "more <", color)
-    if hasattr(os, "system") and os.system("(less) 2>/dev/null") == 0:
-        return _pipepager(generator, "less", color)
+    if (WIN or sys.platform.startswith("os2")) and _tempfilepager(
+        generator, ["more"], color
+    ):
+        return
+    if _pipepager(generator, ["less"], color):
+        return
 
     import tempfile
 
     fd, filename = tempfile.mkstemp()
     os.close(fd)
     try:
-        if hasattr(os, "system") and os.system(f'more "{filename}"') == 0:
-            return _pipepager(generator, "more", color)
+        if _pipepager(generator, ["more"], color):
+            return
         return _nullpager(stdout, generator, color)
     finally:
         os.unlink(filename)
 
 
-def _pipepager(generator: cabc.Iterable[str], cmd: str, color: bool | None) -> None:
-    """Page through text by feeding it to another program.  Invoking a
+def _pipepager(
+    generator: cabc.Iterable[str], cmd_parts: list[str], color: bool | None
+) -> bool:
+    """Page through text by feeding it to another program. Invoking a
     pager through this might support colors.
+
+    Returns `True` if the command was found, `False` otherwise and thus another
+    pager should be attempted.
     """
+    # Split the command into the invoked CLI and its parameters.
+    if not cmd_parts:
+        return False
+    cmd = cmd_parts[0]
+    cmd_params = cmd_parts[1:]
+
+    cmd_filepath = which(cmd)
+    if not cmd_filepath:
+        return False
+    # Resolves symlinks and produces a normalized absolute path string.
+    cmd_path = Path(cmd_filepath).resolve()
+    cmd_name = cmd_path.name
+
     import subprocess
 
+    # Make a local copy of the environment to not affect the global one.
     env = dict(os.environ)
 
-    # If we're piping to less we might support colors under the
-    # condition that
-    cmd_detail = cmd.rsplit("/", 1)[-1].split()
-    if color is None and cmd_detail[0] == "less":
-        less_flags = f"{os.environ.get('LESS', '')}{' '.join(cmd_detail[1:])}"
+    # If we're piping to less and the user hasn't decided on colors, we enable
+    # them by default we find the -R flag in the command line arguments.
+    if color is None and cmd_name == "less":
+        less_flags = f"{os.environ.get('LESS', '')}{' '.join(cmd_params)}"
         if not less_flags:
             env["LESS"] = "-R"
             color = True
         elif "r" in less_flags or "R" in less_flags:
             color = True
 
-    c = subprocess.Popen(cmd, shell=True, stdin=subprocess.PIPE, env=env)
-    stdin = t.cast(t.BinaryIO, c.stdin)
-    encoding = get_best_encoding(stdin)
+    c = subprocess.Popen(
+        [str(cmd_path)] + cmd_params,
+        shell=True,
+        stdin=subprocess.PIPE,
+        env=env,
+        errors="replace",
+        text=True,
+    )
+    assert c.stdin is not None
     try:
         for text in generator:
             if not color:
                 text = strip_ansi(text)
 
-            stdin.write(text.encode(encoding, "replace"))
+            c.stdin.write(text)
     except BrokenPipeError:
         # In case the pager exited unexpectedly, ignore the broken pipe error.
         pass
@@ -440,7 +474,7 @@ def _pipepager(generator: cabc.Iterable[str], cmd: str, color: bool | None) -> N
     finally:
         # We must close stdin and wait for the pager to exit before we continue
         try:
-            stdin.close()
+            c.stdin.close()
         # Close implies flush, so it might throw a BrokenPipeError if the pager
         # process exited already.
         except BrokenPipeError:
@@ -462,9 +496,29 @@ def _pipepager(generator: cabc.Iterable[str], cmd: str, color: bool | None) -> N
             else:
                 break
 
+    return True
+
+
+def _tempfilepager(
+    generator: cabc.Iterable[str], cmd_parts: list[str], color: bool | None
+) -> bool:
+    """Page through text by invoking a program on a temporary file.
+
+    Returns `True` if the command was found, `False` otherwise and thus another
+    pager should be attempted.
+    """
+    # Split the command into the invoked CLI and its parameters.
+    if not cmd_parts:
+        return False
+    cmd = cmd_parts[0]
 
-def _tempfilepager(generator: cabc.Iterable[str], cmd: str, color: bool | None) -> None:
-    """Page through text by invoking a program on a temporary file."""
+    cmd_filepath = which(cmd)
+    if not cmd_filepath:
+        return False
+    # Resolves symlinks and produces a normalized absolute path string.
+    cmd_path = Path(cmd_filepath).resolve()
+
+    import subprocess
     import tempfile
 
     fd, filename = tempfile.mkstemp()
@@ -476,11 +530,16 @@ def _tempfilepager(generator: cabc.Iterable[str], cmd: str, color: bool | None)
     with open_stream(filename, "wb")[0] as f:
         f.write(text.encode(encoding))
     try:
-        os.system(f'{cmd} "{filename}"')
+        subprocess.call([str(cmd_path), filename])
+    except OSError:
+        # Command not found
+        pass
     finally:
         os.close(fd)
         os.unlink(filename)
 
+    return True
+
 
 def _nullpager(
     stream: t.TextIO, generator: cabc.Iterable[str], color: bool | None
@@ -515,7 +574,7 @@ class Editor:
         if WIN:
             return "notepad"
         for editor in "sensible-editor", "vim", "nano":
-            if os.system(f"which {editor} >/dev/null 2>&1") == 0:
+            if which(editor) is not None:
                 return editor
         return "vi"
 
@@ -626,22 +685,33 @@ def open_url(url: str, wait: bool = False, locate: bool = False) -> int:
             null.close()
     elif WIN:
         if locate:
-            url = _unquote_file(url.replace('"', ""))
-            args = f'explorer /select,"{url}"'
+            url = _unquote_file(url)
+            args = ["explorer", f"/select,{url}"]
         else:
-            url = url.replace('"', "")
-            wait_str = "/WAIT" if wait else ""
-            args = f'start {wait_str} "" "{url}"'
-        return os.system(args)
+            args = ["start"]
+            if wait:
+                args.append("/WAIT")
+            args.append("")
+            args.append(url)
+        try:
+            return subprocess.call(args)
+        except OSError:
+            # Command not found
+            return 127
     elif CYGWIN:
         if locate:
-            url = os.path.dirname(_unquote_file(url).replace('"', ""))
-            args = f'cygstart "{url}"'
+            url = _unquote_file(url)
+            args = ["cygstart", os.path.dirname(url)]
         else:
-            url = url.replace('"', "")
-            wait_str = "-w" if wait else ""
-            args = f'cygstart {wait_str} "{url}"'
-        return os.system(args)
+            args = ["cygstart"]
+            if wait:
+                args.append("-w")
+            args.append(url)
+        try:
+            return subprocess.call(args)
+        except OSError:
+            # Command not found
+            return 127
 
     try:
         if locate:
diff --git a/src/click/core.py b/src/click/core.py
index 2d17f46..fe77cb9 100644
--- a/src/click/core.py
+++ b/src/click/core.py
@@ -116,9 +116,16 @@ def iter_params_for_processing(
     invocation_order: cabc.Sequence[Parameter],
     declaration_order: cabc.Sequence[Parameter],
 ) -> 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.
+    """Returns all declared parameters in the order they should be processed.
+
+    The declared parameters are re-shuffled depending on the order in which
+    they were invoked, as well as the eagerness of each parameters.
+
+    The invocation order takes precedence over the declaration order. I.e. the
+    order in which the user provided them to the CLI is respected.
+
+    This behavior and its effect on callback evaluation is detailed at:
+    https://click.palletsprojects.com/en/stable/advanced/#callback-evaluation-order
     """
 
     def sort_key(item: Parameter) -> tuple[bool, float]:
@@ -934,6 +941,7 @@ class Command:
         self.options_metavar = options_metavar
         self.short_help = short_help
         self.add_help_option = add_help_option
+        self._help_option = None
         self.no_args_is_help = no_args_is_help
         self.hidden = hidden
         self.deprecated = deprecated
@@ -1017,18 +1025,28 @@ class Command:
         """Returns the help option object.
 
         Skipped if :attr:`add_help_option` is ``False``.
+
+        .. versionchanged:: 8.1.8
+            The help option is now cached to avoid creating it multiple times.
         """
         help_option_names = self.get_help_option_names(ctx)
 
         if not help_option_names or not self.add_help_option:
             return None
 
-        # Avoid circular import.
-        from .decorators import help_option
+        # Cache the help option object in private _help_option attribute to
+        # avoid creating it multiple times. Not doing this will break the
+        # callback odering by iter_params_for_processing(), which relies on
+        # object comparison.
+        if self._help_option is None:
+            # Avoid circular import.
+            from .decorators import help_option
+
+            # Apply help_option decorator and pop resulting option
+            help_option(*help_option_names)(self)
+            self._help_option = self.params.pop()  # type: ignore[assignment]
 
-        # apply help_option decorator and pop resulting option
-        help_option(*help_option_names)(self)
-        return self.params.pop()  # type: ignore[return-value]
+        return self._help_option
 
     def make_parser(self, ctx: Context) -> _OptionParser:
         """Creates the underlying option parser for this command."""
diff --git a/src/click/shell_completion.py b/src/click/shell_completion.py
index c8655b1..cdb5822 100644
--- a/src/click/shell_completion.py
+++ b/src/click/shell_completion.py
@@ -303,12 +303,19 @@ class BashComplete(ShellComplete):
 
     @staticmethod
     def _check_version() -> None:
+        import shutil
         import subprocess
 
-        output = subprocess.run(
-            ["bash", "-c", 'echo "${BASH_VERSION}"'], stdout=subprocess.PIPE
-        )
-        match = re.search(r"^(\d+)\.(\d+)\.\d+", output.stdout.decode())
+        bash_exe = shutil.which("bash")
+
+        if bash_exe is None:
+            match = None
+        else:
+            output = subprocess.run(
+                [bash_exe, "--norc", "-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()
diff --git a/src/click/types.py b/src/click/types.py
index d0a2715..23da68d 100644
--- a/src/click/types.py
+++ b/src/click/types.py
@@ -726,12 +726,15 @@ class File(ParamType):
     will not be held open until first IO. lazy is mainly useful when opening
     for writing to avoid creating the file until it is needed.
 
-    Starting with Click 2.0, files can also be opened atomically in which
-    case all writes go into a separate file in the same folder and upon
-    completion the file will be moved over to the original location.  This
-    is useful if a file regularly read by other users is modified.
+    Files can also be opened atomically in which case all writes go into a
+    separate file in the same folder and upon completion the file will
+    be moved over to the original location.  This is useful if a file
+    regularly read by other users is modified.
 
     See :ref:`file-args` for more information.
+
+    .. versionchanged:: 2.0
+        Added the ``atomic`` parameter.
     """
 
     name = "filename"


Test patch:
diff --git a/requirements/tests.txt b/requirements/tests.txt
index eef5a97..b0e7799 100644
--- a/requirements/tests.txt
+++ b/requirements/tests.txt
@@ -6,9 +6,9 @@
 #
 iniconfig==2.0.0
     # via pytest
-packaging==24.1
+packaging==24.2
     # via pytest
 pluggy==1.5.0
     # via pytest
-pytest==8.3.3
+pytest==8.3.4
     # via -r tests.in
diff --git a/src/click/testing.py b/src/click/testing.py
index 9972d66..d19c103 100644
--- a/src/click/testing.py
+++ b/src/click/testing.py
@@ -375,6 +375,7 @@ class CliRunner:
         old_hidden_prompt_func = termui.hidden_prompt_func
         old__getchar_func = termui._getchar
         old_should_strip_ansi = utils.should_strip_ansi  # type: ignore
+        old__compat_should_strip_ansi = _compat.should_strip_ansi
         termui.visible_prompt_func = visible_input
         termui.hidden_prompt_func = hidden_input
         termui._getchar = _getchar
@@ -409,6 +410,7 @@ class CliRunner:
             termui.hidden_prompt_func = old_hidden_prompt_func
             termui._getchar = old__getchar_func
             utils.should_strip_ansi = old_should_strip_ansi  # type: ignore
+            _compat.should_strip_ansi = old__compat_should_strip_ansi
             formatting.FORCED_WIDTH = old_forced_width
 
     def invoke(
diff --git a/tests/test_commands.py b/tests/test_commands.py
index eb8df85..a5aa43f 100644
--- a/tests/test_commands.py
+++ b/tests/test_commands.py
@@ -301,6 +301,138 @@ def test_group_add_command_name(runner):
     assert result.exit_code == 0
 
 
+@pytest.mark.parametrize(
+    ("invocation_order", "declaration_order", "expected_order"),
+    [
+        # Non-eager options.
+        ([], ["-a"], ["-a"]),
+        (["-a"], ["-a"], ["-a"]),
+        ([], ["-a", "-c"], ["-a", "-c"]),
+        (["-a"], ["-a", "-c"], ["-a", "-c"]),
+        (["-c"], ["-a", "-c"], ["-c", "-a"]),
+        ([], ["-c", "-a"], ["-c", "-a"]),
+        (["-a"], ["-c", "-a"], ["-a", "-c"]),
+        (["-c"], ["-c", "-a"], ["-c", "-a"]),
+        (["-a", "-c"], ["-a", "-c"], ["-a", "-c"]),
+        (["-c", "-a"], ["-a", "-c"], ["-c", "-a"]),
+        # Eager options.
+        ([], ["-b"], ["-b"]),
+        (["-b"], ["-b"], ["-b"]),
+        ([], ["-b", "-d"], ["-b", "-d"]),
+        (["-b"], ["-b", "-d"], ["-b", "-d"]),
+        (["-d"], ["-b", "-d"], ["-d", "-b"]),
+        ([], ["-d", "-b"], ["-d", "-b"]),
+        (["-b"], ["-d", "-b"], ["-b", "-d"]),
+        (["-d"], ["-d", "-b"], ["-d", "-b"]),
+        (["-b", "-d"], ["-b", "-d"], ["-b", "-d"]),
+        (["-d", "-b"], ["-b", "-d"], ["-d", "-b"]),
+        # Mixed options.
+        ([], ["-a", "-b", "-c", "-d"], ["-b", "-d", "-a", "-c"]),
+        (["-a"], ["-a", "-b", "-c", "-d"], ["-b", "-d", "-a", "-c"]),
+        (["-b"], ["-a", "-b", "-c", "-d"], ["-b", "-d", "-a", "-c"]),
+        (["-c"], ["-a", "-b", "-c", "-d"], ["-b", "-d", "-c", "-a"]),
+        (["-d"], ["-a", "-b", "-c", "-d"], ["-d", "-b", "-a", "-c"]),
+        (["-a", "-b"], ["-a", "-b", "-c", "-d"], ["-b", "-d", "-a", "-c"]),
+        (["-b", "-a"], ["-a", "-b", "-c", "-d"], ["-b", "-d", "-a", "-c"]),
+        (["-d", "-c"], ["-a", "-b", "-c", "-d"], ["-d", "-b", "-c", "-a"]),
+        (["-c", "-d"], ["-a", "-b", "-c", "-d"], ["-d", "-b", "-c", "-a"]),
+        (["-a", "-b", "-c", "-d"], ["-a", "-b", "-c", "-d"], ["-b", "-d", "-a", "-c"]),
+        (["-b", "-d", "-a", "-c"], ["-a", "-b", "-c", "-d"], ["-b", "-d", "-a", "-c"]),
+        ([], ["-b", "-d", "-e", "-a", "-c"], ["-b", "-d", "-e", "-a", "-c"]),
+        (["-a", "-d"], ["-b", "-d", "-e", "-a", "-c"], ["-d", "-b", "-e", "-a", "-c"]),
+        (["-c", "-d"], ["-b", "-d", "-e", "-a", "-c"], ["-d", "-b", "-e", "-c", "-a"]),
+    ],
+)
+def test_iter_params_for_processing(
+    invocation_order, declaration_order, expected_order
+):
+    parameters = {
+        "-a": click.Option(["-a"]),
+        "-b": click.Option(["-b"], is_eager=True),
+        "-c": click.Option(["-c"]),
+        "-d": click.Option(["-d"], is_eager=True),
+        "-e": click.Option(["-e"], is_eager=True),
+    }
+
+    invocation_params = [parameters[opt_id] for opt_id in invocation_order]
+    declaration_params = [parameters[opt_id] for opt_id in declaration_order]
+    expected_params = [parameters[opt_id] for opt_id in expected_order]
+
+    assert (
+        click.core.iter_params_for_processing(invocation_params, declaration_params)
+        == expected_params
+    )
+
+
+def test_help_param_priority(runner):
+    """Cover the edge-case in which the eagerness of help option was not
+    respected, because it was internally generated multiple times.
+
+    See: https://github.com/pallets/click/pull/2811
+    """
+
+    def print_and_exit(ctx, param, value):
+        if value:
+            click.echo(f"Value of {param.name} is: {value}")
+            ctx.exit()
+
+    @click.command(context_settings={"help_option_names": ("--my-help",)})
+    @click.option("-a", is_flag=True, expose_value=False, callback=print_and_exit)
+    @click.option(
+        "-b", is_flag=True, expose_value=False, callback=print_and_exit, is_eager=True
+    )
+    def cli():
+        pass
+
+    # --my-help is properly called and stop execution.
+    result = runner.invoke(cli, ["--my-help"])
+    assert "Value of a is: True" not in result.stdout
+    assert "Value of b is: True" not in result.stdout
+    assert "--my-help" in result.stdout
+    assert result.exit_code == 0
+
+    # -a is properly called and stop execution.
+    result = runner.invoke(cli, ["-a"])
+    assert "Value of a is: True" in result.stdout
+    assert "Value of b is: True" not in result.stdout
+    assert "--my-help" not in result.stdout
+    assert result.exit_code == 0
+
+    # -a takes precedence over -b and stop execution.
+    result = runner.invoke(cli, ["-a", "-b"])
+    assert "Value of a is: True" not in result.stdout
+    assert "Value of b is: True" in result.stdout
+    assert "--my-help" not in result.stdout
+    assert result.exit_code == 0
+
+    # --my-help is eager by default so takes precedence over -a and stop
+    # execution, whatever the order.
+    for args in [["-a", "--my-help"], ["--my-help", "-a"]]:
+        result = runner.invoke(cli, args)
+        assert "Value of a is: True" not in result.stdout
+        assert "Value of b is: True" not in result.stdout
+        assert "--my-help" in result.stdout
+        assert result.exit_code == 0
+
+    # Both -b and --my-help are eager so they're called in the order they're
+    # invoked by the user.
+    result = runner.invoke(cli, ["-b", "--my-help"])
+    assert "Value of a is: True" not in result.stdout
+    assert "Value of b is: True" in result.stdout
+    assert "--my-help" not in result.stdout
+    assert result.exit_code == 0
+
+    # But there was a bug when --my-help is called before -b, because the
+    # --my-help option created by click via help_option_names is internally
+    # created twice and is not the same object, breaking the priority order
+    # produced by iter_params_for_processing.
+    result = runner.invoke(cli, ["--my-help", "-b"])
+    assert "Value of a is: True" not in result.stdout
+    assert "Value of b is: True" not in result.stdout
+    assert "--my-help" in result.stdout
+    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)
diff --git a/tests/test_imports.py b/tests/test_imports.py
index 39dfbdb..e5e5119 100644
--- a/tests/test_imports.py
+++ b/tests/test_imports.py
@@ -49,6 +49,7 @@ ALLOWED_IMPORTS = {
     "typing",
     "types",
     "gettext",
+    "shutil",
 }
 
 if WIN:
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 9adab77..ef73001 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -1,10 +1,15 @@
 import os
 import pathlib
 import stat
+import subprocess
 import sys
 from collections import namedtuple
+from contextlib import nullcontext
+from functools import partial
 from io import StringIO
+from pathlib import Path
 from tempfile import tempdir
+from unittest.mock import patch
 
 import pytest
 
@@ -211,6 +216,9 @@ EchoViaPagerTest = namedtuple(
 
 
 @pytest.mark.skipif(WIN, reason="Different behavior on windows.")
+@pytest.mark.parametrize(
+    "pager_cmd", ["cat", "cat ", " cat ", "less", " less", " less "]
+)
 @pytest.mark.parametrize(
     "test",
     [
@@ -262,7 +270,7 @@ EchoViaPagerTest = namedtuple(
             test_input=lambda: _test_gen_func_fails,
             # Because generator throws early on, the pager did not have
             # a chance yet to write the file.
-            expected_pager=None,
+            expected_pager="",
             expected_stdout="",
             expected_stderr="",
             expected_error=RuntimeError,
@@ -272,7 +280,7 @@ EchoViaPagerTest = namedtuple(
             test_input=lambda: _test_gen_func_fails,
             # Because generator throws early on, the pager did not have a
             # chance yet to write the file.
-            expected_pager=None,
+            expected_pager="",
             expected_stdout="",
             expected_stderr="",
             expected_error=RuntimeError,
@@ -307,13 +315,8 @@ EchoViaPagerTest = namedtuple(
         ),
     ],
 )
-def test_echo_via_pager(monkeypatch, capfd, test):
-    pager_out_tmp = f"{tempdir}/pager_out.txt"
-
-    if os.path.exists(pager_out_tmp):
-        os.remove(pager_out_tmp)
-
-    monkeypatch.setitem(os.environ, "PAGER", f"cat > {pager_out_tmp}")
+def test_echo_via_pager(monkeypatch, capfd, pager_cmd, test):
+    monkeypatch.setitem(os.environ, "PAGER", pager_cmd)
     monkeypatch.setattr(click._termui_impl, "isatty", lambda x: True)
 
     test_input = test.test_input()
@@ -322,21 +325,23 @@ def test_echo_via_pager(monkeypatch, capfd, test):
     expected_stderr = test.expected_stderr
     expected_error = test.expected_error
 
-    if expected_error:
-        with pytest.raises(expected_error):
-            click.echo_via_pager(test_input)
-    else:
-        click.echo_via_pager(test_input)
+    check_raise = pytest.raises(expected_error) if expected_error else nullcontext()
+
+    pager_out_tmp = Path(tempdir) / "pager_out.txt"
+    pager_out_tmp.unlink(missing_ok=True)
+    with pager_out_tmp.open("w") as f:
+        force_subprocess_stdout = patch.object(
+            subprocess,
+            "Popen",
+            partial(subprocess.Popen, stdout=f),
+        )
+        with force_subprocess_stdout:
+            with check_raise:
+                click.echo_via_pager(test_input)
 
     out, err = capfd.readouterr()
 
-    if os.path.exists(pager_out_tmp):
-        with open(pager_out_tmp) as f:
-            pager = f.read()
-    else:
-        # The pager process was not started or has been
-        # terminated before it could finish writing
-        pager = None
+    pager = pager_out_tmp.read_text()
 
     assert (
         pager == expected_pager
diff --git a/tests/typing/typing_aliased_group.py b/tests/typing/typing_aliased_group.py
index a1fdac4..404870b 100644
--- a/tests/typing/typing_aliased_group.py
+++ b/tests/typing/typing_aliased_group.py
@@ -1,4 +1,4 @@
-"""Example from https://click.palletsprojects.com/en/8.1.x/advanced/#command-aliases"""
+"""Example from https://click.palletsprojects.com/en/stable/advanced/#command-aliases"""
 
 from __future__ import annotations
 
diff --git a/tests/typing/typing_confirmation_option.py b/tests/typing/typing_confirmation_option.py
index a568a6a..920df25 100644
--- a/tests/typing/typing_confirmation_option.py
+++ b/tests/typing/typing_confirmation_option.py
@@ -1,4 +1,4 @@
-"""From https://click.palletsprojects.com/en/8.1.x/options/#yes-parameters"""
+"""From https://click.palletsprojects.com/en/stable/options/#yes-parameters"""
 
 from typing_extensions import assert_type
 
diff --git a/tests/typing/typing_options.py b/tests/typing/typing_options.py
index 613e8d4..a3eca16 100644
--- a/tests/typing/typing_options.py
+++ b/tests/typing/typing_options.py
@@ -1,4 +1,4 @@
-"""From https://click.palletsprojects.com/en/8.1.x/quickstart/#adding-parameters"""
+"""From https://click.palletsprojects.com/en/stable/quickstart/#adding-parameters"""
 
 from typing_extensions import assert_type
 
diff --git a/tests/typing/typing_version_option.py b/tests/typing/typing_version_option.py
index 7ff37a7..c7203dd 100644
--- a/tests/typing/typing_version_option.py
+++ b/tests/typing/typing_version_option.py
@@ -1,5 +1,5 @@
 """
-From https://click.palletsprojects.com/en/8.1.x/options/#callbacks-and-eager-options.
+From https://click.palletsprojects.com/en/stable/options/#callbacks-and-eager-options.
 """
 
 from typing_extensions import assert_type