From 2fb8ef6c74488af54dd90ec81364ab969512ecf1 Mon Sep 17 00:00:00 2001 From: david-shiko Date: Fri, 7 Mar 2025 04:31:36 +0300 Subject: [PATCH 1/4] Add `name` and `full_name` properties to the `SharedUser` class just as in the `telegram.User` class. --- telegram/_shared.py | 22 +++++++++++++++++++++- tests/test_shared.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/telegram/_shared.py b/telegram/_shared.py index 9c0d3684ec2..77055624631 100644 --- a/telegram/_shared.py +++ b/telegram/_shared.py @@ -18,7 +18,7 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains two objects used for request chats/users service messages.""" from collections.abc import Sequence -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Optional, Union from telegram._files.photosize import PhotoSize from telegram._telegramobject import TelegramObject @@ -244,6 +244,26 @@ def __init__( self._freeze() + @property + def name(self) -> Union[str, None]: + """:obj:`str`: Convenience property. If available, returns the user's :attr:`username` + prefixed with "@". If :attr:`username` is not available, returns :attr:`full_name`. + """ + if self.username: + return f"@{self.username}" + return self.full_name + + @property + def full_name(self) -> Union[str, None]: + """:obj:`str`: Convenience property. The user's :attr:`first_name`, followed by (if + available) :attr:`last_name`, otherwise None. + """ + if self.first_name and self.last_name: + return f"{self.first_name} {self.last_name}" + if self.first_name or self.last_name: + return f"{self.first_name or self.last_name}" + return None + @classmethod def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "SharedUser": """See :meth:`telegram.TelegramObject.de_json`.""" diff --git a/tests/test_shared.py b/tests/test_shared.py index 239e8600092..61478b7a8aa 100644 --- a/tests/test_shared.py +++ b/tests/test_shared.py @@ -17,6 +17,8 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. +from unittest.mock import patch + import pytest from telegram import ChatShared, PhotoSize, SharedUser, UsersShared @@ -228,3 +230,32 @@ def test_equality(self, chat_shared): assert a != d assert hash(a) != hash(d) + + def test_name(self, shared_user, ): + with shared_user._unfrozen(): + assert shared_user.name == "@user" + with patch.object(shared_user, 'username', None): + assert shared_user.name == "first last" + with patch.multiple( + shared_user, + last_name=None, + first_name=None, + ): + assert shared_user.full_name is None + + def test_full_name(self, shared_user): + with shared_user._unfrozen(): + # Test `and` (both exists) + assert shared_user.full_name == "first last" + # Test `or` (one of them exists) + with patch.object(shared_user, 'first_name', None): + assert shared_user.full_name == "last" + with patch.object(shared_user, 'last_name', None): + assert shared_user.full_name == "first" + # Test None (None of them exists) + with patch.multiple( + shared_user, + last_name=None, + first_name=None, + ): + assert shared_user.full_name is None From 91a8747ee2a6ed8c5ca5facd1f5949a30599287a Mon Sep 17 00:00:00 2001 From: david-shiko Date: Mon, 10 Mar 2025 03:37:18 +0300 Subject: [PATCH 2/4] Initial commit; Add functions to get name, full name and link; Add tests for it; Replace `user` and `shared_user` correspond properties implementations on a new functinos. --- telegram/_shared.py | 18 +++--- telegram/_user.py | 13 ++--- telegram/_utils/usernames.py | 104 +++++++++++++++++++++++++++++++++ tests/_utils/test_usernames.py | 71 ++++++++++++++++++++++ tests/test_shared.py | 28 --------- 5 files changed, 189 insertions(+), 45 deletions(-) create mode 100644 telegram/_utils/usernames.py create mode 100644 tests/_utils/test_usernames.py diff --git a/telegram/_shared.py b/telegram/_shared.py index 77055624631..f22ccd1a4c7 100644 --- a/telegram/_shared.py +++ b/telegram/_shared.py @@ -24,6 +24,7 @@ from telegram._telegramobject import TelegramObject from telegram._utils.argumentparsing import de_list_optional, parse_sequence_arg from telegram._utils.types import JSONDict +from telegram._utils.usernames import get_name, get_full_name, get_link if TYPE_CHECKING: from telegram._bot import Bot @@ -249,20 +250,21 @@ def name(self) -> Union[str, None]: """:obj:`str`: Convenience property. If available, returns the user's :attr:`username` prefixed with "@". If :attr:`username` is not available, returns :attr:`full_name`. """ - if self.username: - return f"@{self.username}" - return self.full_name + return get_name(user=self, ) @property def full_name(self) -> Union[str, None]: """:obj:`str`: Convenience property. The user's :attr:`first_name`, followed by (if available) :attr:`last_name`, otherwise None. """ - if self.first_name and self.last_name: - return f"{self.first_name} {self.last_name}" - if self.first_name or self.last_name: - return f"{self.first_name or self.last_name}" - return None + return get_full_name(user=self, ) + + @property + def link(self) -> Union[str, None]: + """:obj:`str`: Convenience property. The user's :attr:`first_name`, followed by (if + available) :attr:`last_name`, otherwise None. + """ + return get_link(user=self, ) @classmethod def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "SharedUser": diff --git a/telegram/_user.py b/telegram/_user.py index 640a3573acc..63ce8fff850 100644 --- a/telegram/_user.py +++ b/telegram/_user.py @@ -34,6 +34,7 @@ ReplyMarkup, TimePeriod, ) +from telegram._utils.usernames import get_name, get_full_name, get_link from telegram.helpers import mention_html as helpers_mention_html from telegram.helpers import mention_markdown as helpers_mention_markdown @@ -207,27 +208,21 @@ def name(self) -> str: """:obj:`str`: Convenience property. If available, returns the user's :attr:`username` prefixed with "@". If :attr:`username` is not available, returns :attr:`full_name`. """ - if self.username: - return f"@{self.username}" - return self.full_name + return get_name(self, ) @property def full_name(self) -> str: """:obj:`str`: Convenience property. The user's :attr:`first_name`, followed by (if available) :attr:`last_name`. """ - if self.last_name: - return f"{self.first_name} {self.last_name}" - return self.first_name + return get_full_name(self, ) @property def link(self) -> Optional[str]: """:obj:`str`: Convenience property. If :attr:`username` is available, returns a t.me link of the user. """ - if self.username: - return f"https://t.me/{self.username}" - return None + return get_link(self, ) async def get_profile_photos( self, diff --git a/telegram/_utils/usernames.py b/telegram/_utils/usernames.py new file mode 100644 index 00000000000..fb1184efaad --- /dev/null +++ b/telegram/_utils/usernames.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2025 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. + + +from __future__ import annotations +import typing +"""This module contains auxiliary functionality for building strings for __repr__ method.""" + + +class UserLikeOptional(typing.Protocol): + """ + Note: + `User`, `Contact` (and maybe some other) objects always have first_name, + unlike the `Chat` and `Shared`, were they are optional. + The `last_name` is always optional. + """ + last_name: typing.Optional[str] + username: typing.Optional[str] + + +class UserLike(UserLikeOptional): + """ + Note: + `User`, `Contact` (and maybe some other) objects always have first_name, + unlike the `Chat` and `Shared`, were they are optional. + The `last_name` is always optional. + """ + first_name: str + + +class MiniUserLike(UserLikeOptional): + """ + Note: + `User`, `Contact` (and maybe some other) objects always have first_name, + unlike the `Chat` and `Shared`, were they are optional. + The `last_name` is always optional. + """ + first_name: typing.Optional[str] + + +@typing.overload +def get_name(user: UserLike) -> str: + ... + + +@typing.overload +def get_name(user: MiniUserLike) -> str | None: + ... + + +def get_name(user: UserLike | MiniUserLike) -> str | None: + """:obj:`str`: Convenience property. If available, returns the user's :attr:`username` + prefixed with "@". If :attr:`username` is not available, returns :attr:`full_name`. + For the UserLike object str will always be returned as `first_name`always exists. + """ + if user.username: + return f"@{user.username}" + return get_full_name(user=user, ) + + +@typing.overload +def get_full_name(user: UserLike) -> str: + ... + +@typing.overload +def get_full_name(user: MiniUserLike) -> str | None: + ... + + +def get_full_name(user: UserLike | MiniUserLike) -> str | None: + """:obj:`str`: Convenience property. The user's :attr:`first_name`, followed by (if + available) :attr:`last_name`, otherwise None. + For the UserLike object str will always be returned as `first_name`always exists. + """ + if user.first_name and user.last_name: + return f"{user.first_name} {user.last_name}" + if user.first_name or user.last_name: + return f"{user.first_name or user.last_name}" + return None + + +def get_link(user: UserLike | MiniUserLike) -> str | None: + """:obj:`str`: Convenience property. If :attr:`username` is available, returns a t.me link + of the user. + """ + if user.username: + return f"https://t.me/{user.username}" + return None \ No newline at end of file diff --git a/tests/_utils/test_usernames.py b/tests/_utils/test_usernames.py new file mode 100644 index 00000000000..cad327cc36c --- /dev/null +++ b/tests/_utils/test_usernames.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2025 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. + +from __future__ import annotations +import pytest +from typing import TYPE_CHECKING +from telegram import SharedUser +from tests.test_user import user +from telegram._utils.usernames import get_name, get_full_name, get_link + +if TYPE_CHECKING: + from telegram._utils.usernames import UserLike, MiniUserLike + + +@pytest.fixture(scope="class") +def shared_user(): + result = SharedUser( + user_id=1, + first_name="first\u2022name", + last_name="last\u2022name", + username="username", + ) + result._unfreeze() + return result + + +def test_get_name(user, ): + assert get_name(user=user) == "@username" + user.username = None + assert get_name(user=user) == "first\u2022name last\u2022name" + + +def test_full_name_both_exists(user: UserLike, shared_user: MiniUserLike, ): + assert get_full_name(user=user) == get_full_name(user=shared_user) == "first\u2022name last\u2022name" + + +def test_full_name_last_name_missed(user: UserLike, shared_user: MiniUserLike, ): + user.last_name = shared_user.last_name = None + assert get_full_name(user=user) == get_full_name(user=shared_user) == "first\u2022name" + + +def test_full_name_first_name_missed(user: UserLike, shared_user: MiniUserLike, ): + user.first_name = shared_user.first_name = None + assert get_full_name(user=user) == get_full_name(user=shared_user) == "last\u2022name" + + +def test_full_name_both_missed(user: UserLike, shared_user: MiniUserLike, ): + user.first_name = user.last_name = shared_user.first_name = shared_user.last_name = None + assert get_full_name(user=user) is get_full_name(user=shared_user) is None + + +def test_link(user: UserLike, shared_user: MiniUserLike, ): + assert get_link(user=user, ) == get_link(user=shared_user, ) == f"https://t.me/{user.username}" + user.username = shared_user.username = None + assert get_link(user=user, ) is get_link(user=shared_user, ) is None diff --git a/tests/test_shared.py b/tests/test_shared.py index 61478b7a8aa..877eb69578d 100644 --- a/tests/test_shared.py +++ b/tests/test_shared.py @@ -231,31 +231,3 @@ def test_equality(self, chat_shared): assert a != d assert hash(a) != hash(d) - def test_name(self, shared_user, ): - with shared_user._unfrozen(): - assert shared_user.name == "@user" - with patch.object(shared_user, 'username', None): - assert shared_user.name == "first last" - with patch.multiple( - shared_user, - last_name=None, - first_name=None, - ): - assert shared_user.full_name is None - - def test_full_name(self, shared_user): - with shared_user._unfrozen(): - # Test `and` (both exists) - assert shared_user.full_name == "first last" - # Test `or` (one of them exists) - with patch.object(shared_user, 'first_name', None): - assert shared_user.full_name == "last" - with patch.object(shared_user, 'last_name', None): - assert shared_user.full_name == "first" - # Test None (None of them exists) - with patch.multiple( - shared_user, - last_name=None, - first_name=None, - ): - assert shared_user.full_name is None From f44f0184d0a9cd5e64ad2b9e8eb4fe568901bc9f Mon Sep 17 00:00:00 2001 From: david-shiko Date: Mon, 10 Mar 2025 04:30:38 +0300 Subject: [PATCH 3/4] Replace `Chat` `link` property implementation on `telegram._utils.usernames.py` implementation; Note: `full_name` and `effective_name` can not be easily replaced in the such way --- telegram/_chat.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/telegram/_chat.py b/telegram/_chat.py index fe49dc3593e..1ad7d2d229d 100644 --- a/telegram/_chat.py +++ b/telegram/_chat.py @@ -39,6 +39,7 @@ ReplyMarkup, TimePeriod, ) +from telegram._utils.usernames import get_link from telegram.helpers import escape_markdown from telegram.helpers import mention_html as helpers_mention_html from telegram.helpers import mention_markdown as helpers_mention_markdown @@ -162,9 +163,7 @@ def link(self) -> Optional[str]: """:obj:`str`: Convenience property. If the chat has a :attr:`~Chat.username`, returns a t.me link of the chat. """ - if self.username: - return f"https://t.me/{self.username}" - return None + return get_link(user=self) def mention_markdown(self, name: Optional[str] = None) -> str: """ From 0fc4c209db38fee4a27e802899649d488b337211 Mon Sep 17 00:00:00 2001 From: david-shiko Date: Mon, 10 Mar 2025 05:30:22 +0300 Subject: [PATCH 4/4] Adjust code according to pre-isntall hooks. Note: not all issues resolved; Slots tests failed for Protocol class. --- telegram/_shared.py | 2 +- telegram/_user.py | 2 +- telegram/_utils/usernames.py | 25 ++++++++++++------------- tests/_utils/test_usernames.py | 23 ++++++++++++----------- tests/test_shared.py | 3 --- 5 files changed, 26 insertions(+), 29 deletions(-) diff --git a/telegram/_shared.py b/telegram/_shared.py index f22ccd1a4c7..1334625cb42 100644 --- a/telegram/_shared.py +++ b/telegram/_shared.py @@ -23,8 +23,8 @@ from telegram._files.photosize import PhotoSize from telegram._telegramobject import TelegramObject from telegram._utils.argumentparsing import de_list_optional, parse_sequence_arg -from telegram._utils.types import JSONDict from telegram._utils.usernames import get_name, get_full_name, get_link +from telegram._utils.types import JSONDict if TYPE_CHECKING: from telegram._bot import Bot diff --git a/telegram/_user.py b/telegram/_user.py index 63ce8fff850..1e04c59c953 100644 --- a/telegram/_user.py +++ b/telegram/_user.py @@ -26,6 +26,7 @@ from telegram._menubutton import MenuButton from telegram._telegramobject import TelegramObject from telegram._utils.defaultvalue import DEFAULT_NONE +from telegram._utils.usernames import get_name, get_full_name, get_link from telegram._utils.types import ( CorrectOptionID, FileInput, @@ -34,7 +35,6 @@ ReplyMarkup, TimePeriod, ) -from telegram._utils.usernames import get_name, get_full_name, get_link from telegram.helpers import mention_html as helpers_mention_html from telegram.helpers import mention_markdown as helpers_mention_markdown diff --git a/telegram/_utils/usernames.py b/telegram/_utils/usernames.py index fb1184efaad..1f4e393c9d7 100644 --- a/telegram/_utils/usernames.py +++ b/telegram/_utils/usernames.py @@ -16,22 +16,20 @@ # # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. - - +"""Shared properties to extract username, first_name, last_name values if filled.""" from __future__ import annotations -import typing -"""This module contains auxiliary functionality for building strings for __repr__ method.""" +from typing import Protocol, overload -class UserLikeOptional(typing.Protocol): +class UserLikeOptional(Protocol): """ Note: `User`, `Contact` (and maybe some other) objects always have first_name, unlike the `Chat` and `Shared`, were they are optional. The `last_name` is always optional. """ - last_name: typing.Optional[str] - username: typing.Optional[str] + last_name: str | None + username: str | None class UserLike(UserLikeOptional): @@ -51,15 +49,15 @@ class MiniUserLike(UserLikeOptional): unlike the `Chat` and `Shared`, were they are optional. The `last_name` is always optional. """ - first_name: typing.Optional[str] + first_name: str | None -@typing.overload +@overload def get_name(user: UserLike) -> str: ... -@typing.overload +@overload def get_name(user: MiniUserLike) -> str | None: ... @@ -74,11 +72,12 @@ def get_name(user: UserLike | MiniUserLike) -> str | None: return get_full_name(user=user, ) -@typing.overload +@overload def get_full_name(user: UserLike) -> str: ... -@typing.overload + +@overload def get_full_name(user: MiniUserLike) -> str | None: ... @@ -101,4 +100,4 @@ def get_link(user: UserLike | MiniUserLike) -> str | None: """ if user.username: return f"https://t.me/{user.username}" - return None \ No newline at end of file + return None diff --git a/tests/_utils/test_usernames.py b/tests/_utils/test_usernames.py index cad327cc36c..8f84e465d9a 100644 --- a/tests/_utils/test_usernames.py +++ b/tests/_utils/test_usernames.py @@ -21,7 +21,7 @@ import pytest from typing import TYPE_CHECKING from telegram import SharedUser -from tests.test_user import user +from tests.test_user import user # noqa: F401 noqa: F811 from telegram._utils.usernames import get_name, get_full_name, get_link if TYPE_CHECKING: @@ -40,32 +40,33 @@ def shared_user(): return result -def test_get_name(user, ): - assert get_name(user=user) == "@username" - user.username = None - assert get_name(user=user) == "first\u2022name last\u2022name" +def test_get_name(user: UserLike, shared_user: MiniUserLike, ): # noqa: F811 + assert get_name(user=user) == get_name(user=shared_user) == "@username" + shared_user.username = user.username = None + assert get_name(user=user) == get_name(user=shared_user) == "first\u2022name last\u2022name" -def test_full_name_both_exists(user: UserLike, shared_user: MiniUserLike, ): - assert get_full_name(user=user) == get_full_name(user=shared_user) == "first\u2022name last\u2022name" +def test_full_name_both_exists(user: UserLike, shared_user: MiniUserLike, ): # noqa: F811 + expected = "first\u2022name last\u2022name" + assert get_full_name(user=user) == get_full_name(user=shared_user) == expected -def test_full_name_last_name_missed(user: UserLike, shared_user: MiniUserLike, ): +def test_full_name_last_name_missed(user: UserLike, shared_user: MiniUserLike, ): # noqa: F811 user.last_name = shared_user.last_name = None assert get_full_name(user=user) == get_full_name(user=shared_user) == "first\u2022name" -def test_full_name_first_name_missed(user: UserLike, shared_user: MiniUserLike, ): +def test_full_name_first_name_missed(user: UserLike, shared_user: MiniUserLike, ): # noqa: F811 user.first_name = shared_user.first_name = None assert get_full_name(user=user) == get_full_name(user=shared_user) == "last\u2022name" -def test_full_name_both_missed(user: UserLike, shared_user: MiniUserLike, ): +def test_full_name_both_missed(user: UserLike, shared_user: MiniUserLike, ): # noqa: F811 user.first_name = user.last_name = shared_user.first_name = shared_user.last_name = None assert get_full_name(user=user) is get_full_name(user=shared_user) is None -def test_link(user: UserLike, shared_user: MiniUserLike, ): +def test_link(user: UserLike, shared_user: MiniUserLike, ): # noqa: F811 assert get_link(user=user, ) == get_link(user=shared_user, ) == f"https://t.me/{user.username}" user.username = shared_user.username = None assert get_link(user=user, ) is get_link(user=shared_user, ) is None diff --git a/tests/test_shared.py b/tests/test_shared.py index 877eb69578d..239e8600092 100644 --- a/tests/test_shared.py +++ b/tests/test_shared.py @@ -17,8 +17,6 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. -from unittest.mock import patch - import pytest from telegram import ChatShared, PhotoSize, SharedUser, UsersShared @@ -230,4 +228,3 @@ def test_equality(self, chat_shared): assert a != d assert hash(a) != hash(d) -