What Python actually does when it meets a vertical bar, and why the ordered version cannot just wrap a set.
a | b # is a.__or__(b)
a & b # is a.__and__(b)
a - b # is a.__sub__(b)
x in a # is a.__contains__(x)
# A set loses the order, and the order is the point
list({'python', 'web', 'api'}) # -> some arrangement, not yours
# dict.fromkeys keeps it, because dicts remember insertion order
list(dict.fromkeys(['python', 'web', 'python'])) # -> ['python', 'web']
- The operators are ordinary methods with reserved names
There is nothing special about | beyond the fact that Python looks for __or__ when it sees one. Defining them is what lets your type read like the built-ins at the call site — and the reason to define them is exactly that: tags | more_tags says what it means in a way tags.union(more_tags) very nearly does and tags.combine(more_tags) does not.
- Store a list for order, and use a set only to decide
Keeping the tags in a list preserves the sequence; membership questions can still be answered quickly by keeping a set alongside, or — for the handful of tags this is designed for — by asking the list directly. What you must not do is store a set and try to recover the order afterwards, because it is gone.
- Operators return new objects
a | b must not change a, in the same way that 2 + 3 does not change 2. Every one of these methods builds and returns a fresh TagSet, which is what makes expressions composable — (a | b) - c works because each step hands the next one a value rather than a mutated original.
Normalise once, in __init__, and store the result as a list. Every operator then builds a new list of tags and hands it to TagSet() — the constructor will do the deduplicating again, which costs nothing and keeps one rule.
The dunders you need are __init__, __iter__, __len__, __contains__, __eq__, __or__, __and__ and __sub__. Union is self’s tags followed by the other’s; intersection and difference are comprehensions over self’s tags filtered by membership of the other.
list(dict.fromkeys(cleaned)) deduplicates while keeping first-seen order. For __eq__, compare sets of the tags so order does not count — and return NotImplemented when the other object is not a TagSet, rather than False, so Python can try the reflected comparison.
class TagSet: def __init__(self, tags=()): cleaned = [tag.strip().____() for tag in tags] self.tags = list(dict.____(t for t in cleaned if t)) def __iter__(self): return iter(self.tags) def __len__(self): return len(self.tags) def __contains__(self, tag): return tag.strip().lower() in self.____ def __eq__(self, other): if not isinstance(other, TagSet): return ____ return set(self.tags) == set(other.tags) def __or__(self, other): return TagSet(self.tags + other.tags) def __and__(self, other): return TagSet([t for t in self.tags if t ____ other]) def __sub__(self, other): return TagSet([t for t in self.tags if t not in other])
class TagSet:
"""An ordered, normalised collection of tags."""
def __init__(self, tags=()):
cleaned = [tag.strip().lower() for tag in tags]
# fromkeys deduplicates and keeps first-seen order in one pass.
self.tags = list(dict.fromkeys(tag for tag in cleaned if tag))
def __iter__(self):
return iter(self.tags)
def __len__(self):
return len(self.tags)
def __contains__(self, tag):
return tag.strip().lower() in self.tags
def __eq__(self, other):
if not isinstance(other, TagSet):
return NotImplemented
return set(self.tags) == set(other.tags)
def __repr__(self):
return f"TagSet({self.tags!r})"
def __or__(self, other):
return TagSet(self.tags + other.tags)
def __and__(self, other):
return TagSet([tag for tag in self.tags if tag in other])
def __sub__(self, other):
return TagSet([tag for tag in self.tags if tag not in other])
TagSet(self.tags + other.tags) does the union by concatenating and letting __init__ deduplicate — so the definition of 'the same tag' exists once, in one place, and every operator inherits it. Writing bespoke deduplication in __or__ would be faster by a hair and would be the line that gets out of step when the normalising changes.
Returning NotImplemented tells Python 'I do not know how to compare these', so it tries the other object’s __eq__ before finally answering False. Returning False directly ends the conversation and breaks comparison with any class that would have known what to do — a mock, a subclass, a wrapper.
Python sets __hash__ to None whenever you define __eq__, so a TagSet cannot go in a set or be a dictionary key. That is the safe default for something mutable — and if you want it hashable, the honest route is to make it immutable first and define __hash__ over frozenset(self.tags).
If nobody is going to read the tags in order, use a set: it is faster, it is built in, and it has the whole operator family plus symmetric difference and the comparison operators for subset and superset. This class exists for display order, which mostly means user-facing tag lists — and at that size the O(n) membership test in a list is irrelevant. Keep a parallel set alongside the list if the collections ever get large. If you want the whole set interface with your own storage, inherit from collections.abc.Set, which builds all the operators out of __contains__, __iter__ and __len__ — though it returns plain frozensets from them unless you override _from_iterable.
- Add __xor__ for symmetric difference and decide what order the result should be in.
- Add __le__ so that one tag set can be tested as a subset of another, then check it agrees with a & b == a.
- Make it immutable and hashable, and see what that costs you at the call sites that currently build one up piece by piece.