Repository: obspy/obspy

Issue:
Unnessary information when writing to a SACPZ file
<!--

**GitHub should mainly be used for bug reports and code developments/discussions. For generic questions, please use either the [[obspy-users] mailing list](http://lists.swapbytes.de/mailman/listinfo/obspy-users) (for scientific questions to the general ObsPy community) or our [gitter chat](https://gitter.im/obspy/obspy) (for technical questions and direct contact to the developers).**

Before submitting an Issue, please review the [Issue Guidelines](https://github.com/obspy/obspy/blob/master/CONTRIBUTING.md#submitting-an-issue).

-->

* Please check whether the bug was already reported or fixed.
No
* Please provide the following information:
  -  ObsPy version, Python version and Platform (Windows, OSX, Linux ...)
obspy 1.2.2, Python 3.8.5, Linux
  -  How did you install ObsPy and Python (pip, anaconda, from source ...)
anaconda
* issue
https://github.com/obspy/obspy/blob/d56e8ebebc0b4d7797db8e5b2bb76e8e91df13de/obspy/io/sac/sacpz.py#L101

I am planning to write to a SACPZ file while `cha.sensor==None` which means it has no sensor information, the program will crash since `None.type` doesn't exit. The general purpose for us to use a SACPZ file is to remove the instrumental response and the sensor information might not be so essential. So maybe we can add an if flag here to see whether `cha.sensor` exist or not?



Discussion:
`cha.sensor and cha.sensor.type or "---"` should do the trick
Hello, @megies . I am wondering if this is something that you still want to address, considering that 2+ years have passed since it was reported. If yes, may I work on this? (I think no explicit assignment is required, but since I am asking other questions, I thought I could ask that, too).

Now, related to the change itself: would a try... except... approach work for you or would you prefer a boolean approach like the one you suggest?

I was thinking on something like this:

```python
try:
    out.append(f"* INSTTYPE    : {cha.sensor.type}")
except AttributeError:
    out.append("* INSTTYPE    : ---")
```

May I also change `%`s and `.format`s for `f-string`s (wherever possible in this file, at least) to make the PR a bit less simple?
Feel free to go ahead. But your try/except doesn't handle all cases, it will print "None" in output if sensor is set but its type is `None`.

`f"* INSTTYPE    : {cha.sensor and cha.sensor.type or ''}"` handles everything and in a more elegant way, I think.

If you want to work on this we will also need tests for it, not sure if it's worth the hassle, considering that SACPZ is a format that probably very few people use, with how widespread StationXML has become the default.
Oh, that is true. I was only considering the case described in the issue (i.e.  sensor being `None`).

I will give this a try this weekend and update when I am done or if I give up. Thank you!

Resolving patch:
diff --git a/obspy/io/sac/sacpz.py b/obspy/io/sac/sacpz.py
--- a/obspy/io/sac/sacpz.py
+++ b/obspy/io/sac/sacpz.py
@@ -46,12 +46,11 @@ def _write_sacpz(inventory, file_or_file_object):
                 try:
                     paz = resp.get_paz()
                 except Exception:
-                    msg = "{}.{}.{}.{} {} has no paz. Skipping.".format(
-                        net.code,
-                        sta.code,
-                        cha.location_code,
-                        cha.code,
-                        cha.start_date)
+                    msg = (
+                        f"{net.code}.{sta.code}."
+                        f"{cha.location_code}.{cha.code} "
+                        f"{cha.start_date} has no paz. Skipping."
+                    )
                     warnings.warn(msg)
                     continue
                 input_unit = sens.input_units.upper()
@@ -62,48 +61,45 @@ def _write_sacpz(inventory, file_or_file_object):
                 elif input_unit in ["M/S**2", "M/SEC**2"]:
                     paz.zeros.extend([0j, 0j])
                 else:
-                    msg = "{}.{}.{}.{} {} ".format(
-                        net.code,
-                        sta.code,
-                        cha.location_code,
-                        cha.code,
-                        cha.start_date)
-                    msg += "has unrecognized input units in "
-                    msg += "response: {}. Skipping".format(input_unit)
+                    msg = (
+                        f"{net.code}.{sta.code}."
+                        f"{cha.location_code}.{cha.code} "
+                        f"{cha.start_date} "
+                        "has unrecognized input units in "
+                        f"response: {input_unit}. Skipping"
+                    )
                     warnings.warn(msg)
                     continue
                 out.append("* " + "*" * 50)
-                out.append("* NETWORK     : %s" % net.code)
-                out.append("* STATION     : %s" % sta.code)
-                out.append("* LOCATION    : %s" % cha.location_code)
-                out.append("* CHANNEL     : %s" % cha.code)
-                out.append("* CREATED     : %s" % now)
-                out.append("* START       : %s" % cha.start_date)
-                out.append("* END         : %s" % cha.end_date)
-                out.append("* DESCRIPTION : %s" % sta.site.name)
-                out.append("* LATITUDE    : %s" % (cha.latitude or
-                                                   sta.latitude))
-                out.append("* LONGITUDE   : %s" % (cha.longitude or
-                                                   sta.longitude))
-                out.append("* ELEVATION   : %s" % (cha.elevation or
-                                                   sta.elevation))
-                out.append("* DEPTH       : %s" % cha.depth)
+                out.append(f"* NETWORK     : {net.code}")
+                out.append(f"* STATION     : {sta.code}")
+                out.append(f"* LOCATION    : {cha.location_code}")
+                out.append(f"* CHANNEL     : {cha.code}")
+                out.append(f"* CREATED     : {now}")
+                out.append(f"* START       : {cha.start_date}")
+                out.append(f"* END         : {cha.end_date}")
+                out.append(f"* DESCRIPTION : {sta.site.name}")
+                out.append(f"* LATITUDE    : {cha.latitude or sta.latitude}")
+                out.append(f"* LONGITUDE   : {cha.longitude or sta.longitude}")
+                out.append(f"* ELEVATION   : {cha.elevation or sta.elevation}")
+                out.append(f"* DEPTH       : {cha.depth}")
                 # DIP in SACPZ served by IRIS SACPZ web service is
                 # systematically different from the StationXML entries.
                 # It is defined as an incidence angle (like done in SAC for
                 # sensor orientation), rather than an actual dip.
                 # Add '(SEED)' to clarify that we adhere to SEED convention
-                out.append("* DIP (SEED)  : %s" % cha.dip)
-                out.append("* AZIMUTH     : %s" % cha.azimuth)
-                out.append("* SAMPLE RATE : %s" % cha.sample_rate)
+                out.append(f"* DIP (SEED)  : {cha.dip}")
+                out.append(f"* AZIMUTH     : {cha.azimuth}")
+                out.append(f"* SAMPLE RATE : {cha.sample_rate}")
                 out.append("* INPUT UNIT  : M")
-                out.append("* OUTPUT UNIT : %s" % sens.output_units)
-                out.append("* INSTTYPE    : %s" % cha.sensor.type)
-                out.append("* INSTGAIN    : %s (%s)" % (paz.stage_gain,
-                                                        sens.input_units))
-                out.append("* SENSITIVITY : %s (%s)" % (sens.value,
-                                                        sens.input_units))
-                out.append("* A0          : %s" % paz.normalization_factor)
+                out.append(f"* OUTPUT UNIT : {sens.output_units}")
+                inst_type = cha.sensor and cha.sensor.type or ''
+                out.append(f"* INSTTYPE    : {inst_type}")
+                out.append(f"* INSTGAIN    : {paz.stage_gain} "
+                           f"({sens.input_units})")
+                out.append(f"* SENSITIVITY : {sens.value} "
+                           f"({sens.input_units})")
+                out.append(f"* A0          : {paz.normalization_factor}")
                 out.append("* " + "*" * 50)
                 out.append(paz_to_sacpz_string(paz, sens))
                 out.extend(["", ""])


Test patch:
diff --git a/obspy/io/sac/tests/test_sacpz.py b/obspy/io/sac/tests/test_sacpz.py
--- a/obspy/io/sac/tests/test_sacpz.py
+++ b/obspy/io/sac/tests/test_sacpz.py
@@ -3,30 +3,42 @@
 The sac.sacpz test suite.
 """
 import os
-import unittest
 import io
 import warnings
 
 import numpy as np
+import pytest
 
 from obspy import read_inventory, Trace
+from obspy.core.inventory.util import Equipment
 from obspy.core.util import NamedTemporaryFile
 from obspy.io.sac import attach_paz, attach_resp
 
 
-class SACPZTestCase(unittest.TestCase):
+class TestSACPZ:
     """
     """
-    def setUp(self):
-        # directory where the test files are located
-        self.path = os.path.dirname(__file__)
-        # these files were checked against data given by IRIS SACPZ web service
-        # http://service.iris.edu/irisws/sacpz/1/
-        #                                query?net=IU&loc=*&cha=BH?&sta=ANMO
-        # DIP seems to be systematically different in SACPZ output compared to
-        # StationXML served by IRIS...
-        self.file1 = os.path.join(self.path, 'data', 'IU_ANMO_00_BHZ.sacpz')
-        self.file2 = os.path.join(self.path, 'data', 'IU_ANMO_BH.sacpz')
+    # directory where the test files are located
+    path = os.path.dirname(__file__)
+    # these files were checked against data given by IRIS SACPZ web service
+    # http://service.iris.edu/irisws/sacpz/1/
+    #                                query?net=IU&loc=*&cha=BH?&sta=ANMO
+    # DIP seems to be systematically different in SACPZ output compared to
+    # StationXML served by IRIS...
+    file1 = os.path.join(path, 'data', 'IU_ANMO_00_BHZ.sacpz')
+    file2 = os.path.join(path, 'data', 'IU_ANMO_BH.sacpz')
+
+    @pytest.fixture(scope="class")
+    def sacpz_with_no_sensors(self):
+        expected = []
+        with open(self.file1) as fh:
+            for line in fh:
+                if "INSTTYPE" in line:
+                    line = "* INSTTYPE    : "
+                if "CREATED" not in line:
+                    expected.append(line.rstrip("\n"))
+
+        return expected
 
     def test_write_sacpz_single_channel(self):
         """
@@ -43,7 +55,7 @@ def test_write_sacpz_single_channel(self):
         got = [line for line in got.split("\n") if "CREATED" not in line]
         expected = [line for line in expected.split("\n")
                     if "CREATED" not in line]
-        self.assertEqual(got, expected)
+        assert got == expected
 
     def test_write_sacpz_multiple_channels(self):
         """
@@ -60,7 +72,7 @@ def test_write_sacpz_multiple_channels(self):
         got = [line for line in got.split("\n") if "CREATED" not in line]
         expected = [line for line in expected.split("\n")
                     if "CREATED" not in line]
-        self.assertEqual(got, expected)
+        assert got == expected
 
     def test_write_sacpz_soh(self):
         path = os.path.join(self.path, '..', '..', 'stationxml', 'tests',
@@ -71,13 +83,28 @@ def test_write_sacpz_soh(self):
             warnings.simplefilter('always')
             inv.write(f, format='SACPZ')
         # Testxml has 2 channels: 1 no paz, 2 unrecognized units.
-        self.assertEqual(len(w), 2)
+        assert len(w) == 2
         # Assert warning messages contain correct warnings
-        self.assertTrue(any('has no paz' in str(x.message) for x in w))
-        self.assertTrue(any('has unrecognized input units'
-                            in str(x.message) for x in w))
+        assert any('has no paz' in str(x.message) for x in w)
+        assert any('has unrecognized input units' in str(x.message) for x in w)
         # Only 2 newlines are written.
-        self.assertEqual(2, f.tell())
+        assert 2 == f.tell()
+
+    @pytest.mark.parametrize("sensor", [None, Equipment(type=None)])
+    def test_write_sacpz_no_sensor(self, sensor, sacpz_with_no_sensors):
+        """
+        Test sacpz writer when no sensor or sensor type are specified
+        """
+        inv = read_inventory("/path/to/IU_ANMO_00_BHZ.xml")
+        inv.networks[0].stations[0].channels[0].sensor = sensor
+        with NamedTemporaryFile() as tf:
+            tempfile = tf.name
+            inv.write(tempfile, format='SACPZ')
+            with open(tempfile) as fh:
+                got = [line.rstrip("\n") for line in fh
+                       if "CREATED" not in line]
+
+        assert got == sacpz_with_no_sensors
 
     def test_attach_paz(self):
         fvelhz = io.StringIO("""ZEROS 3
@@ -94,7 +121,7 @@ def test_attach_paz(self):
         attach_paz(tr, fvelhz, torad=True, todisp=True)
         np.testing.assert_array_almost_equal(tr.stats.paz['zeros'][0],
                                              - 31.616988, decimal=6)
-        self.assertEqual(len(tr.stats.paz['zeros']), 4)
+        assert len(tr.stats.paz['zeros']) == 4
 
     def test_attach_paz_diff_order(self):
         pazfile = os.path.join(os.path.dirname(__file__),
@@ -103,8 +130,8 @@ def test_attach_paz_diff_order(self):
         attach_paz(tr, pazfile)
         np.testing.assert_array_almost_equal(tr.stats.paz['gain'],
                                              7.4592e-2, decimal=6)
-        self.assertEqual(len(tr.stats.paz['zeros']), 5)
-        self.assertEqual(len(tr.stats.paz['poles']), 4)
+        assert len(tr.stats.paz['zeros']) == 5
+        assert len(tr.stats.paz['poles']) == 4
 
     def test_sacpaz_from_dataless(self):
         # The following dictionary is extracted from a datalessSEED
@@ -127,7 +154,7 @@ def test_sacpaz_from_dataless(self):
         np.testing.assert_almost_equal(tr.stats.paz['gain'] / 1e17,
                                        sacconstant / 1e17, decimal=6)
         # pole-zero files according to the SAC convention are in displacement
-        self.assertEqual(len(tr.stats.paz['zeros']), 3)
+        assert len(tr.stats.paz['zeros']) == 3
 
     def test_sacpaz_from_resp(self):
         # The following two files were both extracted from a dataless
@@ -176,8 +203,8 @@ def test_sacpaz_from_resp(self):
         np.testing.assert_almost_equal(phase1, phase2, decimal=4)
         rms = np.sqrt(np.sum((amp1 - amp2) ** 2) /
                       np.sum(amp2 ** 2))
-        self.assertTrue(rms < 2.02e-06)
-        self.assertTrue(tr1.stats.paz.t_shift, 0.4022344)
+        assert rms < 2.02e-06
+        assert tr1.stats.paz.t_shift == 0.4022344
         # The following plots the comparison between the
         # two frequency response functions.
         # import pylab as plt
@@ -188,11 +215,3 @@ def test_sacpaz_from_resp(self):
         # plt.semilogx(f,phase1)
         # plt.semilogx(f,phase2,'k--')
         # plt.show()
-
-
-def suite():
-    return unittest.makeSuite(SACPZTestCase, 'test')
-
-
-if __name__ == '__main__':
-    unittest.main(defaultTest='suite')