Repository: python-telegram-bot/python-telegram-bot

Issue:
[FEATURE] XOR for filters
I guess that should be easily doable by

```python
def __xor__(self, other):
    (self & ~other) | (~self & other)
```


Discussion:

Resolving patch:
diff --git a/telegram/ext/filters.py b/telegram/ext/filters.py
--- a/telegram/ext/filters.py
+++ b/telegram/ext/filters.py
@@ -24,7 +24,19 @@
 
 from abc import ABC, abstractmethod
 from threading import Lock
-from typing import Dict, FrozenSet, List, Match, Optional, Pattern, Set, Tuple, Union, cast
+from typing import (
+    Dict,
+    FrozenSet,
+    List,
+    Match,
+    Optional,
+    Pattern,
+    Set,
+    Tuple,
+    Union,
+    cast,
+    NoReturn,
+)
 
 from telegram import Chat, Message, MessageEntity, Update
 
@@ -35,6 +47,7 @@
     'UpdateFilter',
     'InvertedFilter',
     'MergedFilter',
+    'XORFilter',
 ]
 
 from telegram.utils.deprecate import TelegramDeprecationWarning
@@ -54,6 +67,10 @@ class BaseFilter(ABC):
 
         >>> (Filters.audio | Filters.video)
 
+    Exclusive Or:
+
+        >>> (Filters.regex('To Be') ^ Filters.regex('Not To Be'))
+
     Not:
 
         >>> ~ Filters.command
@@ -93,7 +110,7 @@ class variable.
             (depends on the handler).
     """
 
-    name = None
+    _name = None
     data_filter = False
 
     @abstractmethod
@@ -106,9 +123,20 @@ def __and__(self, other: 'BaseFilter') -> 'BaseFilter':
     def __or__(self, other: 'BaseFilter') -> 'BaseFilter':
         return MergedFilter(self, or_filter=other)
 
+    def __xor__(self, other: 'BaseFilter') -> 'BaseFilter':
+        return XORFilter(self, other)
+
     def __invert__(self) -> 'BaseFilter':
         return InvertedFilter(self)
 
+    @property
+    def name(self) -> Optional[str]:
+        return self._name
+
+    @name.setter
+    def name(self, name: Optional[str]) -> None:
+        self._name = name
+
     def __repr__(self) -> str:
         # We do this here instead of in a __init__ so filter don't have to call __init__ or super()
         if self.name is None:
@@ -193,9 +221,14 @@ def __init__(self, f: BaseFilter):
     def filter(self, update: Update) -> bool:
         return not bool(self.f(update))
 
-    def __repr__(self) -> str:
+    @property
+    def name(self) -> str:
         return "<inverted {}>".format(self.f)
 
+    @name.setter
+    def name(self, name: str) -> NoReturn:
+        raise RuntimeError('Cannot set name for InvertedFilter')
+
 
 class MergedFilter(UpdateFilter):
     """Represents a filter consisting of two other filters.
@@ -269,11 +302,43 @@ def filter(self, update: Update) -> Union[bool, Dict]:  # pylint: disable=R0911
                 return True
         return False
 
-    def __repr__(self) -> str:
+    @property
+    def name(self) -> str:
         return "<{} {} {}>".format(
             self.base_filter, "and" if self.and_filter else "or", self.and_filter or self.or_filter
         )
 
+    @name.setter
+    def name(self, name: str) -> NoReturn:
+        raise RuntimeError('Cannot set name for MergedFilter')
+
+
+class XORFilter(UpdateFilter):
+    """Convenience filter acting as wrapper for :class:`MergedFilter` representing the an XOR gate
+    for two filters
+
+    Args:
+        base_filter: Filter 1 of the merged filter.
+        xor_filter: Filter 2 of the merged filter.
+
+    """
+
+    def __init__(self, base_filter: BaseFilter, xor_filter: BaseFilter):
+        self.base_filter = base_filter
+        self.xor_filter = xor_filter
+        self.merged_filter = (base_filter & ~xor_filter) | (~base_filter & xor_filter)
+
+    def filter(self, update: Update) -> Optional[Union[bool, Dict]]:
+        return self.merged_filter(update)
+
+    @property
+    def name(self) -> str:
+        return f'<{self.base_filter} xor {self.xor_filter}>'
+
+    @name.setter
+    def name(self, name: str) -> NoReturn:
+        raise RuntimeError('Cannot set name for XORFilter')
+
 
 class _DiceEmoji(MessageFilter):
     def __init__(self, emoji: str = None, name: str = None):
