Repository: pallets/werkzeug

Issue:
FileStorage.save() does not support pathlib
# Description

I expected save uploaded file to `pathlib` object.

# Code

```python
from pathlib import Path

# ...

def path_to_save(document_id):
    return Path(myproject.config.DocumentProcessing.save_to(document_id))

# ...

request.files['document'].save(path_to_save(document_id))

# ...
```

# Actual Behavior

Uploaded file `document` did not save to filesystem:

```python
Traceback (most recent call last):
  File "/usr/local/lib/python3.7/site-packages/flask/app.py", line 1949, in full_dispatch_request
    rv = self.dispatch_request()
  File "/usr/local/lib/python3.7/site-packages/flask/app.py", line 1935, in dispatch_request
    return self.view_functions[rule.endpoint](**req.view_args)
  File "/var/lib/myproject/myproject/website/blueprints/document.py", line 23, in create_document
    result = schemas.DocumentSchema().dump(utils.uploaded_document())
  File "/var/lib/myproject/myproject/website/utils.py", line 39, in uploaded_document
    request.files['document'].save(path_to_save(document_id)) 
  File "/usr/local/lib/python3.7/site-packages/werkzeug/datastructures.py", line 2803, in save
    copyfileobj(self.stream, dst, buffer_size)
  File "/usr/local/lib/python3.7/shutil.py", line 82, in copyfileobj
    fdst.write(buf)
AttributeError: 'PosixPath' object has no attribute 'write'
```

# Expected Behavior

Uploaded file `document` saved to filesystem.

# Versions

Python: **3.7.4**
Werkzeug: **0.16.0**


Discussion:

Resolving patch:
diff --git a/src/werkzeug/_compat.py b/src/werkzeug/_compat.py
--- a/src/werkzeug/_compat.py
+++ b/src/werkzeug/_compat.py
@@ -217,3 +217,12 @@ def to_unicode(
     if charset is None and allow_none_charset:
         return x
     return x.decode(charset, errors)
+
+
+try:
+    from os import fspath
+except ImportError:
+    # Python < 3.6
+    # https://www.python.org/dev/peps/pep-0519/#backwards-compatibility
+    def fspath(path):
+        return path.__fspath__() if hasattr(path, "__fspath__") else path
diff --git a/src/werkzeug/datastructures.py b/src/werkzeug/datastructures.py
--- a/src/werkzeug/datastructures.py
+++ b/src/werkzeug/datastructures.py
@@ -17,6 +17,7 @@
 from . import exceptions
 from ._compat import BytesIO
 from ._compat import collections_abc
+from ._compat import fspath
 from ._compat import integer_types
 from ._compat import iteritems
 from ._compat import iterkeys
@@ -2943,18 +2944,23 @@ def save(self, dst, buffer_size=16384):
 
         For secure file saving also have a look at :func:`secure_filename`.
 
-        :param dst: a filename or open file object the uploaded file
-                    is saved to.
-        :param buffer_size: the size of the buffer.  This works the same as
-                            the `length` parameter of
-                            :func:`shutil.copyfileobj`.
+        :param dst: a filename, :class:`os.PathLike`, or open file
+            object to write to.
+        :param buffer_size: Passed as the ``length`` parameter of
+            :func:`shutil.copyfileobj`.
+
+        .. versionchanged:: 1.0
+            Supports :mod:`pathlib`.
         """
         from shutil import copyfileobj
 
         close_dst = False
+        dst = fspath(dst)
+
         if isinstance(dst, string_types):
             dst = open(dst, "wb")
             close_dst = True
+
         try:
             copyfileobj(self.stream, dst, buffer_size)
         finally:


Test patch:
diff --git a/tests/test_datastructures.py b/tests/test_datastructures.py
--- a/tests/test_datastructures.py
+++ b/tests/test_datastructures.py
@@ -1188,6 +1188,14 @@ def test_proxy_can_access_stream_attrs(self, stream):
         for name in ("fileno", "writable", "readable", "seekable"):
             assert hasattr(file_storage, name)
 
+    def test_save_to_pathlib_dst(self, tmp_path):
+        src = tmp_path / "src.txt"
+        src.write_text(u"test")
+        storage = self.storage_class(src.open("rb"))
+        dst = tmp_path / "dst.txt"
+        storage.save(dst)
+        assert dst.read_text() == "test"
+
 
 @pytest.mark.parametrize("ranges", ([(0, 1), (-5, None)], [(5, None)]))
 def test_range_to_header(ranges):