Repository: pallets/click (python)

Issue:
click.Path returns str, not pathlib.Path
It’s surprising that `INT` returns an `int`, `File` a `file`, `STRING` a `str`, but `Path` a …`str`?

A `click.Path` parameter should return a `pathlib.Path` object.


Pull request:
path type supports pathlib
* `click.Path(path_type=pathlib.Path)` returns the value as a pathlib object. Technically any class is allowed, but this is only intended for pathlib.
* Use `os.fsdecode` and `fsencode` to convert `str` and `bytes`. Since Python 2 isn't supported more, the value from the command line will never be `bytes`.
* Remove the internal `path_type` attribute, which was confusingly named (the `path_type` parameter is stored as `type`), and was just `name.title()`.

<!--
Link to relevant issues or previous PRs, one per line. Use "fixes" to
automatically close an issue.
-->

- fixes #405
- closes #1148
- closes #1234

<!--
Ensure each step in CONTRIBUTING.rst is complete by adding an "x" to
each box below.

If only docs were changed, these aren't relevant and can be removed.
-->

Checklist:

- [x] Add tests that demonstrate the correct behavior of the change. Tests should fail without the change.
- [x] Add or update relevant docs, in the docs folder and in code.
- [x] Add an entry in `CHANGES.rst` summarizing the change and linking to the issue.
- [x] Add `.. versionchanged::` entries in any relevant code docs.
- [x] Run `pre-commit` hooks and fix any issues.
- [x] Run `pytest` and `tox`, no tests failed.


Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index c1649ce..d3795e1 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -180,6 +180,8 @@ Unreleased
     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`
 
 
 Version 7.1.2
diff --git a/src/click/types.py b/src/click/types.py
index 6ca72c9..0ed73c0 100644
--- a/src/click/types.py
+++ b/src/click/types.py
@@ -649,9 +649,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
@@ -667,11 +664,15 @@ 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
@@ -698,13 +699,10 @@ class Path(ParamType):
 
         if self.file_okay and not self.dir_okay:
             self.name = "file"
-            self.path_type = "File"
         elif self.dir_okay and not self.file_okay:
             self.name = "directory"
-            self.path_type = "Directory"
         else:
             self.name = "path"
-            self.path_type = "Path"
 
     def to_info_dict(self):
         info_dict = super().to_info_dict()
@@ -721,9 +719,12 @@ class Path(ParamType):
     def coerce_path_result(self, rv):
         if self.type is not None and not isinstance(rv, self.type):
             if self.type is str:
-                rv = rv.decode(get_filesystem_encoding())
+                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):
@@ -741,32 +742,32 @@ class Path(ParamType):
                 if not self.exists:
                     return self.coerce_path_result(rv)
                 self.fail(
-                    f"{self.path_type} {filename_to_ui(value)!r} does not exist.",
+                    f"{self.name.title()} {filename_to_ui(value)!r} does not exist.",
                     param,
                     ctx,
                 )
 
             if not self.file_okay and stat.S_ISREG(st.st_mode):
                 self.fail(
-                    f"{self.path_type} {filename_to_ui(value)!r} is a file.",
+                    f"{self.name.title()} {filename_to_ui(value)!r} is a file.",
                     param,
                     ctx,
                 )
             if not self.dir_okay and stat.S_ISDIR(st.st_mode):
                 self.fail(
-                    f"{self.path_type} {filename_to_ui(value)!r} is a directory.",
+                    f"{self.name.title()} {filename_to_ui(value)!r} is a directory.",
                     param,
                     ctx,
                 )
             if self.writable and not os.access(value, os.W_OK):
                 self.fail(
-                    f"{self.path_type} {filename_to_ui(value)!r} is not writable.",
+                    f"{self.name.title()} {filename_to_ui(value)!r} is not writable.",
                     param,
                     ctx,
                 )
             if self.readable and not os.access(value, os.R_OK):
                 self.fail(
-                    f"{self.path_type} {filename_to_ui(value)!r} is not readable.",
+                    f"{self.name.title()} {filename_to_ui(value)!r} is not readable.",
                     param,
                     ctx,
                 )


Test patch:
diff --git a/tests/test_types.py b/tests/test_types.py
index 4e995f1..e7e531e 100644
--- a/tests/test_types.py
+++ b/tests/test_types.py
@@ -1,3 +1,5 @@
+import pathlib
+
 import pytest
 
 import click
@@ -78,3 +80,23 @@ def test_cast_multi_default(runner, nargs, multiple, default, expect):
     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