@@ -1355,6 +1420,14 @@ def filter(self, message: Message) -> bool:
                 return self.allow_empty
             return False
 
+        @property
+        def name(self) -> str:
+            return f'Filters.user({", ".join(str(s) for s in (self.usernames or self.user_ids))})'
+
+        @name.setter
+        def name(self, name: str) -> NoReturn:
+            raise RuntimeError('Cannot set name for Filters.user')
+
     class via_bot(MessageFilter):
         """Filters messages to allow only those which are from specified via_bot ID(s) or
         username(s).
@@ -1537,6 +1610,15 @@ def filter(self, message: Message) -> bool:
                 return self.allow_empty
             return False
 
+        @property
+        def name(self) -> str:
+            entries = [str(s) for s in (self.usernames or self.bot_ids)]
+            return f'Filters.via_bot({", ".join(entries)})'
+
+        @name.setter
+        def name(self, name: str) -> NoReturn:
+            raise RuntimeError('Cannot set name for Filters.via_bot')
+
     class chat(MessageFilter):
         """Filters messages to allow only those which are from a specified chat ID or username.
 
@@ -1717,6 +1799,14 @@ def filter(self, message: Message) -> bool:
                 return self.allow_empty
             return False
 
+        @property
+        def name(self) -> str:
+            return f'Filters.chat({", ".join(str(s) for s in (self.usernames or self.chat_ids))})'
+
+        @name.setter
+        def name(self, name: str) -> NoReturn:
+            raise RuntimeError('Cannot set name for Filters.chat')
+
     class _Invoice(MessageFilter):
         name = 'Filters.invoice'
 


Test patch:
diff --git a/tests/test_filters.py b/tests/test_filters.py
--- a/tests/test_filters.py
+++ b/tests/test_filters.py
@@ -1049,6 +1049,22 @@ def test_filters_user_remove_user_by_id(self, update):
             update.message.from_user.username = user
             assert not f(update)
 
+    def test_filters_user_repr(self):
+        f = Filters.user([1, 2])
+        assert str(f) == 'Filters.user(1, 2)'
+        f.remove_user_ids(1)
+        f.remove_user_ids(2)
+        assert str(f) == 'Filters.user()'
+        f.add_usernames('@foobar')
+        assert str(f) == 'Filters.user(foobar)'
+        f.add_usernames('@barfoo')
+        assert str(f).startswith('Filters.user(')
+        # we don't know th exact order
+        assert 'barfoo' in str(f) and 'foobar' in str(f)
+
+        with pytest.raises(RuntimeError, match='Cannot set name'):
+            f.name = 'foo'
+
     def test_filters_chat_init(self):
         with pytest.raises(RuntimeError, match='in conjunction with'):
             Filters.chat(chat_id=1, username='chat')
@@ -1174,6 +1190,22 @@ def test_filters_chat_remove_chat_by_id(self, update):
             update.message.chat.username = chat
             assert not f(update)
 
+    def test_filters_chat_repr(self):
+        f = Filters.chat([1, 2])
+        assert str(f) == 'Filters.chat(1, 2)'
+        f.remove_chat_ids(1)
+        f.remove_chat_ids(2)
+        assert str(f) == 'Filters.chat()'
+        f.add_usernames('@foobar')
+        assert str(f) == 'Filters.chat(foobar)'
+        f.add_usernames('@barfoo')
+        assert str(f).startswith('Filters.chat(')
+        # we don't know th exact order
+        assert 'barfoo' in str(f) and 'foobar' in str(f)
+
+        with pytest.raises(RuntimeError, match='Cannot set name'):
+            f.name = 'foo'
+
     def test_filters_invoice(self, update):
         assert not Filters.invoice(update)
         update.message.invoice = 'test'
@@ -1294,6 +1326,63 @@ def test_and_or_filters(self, update):
             'Filters.entity(mention)>>'
         )
 
