Use enums, removed Helper

This commit is contained in:
Alex Root Junior 2022-11-22 23:08:36 +02:00
parent 024f1e028b
commit 0fd984e016
No known key found for this signature in database
GPG key ID: 074C1D455EBEA4AC
23 changed files with 53 additions and 533 deletions

View file

@ -15,4 +15,3 @@ class BotCommandScopeType(str, Enum):
CHAT = "chat"
CHAT_ADMINISTRATORS = "chat_administrators"
CHAT_MEMBER = "chat_member"
ALL = "all"

View file

@ -15,7 +15,6 @@ from .bot_command_scope_default import BotCommandScopeDefault
from .callback_game import CallbackGame
from .callback_query import CallbackQuery
from .chat import Chat
from .chat_action import ChatAction
from .chat_administrator_rights import ChatAdministratorRights
from .chat_invite_link import ChatInviteLink
from .chat_join_request import ChatJoinRequest
@ -286,7 +285,6 @@ __all__ = (
"Game",
"CallbackGame",
"GameHighScore",
"ChatAction",
)
# Load typing forward refs for every TelegramObject

View file

@ -2,6 +2,7 @@ from __future__ import annotations
from pydantic import Field
from ..enums import BotCommandScopeType
from .bot_command_scope import BotCommandScope
@ -12,5 +13,5 @@ class BotCommandScopeAllChatAdministrators(BotCommandScope):
Source: https://core.telegram.org/bots/api#botcommandscopeallchatadministrators
"""
type: str = Field("all_chat_administrators", const=True)
type: str = Field(BotCommandScopeType.ALL_CHAT_ADMINISTRATORS, const=True)
"""Scope type, must be *all_chat_administrators*"""

View file

@ -2,6 +2,7 @@ from __future__ import annotations
from pydantic import Field
from ..enums import BotCommandScopeType
from .bot_command_scope import BotCommandScope
@ -12,5 +13,5 @@ class BotCommandScopeAllGroupChats(BotCommandScope):
Source: https://core.telegram.org/bots/api#botcommandscopeallgroupchats
"""
type: str = Field("all_group_chats", const=True)
type: str = Field(BotCommandScopeType.ALL_GROUP_CHATS, const=True)
"""Scope type, must be *all_group_chats*"""

View file

@ -2,6 +2,7 @@ from __future__ import annotations
from pydantic import Field
from ..enums import BotCommandScopeType
from .bot_command_scope import BotCommandScope
@ -12,5 +13,5 @@ class BotCommandScopeAllPrivateChats(BotCommandScope):
Source: https://core.telegram.org/bots/api#botcommandscopeallprivatechats
"""
type: str = Field("all_private_chats", const=True)
type: str = Field(BotCommandScopeType.ALL_PRIVATE_CHATS, const=True)
"""Scope type, must be *all_private_chats*"""

View file

@ -4,6 +4,7 @@ from typing import Union
from pydantic import Field
from ..enums import BotCommandScopeType
from .bot_command_scope import BotCommandScope
@ -14,7 +15,7 @@ class BotCommandScopeChat(BotCommandScope):
Source: https://core.telegram.org/bots/api#botcommandscopechat
"""
type: str = Field("chat", const=True)
type: str = Field(BotCommandScopeType.CHAT, const=True)
"""Scope type, must be *chat*"""
chat_id: Union[int, str]
"""Unique identifier for the target chat or username of the target supergroup (in the format :code:`@supergroupusername`)"""

View file

@ -4,6 +4,7 @@ from typing import Union
from pydantic import Field
from ..enums import BotCommandScopeType
from .bot_command_scope import BotCommandScope
@ -14,7 +15,7 @@ class BotCommandScopeChatAdministrators(BotCommandScope):
Source: https://core.telegram.org/bots/api#botcommandscopechatadministrators
"""
type: str = Field("chat_administrators", const=True)
type: str = Field(BotCommandScopeType.CHAT_ADMINISTRATORS, const=True)
"""Scope type, must be *chat_administrators*"""
chat_id: Union[int, str]
"""Unique identifier for the target chat or username of the target supergroup (in the format :code:`@supergroupusername`)"""

View file

@ -4,6 +4,7 @@ from typing import Union
from pydantic import Field
from ..enums import BotCommandScopeType
from .bot_command_scope import BotCommandScope
@ -14,7 +15,7 @@ class BotCommandScopeChatMember(BotCommandScope):
Source: https://core.telegram.org/bots/api#botcommandscopechatmember
"""
type: str = Field("chat_member", const=True)
type: str = Field(BotCommandScopeType.CHAT_MEMBER, const=True)
"""Scope type, must be *chat_member*"""
chat_id: Union[int, str]
"""Unique identifier for the target chat or username of the target supergroup (in the format :code:`@supergroupusername`)"""

