diff --git a/client/doc/_static/custom.css b/client/doc/_static/custom.css new file mode 100644 index 00000000..c6094f81 --- /dev/null +++ b/client/doc/_static/custom.css @@ -0,0 +1,20 @@ +.underline { + text-decoration: underline; +} + +.strikethrough { + text-decoration: line-through; +} + +.spoiler { + border-radius: 5%; + padding: 0.1em; + background-color: gray; + color: gray; + transition: all 200ms; +} + +.spoiler:hover { + background-color: unset; + color: unset; +} diff --git a/client/doc/basic/signing-in.rst b/client/doc/basic/signing-in.rst index 9adb91e4..67e03983 100644 --- a/client/doc/basic/signing-in.rst +++ b/client/doc/basic/signing-in.rst @@ -56,8 +56,8 @@ Interactive login The library offers a method for "quick and dirty" scripts known as :meth:`~Client.interactive_login`. This method will first check whether the account was previously logged-in, and if not, ask for a phone number to be input. -You can write the code in a file (such as ``hello.py``) and then run it, or use the built-in ``asyncio``-enabled REPL. -For this tutorial, we'll be using the ``asyncio`` REPL: +You can write the code in a file (such as ``hello.py``) and then run it, or use the built-in :mod:`asyncio`-enabled REPL. +For this tutorial, we'll be using the :mod:`asyncio` REPL: .. code-block:: shell @@ -209,3 +209,6 @@ Otherwise, you might run into errors such as tasks being destroyed while pending Once a :class:`Client` instance has been connected, you cannot change the :mod:`asyncio` event loop. Methods like :func:`asyncio.run` setup and tear-down a new event loop every time. If the loop changes, the client is likely to be "stuck" because its loop cannot advance. + + If you want to learn how :mod:`asyncio` works under the hood or need an introduction, + you can read my own blog post `An Introduction to Asyncio `_. diff --git a/client/doc/concepts/glossary.rst b/client/doc/concepts/glossary.rst index 0d254768..a1d8e6de 100644 --- a/client/doc/concepts/glossary.rst +++ b/client/doc/concepts/glossary.rst @@ -44,6 +44,11 @@ Glossary .. seealso:: The :doc:`../concepts/botapi-vs-mtproto` concept. + HTTP Bot API + Telegram's simplified HTTP API to control bot accounts only. + + .. seealso:: The :doc:`../concepts/botapi-vs-mtproto` concept. + login Used to refer to the login process as a whole, as opposed to the action to :term:`sign in`. The "login code" or "login token" get their name because they belong to the login process. diff --git a/client/doc/concepts/messages.rst b/client/doc/concepts/messages.rst new file mode 100644 index 00000000..c0539136 --- /dev/null +++ b/client/doc/concepts/messages.rst @@ -0,0 +1,191 @@ +Messages +======== + +.. currentmodule:: telethon + +.. role:: underline + :class: underline + +.. role:: strikethrough + :class: strikethrough + +.. role:: spoiler + :class: spoiler + +Messages are at the heart of a messaging platform. +In Telethon, you will be using the :class:`~types.Message` class to interact with them. + +Formatting messages +------------------- + +The library supports 3 formatting modes: no formatting, CommonMark, HTML. + +Telegram does not natively support markdown or HTML. +Clients such as Telethon parse the text into a list of formatting :tl:`MessageEntity` at different offsets. + +Note that `CommonMark's markdown `_ is not fully compatible with :term:`HTTP Bot API`'s +`MarkdownV2 style `_, and does not support spoilers:: + + *italic* and _italic_ + **bold** and __bold__ + # headings are underlined + ~~strikethrough~~ + [inline URL](https://www.example.com/) + [inline mention](tg://user?id=ab1234cd6789) + custom emoji image with ![👍](tg://emoji?id=1234567890) + `inline code` + ```python + multiline pre-formatted + block with optional language + ``` + +HTML is also not fully compatible with :term:`HTTP Bot API`'s +`MarkdownV2 style `_, +and instead favours more standard `HTML elements `_: + +* ``strong`` and ``b`` for **bold**. +* ``em`` and ``i`` for *italics*. +* ``u`` for :underline:`underlined text`. +* ``del`` and ``s`` for :strikethrough:`strikethrough`. +* ``blockquote`` for quotes. +* ``details`` for :spoiler:`hidden text` (spoiler). +* ``code`` for ``inline code`` +* ``pre`` for multiple lines of code. +* ``a`` for links. +* ``img`` for inline images (only custom emoji). + +Both markdown and HTML recognise the following special URLs using the ``tg:`` protocol: + +* ``tg://user?id=ab1234cd6789`` for inline mentions. + To make sure the mention works, use :attr:`types.PackedChat.hex`. + You can also use :attr:`types.User.id`, but the mention will fail if the user is not in cache. +* ``tg://emoji?id=1234567890`` for custom emoji. + You must use the document identifier as the value. + The alt-text of the image **must** be a emoji such as 👍. + + +To obtain a message's text formatted, use :attr:`types.Message.text_markdown` or :attr:`types.Message.text_html`. + +To send a message with formatted text, use the ``markdown`` or ``html`` parameters in :meth:`Client.send_message`. + +When sending files, the format is appended to the name of the ``caption`` parameter, either ``caption_markdown`` or ``caption_html``. + + +Message identifiers +------------------- + +This is an in-depth explanation for how the :attr:`types.Message.id` works. + +.. note:: + + You can safely skip this section if you're not interested. + +Every account, whether it's an user account or bot account, has its own message counter. +This counter starts at 1, and is incremented by 1 every time a new message is received. +In private conversations or small groups, each account will receive a copy each message. +The message identifier will be based on the message counter of the receiving account. + +In megagroups and broadcast channels, the message counter instead belongs to the channel itself. +It also starts at 1 and is incremented by 1 for every message sent to the group or channel. +This means every account will see the same message identifier for a given mesasge in a group or channel. + +This design has the following implications: + +* The message identifier alone is enough to uniquely identify a message only if it's not from a megagroup or channel. + This is why :class:`events.MessageDeleted` does not need to (and doesn't always) include chat information. +* Messages cannot be deleted for one-side only in megagroups or channels. + Because every account shares the same identifier for the message, it cannot be deleted only for some. +* Links to messages only work for everyone inside megagroups or channels. + In private conversations and small groups, each account will have their own counter, and the identifiers won't match. + +Let's look at a concrete example. + +* You are logged in as User-A. +* Both User-B and User-C are your mutual contacts. +* You have share a small group called Group-S with User-B. +* You also share a megagroup called Group-M with User-C. + +.. graphviz:: + :caption: Demo scenario + + digraph scenario { + "User A" [shape=trapezium]; + "User B" [shape=box]; + "User C" [shape=box]; + + "User A" -> "User B"; + "User A" -> "User C"; + + "Group-S" -> "User A"; + "Group-S" -> "User B"; + + "Group-M" -> "User A"; + "Group-M" -> "User C"; + } + +Every account and channel has just been created. +This means everyone has a message counter of one. + +First, User-A will sent a welcome message to both User-B and User-C:: + + User-A → User-B: Hey, welcome! + User-A → User-C: ¡Bienvenido! + +* For User-A, "Hey, welcome!" will have the message identifier 1. The message with "¡Bienvenido!" will have an ID of 2. +* For User-B, "Hey, welcome" will have ID 1. +* For User-B, "¡Bienvenido!" will have ID 1. + +.. csv-table:: Message identifiers + :header: "Message", "User-A", "User-B", "User-C", "Group-S", "Group-M" + + "Hey, welcome!", 1, 1, "", "", "" + "¡Bienvenido!", 2, "", 1, "", "" + +Next, User-B and User-C will respond to User-A:: + + User-B → User-A: Thanks! + User-C → User-A: Gracias :) + +.. csv-table:: Message identifiers + :header: "Message", "User-A", "User-B", "User-C", "Group-S", "Group-M" + + "Hey, welcome!", 1, 1, "", "", "" + "¡Bienvenido!", 2, "", 1, "", "" + "Thanks!", 3, 2, "", "", "" + "Gracias :)", 4, "", 2, "", "" + +Notice how for each message, the counter goes up by one, and they are independent. + +Let's see what happens when User-B sends a message to Group-S:: + + User-B → Group-S: Nice group + +.. csv-table:: Message identifiers + :header: "Message", "User-A", "User-B", "User-C", "Group-S", "Group-M" + + "Hey, welcome!", 1, 1, "", "", "" + "¡Bienvenido!", 2, "", 1, "", "" + "Thanks!", 3, 2, "", "", "" + "Gracias :)", 4, "", 2, "", "" + "Nice group", 5, 3, "", "", "" + +While the message was sent to a different chat, the group itself doesn't have a counter. +The message identifiers are still unique for each account. +The chat where the message was sent can be completely ignored. + +Megagroups behave differently:: + + User-C → Group-M: Buen grupo + +.. csv-table:: Message identifiers + :header: "Message", "User-A", "User-B", "User-C", "Group-S", "Group-M" + + "Hey, welcome!", 1, 1, "", "", "" + "¡Bienvenido!", 2, "", 1, "", "" + "Thanks!", 3, 2, "", "", "" + "Gracias :)", 4, "", 2, "", "" + "Nice group", 5, 3, "", "", "" + "Buen grupo", "", "", "", "", 1 + +The group has its own message counter. +Each user won't get a copy of the message with their own identifier, but rather everyone sees the same message. diff --git a/client/doc/conf.py b/client/doc/conf.py index c4864dce..38bd3a59 100644 --- a/client/doc/conf.py +++ b/client/doc/conf.py @@ -40,3 +40,4 @@ graphviz_output_format = "svg" html_theme = "sphinx_rtd_theme" html_static_path = ["_static"] +html_css_files = ["custom.css"] diff --git a/client/doc/developing/faq.rst b/client/doc/developing/faq.rst new file mode 100644 index 00000000..d738b9cd --- /dev/null +++ b/client/doc/developing/faq.rst @@ -0,0 +1,2 @@ +Frequently Asked Questions +========================== diff --git a/client/doc/developing/migration-guide.rst b/client/doc/developing/migration-guide.rst index 775c0cb7..84a252c3 100644 --- a/client/doc/developing/migration-guide.rst +++ b/client/doc/developing/migration-guide.rst @@ -37,6 +37,7 @@ I can guarantee you will into far less problems. Without further ado, let's take a look at the biggest changes. This list may not be exhaustive, but it should give you an idea on what to expect. +If you feel like a major change is missing, please `open an issue `_. Complete project restructure @@ -53,6 +54,12 @@ The public modules under the ``telethon`` now make actual sense. It's actually a factory object returning new error types on demand. This means you don't need to wait for new library versions to be released to catch them. +.. note:: + + Be sure to check the documentation for :data:`telethon.errors` to learn about error changes. + Notably, errors such as ``FloodWaitError`` no longer have a ``.seconds`` field. + Instead, every value for every error type is always ``.value``. + This was also a good opportunity to remove a lot of modules that were not supposed to public in their entirety: ``.crypto``, ``.extensions``, ``.network``, ``.custom``, ``.functions``, ``.helpers``, ``.hints``, ``.password``, ``.requestiter``, ``.sync``, ``.types``, ``.utils``. @@ -86,14 +93,49 @@ Each of them can have an additional namespace (as seen above with ``account.``). * ``tl.functions`` contains every :term:`TL` definition treated as a function. The naming convention now follows Python's, and are ``snake_case``. - They're no longer a class with attributes. - They serialize the request immediately. * ``tl.abcs`` contains every abstract class, the "boxed" types from Telegram. You can use these for your type-hinting needs. * ``tl.types`` contains concrete instances, the "bare" types Telegram actually returns. You'll probably use these with :func:`isinstance` a lot. - All types use :term:`__slots__` to save space. - This means you can't add extra fields to these at runtime unless you subclass. + + +Raw API has a reduced feature-set +--------------------------------- + +The string representation is now on :meth:`object.__repr__`, not :meth:`object.__str__`. + +All types use :term:`__slots__` to save space. +This means you can't add extra fields to these at runtime unless you subclass. + +The ``.stringify()`` methods on all TL types no longer exists. +Instead, you can use a library like `beauty-print `_. + +The ``.to_dict()`` method on all TL types no longer exists. +The same is true for ``.to_json()``. +Instead, you can use a library like `json-pickle `_ or write your own: + +.. code-block:: python + + def to_dict(obj): + if obj is None or isinstance(obj, (bool, int, bytes, str)): return obj + if isinstance(obj, list): return [to_dict(x) for x in obj] + if isinstance(obj, dict): return {k: to_dict(v) for k, v in obj.items()} + return {slot: to_dict(getattr(obj, slot)) for slot in obj.__slots__} + +Lesser-known methods such as ``TLObject.pretty_format``, ``serialize_bytes``, ``serialize_datetime`` and ``from_reader`` are also gone. +The remaining methods are: + +* ``Serializable.constructor_id()`` class-method, to get the integer identifier of the corresponding type constructor. +* ``Serializable.from_bytes()`` class-method, to convert serialized :class:`bytes` back into the class. +* :meth:`object.__bytes__` instance-method, to serialize the instance into :class:`bytes` the way Telegram expects. + +Functions are no longer a class with attributes. +They serialize the request immediately. +This means you cannot create request instance and change it later. +Consider using :func:`functools.partial` if you want to reuse parts of a request instead. + +Functions no longer have an asynchronous ``.resolve()``. +This used to let you pass usernames and have them be resolved to :tl:`InputPeer` automatically (unless it was nested). Unified client iter and get methods @@ -159,6 +201,66 @@ The simplest approach could be using a global ``states`` dictionary storing the del states[event.sender.id] +.. rubric:: No ``client.kick_participant()`` method. + +This is not a thing in Telegram. +It was implemented by restricting and then removing the restriction. + +The old ``client.edit_permissions()`` was renamed to :meth:`Client.set_banned_rights`. +This defines the rights a restricted participant has (bans them from doing other things). +Revoking the right to view messages will kick them. +This rename should avoid confusion, as it is now clear this is not to promote users to admin status. + +For administrators, ``client.edit_admin`` was renamed to :meth:`Client.set_admin_rights` for consistency. + +Note that a new method, :meth:`Client.set_default_rights`, must now be used to set a chat's default rights. + +.. rubric:: No ``client.download_profile_photo()`` method. + +You can simply use :meth:`Client.download` now. +Note that :meth:`~Client.download` no longer supports downloading contacts as ``.vcard``. + +.. rubric:: No ``client.set_proxy()`` method. + +Proxy support is no longer built-in. +They were never officially maintained. +This doesn't mean you can't use them. +You're now free to choose your own proxy library and pass a different connector to the :class:`Client` constructor. + +This should hopefully make it clear that most connection issues when using proxies do *not* come from Telethon. + +.. rubric:: No ``client.set_receive_updates`` method. + +It was not working as expected. + +.. rubric:: No ``client.catch_up()`` method. + +You can still configure it when creating the :class:`Client`, which was the only way to make it work anyway. + +.. rubric:: No ``client.action()`` method. + +.. rubric:: No ``client.takeout()`` method. + +.. rubric:: No ``client.qr_login()`` method. + +.. rubric:: No ``client.edit_2fa()`` method. + +.. rubric:: No ``client.get_stats()`` method. + +.. rubric:: No ``client.edit_folder()`` method. + +.. rubric:: No ``client.build_reply_markup()`` method. + +.. rubric:: No ``client.list_event_handlers()`` method. + +These are out of scope for the time being. +They might be re-introduced in the future if there is a burning need for them and are not difficult to maintain. +This doesn't mean you can't do these things anymore though, since the :term:`Raw API` is still available. + +Telethon v2 is committed to not exposing the raw API under any public API of the ``telethon`` package. +This means any method returning data from Telegram must have a custom wrapper object and be maintained too. +Because the standards are higher, the barrier of entry for new additions and features is higher too. + No message.raw_text or message.message -------------------------------------- @@ -201,6 +303,57 @@ This means getting those identifiers is up to you, and you can handle it in a wa In-depth explanation for :doc:`/concepts/updates`. +Behaviour changes in events +--------------------------- + +:class:`events.CallbackQuery` no longer also handles "inline bot callback queries". +This was a hacky workaround. + +:class:`events.MessageRead` no longer triggers when the *contents* of a message are read, such as voice notes being played. + +Albums in Telegram are an illusion. +There is no "album media". +There is only separate messages pretending to be a single message. + +``events.Album`` was a hack that waited for a small amount of time to group messages sharing the same grouped identifier. +If you want to wait for a full album, you will need to wait yourself: + +.. code-block:: python + + pending_albums = {} # global for simplicity + async def gather_album(event, handler): + if pending := pending_albums.get(event.grouped_id): + pending.append(event) + else: + pending_albums[event.grouped_id] = [event] + # Wait for other events to come in. Adjust delay to your needs. + # This will NOT work if sequential updates are enabled (spawn a task to do the rest instead). + await asyncio.sleep(1) + events = pending_albums.pop(grouped_id, []) + await handler(events) + + @client.on(events.NewMessage) + async def handler(event): + if event.grouped_id: + await gather_album(event, handle_album) + else: + await handle_message(event) + + async def handle_album(events): + ... # do stuff with events + + async def handle_message(event): + ... # do stuff with event + +Note that the above code is not foolproof and will not handle more than one client. +It might be possible for album events to be delayed for more than a second. + +Note that messages that do **not** belong to an album can be received in-between an album. + +Overall, it's probably better if you treat albums for what they really are: +separate messages sharing a :attr:`~types.Message.grouped_id`. + + Streamlined chat, input_chat and chat_id ---------------------------------------- @@ -232,6 +385,13 @@ Overall, dealing with users, groups and channels should feel a lot more natural. In-depth explanation for :doc:`/concepts/chats`. +Other methods like ``client.get_peer_id``, ``client.get_input_entity`` and ``client.get_entity`` are gone too. +While not directly related, ``client.is_bot`` is gone as well. +You can use :meth:`Client.get_me` or read it from the session instead. + +The ``telethon.utils`` package is gone entirely, so methods like ``utils.resolve_id`` no longer exist either. + + Session cache no longer exists ------------------------------ @@ -264,7 +424,7 @@ TelegramClient renamed to Client You can rename it with :keyword:`as` during import if you want to use the old name. Python allows using namespaces via packages and modules. -Therefore, the full name :class:`telethon.Client` already indicates it's from ``telethon``, so the old name was redundant. +Therefore, the full name :class:`telethon.Client` already indicates it's from ``telethon``, so the old ``Telegram`` prefix was redundant. Changes to start and client context-manager @@ -283,8 +443,9 @@ It also means you can now use the context manager even with custom login flows. The old ``sign_in()`` method also sent the code, which was rather confusing. Instead, you must now :meth:`~Client.request_login_code` as a separate operation. -The old ``log_out`` was also renamed to :meth:`~Client.sign_out` for consistency with :meth:`~Client.sign_in`. +The old ``log_out()`` was also renamed to :meth:`~Client.sign_out` for consistency with :meth:`~Client.sign_in`. +The old ``is_user_authorized()`` was renamed to :meth:`~Client.is_authorized` since it works for bot accounts too. No telethon.sync hack --------------------- diff --git a/client/doc/developing/project-structure.rst b/client/doc/developing/project-structure.rst index f7ed064b..1821ddd5 100644 --- a/client/doc/developing/project-structure.rst +++ b/client/doc/developing/project-structure.rst @@ -41,6 +41,30 @@ The implementation consists of a parser and a code generator. The parser is able to read parse ``.tl`` files (:term:`Type Language` definition files). It doesn't do anything with the files other than to represent the content as Python objects. +.. admonition:: Type Language brief + + TL-definitions are statements terminated with a semicolon ``;`` and often defined in a single line: + + .. code-block:: + + geoPointEmpty#1117dd5f = GeoPoint; + geoPoint#b2a2f663 flags:# long:double lat:double access_hash:long accuracy_radius:flags.0?int = GeoPoint; + + The first word is the name, optionally followed by the hash sign ``#`` and an hexadecimal number. + Every definition can have a constructor identifier inferred based on its own text representation. + The hexadecimal number will override the constructor identifier used for the definition. + + What follows up to the equals-sign ``=`` are the fields of the definition. + They have a name and a type, separated by the colon ``:``. + + The type ``#`` represents a bitflag. + Other fields can be conditionally serialized by prefixing the type with ``flag_name.bit_index?``. + + After the equal-sign comes the name for the "base class". + This representation is known as "boxed", and it contains the constructor identifier to discriminate a definition. + If the definition name appears on its own, it will be "bare" and will not have the constructor identifier prefix. + + The code generator uses the parsed definitions to generate Python code. Most of the code to serialize and deserialize objects lives under ``serde/``. diff --git a/client/doc/index.rst b/client/doc/index.rst index 7ffdbe55..a3d7dc2e 100644 --- a/client/doc/index.rst +++ b/client/doc/index.rst @@ -102,14 +102,15 @@ A more in-depth explanation of some of the concepts and words used in Telethon. concepts/chats concepts/updates + concepts/messages concepts/sessions concepts/errors concepts/botapi-vs-mtproto concepts/full-api concepts/glossary -Developing -========== +Development resources +===================== Tips and tricks to develop both with the library and for the library. @@ -117,10 +118,11 @@ Tips and tricks to develop both with the library and for the library. .. toctree:: :hidden: - :caption: Developing + :caption: Development resources developing/changelog developing/migration-guide + developing/faq developing/philosophy.rst developing/coding-style.rst developing/project-structure.rst diff --git a/client/doc/modules/types.rst b/client/doc/modules/types.rst index 75e3e33c..8ec7cf96 100644 --- a/client/doc/modules/types.rst +++ b/client/doc/modules/types.rst @@ -31,6 +31,7 @@ Errors except errors.FloodWait as e: await asyncio.sleep(e.value) + Note how the :attr:`RpcError.value` field is still accessible, as it's a subclass of :class:`RpcError`. The code above is equivalent to the following: .. code-block:: python diff --git a/client/src/telethon/_impl/client/client/chats.py b/client/src/telethon/_impl/client/client/chats.py index 5186731b..1f30e03c 100644 --- a/client/src/telethon/_impl/client/client/chats.py +++ b/client/src/telethon/_impl/client/client/chats.py @@ -3,8 +3,9 @@ from __future__ import annotations from typing import TYPE_CHECKING, Optional from ...tl import abcs, functions, types -from ..types import AsyncList, ChatLike, Participant +from ..types import AsyncList, ChatLike, File, Participant, RecentAction from ..utils import build_chat_map +from .messages import SearchList if TYPE_CHECKING: from .client import Client @@ -81,3 +82,106 @@ class ParticipantList(AsyncList[Participant]): def get_participants(self: Client, chat: ChatLike) -> AsyncList[Participant]: return ParticipantList(self, chat) + + +class RecentActionList(AsyncList[RecentAction]): + def __init__( + self, + client: Client, + chat: ChatLike, + ): + super().__init__() + self._client = client + self._chat = chat + self._peer: Optional[types.InputChannel] = None + self._offset = 0 + + async def _fetch_next(self) -> None: + if self._peer is None: + self._peer = ( + await self._client._resolve_to_packed(self._chat) + )._to_input_channel() + + result = await self._client( + functions.channels.get_admin_log( + channel=self._peer, + q="", + min_id=0, + max_id=self._offset, + limit=100, + events_filter=None, + admins=[], + ) + ) + assert isinstance(result, types.channels.AdminLogResults) + + chat_map = build_chat_map(result.users, result.chats) + self._buffer.extend(RecentAction._create(e, chat_map) for e in result.events) + self._total += len(self._buffer) + + if self._buffer: + self._offset = min(e.id for e in self._buffer) + + +def get_admin_log(self: Client, chat: ChatLike) -> AsyncList[RecentAction]: + return RecentActionList(self, chat) + + +class ProfilePhotoList(AsyncList[File]): + def __init__( + self, + client: Client, + chat: ChatLike, + ): + super().__init__() + self._client = client + self._chat = chat + self._peer: Optional[abcs.InputPeer] = None + self._search_iter: Optional[SearchList] = None + + async def _fetch_next(self) -> None: + if self._peer is None: + self._peer = ( + await self._client._resolve_to_packed(self._chat) + )._to_input_peer() + + if isinstance(self._peer, types.InputPeerUser): + result = await self._client( + functions.photos.get_user_photos( + user_id=types.InputUser( + user_id=self._peer.user_id, access_hash=self._peer.access_hash + ), + offset=0, + max_id=0, + limit=0, + ) + ) + + if isinstance(result, types.photos.Photos): + self._buffer.extend( + filter(None, (File._try_from_raw_photo(p) for p in result.photos)) + ) + self._total = len(result.photos) + elif isinstance(result, types.photos.PhotosSlice): + self._buffer.extend( + filter(None, (File._try_from_raw_photo(p) for p in result.photos)) + ) + self._total = result.count + else: + raise RuntimeError("unexpected case") + + +def get_profile_photos(self: Client, chat: ChatLike) -> AsyncList[File]: + return ProfilePhotoList(self, chat) + + +def set_banned_rights(self: Client, chat: ChatLike, user: ChatLike) -> None: + pass + + +def set_admin_rights(self: Client, chat: ChatLike, user: ChatLike) -> None: + pass + + +def set_default_rights(self: Client, chat: ChatLike, user: ChatLike) -> None: + pass diff --git a/client/src/telethon/_impl/client/client/client.py b/client/src/telethon/_impl/client/client/client.py index 5cb7bc6f..10499747 100644 --- a/client/src/telethon/_impl/client/client/client.py +++ b/client/src/telethon/_impl/client/client/client.py @@ -36,6 +36,7 @@ from ..types import ( Chat, ChatLike, Dialog, + Draft, File, InFileLike, LoginToken, @@ -43,6 +44,7 @@ from ..types import ( OutFileLike, Participant, PasswordToken, + RecentAction, User, ) from .auth import ( @@ -55,8 +57,15 @@ from .auth import ( sign_out, ) from .bots import InlineResult, inline_query -from .chats import get_participants -from .dialogs import delete_dialog, get_dialogs +from .chats import ( + get_admin_log, + get_participants, + get_profile_photos, + set_admin_rights, + set_banned_rights, + set_default_rights, +) +from .dialogs import delete_dialog, get_dialogs, get_drafts from .files import ( download, get_file_bytes, @@ -198,7 +207,7 @@ class Client: if self._config.catch_up and self._config.session.state: self._message_box.load(self._config.session.state) - # --- + # Begin partially @generated def add_event_handler( self, @@ -511,6 +520,29 @@ class Client: """ return await forward_messages(self, target, message_ids, source) + def get_admin_log(self, chat: ChatLike) -> AsyncList[RecentAction]: + """ + Get the recent actions from the administrator's log. + + This method requires you to be an administrator in the :term:`chat`. + + The returned actions are also known as "admin log events". + + :param chat: + The :term:`chat` to fetch recent actions from. + + :return: The recent actions. + + .. rubric:: Example + + .. code-block:: python + + async for admin_log_event in client.get_admin_log(chat): + if message := admin_log_event.deleted_message: + print('Deleted:', message.text) + """ + return get_admin_log(self, chat) + def get_contacts(self) -> AsyncList[User]: """ Get the users in your contact list. @@ -548,6 +580,47 @@ class Client: """ return get_dialogs(self) + def get_drafts(self) -> AsyncList[Draft]: + """ + Get all message drafts saved in any dialog. + + :return: The existing message drafts. + + .. rubric:: Example + + .. code-block:: python + + async for draft in client.get_drafts(): + await draft.delete() + """ + return get_drafts(self) + + def get_file_bytes(self, media: File) -> AsyncList[bytes]: + """ + Get the contents of an uploaded media file as chunks of :class:`bytes`. + + This lets you iterate over the chunks of a file and print progress while the download occurs. + + If you just want to download a file to disk without printing progress, use :meth:`download` instead. + + :param media: + The media file to download. + This will often come from :attr:`telethon.types.Message.file`. + + .. rubric:: Example + + .. code-block:: python + + if file := message.file: + with open(f'media{file.ext}', 'wb') as fd: + downloaded = 0 + async for chunk in client.get_file_bytes(file): + downloaded += len(chunk) + fd.write(chunk) + print(f'Downloaded {downloaded // 1024}/{file.size // 1024} KiB') + """ + return get_file_bytes(self, media) + def get_handler_filter( self, handler: Callable[[Event], Awaitable[Any]] ) -> Optional[Filter]: @@ -666,6 +739,23 @@ class Client: """ return get_participants(self, chat) + def get_profile_photos(self, chat: ChatLike) -> AsyncList[File]: + """ + Get the profile pictures set in a chat, or user avatars. + + :return: The photo files. + + .. rubric:: Example + + .. code-block:: python + + i = 0 + async for photo in client.get_profile_photos(chat): + await client.download(photo, f'{i}.jpg') + i += 1 + """ + return get_profile_photos(self, chat) + async def inline_query( self, bot: ChatLike, query: str = "", *, chat: Optional[ChatLike] = None ) -> AsyncIterator[InlineResult]: @@ -730,34 +820,15 @@ class Client: Check whether the client instance is authorized (i.e. logged-in). :return: :data:`True` if the client instance has signed-in. - """ - return await is_authorized(self) - - def get_file_bytes(self, media: File) -> AsyncList[bytes]: - """ - Get the contents of an uploaded media file as chunks of :class:`bytes`. - - This lets you iterate over the chunks of a file and print progress while the download occurs. - - If you just want to download a file to disk without printing progress, use :meth:`download` instead. - - :param media: - The media file to download. - This will often come from :attr:`telethon.types.Message.file`. .. rubric:: Example .. code-block:: python - if file := message.file: - with open(f'media{file.ext}', 'wb') as fd: - downloaded = 0 - async for chunk in client.get_file_bytes(file): - downloaded += len(chunk) - fd.write(chunk) - print(f'Downloaded {downloaded // 1024}/{file.size // 1024} KiB') + if not await client.is_authorized(): + ... # need to sign in """ - return get_file_bytes(self, media) + return await is_authorized(self) def on( self, event_cls: Type[Event], filter: Optional[Filter] = None @@ -934,6 +1005,13 @@ class Client: This means only messages sent before *offset_date* will be fetched. :return: The found messages. + + .. rubric:: Example + + .. code-block:: python + + async for message in client.search_all_messages(query='hello'): + print(message.text) """ return search_all_messages( self, limit, query=query, offset_id=offset_id, offset_date=offset_date @@ -970,6 +1048,13 @@ class Client: This means only messages sent before *offset_date* will be fetched. :return: The found messages. + + .. rubric:: Example + + .. code-block:: python + + async for message in client.search_messages(chat, query='hello'): + print(message.text) """ return search_messages( self, chat, limit, query=query, offset_id=offset_id, offset_date=offset_date @@ -988,6 +1073,9 @@ class Client: voice: bool = False, title: Optional[str] = None, performer: Optional[str] = None, + caption: Optional[str] = None, + caption_markdown: Optional[str] = None, + caption_html: Optional[str] = None, ) -> Message: """ Send an audio file. @@ -1002,6 +1090,12 @@ class Client: A local file path or :class:`~telethon.types.File` to send. The rest of parameters behave the same as they do in :meth:`send_file`. + + .. rubric:: Example + + .. code-block:: python + + await client.send_audio(chat, 'file.ogg', voice=True) """ return await send_audio( self, @@ -1015,6 +1109,9 @@ class Client: voice=voice, title=title, performer=performer, + caption=caption, + caption_markdown=caption_markdown, + caption_html=caption_html, ) async def send_file( @@ -1072,7 +1169,16 @@ class Client: if *path* isn't a :class:`~telethon.types.File`. See the documentation of :meth:`~telethon.types.File.new` to learn what they do. + See the section on :doc:`/concepts/messages` to learn about message formatting. + Note that only one *caption* parameter can be provided. + + .. rubric:: Example + + .. code-block:: python + + login_token = await client.request_login_code('+1 23 456...') + print(login_token.timeout, 'seconds before code expires') """ return await send_file( self, @@ -1120,20 +1226,23 @@ class Client: Message text, with no formatting. :param text_markdown: - Message text, parsed as markdown. + Message text, parsed as CommonMark. :param text_html: Message text, parsed as HTML. Note that exactly one *text* parameter must be provided. + + See the section on :doc:`/concepts/messages` to learn about message formatting. + + .. rubric:: Example + + .. code-block:: python + + await client.send_message(chat, markdown='**Hello!**') """ return await send_message( - self, - chat, - text, - markdown=markdown, - html=html, - link_preview=link_preview, + self, chat, text, markdown=markdown, html=html, link_preview=link_preview ) async def send_photo( @@ -1148,6 +1257,9 @@ class Client: compress: bool = True, width: Optional[int] = None, height: Optional[int] = None, + caption: Optional[str] = None, + caption_markdown: Optional[str] = None, + caption_html: Optional[str] = None, ) -> Message: """ Send a photo file. @@ -1166,6 +1278,12 @@ class Client: A local file path or :class:`~telethon.types.File` to send. The rest of parameters behave the same as they do in :meth:`send_file`. + + .. rubric:: Example + + .. code-block:: python + + await client.send_photo(chat, 'photo.jpg', caption='Check this out!') """ return await send_photo( self, @@ -1178,6 +1296,9 @@ class Client: compress=compress, width=width, height=height, + caption=caption, + caption_markdown=caption_markdown, + caption_html=caption_html, ) async def send_video( @@ -1194,6 +1315,9 @@ class Client: height: Optional[int] = None, round: bool = False, supports_streaming: bool = False, + caption: Optional[str] = None, + caption_markdown: Optional[str] = None, + caption_html: Optional[str] = None, ) -> Message: """ Send a video file. @@ -1208,6 +1332,12 @@ class Client: A local file path or :class:`~telethon.types.File` to send. The rest of parameters behave the same as they do in :meth:`send_file`. + + .. rubric:: Example + + .. code-block:: python + + await client.send_video(chat, 'video.mp4', caption_markdown='*I cannot believe this just happened*') """ return await send_video( self, @@ -1222,8 +1352,20 @@ class Client: height=height, round=round, supports_streaming=supports_streaming, + caption=caption, + caption_markdown=caption_markdown, + caption_html=caption_html, ) + def set_admin_rights(self, chat: ChatLike, user: ChatLike) -> None: + set_admin_rights(self, chat, user) + + def set_banned_rights(self, chat: ChatLike, user: ChatLike) -> None: + set_banned_rights(self, chat, user) + + def set_default_rights(self, chat: ChatLike, user: ChatLike) -> None: + set_default_rights(self, chat, user) + def set_handler_filter( self, handler: Callable[[Event], Awaitable[Any]], @@ -1314,7 +1456,7 @@ class Client: """ await unpin_message(self, chat, message_id) - # --- + # End partially @generated @property def connected(self) -> bool: diff --git a/client/src/telethon/_impl/client/client/dialogs.py b/client/src/telethon/_impl/client/client/dialogs.py index 7ab8ee1a..41a6c77c 100644 --- a/client/src/telethon/_impl/client/client/dialogs.py +++ b/client/src/telethon/_impl/client/client/dialogs.py @@ -2,8 +2,8 @@ from __future__ import annotations from typing import TYPE_CHECKING -from ...tl import abcs, functions, types -from ..types import AsyncList, ChatLike, Dialog, User +from ...tl import functions, types +from ..types import AsyncList, ChatLike, Dialog, Draft from ..utils import build_chat_map if TYPE_CHECKING: @@ -78,3 +78,29 @@ async def delete_dialog(self: Client, chat: ChatLike) -> None: max_date=None, ) ) + + +class DraftList(AsyncList[Draft]): + def __init__(self, client: Client): + super().__init__() + self._client = client + self._offset = 0 + + async def _fetch_next(self) -> None: + result = await self._client(functions.messages.get_all_drafts()) + assert isinstance(result, types.Updates) + + chat_map = build_chat_map(result.users, result.chats) + + self._buffer.extend( + Draft._from_raw(u, chat_map) + for u in result.updates + if isinstance(u, types.UpdateDraftMessage) + ) + + self._total = len(result.updates) + self._done = True + + +def get_drafts(self: Client) -> AsyncList[Draft]: + return DraftList(self) diff --git a/client/src/telethon/_impl/client/client/files.py b/client/src/telethon/_impl/client/client/files.py index 3f1c5fa0..2c4db2f2 100644 --- a/client/src/telethon/_impl/client/client/files.py +++ b/client/src/telethon/_impl/client/client/files.py @@ -1,12 +1,9 @@ from __future__ import annotations -import asyncio import hashlib -from functools import partial from inspect import isawaitable -from io import BufferedWriter from pathlib import Path -from typing import TYPE_CHECKING, Any, Coroutine, Optional, Tuple, Union +from typing import TYPE_CHECKING, Optional, Union from ...tl import abcs, functions, types from ..types import ( @@ -43,6 +40,9 @@ async def send_photo( compress: bool = True, width: Optional[int] = None, height: Optional[int] = None, + caption: Optional[str] = None, + caption_markdown: Optional[str] = None, + caption_html: Optional[str] = None, ) -> Message: return await send_file( self, @@ -55,6 +55,9 @@ async def send_photo( compress=compress, width=width, height=height, + caption=caption, + caption_markdown=caption_markdown, + caption_html=caption_html, ) @@ -71,6 +74,9 @@ async def send_audio( voice: bool = False, title: Optional[str] = None, performer: Optional[str] = None, + caption: Optional[str] = None, + caption_markdown: Optional[str] = None, + caption_html: Optional[str] = None, ) -> Message: return await send_file( self, @@ -84,6 +90,9 @@ async def send_audio( voice=voice, title=title, performer=performer, + caption=caption, + caption_markdown=caption_markdown, + caption_html=caption_html, ) @@ -101,6 +110,9 @@ async def send_video( height: Optional[int] = None, round: bool = False, supports_streaming: bool = False, + caption: Optional[str] = None, + caption_markdown: Optional[str] = None, + caption_html: Optional[str] = None, ) -> Message: return await send_file( self, @@ -115,6 +127,9 @@ async def send_video( height=height, round=round, supports_streaming=supports_streaming, + caption=caption, + caption_markdown=caption_markdown, + caption_html=caption_html, ) diff --git a/client/src/telethon/_impl/client/client/messages.py b/client/src/telethon/_impl/client/client/messages.py index 91cedbc1..515b9817 100644 --- a/client/src/telethon/_impl/client/client/messages.py +++ b/client/src/telethon/_impl/client/client/messages.py @@ -319,6 +319,7 @@ class SearchList(MessageList): self._peer: Optional[abcs.InputPeer] = None self._limit = limit self._query = query + self._filter = types.InputMessagesFilterEmpty() self._offset_id = offset_id self._offset_date = offset_date @@ -334,7 +335,7 @@ class SearchList(MessageList): q=self._query, from_id=None, top_msg_id=None, - filter=types.InputMessagesFilterEmpty(), + filter=self._filter, min_date=0, max_date=self._offset_date, offset_id=self._offset_id, diff --git a/client/src/telethon/_impl/client/client/updates.py b/client/src/telethon/_impl/client/client/updates.py index 0eb4be0e..82233e72 100644 --- a/client/src/telethon/_impl/client/client/updates.py +++ b/client/src/telethon/_impl/client/client/updates.py @@ -7,12 +7,10 @@ from typing import ( Any, Awaitable, Callable, - Dict, List, Optional, Type, TypeVar, - Union, ) from ...session import Gap diff --git a/client/src/telethon/_impl/client/types/__init__.py b/client/src/telethon/_impl/client/types/__init__.py index 406db0bc..d1cd7826 100644 --- a/client/src/telethon/_impl/client/types/__init__.py +++ b/client/src/telethon/_impl/client/types/__init__.py @@ -1,12 +1,14 @@ from .async_list import AsyncList from .chat import Channel, Chat, ChatLike, Group, RestrictionReason, User from .dialog import Dialog +from .draft import Draft from .file import File, InFileLike, InWrapper, OutFileLike, OutWrapper from .login_token import LoginToken from .message import Message from .meta import NoPublicConstructor from .participant import Participant from .password_token import PasswordToken +from .recent_action import RecentAction __all__ = [ "AsyncList", @@ -17,6 +19,7 @@ __all__ = [ "RestrictionReason", "User", "Dialog", + "Draft", "File", "InFileLike", "InWrapper", @@ -27,4 +30,5 @@ __all__ = [ "NoPublicConstructor", "Participant", "PasswordToken", + "RecentAction", ] diff --git a/client/src/telethon/_impl/client/types/draft.py b/client/src/telethon/_impl/client/types/draft.py new file mode 100644 index 00000000..8cb80509 --- /dev/null +++ b/client/src/telethon/_impl/client/types/draft.py @@ -0,0 +1,29 @@ +from typing import Dict, List, Optional, Self + +from ...session import PackedChat, PackedType +from ...tl import abcs, types +from .chat import Chat +from .meta import NoPublicConstructor + + +class Draft(metaclass=NoPublicConstructor): + """ + A draft message in a chat. + """ + + __slots__ = ("_raw", "_chat_map") + + def __init__( + self, raw: types.UpdateDraftMessage, chat_map: Dict[int, Chat] + ) -> None: + self._raw = raw + self._chat_map = chat_map + + @classmethod + def _from_raw( + cls, draft: types.UpdateDraftMessage, chat_map: Dict[int, Chat] + ) -> Self: + return cls._create(draft, chat_map) + + async def delete(self) -> None: + pass diff --git a/client/src/telethon/_impl/client/types/file.py b/client/src/telethon/_impl/client/types/file.py index 1d073b54..22ae6086 100644 --- a/client/src/telethon/_impl/client/types/file.py +++ b/client/src/telethon/_impl/client/types/file.py @@ -137,7 +137,7 @@ class File(metaclass=NoPublicConstructor): self._raw = raw @classmethod - def _try_from_raw(cls, raw: abcs.MessageMedia) -> Optional[Self]: + def _try_from_raw_message_media(cls, raw: abcs.MessageMedia) -> Optional[Self]: if isinstance(raw, types.MessageMediaDocument): if isinstance(raw.document, types.Document): return cls._create( @@ -176,30 +176,47 @@ class File(metaclass=NoPublicConstructor): raw=raw, ) elif isinstance(raw, types.MessageMediaPhoto): - if isinstance(raw.photo, types.Photo): - return cls._create( - path=None, - file=None, - attributes=[], - size=max(map(photo_size_byte_count, raw.photo.sizes)), - name="", - mime="image/jpeg", - photo=True, - muted=False, - input_media=types.InputMediaPhoto( - spoiler=raw.spoiler, - id=types.InputPhoto( - id=raw.photo.id, - access_hash=raw.photo.access_hash, - file_reference=raw.photo.file_reference, - ), - ttl_seconds=raw.ttl_seconds, - ), - raw=raw, + if raw.photo: + return cls._try_from_raw_photo( + raw.photo, spoiler=raw.spoiler, ttl_seconds=raw.ttl_seconds ) return None + @classmethod + def _try_from_raw_photo( + cls, + raw: abcs.Photo, + *, + spoiler: bool = False, + ttl_seconds: Optional[int] = None, + ) -> Optional[Self]: + if isinstance(raw, types.Photo): + return cls._create( + path=None, + file=None, + attributes=[], + size=max(map(photo_size_byte_count, raw.sizes)), + name="", + mime="image/jpeg", + photo=True, + muted=False, + input_media=types.InputMediaPhoto( + spoiler=spoiler, + id=types.InputPhoto( + id=raw.id, + access_hash=raw.access_hash, + file_reference=raw.file_reference, + ), + ttl_seconds=ttl_seconds, + ), + raw=types.MessageMediaPhoto( + spoiler=spoiler, photo=raw, ttl_seconds=ttl_seconds + ), + ) + + return None + @classmethod def new( cls, diff --git a/client/src/telethon/_impl/client/types/message.py b/client/src/telethon/_impl/client/types/message.py index 1cb46188..30d52b28 100644 --- a/client/src/telethon/_impl/client/types/message.py +++ b/client/src/telethon/_impl/client/types/message.py @@ -39,8 +39,26 @@ class Message(metaclass=NoPublicConstructor): @property def id(self) -> int: + """ + The message identifier. + + .. seealso:: + + :doc:`/concepts/messages`, which contains an in-depth explanation of message counters. + """ return self._raw.id + @property + def grouped_id(self) -> Optional[int]: + """ + If the message is grouped with others in an album, return the group identifier. + + Messages with the same :attr:`grouped_id` will belong to the same album. + + Note that there can be messages in-between that do not have a :attr:`grouped_id`. + """ + return getattr(self._raw, "grouped_id", None) + @property def text(self) -> Optional[str]: return getattr(self._raw, "message", None) @@ -91,7 +109,7 @@ class Message(metaclass=NoPublicConstructor): def _file(self) -> Optional[File]: return ( - File._try_from_raw(self._raw.media) + File._try_from_raw_message_media(self._raw.media) if isinstance(self._raw, types.Message) and self._raw.media else None ) diff --git a/client/src/telethon/_impl/client/types/recent_action.py b/client/src/telethon/_impl/client/types/recent_action.py new file mode 100644 index 00000000..ffb1acc0 --- /dev/null +++ b/client/src/telethon/_impl/client/types/recent_action.py @@ -0,0 +1,29 @@ +from typing import Dict, List, Optional, Self, Union + +from ...session import PackedChat, PackedType +from ...tl import abcs, types +from .chat import Chat +from .meta import NoPublicConstructor + + +class RecentAction(metaclass=NoPublicConstructor): + """ + A recent action in a chat, also known as an "admin log event action" or :tl:`ChannelAdminLogEvent`. + + Only administrators of the chat can access these. + """ + + __slots__ = ("_raw", "_chat_map") + + def __init__( + self, + event: abcs.ChannelAdminLogEvent, + chat_map: Dict[int, Chat], + ) -> None: + assert isinstance(event, types.ChannelAdminLogEvent) + self._raw = event + self._chat_map = chat_map + + @property + def id(self) -> int: + return self._raw.id diff --git a/client/src/telethon/_impl/session/chat/packed.py b/client/src/telethon/_impl/session/chat/packed.py index 350bbb2f..3e6d2738 100644 --- a/client/src/telethon/_impl/session/chat/packed.py +++ b/client/src/telethon/_impl/session/chat/packed.py @@ -1,11 +1,11 @@ import struct -from enum import Enum +from enum import IntFlag from typing import Optional, Self from ...tl import abcs, types -class PackedType(Enum): +class PackedType(IntFlag): """ The type of a :class:`PackedChat`. """ @@ -52,6 +52,27 @@ class PackedChat: ty = PackedType(ty_byte & 0b0011_1111) return cls(ty, id, access_hash if has_hash else None) + @property + def hex(self) -> str: + """ + Convenience property to convert to bytes and represent them as hexadecimal numbers: + + .. code-block:: + + assert packed.hex == bytes(packed).hex() + """ + return bytes(self).hex() + + def from_hex(cls, hex: str) -> Self: + """ + Convenience method to convert hexadecimal numbers into bytes then passed to :meth:`from_bytes`: + + .. code-block:: + + assert PackedChat.from_hex(packed.hex) == packed + """ + return cls.from_bytes(bytes.fromhex(hex)) + def is_user(self) -> bool: return self.ty in (PackedType.USER, PackedType.BOT) @@ -93,13 +114,13 @@ class PackedChat: if self.is_user(): return types.InputUser(user_id=self.id, access_hash=self.access_hash or 0) else: - raise ValueError("chat is not user") + raise TypeError("chat is not a user") def _to_chat_id(self) -> int: if self.is_chat(): return self.id else: - raise ValueError("chat is not small group") + raise TypeError("chat is not a group") def _to_input_channel(self) -> types.InputChannel: if self.is_channel(): @@ -107,7 +128,7 @@ class PackedChat: channel_id=self.id, access_hash=self.access_hash or 0 ) else: - raise ValueError("chat is not channel") + raise TypeError("chat is not a channel") def __eq__(self, other: object) -> bool: if not isinstance(other, self.__class__): diff --git a/tools/copy_client_signatures.py b/tools/copy_client_signatures.py index 0c95cdbd..104f7618 100644 --- a/tools/copy_client_signatures.py +++ b/tools/copy_client_signatures.py @@ -11,6 +11,7 @@ Properties and private methods can use a different parameter name than `self` to avoid being included. """ import ast +import subprocess import sys from pathlib import Path from typing import Dict, List, Union @@ -62,6 +63,7 @@ class MethodVisitor(ast.NodeVisitor): def main() -> None: client_root = Path.cwd() / "client/src/telethon/_impl/client/client" + client_py = client_root / "client.py" fm_visitor = FunctionMethodsVisitor() m_visitor = MethodVisitor() @@ -74,7 +76,7 @@ def main() -> None: fm_visitor.visit(ast.parse(contents)) - with (client_root / "client.py").open(encoding="utf-8") as fd: + with client_py.open(encoding="utf-8") as fd: contents = fd.read() m_visitor.visit(ast.parse(contents)) @@ -109,13 +111,22 @@ def main() -> None: function.body.append(call) class_body.append(function) - print( - ast.unparse( - ast.ClassDef( - name="Client", bases=[], keywords=[], body=class_body, decorator_list=[] - ) + generated = ast.unparse( + ast.ClassDef( + name="Client", bases=[], keywords=[], body=class_body, decorator_list=[] ) - ) + )[len("class Client:") :].strip() + + start_idx = contents.index("\n", contents.index("# Begin partially @generated")) + end_idx = contents.index("# End partially @generated") + + with client_py.open("w", encoding="utf-8") as fd: + fd.write( + f"{contents[:start_idx]}\n\n {generated}\n\n {contents[end_idx:]}" + ) + + print("written @generated") + exit(subprocess.run((sys.executable, "-m", "black", str(client_py))).returncode) if __name__ == "__main__":