Repository: gammapy/gammapy

Issue:
string representation of various Maker classes may cause a TypeError
This was tested against Gammapy 0.19 and the development version.

In some cases, printing (or using the string representation in another way) of an instance of a `gammapy.makers.Maker` subclass may cause a TypeError.

An example directly from the tutorials (introduction, low-level analysis):
```
from astropy import units
from regions import CircleSkyRegion
from gammapy.makers import FoVBackgroundMaker
from astropy.coordinates import SkyCoord
from gammapy.maps import WcsGeom, MapAxis

energy_axis = MapAxis.from_energy_bounds(1.0, 10.0, 4, unit="TeV")
geom = WcsGeom.create(
    skydir=(83.633, 22.014),
    binsz=0.02,
    width=(2, 2),
    frame="icrs",
    proj="CAR",
    axes=[],
)
circle = CircleSkyRegion(
    center=SkyCoord("83.63 deg", "22.14 deg"), radius=0.2 * units.deg
)
exclusion_mask = ~geom.region_mask(regions=[circle])
maker_fov = FoVBackgroundMaker(method="fit", exclusion_mask=exclusion_mask)
str(maker_fov)
```

will cause a 
```
TypeError: Cannot parse "not available" as a Quantity. It does not start with a number.
```
(full traceback at the bottom).

The reason is in the `__str__` implementation of the `gammapy.makers.Maker` abstract class:
```
    def __str__(self):
        # <snip>
        for name in names:
            value = getattr(self, name, "not available")

            if value == "not available":
                continue
            else:
                s += f"\t{name:{max_len}s}: {value}\n"

        return s.expandtabs(tabsize=2)
```

When an attribute is not found, it is set to the string "not available". Otherwise, the attribute's value is retrieved.
The resulting value is then compared to the string "not available" to determine whether it is an existing attribute. But some classes can't compare to string. In this particular case, comparing an instance of `WcsNDMap` fails this comparison, resulting (indirectly) in the `TypeError`.

Perhaps the most Pythonic solution is to have `WcsNDMap` handle comparisons with any arbirtrary type, and return `False`. This is what Python does: `1 == "abc"` is valid and returns `False`; similar for e.g. `1 = ["abc", 5.5]`.

Perhaps easier, and in my opinion semantically better, is to use
```
value = getattr(self, name, None)
if value is None:
    continue
s += f"\t{name:{max_len}s}: {value}\n"
```
since `None` signifies the optional type in Python.

Though even better, in my opinion, is to simply use
```
for name in names:
    try:
        value = getattr(self, name)
    except AttributeError:
        continue
    s += f"\t{name:{max_len}s}: {value}\n" 
```
After all, this is what exceptions are for. (People sometimes mention speed reasons if the lookup fails a lot of times, but I don't think that's relevant here for a `__str__` implementation.)

I would even simply use `self.name`, but that'll fail because `Maker` is an abstract class, so more dynamic retrieval of attributes is required. I assume this is why it's implemented in its current way.


-----

Full traceback:
```
Traceback (most recent call last):
  File "/home/user/.miniconda3/envs/gammapy-dev/lib/python3.7/site-packages/astropy/units/quantity.py", line 333, in __new__
    value = float(v.group())
AttributeError: 'NoneType' object has no attribute 'group'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "gammapy_maker_str.py", line 22, in <module>
    str(maker_fov)
  File "/home/user/.miniconda3/envs/gammapy-dev/lib/python3.7/site-packages/gammapy/makers/core.py", line 31, in __str__
    if value == "not available":
  File "/home/user/.miniconda3/envs/gammapy-dev/lib/python3.7/site-packages/gammapy/maps/core.py", line 1612, in __eq__
    return self._arithmetics(np.equal, other, copy=True)
  File "/home/user/.miniconda3/envs/gammapy-dev/lib/python3.7/site-packages/gammapy/maps/core.py", line 1552, in _arithmetics
    q = u.Quantity(other, copy=False)
  File "/home/user/.miniconda3/envs/gammapy-dev/lib/python3.7/site-packages/astropy/units/quantity.py", line 338, in __new__
    .format(value, cls.__name__))
TypeError: Cannot parse "not available" as a Quantity. It does not start with a number.
```



Discussion:
Thanks a lot @evertrol, I can confirm this is a real bug.

Resolving patch:
diff --git a/gammapy/makers/core.py b/gammapy/makers/core.py
--- a/gammapy/makers/core.py
+++ b/gammapy/makers/core.py
@@ -26,9 +26,9 @@ def __str__(self):
         max_len = np.max([len(_) for _ in names]) + 1
 
         for name in names:
-            value = getattr(self, name, "not available")
+            value = getattr(self, name, None)
 
-            if value == "not available":
+            if value is None:
                 continue
             else:
                 s += f"\t{name:{max_len}s}: {value}\n"


Test patch:
diff --git a/gammapy/makers/background/tests/test_fov.py b/gammapy/makers/background/tests/test_fov.py
--- a/gammapy/makers/background/tests/test_fov.py
+++ b/gammapy/makers/background/tests/test_fov.py
@@ -7,7 +7,7 @@
 from gammapy.data import DataStore
 from gammapy.datasets import MapDataset, SpectrumDataset
 from gammapy.makers import FoVBackgroundMaker, MapDatasetMaker, SafeMaskMaker
-from gammapy.maps import MapAxis, WcsGeom, RegionGeom
+from gammapy.maps import MapAxis, WcsGeom, RegionGeom, Map
 from gammapy.modeling import Fit
 from gammapy.modeling.models import (
     FoVBackgroundModel,
@@ -308,6 +308,7 @@ def test_fov_bkg_maker_mask_fit_handling(obs_dataset, exclusion_mask):
     assert_allclose(model.norm.error, 0.1115, rtol=1e-3)
     assert_allclose(model.tilt.value, 0.0, rtol=1e-2)
 
+
 @requires_data()
 def test_fov_bkg_maker_spectrumdataset(obs_dataset):
     from regions import CircleSkyRegion
@@ -323,3 +324,9 @@ def test_fov_bkg_maker_spectrumdataset(obs_dataset):
     region_dataset = obs_dataset.to_region_map_dataset(region)
     with pytest.raises(TypeError):
         maker.run(region_dataset)
+
+
+def test_fov_background_maker_str():
+    exclusion_mask = Map.create(binsz=0.2, width=(2, 2))
+    maker_fov = FoVBackgroundMaker(exclusion_mask=exclusion_mask)
+    assert "FoVBackgroundMaker" in str(maker_fov)