View file

@ -2,6 +2,7 @@ from __future__ import annotations
from pydantic import Field
from ..enums import BotCommandScopeType
from .bot_command_scope import BotCommandScope
@ -12,5 +13,5 @@ class BotCommandScopeDefault(BotCommandScope):
Source: https://core.telegram.org/bots/api#botcommandscopedefault
"""
type: str = Field("default", const=True)
type: str = Field(BotCommandScopeType.DEFAULT, const=True)
"""Scope type, must be *default*"""

View file

@ -1,33 +0,0 @@
import enum
from ..utils.enum import AutoName
class ChatAction(AutoName):
"""
This object represents bot actions.
Choose one, depending on what the user is about to receive:
typing for text messages,
upload_photo for photos,
record_video or upload_video for videos,
record_voice or upload_voice for voice notes,
upload_document for general files,
choose_sticker for stickers,
find_location for location data,
record_video_note or upload_video_note for video notes.
Source: https://core.telegram.org/bots/api#sendchataction
"""
TYPING = enum.auto()
UPLOAD_PHOTO = enum.auto()
RECORD_VIDEO = enum.auto()
UPLOAD_VIDEO = enum.auto()
RECORD_VOICE = enum.auto()
UPLOAD_VOICE = enum.auto()
UPLOAD_DOCUMENT = enum.auto()
CHOOSE_STICKER = enum.auto()
FIND_LOCATION = enum.auto()
RECORD_VIDEO_NOTE = enum.auto()
UPLOAD_VIDEO_NOTE = enum.auto()

View file

@ -4,6 +4,7 @@ from typing import TYPE_CHECKING, Optional
from pydantic import Field
from ..enums import ChatMemberStatus
from .chat_member import ChatMember
if TYPE_CHECKING:
@ -17,7 +18,7 @@ class ChatMemberAdministrator(ChatMember):
Source: https://core.telegram.org/bots/api#chatmemberadministrator
"""
status: str = Field("administrator", const=True)
status: str = Field(ChatMemberStatus.ADMINISTRATOR, const=True)
"""The member's status in the chat, always 'administrator'"""
user: User
"""Information about the user"""

View file

@ -5,6 +5,7 @@ from typing import TYPE_CHECKING
from pydantic import Field
from ..enums import ChatMemberStatus
from .chat_member import ChatMember
if TYPE_CHECKING:
@ -18,7 +19,7 @@ class ChatMemberBanned(ChatMember):
Source: https://core.telegram.org/bots/api#chatmemberbanned
"""
status: str = Field("kicked", const=True)
status: str = Field(ChatMemberStatus.KICKED, const=True)
"""The member's status in the chat, always 'kicked'"""
user: User
"""Information about the user"""

View file

@ -4,6 +4,7 @@ from typing import TYPE_CHECKING
from pydantic import Field
from ..enums import ChatMemberStatus
from .chat_member import ChatMember
if TYPE_CHECKING:
@ -17,7 +18,7 @@ class ChatMemberLeft(ChatMember):
Source: https://core.telegram.org/bots/api#chatmemberleft
"""
status: str = Field("left", const=True)
status: str = Field(ChatMemberStatus.LEFT, const=True)
"""The member's status in the chat, always 'left'"""
user: User
"""Information about the user"""

View file

@ -4,6 +4,7 @@ from typing import TYPE_CHECKING
from pydantic import Field
from ..enums import ChatMemberStatus
from .chat_member import ChatMember
if TYPE_CHECKING:
@ -17,7 +18,7 @@ class ChatMemberMember(ChatMember):
Source: https://core.telegram.org/bots/api#chatmembermember
"""
status: str = Field("member", const=True)
status: str = Field(ChatMemberStatus.MEMBER, const=True)
"""The member's status in the chat, always 'member'"""
user: User
"""Information about the user"""

