Repository: sunpy/sunpy

Issue:
sunpy.io.fits.header_to_fits modifies input MetaDict in place, removing 'KEYCOMMENTS` entry.
<!--
We know asking good questions takes effort, and we appreciate your time.
Thank you.

Please be aware that everyone has to follow our code of conduct:
https://github.com/sunpy/sunpy/blob/master/CODE_OF_CONDUCT.rst

Also that these comments are hidden when you submit this github issue.

Please have a search on our GitHub repository to see if a similar issue has already been posted.
If a similar issue is closed, have a quick look to see if you are satisfied by the resolution.
If not please go ahead and open an issue!
-->

### Description
<!-- Provide a general description of the bug. -->
`sunpy.io.fits.header_to_fits(header)` edits `header` in place, removing the `KEYCOMMENTS` dictionary entry. This function is often called by `sunpy.map.GenericMap` methods on `self.meta` causing these functions to edit `self.meta` in place. This can then cause problems if, for example, making `GenericMap` subclasses which parse information in the 'KEYCOMMENTS' dictionary (see e.g. https://github.com/dstansby/pfsspy/issues/178)

### Expected behavior
<!-- What did you expect to happen. -->
I expected that after reading in a fits file as a `sunpy.map.Map`, it's header will remain fixed unless I call a method which explicitly transforms the map such that it's header description should change.

### Actual behavior
<!--
What actually happened.
Was the output confusing or poorly described?
-->
Instead, I found that simply calling the map, or calling methods such as `.bottom_left_coord` (which should just passively access a property of the map) edited the header, deleting the 'keycomments' dictionary entry.

As far as I can tell, this occurs for any `GenericMap` method which at some point calls `sunpy.io.fits.header_to_fits(self.meta)`, and happens because `sunpy.io.fits.header_to_fits(self.meta)` performs `.pop` on `self.meta` to isolate 'keycomments' and treat it differently when building a `astropy.fits.Header` instance.

### Steps to Reproduce
<!--
Please include **code** that reproduces the issue whenever possible.
The best reproductions are self-contained scripts with minimal dependencies.
-->

1. Higher level : `sunpy.map.GenericMap`

```python
>>> import sunpy.map 
>>> import sunpy.data.sample 
>>>  aia = sunpy.map.Map(sunpy.data.sample.AIA_171_IMAGE)
>>> 'KEYCOMMENTS' in aia.meta # 'KEYCOMMENTS' entry exists
True
>>> aia
SunPy Map
---------
Observatory:		 SDO
Instrument:		 AIA 3
Detector:		 AIA
Measurement:		 171.0 Angstrom
Wavelength:		 171.0 Angstrom
Observation Date:	 2011-06-07 06:33:02
Exposure Time:		 0.234256 s
Dimension:		 [1024. 1024.] pix
Coordinate System:	 helioprojective
Scale:			 [2.402792 2.402792] arcsec / pix
Reference Pixel:	 [512.5 512.5] pix
Reference Coord:	 [3.22309951 1.38578135] arcsec
array([[ -95.92475  ,    7.076416 ,   -1.9656711, ..., -127.96519  ,
        -127.96519  , -127.96519  ],
       [ -96.97533  ,   -5.1167884,    0.       , ...,  -98.924576 ,
        -104.04137  , -127.919716 ],
       [ -93.99607  ,    1.0189276,   -4.0757103, ...,   -5.094638 ,
         -37.95505  , -127.87541  ],
       ...,
       [-128.01454  , -128.01454  , -128.01454  , ..., -128.01454  ,
        -128.01454  , -128.01454  ],
       [-127.899666 , -127.899666 , -127.899666 , ..., -127.899666 ,
        -127.899666 , -127.899666 ],
       [-128.03072  , -128.03072  , -128.03072  , ..., -128.03072  ,
        -128.03072  , -128.03072  ]], dtype=float32)
>>>  'KEYCOMMENTS' in aia.meta # 'KEYCOMMENTS' now deleted
False
```
2. Lower level : `astropy.io.fits.header_to_fits`

```python 
>>> import sunpy.io
>>> import sunpy.map
>>> import sunpy.data.sample
>>> aia_header = sunpy.map.Map(sunpy.data.sample.AIA_171_IMAGE).meta
>>> 'KEYCOMMENTS' in aia_header # 'KEYCOMMENTS' exists
True
>>> _=sunpy.io.fits.header_to_fits(aia_header)  
>>> 'KEYCOMMENTS' in aia_header # 'KEYCOMMENTS' now deleted    
False
```

### System Details
<!--
We at least need to know the sunpy version you are using.
We provide a short function (``sunpy.util.system_info()``) that will provide most of the below information.
This step is optional but strongly recommended.
-->
- SunPy Version: 1.1.2
- Astropy Version: 4.0.1.post1
- Python Version: 3.7.6
- OS information: Ubuntu 18.04 bionic (Linux 4.15.0-96-generic x86_64) 




Discussion:
Thanks for the report :smile: 

Resolving patch:
diff --git a/sunpy/io/fits.py b/sunpy/io/fits.py
--- a/sunpy/io/fits.py
+++ b/sunpy/io/fits.py
@@ -193,6 +193,8 @@ def header_to_fits(header):
     """
     Convert a header dict to a `~astropy.fits.Header`.
     """
+    # Copy the header to avoid modifying it in place
+    header = header.copy()
     # The comments need to be added to the header separately from the normal
     # kwargs. Find and deal with them:
     fits_header = fits.Header()


Test patch:
diff --git a/sunpy/map/tests/test_mapbase.py b/sunpy/map/tests/test_mapbase.py
--- a/sunpy/map/tests/test_mapbase.py
+++ b/sunpy/map/tests/test_mapbase.py
@@ -121,6 +121,13 @@ def test_wcs(aia171_test_map):
     np.testing.assert_allclose(wcs.wcs.pc, aia171_test_map.rotation_matrix)
 
 
+def test_header_immutability(aia171_test_map):
+    # Check that accessing the wcs of a map doesn't modify the meta data
+    assert 'KEYCOMMENTS' in aia171_test_map.meta
+    wcs = aia171_test_map.wcs
+    assert 'KEYCOMMENTS' in aia171_test_map.meta
+
+
 def test_dtype(generic_map):
     assert generic_map.dtype == np.float64