Repository: pydicom/pydicom

Issue:
deepcopy of dataset read from a io.BytesIO file-like object failed (ValueError: I/O operation on closed file.)
**Describe the bug**
I sometimes have to read datasets directly from bytes objects, to do so I use the following snippet:
```python
import pydicom
import io


def dcmreadb(data: bytes, *args, **kwargs) -> pydicom.FileDataset:
    buff = io.BytesIO(data)
    ds = pydicom.dcmread(buff, *args, **kwargs)
    buff.close()
    return ds
```

While attempting to deep copying a `FileDataset` read from a BytesIO file-like object a ValueError is thrown.

Read  the dataset using the file-like object returned by the open built-in function `open(path, 'rb')` works as expected, it should be the same with BytesIO if my understanding of the doc is correct: https://docs.python.org/3.7/library/io.html#binary-i-o. It it was working using pydicom 1.2.x

**Steps To Reproduce**
```python
import pydicom
import io
import copy


def dcmreadb(data: bytes, *args, **kwargs) -> pydicom.FileDataset:
    buff = io.BytesIO(data)
    ds = pydicom.dcmread(buff, *args, **kwargs)
    buff.close()
    return ds

# here get a dataset as a bytes object, deepcopy would work if we would read directely from fb rather than a BytesIO object.
with open('path/to/any/dicom/file', 'rb') as fb:
    data = fb.read()

# use the dcmreadb function to read from bytes
ds = dcmreadb(data)
ds_copy = copy.deepcopy(ds)
```
result in 
```
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/home/rdebroiz/.pyenv/versions/3.7.6/lib/python3.7/copy.py", line 180, in deepcopy
    y = _reconstruct(x, memo, *rv)
  File "/home/rdebroiz/.pyenv/versions/3.7.6/lib/python3.7/copy.py", line 280, in _reconstruct
    state = deepcopy(state, memo)
  File "/home/rdebroiz/.pyenv/versions/3.7.6/lib/python3.7/copy.py", line 150, in deepcopy
    y = copier(x, memo)
  File "/home/rdebroiz/.pyenv/versions/3.7.6/lib/python3.7/copy.py", line 240, in _deepcopy_dict
    y[deepcopy(key, memo)] = deepcopy(value, memo)
  File "/home/rdebroiz/.pyenv/versions/3.7.6/lib/python3.7/copy.py", line 169, in deepcopy
    rv = reductor(4)
ValueError: I/O operation on closed file.
```