View file

@ -4,6 +4,7 @@ from typing import TYPE_CHECKING, Optional
from pydantic import Field
from ..enums import ChatMemberStatus
from .chat_member import ChatMember
if TYPE_CHECKING:
@ -17,7 +18,7 @@ class ChatMemberOwner(ChatMember):
Source: https://core.telegram.org/bots/api#chatmemberowner
"""
status: str = Field("creator", const=True)
status: str = Field(ChatMemberStatus.CREATOR, const=True)
"""The member's status in the chat, always 'creator'"""
user: User
"""Information about the user"""

View file

@ -5,6 +5,7 @@ from typing import TYPE_CHECKING
from pydantic import Field
from ..enums import ChatMemberStatus
from .chat_member import ChatMember
if TYPE_CHECKING:
@ -18,7 +19,7 @@ class ChatMemberRestricted(ChatMember):
Source: https://core.telegram.org/bots/api#chatmemberrestricted
"""
status: str = Field("restricted", const=True)
status: str = Field(ChatMemberStatus.RESTRICTED, const=True)
"""The member's status in the chat, always 'restricted'"""
user: User
"""Information about the user"""

View file

@ -2,6 +2,7 @@ from __future__ import annotations
from pydantic import Field
from ..enums import MenuButtonType
from .menu_button import MenuButton
@ -12,5 +13,5 @@ class MenuButtonCommands(MenuButton):
Source: https://core.telegram.org/bots/api#menubuttoncommands
"""
type: str = Field("commands", const=True)
type: str = Field(MenuButtonType.COMMANDS, const=True)
"""Type of the button, must be *commands*"""

View file

@ -2,6 +2,7 @@ from __future__ import annotations
from pydantic import Field
from ..enums import MenuButtonType
from .menu_button import MenuButton
@ -12,5 +13,5 @@ class MenuButtonDefault(MenuButton):
Source: https://core.telegram.org/bots/api#menubuttondefault
"""
type: str = Field("default", const=True)
type: str = Field(MenuButtonType.DEFAULT, const=True)
"""Type of the button, must be *default*"""

View file

