Repository: pydicom/pydicom

Issue:
dcmread cannot handle pathlib.Path objects
**Describe the bug**
The `dcmread()` currently fails when passed an instance of `pathlib.Path`. The problem is the following line:
https://github.com/pydicom/pydicom/blob/8b0bbaf92d7a8218ceb94dedbee3a0463c5123e3/pydicom/filereader.py#L832

**Expected behavior**
`dcmread()` should open and read the file to which the `pathlib.Path` object points.

The line above should probably be:
```python
if isinstance(fp, (str, Path)):
````

**Steps To Reproduce**
```python
from pathlib import Path
from pydicom.filereader import dcmread

dcm_filepath = Path('path/to/file')
dcmread(dcm_filepath)
```


Discussion:
Good point. We could use `os.fspath`, but that is only available from Python 3.6 onwards, so we probably have to add a respective check...
The `save_as()` method of `Dataset` has the same issue.
Yes, I noticed. I'm on it.
Yep.  I actually started writing code for it at one point, but got side-tracked with other issues.  Basically just wrapping the argument in str() fixes it.


Patch:
diff --git a/pydicom/data/data_manager.py b/pydicom/data/data_manager.py
--- a/pydicom/data/data_manager.py
+++ b/pydicom/data/data_manager.py
@@ -5,6 +5,8 @@
 import os
 from os.path import abspath, dirname, join
 
+from pydicom.fileutil import path_from_pathlike
+
 DATA_ROOT = abspath(dirname(__file__))
 
 
@@ -13,7 +15,7 @@ def get_files(base, pattern):
 
     Parameters
     ----------
-    base : str
+    base : str or PathLike
         Base directory to recursively search.
 
     pattern : str
@@ -26,6 +28,7 @@ def get_files(base, pattern):
         The list of filenames matched.
     """
 
+    base = path_from_pathlike(base)
     # if the user forgot to add them
     pattern = "*" + pattern + "*"
 
diff --git a/pydicom/dataset.py b/pydicom/dataset.py
--- a/pydicom/dataset.py
+++ b/pydicom/dataset.py
@@ -35,6 +35,7 @@
 from pydicom.datadict import (tag_for_keyword, keyword_for_tag,
                               repeater_has_keyword)
 from pydicom.dataelem import DataElement, DataElement_from_raw, RawDataElement
+from pydicom.fileutil import path_from_pathlike
 from pydicom.pixel_data_handlers.util import (
     convert_color_space, reshape_pixel_array, get_image_pixel_ids
 )
@@ -1733,7 +1734,7 @@ def save_as(self, filename, write_like_original=True):
 
         Parameters
         ----------
-        filename : str or file-like
+        filename : str or PathLike or file-like
             Name of file or the file-like to write the new DICOM file to.
         write_like_original : bool, optional
             If ``True`` (default), preserves the following information from
@@ -2238,7 +2239,7 @@ def __init__(self,
 
         Parameters
         ----------
-        filename_or_obj : str or BytesIO or None
+        filename_or_obj : str or PathLike or BytesIO or None
             Full path and filename to the file, memory buffer object, or
             ``None`` if is a :class:`io.BytesIO`.
         dataset : Dataset or dict
@@ -2263,6 +2264,7 @@ def __init__(self,
         self.is_implicit_VR = is_implicit_VR
         self.is_little_endian = is_little_endian
         filename = None
+        filename_or_obj = path_from_pathlike(filename_or_obj)
         if isinstance(filename_or_obj, str):
             filename = filename_or_obj
             self.fileobj_type = open
diff --git a/pydicom/dicomdir.py b/pydicom/dicomdir.py
--- a/pydicom/dicomdir.py
+++ b/pydicom/dicomdir.py
@@ -28,7 +28,7 @@ def __init__(self,
 
         Parameters
         ----------
-        filename_or_obj : str or None
+        filename_or_obj : str or PathLike or file-like or None
             Full path and filename to the file of ``None`` if
             :class:`io.BytesIO`.
         dataset : dataset.Dataset
diff --git a/pydicom/filereader.py b/pydicom/filereader.py
--- a/pydicom/filereader.py
+++ b/pydicom/filereader.py
@@ -19,7 +19,7 @@
 from pydicom.dicomdir import DicomDir
 from pydicom.errors import InvalidDicomError
 from pydicom.filebase import DicomFile
-from pydicom.fileutil import read_undefined_length_value
+from pydicom.fileutil import read_undefined_length_value, path_from_pathlike
 from pydicom.misc import size_in_bytes
 from pydicom.sequence import Sequence
 from pydicom.tag import (ItemTag, SequenceDelimiterTag, TupleTag, Tag, BaseTag)
@@ -769,7 +769,7 @@ def dcmread(fp, defer_size=None, stop_before_pixels=False,
 
     Parameters
     ----------
-    fp : str or file-like
+    fp : str or PathLike or file-like
         Either a file-like object, or a string containing the file name. If a
         file-like object, the caller is responsible for closing it.
     defer_size : int or str or None, optional
@@ -829,6 +829,7 @@ def dcmread(fp, defer_size=None, stop_before_pixels=False,
     """
     # Open file if not already a file object
     caller_owns_file = True
+    fp = path_from_pathlike(fp)
     if isinstance(fp, str):
         # caller provided a file name; we own the file handle
         caller_owns_file = False
diff --git a/pydicom/fileutil.py b/pydicom/fileutil.py
--- a/pydicom/fileutil.py
+++ b/pydicom/fileutil.py
@@ -1,6 +1,8 @@
 # Copyright 2008-2018 pydicom authors. See LICENSE file for details.
 """Functions for reading to certain bytes, e.g. delimiters."""
-
+import os
+import pathlib
+import sys
 from struct import pack, unpack
 
 from pydicom.misc import size_in_bytes
@@ -93,7 +95,7 @@ def read_undefined_length_value(fp,
                                 is_little_endian,
                                 delimiter_tag,
                                 defer_size=None,
-                                read_size=1024*8):
+                                read_size=1024 * 8):
     """Read until `delimiter_tag` and return the value up to that point.
 
     On completion, the file will be set to the first byte after the delimiter
@@ -260,3 +262,32 @@ def read_delimiter_item(fp, delimiter):
     if length != 0:
         logger.warn("Expected delimiter item to have length 0, "
                     "got %d at file position 0x%x", length, fp.tell() - 4)
+
+
+def path_from_pathlike(file_object):
+    """Returns the path if `file_object` is a path-like object, otherwise the
+    original `file_object`.
+
+    Parameters
+    ----------
+    file_object: str or PathLike or file-like
+
+    Returns
+    -------
+    str or file-like
+        the string representation of the given path object, or the object
+        itself in case of an object not representing a path.
+
+    ..note:
+
+        ``PathLike`` objects have been introduced in Python 3.6. In Python 3.5,
+        only objects of type :class:`pathlib.Path` are considered.
+    """
+    if sys.version_info < (3, 6):
+        if isinstance(file_object, pathlib.Path):
+            return str(file_object)
+        return file_object
+    try:
+        return os.fspath(file_object)
+    except TypeError:
+        return file_object
diff --git a/pydicom/filewriter.py b/pydicom/filewriter.py
--- a/pydicom/filewriter.py
+++ b/pydicom/filewriter.py
@@ -11,6 +11,7 @@
 from pydicom.dataelem import DataElement_from_raw
 from pydicom.dataset import Dataset, validate_file_meta
 from pydicom.filebase import DicomFile, DicomFileLike, DicomBytesIO
+from pydicom.fileutil import path_from_pathlike
 from pydicom.multival import MultiValue
 from pydicom.tag import (Tag, ItemTag, ItemDelimiterTag, SequenceDelimiterTag,
                          tag_in_exception)
@@ -810,7 +811,7 @@ def dcmwrite(filename, dataset, write_like_original=True):
 
     Parameters
     ----------
-    filename : str or file-like
+    filename : str or PathLike or file-like
         Name of file or the file-like to write the new DICOM file to.
     dataset : pydicom.dataset.FileDataset
         Dataset holding the DICOM information; e.g. an object read with
@@ -883,6 +884,7 @@ def dcmwrite(filename, dataset, write_like_original=True):
 
     caller_owns_file = True
     # Open file if not already a file object
+    filename = path_from_pathlike(filename)
     if isinstance(filename, str):
         fp = DicomFile(filename, 'wb')
         # caller provided a file name; we own the file handle


Test patch:
diff --git a/pydicom/tests/test_filereader.py b/pydicom/tests/test_filereader.py
--- a/pydicom/tests/test_filereader.py
+++ b/pydicom/tests/test_filereader.py
@@ -7,6 +7,7 @@
 from io import BytesIO
 import os
 import shutil
+from pathlib import Path
 from struct import unpack
 import sys
 import tempfile
@@ -114,6 +115,10 @@ def test_UTF8_filename(self):
         os.remove(utf8_filename)
         assert ds is not None
 
+    def test_pathlib_path_filename(self):
+        """Check that file can be read using pathlib.Path"""
+        ds = dcmread(Path(priv_SQ_name))
+
     def test_RTPlan(self):
         """Returns correct values for sample data elements in test
         RT Plan file.
diff --git a/pydicom/tests/test_fileutil.py b/pydicom/tests/test_fileutil.py
new file mode 100644
--- /dev/null
+++ b/pydicom/tests/test_fileutil.py
@@ -0,0 +1,37 @@
+# Copyright 2008-2020 pydicom authors. See LICENSE file for details.
+"""Test suite for util functions"""
+import sys
+from io import BytesIO
+from pathlib import Path
+
+import pytest
+
+from pydicom.fileutil import path_from_pathlike
+
+
+class PathLike:
+    """Minimal example for path-like object"""
+    def __init__(self, path: str):
+        self.path = path
+
+    def __fspath__(self):
+        return self.path
+
+
+class TestPathFromPathLike:
+    """Test the fileutil module"""
+
+    def test_non_pathlike_is_returned_unaltered(self):
+        assert 'test.dcm' == path_from_pathlike('test.dcm')
+        assert path_from_pathlike(None) is None
+        file_like = BytesIO()
+        assert file_like == path_from_pathlike(file_like)
+        assert 42 == path_from_pathlike(42)
+
+    def test_pathlib_path(self):
+        assert 'test.dcm' == path_from_pathlike(Path('test.dcm'))
+
+    @pytest.mark.skipif(sys.version_info < (3, 6),
+                        reason="Path-like objects introduced in Python 3.6")
+    def test_path_like(self):
+        assert 'test.dcm' == path_from_pathlike(PathLike('test.dcm'))
diff --git a/pydicom/tests/test_filewriter.py b/pydicom/tests/test_filewriter.py
--- a/pydicom/tests/test_filewriter.py
+++ b/pydicom/tests/test_filewriter.py
@@ -6,6 +6,7 @@
 from datetime import date, datetime, time, timedelta, timezone
 from io import BytesIO
 import os
+from pathlib import Path
 from platform import python_implementation
 
 from struct import unpack
@@ -137,6 +138,14 @@ def testJPEG2000(self):
            them identical (JPEG2K file)."""
         self.compare(jpeg_name)
 
+    def test_pathlib_path_filename(self):
+        """Check that file can be written using pathlib.Path"""
+        ds = dcmread(Path(ct_name))
+        ds.save_as(self.file_out, write_like_original=True)
+        self.file_out.seek(0)
+        ds1 = dcmread(self.file_out)
+        assert ds.PatientName == ds1.PatientName
+
     def testListItemWriteBack(self):
         """Change item in a list and confirm
           it is written to file"""