**Your environment**
```python
>>> import platform, sys, pydicom
>>> print(platform.platform(),
...       "\nPython", sys.version,
...       "\npydicom", pydicom.__version__)
Linux-5.4.0-40-generic-x86_64-with-debian-bullseye-sid 
Python 3.7.6 (default, May 20 2020, 16:52:50) 
[GCC 9.3.0] 
pydicom 1.4.2
```

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/pydicom/pydicom/pull/1380?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pydicom) Report
> Merging [#1380](https://codecov.io/gh/pydicom/pydicom/pull/1380?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pydicom) (c26a9ef) into [master](https://codecov.io/gh/pydicom/pydicom/commit/f36517e10a6d65e790f7420cf3e90fd3982b81ad?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pydicom) (f36517e) will **increase** coverage by `0.00%`.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/pydicom/pydicom/pull/1380/graphs/tree.svg?width=650&height=150&src=pr&token=YciLV15Lwr&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pydicom)](https://codecov.io/gh/pydicom/pydicom/pull/1380?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pydicom)

```diff
@@           Coverage Diff           @@
##           master    #1380   +/-   ##
=======================================
  Coverage   95.37%   95.38%           
=======================================
  Files          63       63           
  Lines        9995    10004    +9     
=======================================
+ Hits         9533     9542    +9     
  Misses        462      462           
```


| [Impacted Files](https://codecov.io/gh/pydicom/pydicom/pull/1380?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pydicom) | Coverage Δ | |
|---|---|---|
| [pydicom/dataset.py](https://codecov.io/gh/pydicom/pydicom/pull/1380/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pydicom#diff-cHlkaWNvbS9kYXRhc2V0LnB5) | `97.66% <100.00%> (+0.02%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/pydicom/pydicom/pull/1380?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pydicom).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pydicom)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/pydicom/pydicom/pull/1380?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pydicom). Last update [f36517e...c26a9ef](https://codecov.io/gh/pydicom/pydicom/pull/1380?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pydicom). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pydicom).


Patch:
diff --git a/doc/release_notes/v2.2.0.rst b/doc/release_notes/v2.2.0.rst
index d90b69a45..f3bb5c7f2 100644
--- a/doc/release_notes/v2.2.0.rst
+++ b/doc/release_notes/v2.2.0.rst
@@ -16,7 +16,10 @@ Changes
   :meth:`TM.__str__()<pydicom.valuerep.TM.__str__>` return valid DICOM
   strings instead of the formatted date and time representations
   (:issue:`1262`)
-
+* If comparing :class:`~pydicom.dataset.FileDataset` instances, the file
+  metadata is now ignored. This makes it possible to compare a
+  :class:`~pydicom.dataset.FileDataset` object with a
+  :class:`~pydicom.dataset.Dataset` object.
 
 Enhancements
 ------------
@@ -90,3 +93,4 @@ Fixes
   :class:`~pydicom.valuerep.PersonName` (:issue:`1338`)
 * Fixed handling of JPEG (10918-1) images compressed using RGB colourspace
   rather than YBR with the Pillow pixel data handler (:pr:`878`)
+* Allow to deepcopy a `~pydicom.dataset.FileDataset` object (:issue:`1147`)
diff --git a/pydicom/dataset.py b/pydicom/dataset.py
index 6c240dff5..a064ab569 100644
--- a/pydicom/dataset.py
+++ b/pydicom/dataset.py
@@ -43,7 +43,7 @@ if TYPE_CHECKING:
 import pydicom  # for dcmwrite
 import pydicom.charset
 import pydicom.config
-from pydicom import datadict, jsonrep, config
+from pydicom import jsonrep, config
 from pydicom._version import __version_info__
 from pydicom.charset import default_encoding, convert_encodings
 from pydicom.config import logger
@@ -2561,6 +2561,9 @@ class Dataset(Dict[BaseTag, _DatasetValue]):
     __repr__ = __str__
 
 
+_FileDataset = TypeVar("_FileDataset", bound="FileDataset")
+
+
 class FileDataset(Dataset):
     """An extension of :class:`Dataset` to make reading and writing to
     file-like easier.
@@ -2658,31 +2661,50 @@ class FileDataset(Dataset):
                 statinfo = os.stat(filename)
                 self.timestamp = statinfo.st_mtime
 
-    def __eq__(self, other: object) -> bool:
-        """Compare `self` and `other` for equality.
+    def _copy_implementation(self, copy_function: Callable) -> _FileDataset:
+        """Implementation of ``__copy__`` and ``__deepcopy__``.
+        Sets the filename to ``None`` if it isn't a string,
+        and copies all other attributes using `copy_function`.
+        """
+        copied = self.__class__(
+            self.filename, self, self.preamble, self.file_meta,
+            self.is_implicit_VR, self.is_little_endian
+        )
+        filename = self.filename
+        if filename is not None and not isinstance(filename, str):
+            warnings.warn("The 'filename' attribute of the dataset is a "
+                          "file-like object and will be set to None "
+                          "in the copied object")
+            self.filename = None
+        for (k, v) in self.__dict__.items():
+            copied.__dict__[k] = copy_function(v)
+
+        self.filename = filename
+        return copied
+
+    def __copy__(self) -> _FileDataset:
+        """Return a shallow copy of the file dataset.
+        Make sure that the filename is not copied in case it is a file-like
+        object.
 
         Returns
         -------
-        bool
-            The result if `self` and `other` are the same class
-        NotImplemented
-            If `other` is not the same class as `self` then returning
-            :class:`NotImplemented` delegates the result to
-            ``superclass.__eq__(subclass)``.
+        FileDataset
+            A shallow copy of the file data set.
         """
-        # When comparing against self this will be faster
-        if other is self:
-            return True
+        return self._copy_implementation(copy.copy)
 
-        if isinstance(other, self.__class__):
-            return (
-                _dict_equal(self, other)
-                and _dict_equal(
-                    self.__dict__, other.__dict__, exclude=['_dict']
-                )
-            )
+    def __deepcopy__(self, _) -> _FileDataset:
+        """Return a deep copy of the file dataset.
+        Make sure that the filename is not copied in case it is a file-like
+        object.
 
-        return NotImplemented
+        Returns
+        -------
+        FileDataset
+            A deep copy of the file data set.
+        """
+        return self._copy_implementation(copy.deepcopy)
 
 
 def validate_file_meta(


Test patch:
diff --git a/pydicom/tests/test_dataset.py b/pydicom/tests/test_dataset.py
index 0ddd00b80..3ef2c5b76 100644
--- a/pydicom/tests/test_dataset.py
+++ b/pydicom/tests/test_dataset.py
@@ -2,6 +2,7 @@
 """Unit tests for the pydicom.dataset module."""
 
 import copy
+import io
 import math
 import pickle
 import weakref
@@ -1805,23 +1806,23 @@ class TestFileDataset:
         assert [1, 2, 1] == ds1.PixelSpacing
 
     def test_equality_file_meta(self):
-        """Dataset: equality returns correct value if with metadata"""
+        """Dataset: equality ignores metadata"""
         d = dcmread(self.test_file)
         e = dcmread(self.test_file)
         assert d == e
 
         e.is_implicit_VR = not e.is_implicit_VR
-        assert not d == e
+        assert d == e
 
         e.is_implicit_VR = not e.is_implicit_VR
         assert d == e
         e.is_little_endian = not e.is_little_endian
-        assert not d == e
+        assert d == e
 
         e.is_little_endian = not e.is_little_endian
         assert d == e
         e.filename = 'test_filename.dcm'
-        assert not d == e
+        assert d == e
 
     def test_creation_with_container(self):
         """FileDataset.__init__ works OK with a container such as gzip"""
@@ -1858,6 +1859,46 @@ class TestFileDataset:
         expected_diff = {'__class__', '__doc__', '__hash__'}
         assert expected_diff == set(dir(di)) - set(dir(ds))
 
+    def test_copy_filedataset(self):
+        with open(get_testdata_file("CT_small.dcm"), "rb") as fb:
+            data = fb.read()
+        buff = io.BytesIO(data)
+        ds = pydicom.dcmread(buff)
+        buff.close()
+        msg = ("The 'filename' attribute of the dataset is a "
+               "file-like object and will be set to None")
+        with pytest.warns(UserWarning, match=msg):
+            ds_copy = copy.copy(ds)
+        assert ds.filename is not None
+        assert ds_copy.filename is None
+        assert ds_copy == ds
+
+    def test_deepcopy_filedataset(self):
+        # regression test for #1147
+        with open(get_testdata_file("CT_small.dcm"), "rb") as fb:
+            data = fb.read()
+        buff = io.BytesIO(data)
+        ds = pydicom.dcmread(buff)
+        buff.close()
+        msg = ("The 'filename' attribute of the dataset is a "
+               "file-like object and will be set to None")
+        with pytest.warns(UserWarning, match=msg):
+            ds_copy = copy.deepcopy(ds)
+        assert ds.filename is not None
+        assert ds_copy.filename is None
+        assert ds_copy == ds
+
+    def test_equality_with_different_metadata(self):
+        ds = dcmread(get_testdata_file("CT_small.dcm"))
+        ds2 = copy.deepcopy(ds)
+        assert ds == ds2
+        ds.filename = "foo.dcm"
+        ds.is_implicit_VR = not ds.is_implicit_VR
+        ds.is_little_endian = not ds.is_little_endian
+        ds.file_meta = None
+        ds.preamble = None
+        assert ds == ds2
+
 
 class TestDatasetOverlayArray:
     """Tests for Dataset.overlay_array()."""
diff --git a/pydicom/tests/test_fileset.py b/pydicom/tests/test_fileset.py
index a32411828..dc2e386f6 100644
--- a/pydicom/tests/test_fileset.py
+++ b/pydicom/tests/test_fileset.py
@@ -1375,15 +1375,14 @@ class TestFileSet:
         root = os.fspath(Path(*Path(tdir.name).parts[-2:]))
         assert root in s
         assert 1 == len(paths)
-        # Only want to compare Dataset rather than FileDataset
-        assert Dataset(ct) == dcmread(paths[0])
+        assert ct == dcmread(paths[0])
 
         # Calling write() again shouldn't change anything
         ds2, paths = write_fs(fs)
-        assert Dataset(ds) == ds2
+        assert ds == ds2
         assert ds2.filename == ds.filename
         assert 1 == len(paths)
-        assert Dataset(ct) == dcmread(paths[0])
+        assert ct == dcmread(paths[0])
 
     def test_add_bad_dataset(self, ct):
         """Test adding a dataset missing Type 1 element value."""
diff --git a/pydicom/tests/test_json.py b/pydicom/tests/test_json.py
index fa38f5b6c..d1b19cd62 100644
--- a/pydicom/tests/test_json.py
+++ b/pydicom/tests/test_json.py
@@ -160,7 +160,7 @@ class TestAT:
 
 class TestDataSetToJson:
     def test_json_from_dicom_file(self, no_numpy_use):
-        ds1 = Dataset(dcmread(get_testdata_file("CT_small.dcm")))
+        ds1 = dcmread(get_testdata_file("CT_small.dcm"))
         ds_json = ds1.to_json()
         ds2 = Dataset.from_json(ds_json)
         assert ds1 == ds2