@ -4,6 +4,7 @@ from typing import TYPE_CHECKING
from pydantic import Field
from ..enums import MenuButtonType
from .menu_button import MenuButton
if TYPE_CHECKING:
@ -17,7 +18,7 @@ class MenuButtonWebApp(MenuButton):
Source: https://core.telegram.org/bots/api#menubuttonwebapp
"""
type: str = Field("web_app", const=True)
type: str = Field(MenuButtonType.WEB_APP, const=True)
"""Type of the button, must be *web_app*"""
text: str
"""Text on the button"""

View file

@ -1,293 +0,0 @@
"""
Example:
>>> from aiogram.utils.helper import Helper, ListItem, HelperMode, Item
>>> class MyHelper(Helper):
... mode = HelperMode.lowerCamelCase
... FOO_ITEM = ListItem()
... BAR_ITEM = ListItem()
... BAZ_ITEM = ListItem()
... LOREM = Item()
...
>>> print(MyHelper.FOO_ITEM & MyHelper.BAR_ITEM)
<<< ['fooItem', 'barItem']
>>> print(MyHelper.all())
<<< ['barItem', 'bazItem', 'fooItem', 'lorem']
"""
import inspect
from typing import Any, Callable, Generic, Iterable, List, Optional, TypeVar, Union, cast
from weakref import WeakKeyDictionary
T = TypeVar("T")
PROPS_KEYS_ATTR_NAME = "_props_keys"
class Helper:
mode = ""
@classmethod
def all(cls) -> List[Any]:
"""
Get all consts
:return: list
"""
result: List[Any] = []
for name in dir(cls):
if not name.isupper():
continue
value = getattr(cls, name)
if isinstance(value, ItemsList):
result.append(value[0])
else:
result.append(value)
return result
class HelperMode(Helper):
mode = "original"
SCREAMING_SNAKE_CASE = "SCREAMING_SNAKE_CASE"
lowerCamelCase = "lowerCamelCase"
CamelCase = "CamelCase"
snake_case = "snake_case"
lowercase = "lowercase"
@classmethod
def all(cls) -> List[str]:
return [
cls.SCREAMING_SNAKE_CASE,
cls.lowerCamelCase,
cls.CamelCase,
cls.snake_case,
cls.lowercase,
]
@classmethod
def _screaming_snake_case(cls, text: str) -> str:
"""
Transform text to SCREAMING_SNAKE_CASE
:param text:
:return:
"""
if text.isupper():
return text
result = ""
for pos, symbol in enumerate(text):
if symbol.isupper() and pos > 0:
result += "_" + symbol
else:
result += symbol.upper()
return result
@classmethod
def _snake_case(cls, text: str) -> str:
"""
Transform text to snake case (Based on SCREAMING_SNAKE_CASE)
:param text:
:return:
"""
if text.islower():
return text
return cls._screaming_snake_case(text).lower()
@classmethod
def _camel_case(cls, text: str, first_upper: bool = False) -> str:
"""
Transform text to camelCase or CamelCase
:param text:
:param first_upper: first symbol must be upper?
:return:
"""
result = ""
need_upper = False
for pos, symbol in enumerate(text):
if symbol == "_" and pos > 0:
need_upper = True
else:
if need_upper:
result += symbol.upper()
else:
result += symbol.lower()
need_upper = False
if first_upper:
result = result[0].upper() + result[1:]
return result
@classmethod
def apply(cls, text: str, mode: Union[str, Callable[[str], str]]) -> str:
"""
Apply mode for text
:param text:
:param mode:
:return:
"""
if mode == cls.SCREAMING_SNAKE_CASE:
return cls._screaming_snake_case(text)
if mode == cls.snake_case:
return cls._snake_case(text)
if mode == cls.lowercase:
return cls._snake_case(text).replace("_", "")
if mode == cls.lowerCamelCase:
return cls._camel_case(text)
if mode == cls.CamelCase:
return cls._camel_case(text, True)
if callable(mode):
return mode(text)
return text
class _BaseItem:
def __init__(self, value: Optional[str] = None):
self._value = cast(str, value)
def __set_name__(self, owner: Any, name: str) -> None:
if not name.isupper():
raise NameError("Name for helper item must be in uppercase!")
if not self._value:
if not inspect.isclass(owner) or not issubclass(owner, Helper):
raise RuntimeError("Instances of Item can be used only as Helper attributes")
self._value = HelperMode.apply(name, owner.mode)
class Item(_BaseItem):
"""
Helper item
If a value is not provided,
it will be automatically generated based on a variable's name
"""
def __get__(self, instance: Any, owner: Any) -> str:
return self._value
class ListItem(_BaseItem):
"""
This item is always a list
You can use &, | and + operators for that.
"""
def add(self, other: "ListItem") -> "ListItem": # pragma: no cover
return self + other
def __get__(self, instance: Any, owner: Any) -> "ItemsList":
return ItemsList(self._value)
def __getitem__(self, item: Any) -> Any: # pragma: no cover
# Only for IDE. This method is never be called.
return self._value
# Need only for IDE
__iadd__ = __add__ = __rand__ = __and__ = __ror__ = __or__ = add
class ItemsList(List[str]):
"""
Patch for default list
This class provides +, &, |, +=, &=, |= operators for extending the list
"""
def __init__(self, *seq: Any):
super(ItemsList, self).__init__(map(str, seq))
def add(self, other: Iterable[str]) -> "ItemsList":
self.extend(other)
return self
__iadd__ = __add__ = __rand__ = __and__ = __ror__ = __or__ = add # type: ignore
class OrderedHelperMeta(type):
def __new__(mcs, name: Any, bases: Any, namespace: Any, **kwargs: Any) -> "OrderedHelperMeta":
cls = super().__new__(mcs, name, bases, namespace)
props_keys = []
for prop_name in (
name for name, prop in namespace.items() if isinstance(prop, (Item, ListItem))
):
props_keys.append(prop_name)
setattr(cls, PROPS_KEYS_ATTR_NAME, props_keys)
return cls
class OrderedHelper(Helper, metaclass=OrderedHelperMeta):
mode = ""
@classmethod
def all(cls) -> List[str]:
"""
Get all Items values
"""
result = []
for name in getattr(cls, PROPS_KEYS_ATTR_NAME, []):
value = getattr(cls, name)
if isinstance(value, ItemsList):
result.append(value[0])
else:
result.append(value)
return result
class Default(Generic[T]):
"""
Descriptor that holds default value getter
Example:
>>> class MyClass:
... att = Default("dflt")
...
>>> my_instance = MyClass()
>>> my_instance.att = "not dflt"
>>> my_instance.att
'not dflt'
>>> MyClass.att
'dflt'
>>> del my_instance.att
>>> my_instance.att
'dflt'
>>>
Intended to be used as a class attribute and only internally.
"""
__slots__ = "fget", "_descriptor_instances"
def __init__(
self,
default: Optional[T] = None,
*,
fget: Optional[Callable[[Any], T]] = None,
) -> None:
self.fget = fget or (lambda _: cast(T, default))
self._descriptor_instances = WeakKeyDictionary() # type: ignore
def __get__(self, instance: Any, owner: Any) -> T:
if instance is None:
return self.fget(instance)
return self._descriptor_instances.get(instance, self.fget(instance))
def __set__(self, instance: Any, value: T) -> None:
if instance is None or isinstance(instance, type):
raise AttributeError(
"Instance cannot be class or None. Setter must be called from a class."
)
self._descriptor_instances[instance] = value
def __delete__(self, instance: Any) -> None:
if instance is None or isinstance(instance, type):
raise AttributeError(
"Instance cannot be class or None. Deleter must be called from a class."
)
self._descriptor_instances.pop(instance, None)

View file

@ -5,6 +5,8 @@ import re
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Generator, List, Optional, Pattern, cast
from aiogram.enums import MessageEntityType
if TYPE_CHECKING:
from aiogram.types import MessageEntity
@ -36,25 +38,37 @@ class TextDecoration(ABC):
:param text:
:return:
"""
if entity.type in {"bot_command", "url", "mention", "phone_number"}:
if entity.type in {
MessageEntityType.BOT_COMMAND,
MessageEntityType.URL,
MessageEntityType.MENTION,
MessageEntityType.PHONE_NUMBER,
}:
# This entities should not be changed
return text
if entity.type in {"bold", "italic", "code", "underline", "strikethrough", "spoiler"}:
if entity.type in {
MessageEntityType.BOLD,
MessageEntityType.ITALIC,
MessageEntityType.CODE,
MessageEntityType.UNDERLINE,
MessageEntityType.STRIKETHROUGH,
MessageEntityType.SPOILER,
}:
return cast(str, getattr(self, entity.type)(value=text))
if entity.type == "pre":
if entity.type == MessageEntityType.PRE:
return (
self.pre_language(value=text, language=entity.language)
if entity.language
else self.pre(value=text)
)
if entity.type == "text_mention":
if entity.type == MessageEntityType.TEXT_MENTION:
from aiogram.types import User
user = cast(User, entity.user)
return self.link(value=text, link=f"tg://user?id={user.id}")
if entity.type == "text_link":
if entity.type == MessageEntityType.TEXT_LINK:
return self.link(value=text, link=cast(str, entity.url))
if entity.type == "custom_emoji":
if entity.type == MessageEntityType.CUSTOM_EMOJI:
return self.custom_emoji(value=text, custom_emoji_id=cast(str, entity.custom_emoji_id))
return self.quote(text)