+    def test_xor_filters(self, update):
+        update.message.text = 'test'
+        update.effective_user.id = 123
+        assert not (Filters.text ^ Filters.user(123))(update)
+        update.message.text = None
+        update.effective_user.id = 1234
+        assert not (Filters.text ^ Filters.user(123))(update)
+        update.message.text = 'test'
+        assert (Filters.text ^ Filters.user(123))(update)
+        update.message.text = None
+        update.effective_user.id = 123
+        assert (Filters.text ^ Filters.user(123))(update)
+
+    def test_xor_filters_repr(self, update):
+        assert str(Filters.text ^ Filters.user(123)) == '<Filters.text xor Filters.user(123)>'
+        with pytest.raises(RuntimeError, match='Cannot set name'):
+            (Filters.text ^ Filters.user(123)).name = 'foo'
+
+    def test_and_xor_filters(self, update):
+        update.message.text = 'test'
+        update.message.forward_date = datetime.datetime.utcnow()
+        assert (Filters.forwarded & (Filters.text ^ Filters.user(123)))(update)
+        update.message.text = None
+        update.effective_user.id = 123
+        assert (Filters.forwarded & (Filters.text ^ Filters.user(123)))(update)
+        update.message.text = 'test'
+        assert not (Filters.forwarded & (Filters.text ^ Filters.user(123)))(update)
+        update.message.forward_date = None
+        update.message.text = None
+        update.effective_user.id = 123
+        assert not (Filters.forwarded & (Filters.text ^ Filters.user(123)))(update)
+        update.message.text = 'test'
+        update.effective_user.id = 456
+        assert not (Filters.forwarded & (Filters.text ^ Filters.user(123)))(update)
+
+        assert (
+            str(Filters.forwarded & (Filters.text ^ Filters.user(123)))
+            == '<Filters.forwarded and <Filters.text xor '
+            'Filters.user(123)>>'
+        )
+
+    def test_xor_regex_filters(self, update):
+        SRE_TYPE = type(re.match("", ""))
+        update.message.text = 'test'
+        update.message.forward_date = datetime.datetime.utcnow()
+        assert not (Filters.forwarded ^ Filters.regex('^test$'))(update)
+        update.message.forward_date = None
+        result = (Filters.forwarded ^ Filters.regex('^test$'))(update)
+        assert result
+        assert isinstance(result, dict)
+        matches = result['matches']
+        assert isinstance(matches, list)
+        assert type(matches[0]) is SRE_TYPE
+        update.message.forward_date = datetime.datetime.utcnow()
+        update.message.text = None
+        assert (Filters.forwarded ^ Filters.regex('^test$'))(update) is True
+
     def test_inverted_filters(self, update):
         update.message.text = '/test'
         update.message.entities = [MessageEntity(MessageEntity.BOT_COMMAND, 0, 5)]
@@ -1304,6 +1393,11 @@ def test_inverted_filters(self, update):
         assert not Filters.command(update)
         assert (~Filters.command)(update)
 
+    def test_inverted_filters_repr(self, update):
+        assert str(~Filters.text) == '<inverted Filters.text>'
+        with pytest.raises(RuntimeError, match='Cannot set name'):
+            (~Filters.text).name = 'foo'
+
     def test_inverted_and_filters(self, update):
         update.message.text = '/test'
         update.message.entities = [MessageEntity(MessageEntity.BOT_COMMAND, 0, 5)]
@@ -1398,6 +1492,10 @@ def filter(self, _):
         update.message.entities = []
         (Filters.command & raising_filter)(update)
 
+    def test_merged_filters_repr(self, update):
+        with pytest.raises(RuntimeError, match='Cannot set name'):
+            (Filters.text & Filters.photo).name = 'foo'
+
     def test_merged_short_circuit_or(self, update, base_class):
         update.message.text = 'test'
 
@@ -1587,3 +1685,19 @@ def test_filters_via_bot_remove_user_by_id(self, update):
         for user in users:
             update.message.via_bot.username = user
             assert not f(update)
+
+    def test_filters_via_bot_repr(self):
+        f = Filters.via_bot([1, 2])
+        assert str(f) == 'Filters.via_bot(1, 2)'
+        f.remove_bot_ids(1)
+        f.remove_bot_ids(2)
+        assert str(f) == 'Filters.via_bot()'
+        f.add_usernames('@foobar')
+        assert str(f) == 'Filters.via_bot(foobar)'
+        f.add_usernames('@barfoo')
+        assert str(f).startswith('Filters.via_bot(')
+        # we don't know th exact order
+        assert 'barfoo' in str(f) and 'foobar' in str(f)
+
+        with pytest.raises(RuntimeError, match='Cannot set name'):
+            f.name = 'foo'