Repository: sunpy/sunpy

Issue:
Net Attrs Underlying refactor
### Description

As we have encountered more cases with our attr registration system (such as  https://github.com/sunpy/sunpy/pull/4466), the underlying code has become more complex, slower and harder to maintain. With this in mind, I think it should be possible to refactor the underlying to be "better".

Aims:
- Less Code
- Fewer loops
- Fewer special cases

The test suite should be enough to catch any issues during the refactor. 



Discussion:
Hi, I'd like to work on this issue if nobody is already doing it
No one else is working on this but this is not a straightforward task to start with. 

Resolving patch:
diff --git a/sunpy/database/attrs.py b/sunpy/database/attrs.py
--- a/sunpy/database/attrs.py
+++ b/sunpy/database/attrs.py
@@ -62,11 +62,15 @@ def __repr__(self):
 
 
 class Starred(_BooleanAttr, Attr):
+    type_name = "starred"
+
     def __init__(self):
-        _BooleanAttr.__init__(self, True, self.__class__)
+        super().__init__(True, self.__class__)
 
 
 class Tag(Attr):
+    type_name = "tag"
+
     def __init__(self, tagname):
         self.tagname = tagname
         self.inverted = False
@@ -85,14 +89,14 @@ def __repr__(self):
 
 
 class Path(Attr):
-    def __init__(self, value):
+    type_name = 'path'
+
+    def __init__(self, value, inverted=False):
         self.value = value
-        self.inverted = False
+        self.inverted = inverted
 
     def __invert__(self):
-        path = self.__class__(self.value)
-        path.inverted = True
-        return path
+        return self.__class__(self.value, True)
 
     def collides(self, other):  # pragma: no cover
         return isinstance(other, self.__class__)
@@ -105,6 +109,8 @@ def __repr__(self):
 # TODO: support excluding ranges as soon as
 # attr.Range.__xor__ is fixed / renamed
 class DownloadTime(Range):
+    type_name = 'download time'
+
     def __init__(self, start, end):
         self.start = parse_time(start).datetime
         self.end = parse_time(end).datetime
@@ -125,15 +131,15 @@ def __repr__(self):
 
 
 class FitsHeaderEntry(Attr):
-    def __init__(self, key, value):
+    type_name = "fitsheaderentry"
+
+    def __init__(self, key, value, inverted=False):
         self.key = key
         self.value = value
-        self.inverted = False
+        self.inverted = inverted
 
     def __invert__(self):
-        entry = self.__class__(self.key, self.value)
-        entry.inverted = True
-        return entry
+        return self.__class__(self.key, self.value, True)
 
     def collides(self, other):  # pragma: no cover
         return False
@@ -148,11 +154,8 @@ def __repr__(self):
 
 @walker.add_creator(AttrOr)
 def _create(wlk, root, session):
-    entries = set()
-    for attr in root.attrs:
-        # make sure to add only new entries to the set to avoid duplicates
-        entries.update(set(wlk.create(attr, session)) - entries)
-    return list(entries)
+    entries = [set(wlk.create(attr, session)) for attr in root.attrs]
+    return list(set.union(*entries))
 
 
 @walker.add_creator(AttrAnd)
@@ -161,103 +164,142 @@ def _create(wlk, root, session):
     return list(set.intersection(*entries))
 
 
+def _inverter_helper(query, inverted):
+    return not_(query) if inverted else query
+
+
 @walker.add_creator(ValueAttr)
 def _create(wlk, root, session):
     query = session.query(DatabaseEntry)
     for key, value in root.attrs.items():
-        typ = key[0]
-        if typ == 'tag':
-            criterion = TableTag.name.in_([value])
-            # `key[1]` is here the `inverted` attribute of the tag. That means
-            # that if it is True, the given tag must not be included in the
-            # resulting entries.
-            if key[1]:
-                query = query.filter(~DatabaseEntry.tags.any(criterion))
-            else:
-                query = query.filter(DatabaseEntry.tags.any(criterion))
-        elif typ == 'fitsheaderentry':
-            key, val, inverted = value
+        # `key[1]` is here the `inverted` attribute of the tag. That means
+        # that if it is True, the given tag must not be included in the
+        # resulting entries.
+        typ, inverted = key[0].lower(), key[1]
+
+        if typ == Tag.type_name:
+            criterion = TableTag.name.in_(value)
+            base_query = DatabaseEntry.tags.any(criterion)
+            query = query.filter(_inverter_helper(base_query, inverted))
+
+        elif typ == FitsHeaderEntry.type_name:
+            key, val = value
             key_criterion = TableFitsHeaderEntry.key == key
             value_criterion = TableFitsHeaderEntry.value == val
+            base_query = and_(
+                DatabaseEntry.fits_header_entries.any(key_criterion),
+                DatabaseEntry.fits_header_entries.any(value_criterion))
+            query = query.filter(_inverter_helper(base_query, inverted))
+
+        elif typ == DownloadTime.type_name:
+            start, end = value
+            base_query = DatabaseEntry.download_time.between(start, end)
+            query = query.filter(_inverter_helper(base_query, inverted))
+
+        elif typ == Path.type_name:
+            path, = value
+            base_query = _inverter_helper(DatabaseEntry.path == path, inverted)
             if inverted:
-                query = query.filter(not_(and_(
-                    DatabaseEntry.fits_header_entries.any(key_criterion),
-                    DatabaseEntry.fits_header_entries.any(value_criterion))))
-            else:
-                query = query.filter(and_(
-                    DatabaseEntry.fits_header_entries.any(key_criterion),
-                    DatabaseEntry.fits_header_entries.any(value_criterion)))
-        elif typ == 'download time':
-            start, end, inverted = value
-            if inverted:
-                query = query.filter(
-                    ~DatabaseEntry.download_time.between(start, end))
-            else:
-                query = query.filter(
-                    DatabaseEntry.download_time.between(start, end))
-        elif typ == 'path':
-            path, inverted = value
-            if inverted:
-                query = query.filter(or_(
-                    DatabaseEntry.path != path, DatabaseEntry.path == None))  # NOQA
-            else:
-                query = query.filter(DatabaseEntry.path == path)
-        elif typ == 'wave':
+                base_query = or_(base_query, DatabaseEntry.path == None)  # NOQA
+            query = query.filter(base_query)
+
+        elif typ == core_attrs.Wavelength.type_name:
             wavemin, wavemax, waveunit = value
             query = query.filter(and_(
                 DatabaseEntry.wavemin >= wavemin,
                 DatabaseEntry.wavemax <= wavemax))
-        elif typ == 'time':
-            start, end, near = value
+
+        elif typ == core_attrs.Time.type_name:
+            start, end, _ = value
             query = query.filter(and_(
                 DatabaseEntry.observation_time_start < end,
                 DatabaseEntry.observation_time_end > start))
+
+        elif typ in (SUPPORTED_SIMPLE_VSO_ATTRS | SUPPORTED_NONVSO_ATTRS):
+            query_value, = value
+            query = query.filter_by(**{typ: query_value})
+
         else:
-            if typ.lower() not in SUPPORTED_SIMPLE_VSO_ATTRS.union(SUPPORTED_NONVSO_ATTRS):
-                raise NotImplementedError(
-                    f"The attribute {typ!r} is not yet supported to query a database.")
-            query = query.filter_by(**{typ: value})
+            raise NotImplementedError(
+                f"The attribute {typ!r} is not yet supported to query a database.")
+
     return query.all()
 
 
+def _convert_decorator(specify_invertible=False):
+    """
+    Given a converter for the walker this function creates a ValueAttr
+    standardized for the _create.
+
+    Parameters
+    ----------
+    specify_invertible : bool
+        Specify if the decorated function returns the inverted parameter as last one.
+
+    Returns
+    -------
+    `Callable`
+        Decorator that wraps the result of the function in a
+        `ValueAttr({(type_name, invertible), (*list_of_parameters)})`.
+    """
+    def decorator(convert_func):
+        def func_wrapper(attr):
+            if specify_invertible:
+                params_with_flag = convert_func(attr)
+                params = params_with_flag[:-1]
+                invert_flag = params_with_flag[-1]
+            else:
+                params = convert_func(attr)
+                invert_flag = False
+            return ValueAttr({(attr.type_name, invert_flag): params})
+        return func_wrapper
+    return decorator
+
+
 @walker.add_converter(Tag)
+@_convert_decorator(specify_invertible=True)
 def _convert(attr):
-    return ValueAttr({('tag', attr.inverted): attr.tagname})
+    return attr.tagname, attr.inverted
 
 
 @walker.add_converter(Starred)
+@_convert_decorator()
 def _convert(attr):
-    return ValueAttr({('starred', ): attr.value})
+    return attr.value,
 
 
 @walker.add_converter(Path)
+@_convert_decorator(specify_invertible=True)
 def _convert(attr):
-    return ValueAttr({('path', ): (attr.value, attr.inverted)})
+    return attr.value, attr.inverted
 
 
 @walker.add_converter(DownloadTime)
+@_convert_decorator(specify_invertible=True)
 def _convert(attr):
-    return ValueAttr({
-        ('download time', ): (attr.start, attr.end, attr.inverted)})
+    return attr.start, attr.end, attr.inverted
 
 
 @walker.add_converter(FitsHeaderEntry)
+@_convert_decorator(specify_invertible=True)
 def _convert(attr):
-    return ValueAttr(
-        {('fitsheaderentry', ): (attr.key, attr.value, attr.inverted)})
+    return attr.key, attr.value, attr.inverted
 
 
 @walker.add_converter(SimpleAttr)
+@_convert_decorator()
 def _convert(attr):
-    return ValueAttr({(attr.__class__.__name__.lower(), ): attr.value})
+    return attr.value,
 
 
 @walker.add_converter(core_attrs.Wavelength)
+@_convert_decorator()
 def _convert(attr):
-    return ValueAttr({('wave', ): (attr.min.value, attr.max.value, str(attr.unit))})
+    return attr.min.value, attr.max.value, str(attr.unit)
 
 
 @walker.add_converter(core_attrs.Time)
+@_convert_decorator()
 def _convert(attr):
-    near = None if not attr.near else attr.near.datetime
-    return ValueAttr({('time', ): (attr.start.datetime, attr.end.datetime, near)})
+    near = attr.near.datetime if attr.near else None
+    return attr.start.datetime, attr.end.datetime, near
diff --git a/sunpy/net/_attrs.py b/sunpy/net/_attrs.py
--- a/sunpy/net/_attrs.py
+++ b/sunpy/net/_attrs.py
@@ -35,26 +35,25 @@ class Time(Range):
         functionality.
 
     """
+    type_name = "time"
 
     def __init__(self, start, end=None, near=None):
         if end is None and not isinstance(start, TimeRange):
             raise ValueError("Specify start and end or start has to be a TimeRange")
         if isinstance(start, TimeRange):
-            self.start = start.start
-            self.end = start.end
+            self.start, self.end = start.start, start.end
         else:
-            self.start = parse_time(start)
-            self.end = parse_time(end)
+            self.start, self.end = parse_time(start), parse_time(end)
 
         if self.start > self.end:
             raise ValueError("End time must be after start time.")
-        self.near = None if near is None else parse_time(near)
+        self.near = parse_time(near) if near else None
 
         super().__init__(self.start, self.end)
 
     def __hash__(self):
-        if not (isinstance(self.start, collections.abc.Hashable) and
-                isinstance(self.end, collections.abc.Hashable)):
+        if not isinstance(self.start, collections.abc.Hashable) or \
+           not isinstance(self.end, collections.abc.Hashable):
             # The hash is the hash of the start and end time
             return hash((self.start.jd1, self.start.jd2, self.start.scale,
                          self.end.jd1, self.end.jd2, self.end.scale))
@@ -72,16 +71,22 @@ def __xor__(self, other):
             raise TypeError
         if self.near is not None or other.near is not None:
             raise TypeError
-        return Range.__xor__(self, other)
+        return super().__xor__(other)
 
     def pad(self, timedelta):
         return type(self)(self.start - timedelta, self.start + timedelta)
 
     def __repr__(self):
-        return f'<sunpy.net.attrs.Time({self.start.iso}, {self.end.iso}{", " + self.near.iso if self.near else ""})>'
+        start = self.start.iso
+        end = self.end.iso
+        iso = self.near.iso if self.near else None
+        str_repr = ", ".join(str(param) for param in [start, end, iso] if param)
+        return f'<sunpy.net.attrs.Time({str_repr})>'
 
 
 class Wavelength(Range):
+    type_name = 'wave'
+
     def __init__(self, wavemin, wavemax=None):
         """
         Specifies the wavelength or spectral energy range of the detector.
@@ -118,9 +123,7 @@ def __init__(self, wavemin, wavemax=None):
         for unit in supported_units:
             if wavemin.unit.is_equivalent(unit):
                 break
-            else:
-                unit = None
-        if unit is None:
+        else:
             raise u.UnitsError(f"This unit is not convertable to any of {supported_units}")
 
         wavemin, wavemax = sorted([wavemin.to(unit), wavemax.to(unit)])
diff --git a/sunpy/net/attr.py b/sunpy/net/attr.py
--- a/sunpy/net/attr.py
+++ b/sunpy/net/attr.py
@@ -31,7 +31,7 @@
 
 _ATTR_TUPLE = namedtuple("attr", "name client name_long desc")
 # Matches any number.
-NUMBER_REGEX = re.compile(r"(\d+(?:\.\d+)?)")
+NUMBER_REGEX = re.compile(r"^(\d+$|\d(?:\.\d+)?)")
 
 __all__ = ['Attr', 'DummyAttr', 'SimpleAttr', 'Range', 'AttrAnd', 'AttrOr',
            'ValueAttr', 'and_', 'or_', 'AttrWalker']
@@ -57,32 +57,28 @@ def _print_attrs(attr, html=False):
     `str`
         String with the registered attributes.
     """
-    class_name = f"{attr.__module__+'.' or ''}{attr.__name__}"
     attrs = attr._attr_registry[attr]
-    sorted_attrs = make_tuple()
     # Only sort the attrs if any have been registered
-    if attrs.name:
-        sorted_attrs = _ATTR_TUPLE(*zip(*sorted(zip(*attrs))))
-    names = sorted_attrs.name
-    clients = sorted_attrs.client
-    names_long = sorted_attrs.name_long
-    descs = sorted_attrs.desc
-    descs = [x[:77] + '...' if len(x) > 80 else x for x in descs]
-    lines = []
-    t = Table(names=["Attribute Name", "Client", "Full Name",
-                     "Description"], dtype=["U80", "U80", "U80", "U80"])
-    for name, client, name_long, desc in zip(names, clients, names_long, descs):
-        t.add_row((name, client, name_long, desc))
-    lines.insert(0, class_name)
+    sorted_attrs = _ATTR_TUPLE(*zip(*sorted(zip(*attrs)))) if attrs.name else make_tuple()
+    *other_row_data, descs = sorted_attrs
+    descs = [(dsc[:77] + '...') if len(dsc) > 80 else dsc for dsc in descs]
+    table = Table(names=["Attribute Name", "Client", "Full Name", "Description"],
+                  dtype=["U80", "U80", "U80", "U80"],
+                  data=[*other_row_data, descs])
+
+    class_name = f"{(attr.__module__ + '.') or ''}{attr.__name__}"
+    lines = [class_name]
     # If the attr lacks a __doc__ this will error and prevent this from returning anything.
     try:
-        lines.insert(1, dedent(attr.__doc__.partition("\n\n")[0])+"\n")
+        lines.append(dedent(attr.__doc__.partition("\n\n")[0]) + "\n")
     except AttributeError:
         pass
-    if html:
-        lines = [f"<p>{line}</p>" for line in lines]
+
+    format_line = "<p>{}</p>" if html else "{}"
     width = -1 if html else get_width()
-    lines.extend(t.pformat_all(show_dtype=False, max_width=width, align="<", html=html))
+
+    lines = [*[format_line.format(line) for line in lines],
+             *table.pformat_all(show_dtype=False, max_width=width, align="<", html=html)]
     return '\n'.join(lines)
 
 
@@ -112,10 +108,10 @@ def __getattr__(self, item):
         registry = self._attr_registry[self]
         # All the attribute names under that type(Attr)
         names = registry.name
-        if item in names:
+        try:
             # We return Attr(name_long) to create the Attr requested.
             return self(registry.name_long[names.index(item)])
-        else:
+        except ValueError:
             raise AttributeError(f'This attribute, {item} is not defined, please register it.')
 
     def __dir__(self):
@@ -123,17 +119,16 @@ def __dir__(self):
         To tab complete in Python we need to add to the `__dir__()` return.
         So we add all the registered values for this subclass of Attr to the output.
         """
-        custom_attrs = list(set(self._attr_registry[self].name))
+        custom_attrs = set(self._attr_registry[self].name)
         # "all" can be registered as a documentation helper, but isn't a valid attr
-        if "all" in custom_attrs:
-            custom_attrs.remove("all")
-        return super().__dir__() + custom_attrs
+        custom_attrs.discard("all")
+        return super().__dir__() + list(custom_attrs)
 
     def __repr__(self):
         """
         Returns the normal repr plus the pretty attr __str__.
         """
-        return type.__repr__(self) + "\n" + str(self)
+        return f"{type.__repr__(self)}\n{str(self)}"
 
     def __str__(self):
         """
@@ -157,7 +152,7 @@ def __and__(self, other):
         if self.collides(other):
             return NotImplemented
         if isinstance(other, AttrAnd):
-            return AttrAnd([self] + list(other.attrs))
+            return AttrAnd([self, *other.attrs])
         return AttrAnd([self, other])
 
     def __hash__(self):
@@ -168,14 +163,16 @@ def __or__(self, other):
         if self == other:
             return self
         if isinstance(other, AttrOr):
-            return AttrOr([self] + list(other.attrs))
+            return AttrOr([self, *other.attrs])
         return AttrOr([self, other])
 
     def collides(self, other):
         raise NotImplementedError
 
     def __eq__(self, other):
-        return dict(vars(self)) == dict(vars(other))
+        if not isinstance(other, Attr):
+            return False
+        return vars(self) == vars(other)
 
     @classmethod
     def update_values(cls, adict):
@@ -223,58 +220,53 @@ def update_values(cls, adict):
         (<sunpy.net.attrs.Instrument(AIA: AIA is in Space.) object at 0x...>,
         <sunpy.net.attrs.Instrument(HMI: HMI is next to AIA.) object at 0x...>)
         """
-        for client in adict.keys():
-            for attr, attr_values in adict[client].items():
-                if isiterable(attr_values) and not isinstance(attr_values, str):
-                    for pair in attr_values:
-                        if len(pair) != 2:
-                            if len(pair) == 1:
-                                # Special case handling for * aka all values allowed.
-                                if pair[0] == "*":
-                                    pair = ["all", "All values of this type are supported."]
-                                else:
-                                    raise ValueError(
-                                        f'Invalid value given for * registration: {attr_values}.')
-                            else:
-                                raise ValueError(f'Invalid length (!=2) for values: {attr_values}.')
-                        p = inflect.engine()
-                        # Sanitize part one: Check if the name is has a number in it
-                        if NUMBER_REGEX.match(pair[0]):
-                            # Now check if the entire name is a number
-                            if len(pair[0]) == NUMBER_REGEX.match(pair[0]).span()[1]:
-                                # This turns that number into its name
-                                name = p.number_to_words(pair[0])
-                            # What if just the first character is
-                            elif NUMBER_REGEX.match(pair[0][0]):
-                                name = p.number_to_words(pair[0][0])
-                                # Then we append the rest of the name here with a _ to break it up.
-                                if pair[0][1:]:
-                                    name = name + "_" + pair[0][1:]
-                            else:
-                                # Give up
-                                name = pair[0]
-                        else:
-                            name = pair[0]
-                        # Sanitize part two: remove punctuation and replace it with _
-                        name = ''.join(
-                            char if char not in string.punctuation else "_" for char in name).lower()
-                        # Sanitize name, we remove all special characters and make it all lower case
-                        name = ''.join(char for char in name if char.isidentifier()
-                                       or char.isnumeric()).lower()
-                        if keyword.iskeyword(name):
-                            # Attribute name has been appended with `_`
-                            # to make it a valid identifier since its a python keyword.
-                            name = name + '_'
-                        if not name.isidentifier():
-                            raise ValueError(f'Unable to figure out {pair}')
-                        cls._attr_registry[attr][0].append(name)
-                        cls._attr_registry[attr][1].append(client.__name__.replace("Client", ""))
-                        cls._attr_registry[attr][2].append(pair[0])
-                        cls._attr_registry[attr][3].append(pair[1])
-                else:
+        for client, attr_dict in adict.items():
+            for attr, attr_values in attr_dict.items():
+                if not isiterable(attr_values) or isinstance(attr_values, str):
                     raise ValueError(f"Invalid input value: {attr_values} for key: {repr(attr)}. "
                                      "The value is not iterable or just a string.")
 
+                attr_tuple = cls._attr_registry[attr]
+
+                for pair in attr_values:
+                    if len(pair) > 2:
+                        raise ValueError(f'Invalid length (!=2) for values: {attr_values}.')
+                    elif len(pair) == 1:
+                        if pair[0] != "*":
+                            raise ValueError(
+                                f'Invalid value given for * registration: {attr_values}.')
+                        # Special case handling for * aka all values allowed.
+                        pair = ["all", "All values of this type are supported."]
+
+                    # Sanitize part one: Check if the name has a number in it
+                    number_match = NUMBER_REGEX.match(pair[0])
+                    p = inflect.engine()
+                    try:
+                        number_str = number_match.group(1)
+                        name = p.number_to_words(number_str)
+                        if number_str != number_match.string:
+                            name = name + "_" + number_match.string[number_match.end(1):]
+                    except AttributeError:
+                        name = pair[0]
+
+                    # Sanitize part two: remove punctuation and replace it with _
+                    name = re.sub('[%s]' % re.escape(string.punctuation), '_', name)
+                    # Sanitize name, we remove all special characters
+                    name = ''.join(char for char in name
+                                   if char.isidentifier() or char.isnumeric())
+                    # Make name lower case
+                    name = name.lower()
+
+                    if keyword.iskeyword(name):
+                        # Attribute name has been appended with `_`
+                        # to make it a valid identifier since its a python keyword.
+                        name = name + '_'
+
+                    attr_tuple[0].append(name)
+                    attr_tuple[1].append(client.__name__.replace("Client", ""))
+                    attr_tuple[2].append(pair[0])
+                    attr_tuple[3].append(pair[1])
+
 
 class DataAttr(Attr):
     """
@@ -346,18 +338,23 @@ def collides(self, other):
         return isinstance(other, self.__class__)
 
     def __repr__(self):
+        obj_placeholder = " object "
         attr_reg = AttrMeta._attr_registry[self.__class__]
-        new_repr = object.__repr__(self).split(" object ")
-        # If somehow the idx isn't in the attr reg, we still want it to print it
-        # repr without error.
+        new_repr = object.__repr__(self).split(obj_placeholder)
+        # If somehow the idx isn't in the attr reg,
+        # we still want it to print it repr without error.
         try:
             idx = attr_reg.name_long.index(self.value)
-            new_repr.insert(1, f"({self.value}: {attr_reg.desc[idx]})")
+            obj_value_repr = f"({self.value}: {attr_reg.desc[idx]})"
         except ValueError:
-            new_repr.insert(1, f": {self.value}")
-        new_repr.insert(2, " object ")
+            obj_value_repr = f": {self.value}"
+        new_repr = [new_repr[0], obj_value_repr, obj_placeholder, new_repr[1]]
         return textwrap.fill("".join(new_repr), 100)
 
+    @property
+    def type_name(self):
+        return self.__class__.__name__.lower()
+
 
 class Range(DataAttr):
     """
@@ -409,10 +406,10 @@ def __and__(self, other):
         if any(other.collides(elem) for elem in self.attrs):
             return NotImplemented
         if isinstance(other, AttrAnd):
-            return AttrAnd(self.attrs + other.attrs)
+            return AttrAnd([*self.attrs, *other.attrs])
         if isinstance(other, AttrOr):
             return AttrOr([elem & self for elem in other.attrs])
-        return AttrAnd(self.attrs + [other])
+        return AttrAnd([*self.attrs, other])
 
     __rand__ = __and__
 
@@ -440,8 +437,8 @@ def __init__(self, attrs):
 
     def __or__(self, other):
         if isinstance(other, AttrOr):
-            return AttrOr(self.attrs + other.attrs)
-        return AttrOr(self.attrs + [other])
+            return AttrOr([*self.attrs, *other.attrs])
+        return AttrOr([*self.attrs, other])
 
     __ror__ = __or__
 


Test patch:
diff --git a/sunpy/net/tests/test_attr.py b/sunpy/net/tests/test_attr.py
--- a/sunpy/net/tests/test_attr.py
+++ b/sunpy/net/tests/test_attr.py
@@ -80,6 +80,10 @@ class SA3(attr.SimpleAttr):
     pass
 
 
+class SA4(attr.SimpleAttr):
+    pass
+
+
 def test_empty():
     class TestAttr(attr.Attr):
         pass
@@ -87,6 +91,14 @@ class TestAttr(attr.Attr):
     assert repr(TestAttr)
 
 
+@pytest.mark.parametrize("different_type", [
+    int, str, float, list, set, tuple, dict, object
+])
+def test_empty(different_type):
+    attr_ = attr.Attr()
+    assert attr_ != different_type()
+
+
 def test_attr_and():
     a1 = SA1(1)
     a2 = SA2(2)
@@ -111,6 +123,25 @@ def test_attr_and_AttrAnd():
     assert len(an.attrs) == 3
 
 
+def test_attr_multi_and_AttrAnd():
+    a1 = SA1(1)
+    a2 = SA2(2)
+    a3 = SA3(3)
+    a4 = SA4(4)
+    a_and1 = (a2 & a3)
+    a_and2 = (a1 & a4)
+    an = a_and1 & a_and2
+
+    assert isinstance(a_and1, attr.AttrAnd)
+    assert isinstance(a_and2, attr.AttrAnd)
+    assert isinstance(an, attr.AttrAnd)
+    assert a1 in an.attrs
+    assert a2 in an.attrs
+    assert a3 in an.attrs
+    assert a4 in an.attrs
+    assert len(an.attrs) == 4
+
+
 def test_attr_and_AttrOr():
     a1 = SA1(1)
     a2 = SA2(2)
@@ -329,6 +360,15 @@ def test_asterisk_attrs(ALL):
     assert "Instrument(all: All values of this type are supported.)" in repr(Instrument.all)
 
 
+@pytest.mark.parametrize("wrong_name", [
+    ("not star",), ("*whoops",)
+])
+def test_single_pair_argument_attrs(wrong_name):
+    # This checks that other single string entries fail.
+    with pytest.raises(ValueError):
+        attr.Attr.update_values({GenericClient: {Instrument: [wrong_name]}})
+
+
 def test_asterisk_attrs_time():
     # This checks we can submit * for time/wavelength (both are ranges)
     attr.Attr.update_values({GenericClient: {Time: [('*')]}})