View file

@ -1,7 +1,7 @@
import pytest
from aiogram.enums import ChatAction
from aiogram.methods import Request, SendChatAction
from aiogram.types import ChatAction
from tests.mocked_bot import MockedBot
pytestmark = pytest.mark.asyncio

View file

@ -1,181 +0,0 @@
import pytest
from aiogram.utils.helper import Default, Helper, HelperMode, Item, ListItem, OrderedHelper
class TestHelper:
def test_items_all(self):
class MyHelper(Helper):
A = Item()
B = Item()
C = Item()
D = Item()
assert set(MyHelper.all()) == {"A", "B", "C", "D"}
def test_listed_items_all(self):
class MyHelper(Helper):
A = ListItem()
B = ListItem()
C = ListItem()
D = ListItem()
assert set(MyHelper.all()) == {"A", "B", "C", "D"}
def test_listed_items_combinations(self):
class MyHelper(Helper):
A = ListItem()
B = ListItem()
C = ListItem()
D = ListItem()
assert (MyHelper.A | MyHelper.B) == ["A", "B"]
assert (MyHelper.C & MyHelper.D) == ["C", "D"]
assert MyHelper.A.add(MyHelper.D) == ["A", "D"]
assert MyHelper.B + MyHelper.D == ["B", "D"]
def test_wrong_name(self):
with pytest.raises(RuntimeError):
class MyHelper(Helper):
kaboom = Item()
def test_not_a_helper_subclass(self):
with pytest.raises(RuntimeError):
class NotAHelperSubclass:
A = Item()
class TestHelperMode:
def test_helper_mode_all(self):
assert set(HelperMode.all()) == {
"SCREAMING_SNAKE_CASE",
"lowerCamelCase",
"CamelCase",
"snake_case",
"lowercase",
}
def test_screaming_snake_case(self):
class MyHelper(Helper):
mode = HelperMode.SCREAMING_SNAKE_CASE
FOO = Item()
BAR_BAZ = Item()
assert MyHelper.FOO == "FOO"
assert MyHelper.BAR_BAZ == "BAR_BAZ"
def test_lower_camel_case(self):
class MyHelper(Helper):
mode = HelperMode.lowerCamelCase
FOO = Item()
BAR_BAZ = Item()
assert MyHelper.FOO == "foo"
assert MyHelper.BAR_BAZ == "barBaz"
def test_camel_case(self):
class MyHelper(Helper):
mode = HelperMode.CamelCase
FOO = Item()
BAR_BAZ = Item()
assert MyHelper.FOO == "Foo"
assert MyHelper.BAR_BAZ == "BarBaz"
def test_snake_case(self):
class MyHelper(Helper):
mode = HelperMode.snake_case
FOO = Item()
BAR_BAZ = Item()
assert MyHelper.FOO == "foo"
assert MyHelper.BAR_BAZ == "bar_baz"
def test_lowercase(self):
class MyHelper(Helper):
mode = HelperMode.lowercase
FOO = Item()
BAR_BAZ = Item()
assert MyHelper.FOO == "foo"
assert MyHelper.BAR_BAZ == "barbaz"
def test_extended_converters(self):
assert HelperMode.apply("test_text", mode=HelperMode.SCREAMING_SNAKE_CASE) == "TEST_TEXT"
assert HelperMode.apply("TestText", mode=HelperMode.SCREAMING_SNAKE_CASE) == "TEST_TEXT"
assert HelperMode.apply("test_text", mode=HelperMode.snake_case) == "test_text"
assert HelperMode.apply("foo", mode=lambda m: m.upper()) == "FOO"
class TestOrderedHelper:
def test_items_are_ordered(self):
class MyOrderedHelper(OrderedHelper):
A = Item()
D = Item()
C = Item()
B = Item()
assert MyOrderedHelper.all() == ["A", "D", "C", "B"]
def test_list_items_are_ordered(self):
class MyOrderedHelper(OrderedHelper):
A = ListItem()
D = ListItem()
C = ListItem()
B = ListItem()
assert MyOrderedHelper.all() == ["A", "D", "C", "B"]
class TestDefaultDescriptor:
def test_descriptor_fs(self):
obj = type("ClassA", (), {})()
default_x_val = "some_x"
x = Default(default_x_val)
# we can omit owner, usually it's just obj.__class__
assert x.__get__(instance=obj, owner=None) == default_x_val
assert x.__get__(instance=obj, owner=obj.__class__) == default_x_val
new_x_val = "new_x"
assert x.__set__(instance=obj, value=new_x_val) is None
with pytest.raises(AttributeError) as exc:
x.__set__(instance=obj.__class__, value="will never be set")
assert "Instance cannot be class or None" in str(exc.value)
assert x.__get__(instance=obj, owner=obj.__class__) == new_x_val
with pytest.raises(AttributeError) as exc:
x.__delete__(instance=obj.__class__)
assert "Instance cannot be class or None" in str(exc.value)
x.__delete__(instance=obj)
assert x.__get__(instance=obj, owner=obj.__class__) == default_x_val
def test_init(self):
class A:
x = Default(fget=lambda a_inst: "nothing")
assert isinstance(A.__dict__["x"], Default)
a = A()
assert a.x == "nothing"
x = Default("x")
assert x.__get__(None, None) == "x"
assert x.fget(None) == x.__get__(None, None)
def test_nullability(self):
class A:
x = Default(default=None, fget=None)
assert A.x is None
assert A().x is None