diff --git a/optional-requirements.txt b/optional-requirements.txt index fb83c1ab..55bfc014 100644 --- a/optional-requirements.txt +++ b/optional-requirements.txt @@ -1,4 +1,3 @@ cryptg pysocks hachoir3 -sqlalchemy diff --git a/readthedocs/extra/advanced-usage/sessions.rst b/readthedocs/extra/advanced-usage/sessions.rst index 66fa0560..592d8334 100644 --- a/readthedocs/extra/advanced-usage/sessions.rst +++ b/readthedocs/extra/advanced-usage/sessions.rst @@ -36,77 +36,26 @@ one of the other implementations or implement your own storage. To use a custom session storage, simply pass the custom session instance to ``TelegramClient`` instead of the session name. -Currently, there are three implementations of the abstract ``Session`` class: -* ``MemorySession``. Stores session data in Python variables. -* ``SQLiteSession``, (default). Stores sessions in their own SQLite databases. -* ``AlchemySession``. Stores all sessions in a single database via SQLAlchemy. +Telethon contains two implementations of the abstract ``Session`` class: -Using AlchemySession -~~~~~~~~~~~~~~~~~~~~ -The ``AlchemySession`` implementation can store multiple Sessions in the same -database, but to do this, each session instance needs to have access to the -same models and database session. +* ``MemorySession``: stores session data in Python variables. +* ``SQLiteSession``, (default): stores sessions in their own SQLite databases. -To get started, you need to create an ``AlchemySessionContainer`` which will -contain that shared data. The simplest way to use ``AlchemySessionContainer`` -is to simply pass it the database URL: +There are other community-maintained implementations available: - .. code-block:: python - - container = AlchemySessionContainer('mysql://user:pass@localhost/telethon') - -If you already have SQLAlchemy set up for your own project, you can also pass -the engine separately: - - .. code-block:: python - - my_sqlalchemy_engine = sqlalchemy.create_engine('...') - container = AlchemySessionContainer(engine=my_sqlalchemy_engine) - -By default, the session container will manage table creation/schema updates/etc -automatically. If you want to manage everything yourself, you can pass your -SQLAlchemy Session and ``declarative_base`` instances and set ``manage_tables`` -to ``False``: - - .. code-block:: python - - from sqlalchemy.ext.declarative import declarative_base - from sqlalchemy import orm - import sqlalchemy - - ... - - session_factory = orm.sessionmaker(bind=my_sqlalchemy_engine) - session = session_factory() - my_base = declarative_base() - - ... - - container = AlchemySessionContainer( - session=session, table_base=my_base, manage_tables=False - ) - -You always need to provide either ``engine`` or ``session`` to the container. -If you set ``manage_tables=False`` and provide a ``session``, ``engine`` is not -needed. In any other case, ``engine`` is always required. - -After you have your ``AlchemySessionContainer`` instance created, you can -create new sessions by calling ``new_session``: - - .. code-block:: python - - session = container.new_session('some session id') - client = TelegramClient(session) - -where ``some session id`` is an unique identifier for the session. +* `SQLAlchemy `_: stores all sessions in a single database via SQLAlchemy. +* `Redis `_: stores all sessions in a single Redis data store. Creating your own storage ~~~~~~~~~~~~~~~~~~~~~~~~~ -The easiest way to create your own implementation is to use ``MemorySession`` -as the base and check out how ``SQLiteSession`` or ``AlchemySession`` work. -You can find the relevant Python files under the ``sessions`` directory. +The easiest way to create your own storage implementation is to use ``MemorySession`` +as the base and check out how ``SQLiteSession`` or one of the community-maintained +implementations work. You can find the relevant Python files under the ``sessions`` +directory in Telethon. +After you have made your own implementation, you can add it to the community-maintained +session implementation list above with a pull request. SQLite Sessions and Heroku -------------------------- diff --git a/readthedocs/extra/basic/entities.rst b/readthedocs/extra/basic/entities.rst index 84be3250..ce7e569a 100644 --- a/readthedocs/extra/basic/entities.rst +++ b/readthedocs/extra/basic/entities.rst @@ -10,6 +10,14 @@ The library widely uses the concept of "entities". An entity will refer to any ``User``, ``Chat`` or ``Channel`` object that the API may return in response to certain methods, such as ``GetUsersRequest``. +.. note:: + + When something "entity-like" is required, it means that you need to + provide something that can be turned into an entity. These things include, + but are not limited to, usernames, exact titles, IDs, ``Peer`` objects, + or even entire ``User``, ``Chat`` and ``Channel`` objects and even phone + numbers from people you have in your contacts. + Getting entities **************** diff --git a/readthedocs/extra/basic/telegram-client.rst b/readthedocs/extra/basic/telegram-client.rst index d3375200..decb3765 100644 --- a/readthedocs/extra/basic/telegram-client.rst +++ b/readthedocs/extra/basic/telegram-client.rst @@ -8,6 +8,11 @@ TelegramClient Introduction ************ +.. note:: + + Check the :ref:`telethon-package` if you're looking for the methods + reference instead of this tutorial. + The ``TelegramClient`` is the central class of the library, the one you will be using most of the time. For this reason, it's important to know what it offers. @@ -86,13 +91,7 @@ Please refer to :ref:`accessing-the-full-api` if these aren't enough, and don't be afraid to read the source code of the InteractiveTelegramClient_ or even the TelegramClient_ itself to learn how it works. +To see the methods available in the client, see :ref:`telethon-package`. .. _InteractiveTelegramClient: https://github.com/LonamiWebs/Telethon/blob/master/telethon_examples/interactive_telegram_client.py .. _TelegramClient: https://github.com/LonamiWebs/Telethon/blob/master/telethon/telegram_client.py - - - -.. automodule:: telethon.telegram_client - :members: - :undoc-members: - :show-inheritance: diff --git a/readthedocs/extra/basic/working-with-updates.rst b/readthedocs/extra/basic/working-with-updates.rst index 3c57b792..308c3a79 100644 --- a/readthedocs/extra/basic/working-with-updates.rst +++ b/readthedocs/extra/basic/working-with-updates.rst @@ -7,7 +7,9 @@ Working with Updates The library comes with the :mod:`events` module. *Events* are an abstraction over what Telegram calls `updates`__, and are meant to ease simple and common -usage when dealing with them, since there are many updates. Let's dive in! +usage when dealing with them, since there are many updates. If you're looking +for the method reference, check :ref:`telethon-events-package`, otherwise, +let's dive in! .. note:: @@ -114,12 +116,15 @@ for example: import random - @client.on(events.NewMessage(chats='TelethonOffTopic', incoming=True)) + # Either a single item or a list of them will work for the chats. + # You can also use the IDs, Peers, or even User/Chat/Channel objects. + @client.on(events.NewMessage(chats=('TelethonChat', 'TelethonOffTopic'))) def normal_handler(event): if 'roll' in event.raw_text: event.reply(str(random.randint(1, 6))) + # Similarly, you can use incoming=True for messages that you receive @client.on(events.NewMessage(chats='TelethonOffTopic', outgoing=True)) def admin_handler(event): if event.raw_text.startswith('eval'): @@ -135,6 +140,20 @@ random number, while if you say ``'eval 4+4'``, you will reply with the solution. Try it! +Events without decorators +************************* + +If for any reason you can't use the ``@client.on`` syntax, don't worry. +You can call ``client.add_event_handler(callback, event)`` to achieve +the same effect. + +Similar to that method, you also have :meth:`client.remove_event_handler` +and :meth:`client.list_event_handlers` which do as they names indicate. + +The ``event`` type is optional in all methods and defaults to ``events.Raw`` +for adding, and ``None`` when removing (so all callbacks would be removed). + + Stopping propagation of Updates ******************************* @@ -162,14 +181,8 @@ propagation of the update through your handlers to stop: pass -Events module -************* - -.. automodule:: telethon.events - :members: - :undoc-members: - :show-inheritance: - +Remember to check :ref:`telethon-events-package` if you're looking for +the methods reference. __ https://lonamiwebs.github.io/Telethon/types/update.html diff --git a/readthedocs/extra/changelog.rst b/readthedocs/extra/changelog.rst index e8876d5c..31d58d6b 100644 --- a/readthedocs/extra/changelog.rst +++ b/readthedocs/extra/changelog.rst @@ -14,6 +14,69 @@ it can take advantage of new goodies! .. contents:: List of All Versions +Iterator methods (v0.18.1) +========================== + +*Published at 2018/03/17* + +All the ``.get_`` methods in the ``TelegramClient`` now have a ``.iter_`` +counterpart, so you can do operations while retrieving items from them. +For instance, you can ``client.iter_dialogs()`` and ``break`` once you +find what you're looking for instead fetching them all at once. + +Another big thing, you can get entities by just their positive ID. This +may cause some collisions (although it's very unlikely), and you can (should) +still be explicit about the type you want. However, it's a lot more convenient +and less confusing. + +Breaking changes +~~~~~~~~~~~~~~~~ + +- The library only offers the default ``SQLiteSession`` again. + See :ref:`sessions` for more on how to use a different storage from now on. + +Additions +~~~~~~~~~ + +- Events now override ``__str__`` and implement ``.stringify()``, just like + every other ``TLObject`` does. +- ``events.ChatAction`` now has :meth:`respond`, :meth:`reply` and + :meth:`delete` for the message that triggered it. +- :meth:`client.iter_participants` (and its :meth:`client.get_participants` + counterpart) now expose the ``filter`` argument, and the returned users + also expose the ``.participant`` they are. +- You can now use :meth:`client.remove_event_handler` and + :meth:`client.list_event_handlers` similar how you could with normal updates. +- New properties on ``events.NewMessage``, like ``.video_note`` and ``.gif`` + to access only specific types of documents. +- The ``Draft`` class now exposes ``.text`` and ``.raw_text``, as well as a + new :meth:`Draft.send` to send it. + +Bug fixes +~~~~~~~~~ + +- ``MessageEdited`` was ignoring ``NewMessage`` constructor arguments. +- Fixes for ``Event.delete_messages`` which wouldn't handle ``MessageService``. +- Bot API style IDs not working on :meth:`client.get_input_entity`. +- :meth:`client.download_media` didn't support ``PhotoSize``. + +Enhancements +~~~~~~~~~~~~ + +- Less RPC are made when accessing the ``.sender`` and ``.chat`` of some + events (mostly those that occur in a channel). +- You can send albums larger than 10 items (they will be sliced for you), + as well as mixing normal files with photos. +- ``TLObject`` now have Python type hints. + +Internal changes +~~~~~~~~~~~~~~~~ + +- Several documentation corrections. +- :meth:`client.get_dialogs` is only called once again when an entity is + not found to avoid flood waits. + + Sessions overhaul (v0.18) ========================= diff --git a/readthedocs/extra/developing/api-status.rst b/readthedocs/extra/developing/api-status.rst index 492340a4..e113c48e 100644 --- a/readthedocs/extra/developing/api-status.rst +++ b/readthedocs/extra/developing/api-status.rst @@ -1,3 +1,5 @@ +.. _api-status: + ========== API Status ========== @@ -10,11 +12,9 @@ anyone can query, made by `Daniil `__. All the information sent is a ``GET`` request with the error code, error message and method used. -If you still would like to opt out, simply set -``client.session.report_errors = False`` to disable this feature, or -pass ``report_errors=False`` as a named parameter when creating a -``TelegramClient`` instance. However Daniil would really thank you if -you helped him (and everyone) by keeping it on! +If you still would like to opt out, you can disable this feature by setting +``client.session.report_errors = False``. However Daniil would really thank +you if you helped him (and everyone) by keeping it on! Querying the API status *********************** diff --git a/readthedocs/extra/developing/telegram-api-in-other-languages.rst b/readthedocs/extra/developing/telegram-api-in-other-languages.rst index 7637282e..22bb416a 100644 --- a/readthedocs/extra/developing/telegram-api-in-other-languages.rst +++ b/readthedocs/extra/developing/telegram-api-in-other-languages.rst @@ -38,9 +38,9 @@ Kotlin ****** `Kotlogram `__ is a Telegram -implementation written in Kotlin (the now +implementation written in Kotlin (one of the `official `__ -language for +languages for `Android `__) by `@badoualy `__, currently as a beta– yet working. diff --git a/readthedocs/extra/developing/test-servers.rst b/readthedocs/extra/developing/test-servers.rst index 2ba66897..a3288a25 100644 --- a/readthedocs/extra/developing/test-servers.rst +++ b/readthedocs/extra/developing/test-servers.rst @@ -8,8 +8,7 @@ To run Telethon on a test server, use the following code: .. code-block:: python client = TelegramClient(None, api_id, api_hash) - client.session.server_address = '149.154.167.40' - client.connect() + client.session.set_dc(dc_id, '149.154.167.40', 80) You can check your ``'test ip'`` on https://my.telegram.org. @@ -17,16 +16,20 @@ You should set ``None`` session so to ensure you're generating a new authorization key for it (it would fail if you used a session where you had previously connected to another data center). -Once you're connected, you'll likely need to ``.sign_up()``. Remember -`anyone can access the phone you +Note that port 443 might not work, so you can try with 80 instead. + +Once you're connected, you'll likely be asked to either sign in or sign up. +Remember `anyone can access the phone you choose `__, -so don't store sensitive data here: +so don't store sensitive data here. + +Valid phone numbers are ``99966XYYYY``, where ``X`` is the ``dc_id`` and +``YYYY`` is any number you want, for example, ``1234`` in ``dc_id = 2`` would +be ``9996621234``. The code sent by Telegram will be ``dc_id`` repeated five +times, in this case, ``22222`` so we can hardcode that: .. code-block:: python - from random import randint - - dc_id = '2' # Change this to the DC id of the test server you chose - phone = '99966' + dc_id + str(randint(9999)).zfill(4) - client.send_code_request(phone) - client.sign_up(dc_id * 5, 'Some', 'Name') + client = TelegramClient(None, api_id, api_hash) + client.session.set_dc(2, '149.154.167.40', 80) + client.start(phone='9996621234', code_callback=lambda: '22222') diff --git a/readthedocs/index.rst b/readthedocs/index.rst index c1d2b6ec..a3982d86 100644 --- a/readthedocs/index.rst +++ b/readthedocs/index.rst @@ -15,7 +15,8 @@ or use the menu on the left. Remember to read the :ref:`changelog` when you upgrade! .. important:: - If you're new here, you want to read :ref:`getting-started`. + If you're new here, you want to read :ref:`getting-started`. If you're + looking for the method reference, you should check :ref:`telethon-package`. What is this? diff --git a/readthedocs/telethon.crypto.rst b/readthedocs/telethon.crypto.rst index 3c11416d..8adf55d5 100644 --- a/readthedocs/telethon.crypto.rst +++ b/readthedocs/telethon.crypto.rst @@ -42,14 +42,6 @@ telethon\.crypto\.factorization module :undoc-members: :show-inheritance: -telethon\.crypto\.libssl module -------------------------------- - -.. automodule:: telethon.crypto.libssl - :members: - :undoc-members: - :show-inheritance: - telethon\.crypto\.rsa module ---------------------------- diff --git a/readthedocs/telethon.events.rst b/readthedocs/telethon.events.rst index 071a39bf..37ce9f48 100644 --- a/readthedocs/telethon.events.rst +++ b/readthedocs/telethon.events.rst @@ -1,4 +1,9 @@ +.. _telethon-events-package: + telethon\.events package ======================== - +.. automodule:: telethon.events + :members: + :undoc-members: + :show-inheritance: diff --git a/readthedocs/telethon.rst b/readthedocs/telethon.rst index 96becc9b..0b60b007 100644 --- a/readthedocs/telethon.rst +++ b/readthedocs/telethon.rst @@ -1,11 +1,14 @@ +.. _telethon-package: + + telethon package ================ -telethon\.helpers module ------------------------- +telethon\.telegram\_client module +--------------------------------- -.. automodule:: telethon.helpers +.. automodule:: telethon.telegram_client :members: :undoc-members: :show-inheritance: @@ -18,10 +21,18 @@ telethon\.telegram\_bare\_client module :undoc-members: :show-inheritance: -telethon\.telegram\_client module ---------------------------------- +telethon\.utils module +---------------------- -.. automodule:: telethon.telegram_client +.. automodule:: telethon.utils + :members: + :undoc-members: + :show-inheritance: + +telethon\.helpers module +------------------------ + +.. automodule:: telethon.helpers :members: :undoc-members: :show-inheritance: @@ -42,18 +53,10 @@ telethon\.update\_state module :undoc-members: :show-inheritance: -telethon\.utils module ----------------------- +telethon\.sessions module +------------------------- -.. automodule:: telethon.utils - :members: - :undoc-members: - :show-inheritance: - -telethon\.session module ------------------------- - -.. automodule:: telethon.session +.. automodule:: telethon.sessions :members: :undoc-members: :show-inheritance: diff --git a/requirements.txt b/requirements.txt index 2b650ec4..45e8c141 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ pyaes rsa +typing diff --git a/setup.py b/setup.py index 8e5d5bcd..012febbf 100755 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ Extra supported commands are: # To use a consistent encoding from codecs import open -from sys import argv +from sys import argv, version_info import os import re @@ -154,10 +154,10 @@ def main(): 'telethon_generator/parser/tl_object.py', 'telethon_generator/parser/tl_parser.py', ]), - install_requires=['pyaes', 'rsa'], + install_requires=['pyaes', 'rsa', + 'typing' if version_info < (3, 5) else ""], extras_require={ - 'cryptg': ['cryptg'], - 'sqlalchemy': ['sqlalchemy'] + 'cryptg': ['cryptg'] } ) diff --git a/telethon/update_state.py b/telethon/update_state.py new file mode 100644 index 00000000..9f26e3a4 --- /dev/null +++ b/telethon/update_state.py @@ -0,0 +1,150 @@ +import itertools +import logging +from datetime import datetime +from queue import Queue, Empty +from threading import RLock, Thread + +from . import utils +from .tl import types as tl + +__log__ = logging.getLogger(__name__) + + +class UpdateState: + """Used to hold the current state of processed updates. + To retrieve an update, .poll() should be called. + """ + WORKER_POLL_TIMEOUT = 5.0 # Avoid waiting forever on the workers + + def __init__(self, workers=None): + """ + :param workers: This integer parameter has three possible cases: + workers is None: Updates will *not* be stored on self. + workers = 0: Another thread is responsible for calling self.poll() + workers > 0: 'workers' background threads will be spawned, any + any of them will invoke the self.handler. + """ + self._workers = workers + self._worker_threads = [] + + self.handler = None + self._updates_lock = RLock() + self._updates = Queue() + + # https://core.telegram.org/api/updates + self._state = tl.updates.State(0, 0, datetime.now(), 0, 0) + + def can_poll(self): + """Returns True if a call to .poll() won't lock""" + return not self._updates.empty() + + def poll(self, timeout=None): + """ + Polls an update or blocks until an update object is available. + If 'timeout is not None', it should be a floating point value, + and the method will 'return None' if waiting times out. + """ + try: + return self._updates.get(timeout=timeout) + except Empty: + return None + + def get_workers(self): + return self._workers + + def set_workers(self, n): + """Changes the number of workers running. + If 'n is None', clears all pending updates from memory. + """ + if n is None: + self.stop_workers() + else: + self._workers = n + self.setup_workers() + + workers = property(fget=get_workers, fset=set_workers) + + def stop_workers(self): + """ + Waits for all the worker threads to stop. + """ + # Put dummy ``None`` objects so that they don't need to timeout. + n = self._workers + self._workers = None + if n: + with self._updates_lock: + for _ in range(n): + self._updates.put(None) + + for t in self._worker_threads: + t.join() + + self._worker_threads.clear() + self._workers = n + + def setup_workers(self): + if self._worker_threads or not self._workers: + # There already are workers, or workers is None or 0. Do nothing. + return + + for i in range(self._workers): + thread = Thread( + target=UpdateState._worker_loop, + name='UpdateWorker{}'.format(i), + daemon=True, + args=(self, i) + ) + self._worker_threads.append(thread) + thread.start() + + def _worker_loop(self, wid): + while self._workers is not None: + try: + update = self.poll(timeout=UpdateState.WORKER_POLL_TIMEOUT) + if update and self.handler: + self.handler(update) + except StopIteration: + break + except: + # We don't want to crash a worker thread due to any reason + __log__.exception('Unhandled exception on worker %d', wid) + + def process(self, update): + """Processes an update object. This method is normally called by + the library itself. + """ + if self._workers is None: + return # No processing needs to be done if nobody's working + + with self._updates_lock: + if isinstance(update, tl.updates.State): + __log__.debug('Saved new updates state') + self._state = update + return # Nothing else to be done + + if hasattr(update, 'pts'): + self._state.pts = update.pts + + # After running the script for over an hour and receiving over + # 1000 updates, the only duplicates received were users going + # online or offline. We can trust the server until new reports. + # + # TODO Note somewhere that all updates are modified to include + # .entities, which is a dictionary you can access but may be empty. + # This should only be used as read-only. + if isinstance(update, tl.UpdateShort): + update.update.entities = {} + self._updates.put(update.update) + # Expand "Updates" into "Update", and pass these to callbacks. + # Since .users and .chats have already been processed, we + # don't need to care about those either. + elif isinstance(update, (tl.Updates, tl.UpdatesCombined)): + entities = {utils.get_peer_id(x): x for x in + itertools.chain(update.users, update.chats)} + for u in update.updates: + u.entities = entities + self._updates.put(u) + # TODO Handle "tl.UpdatesTooLong" + else: + update.entities = {} + self._updates.put(update) diff --git a/telethon_aio/events/__init__.py b/telethon_aio/events/__init__.py index 09035fd0..fea26355 100644 --- a/telethon_aio/events/__init__.py +++ b/telethon_aio/events/__init__.py @@ -2,11 +2,12 @@ import abc import datetime import itertools import re +import warnings from .. import utils from ..errors import RPCError from ..extensions import markdown -from ..tl import types, functions +from ..tl import TLObject, types, functions async def _into_id_set(client, chats): @@ -71,6 +72,7 @@ class _EventCommon(abc.ABC): """Intermediate class with common things to all events""" def __init__(self, chat_peer=None, msg_id=None, broadcast=False): + self._entities = {} self._client = None self._chat_peer = chat_peer self._message_id = msg_id @@ -86,12 +88,15 @@ class _EventCommon(abc.ABC): ) self.is_channel = isinstance(chat_peer, types.PeerChannel) - async def _get_input_entity(self, msg_id, entity_id, chat=None): + async def _get_entity(self, msg_id, entity_id, chat=None): """ Helper function to call GetMessages on the give msg_id and return the input entity whose ID is the given entity ID. If ``chat`` is present it must be an InputPeer. + + Returns a tuple of (entity, input_peer) if it was found, or + a tuple of (None, None) if it couldn't be. """ try: if isinstance(chat, types.InputPeerChannel): @@ -103,14 +108,17 @@ class _EventCommon(abc.ABC): functions.messages.GetMessagesRequest([msg_id]) ) except RPCError: - return + return None, None + entity = { utils.get_peer_id(x): x for x in itertools.chain( getattr(result, 'chats', []), getattr(result, 'users', [])) }.get(entity_id) if entity: - return utils.get_input_peer(entity) + return entity, utils.get_input_peer(entity) + else: + return None, None @property async def input_chat(self): @@ -134,7 +142,7 @@ class _EventCommon(abc.ABC): # TODO For channels, getDifference? Maybe looking # in the dialogs (which is already done) is enough. if self._message_id is not None: - self._input_chat = await self._get_input_entity( + self._chat, self._input_chat = await self._get_entity( self._message_id, utils.get_peer_id(self._chat_peer) ) @@ -147,14 +155,32 @@ class _EventCommon(abc.ABC): async def chat(self): """ The (:obj:`User` | :obj:`Chat` | :obj:`Channel`, optional) on which - the event occurred. This property will make an API call the first time - to get the most up to date version of the chat, so use with care as - there is no caching besides local caching yet. + the event occurred. This property may make an API call the first time + to get the most up to date version of the chat (mostly when the event + doesn't belong to a channel), so keep that in mind. """ - if self._chat is None and await self.input_chat: - self._chat = await self._client.get_entity(await self._input_chat) + if not await self.input_chat: + return None + + if self._chat is None: + self._chat = self._entities.get(utils.get_peer_id(self._input_chat)) + + if self._chat is None: + self._chat = await self._client.get_entity(self._input_chat) + return self._chat + def __str__(self): + return TLObject.pretty_format(self.to_dict()) + + def stringify(self): + return TLObject.pretty_format(self.to_dict(), indent=0) + + def to_dict(self): + d = {k: v for k, v in self.__dict__.items() if k[0] != '_'} + d['_'] = self.__class__.__name__ + return d + class Raw(_EventBuilder): """ @@ -167,9 +193,19 @@ class Raw(_EventBuilder): return update +def _name_inner_event(cls): + """Decorator to rename cls.Event 'Event' as 'cls.Event'""" + if hasattr(cls, 'Event'): + cls.Event.__name__ = '{}.Event'.format(cls.__name__) + else: + warnings.warn('Class {} does not have a inner Event'.format(cls)) + return cls + + # Classes defined here are actually Event builders # for their inner Event classes. Inner ._client is # set later by the creator TelegramClient. +@_name_inner_event class NewMessage(_EventBuilder): """ Represents a new message event builder. @@ -247,6 +283,10 @@ class NewMessage(_EventBuilder): else: return + event._entities = update.entities + return self._message_filter_event(event) + + def _message_filter_event(self, event): # Short-circuit if we let pass all events if all(x is None for x in (self.incoming, self.outgoing, self.chats, self.pattern)): @@ -299,8 +339,6 @@ class NewMessage(_EventBuilder): self.message = message self._text = None - self._input_chat = None - self._chat = None self._input_sender = None self._sender = None @@ -384,7 +422,7 @@ class NewMessage(_EventBuilder): ) except (ValueError, TypeError): # We can rely on self.input_chat for this - self._input_sender = await self._get_input_entity( + self._sender, self._input_sender = await self._get_entity( self.message.id, self.message.from_id, chat=await self.input_chat @@ -395,14 +433,22 @@ class NewMessage(_EventBuilder): @property async def sender(self): """ - This (:obj:`User`) will make an API call the first time to get - the most up to date version of the sender, so use with care as - there is no caching besides local caching yet. + This (:obj:`User`) may make an API call the first time to get + the most up to date version of the sender (mostly when the event + doesn't belong to a channel), so keep that in mind. ``input_sender`` needs to be available (often the case). """ - if self._sender is None and await self.input_sender: + if not await self.input_sender: + return None + + if self._sender is None: + self._sender = \ + self._entities.get(utils.get_peer_id(self._input_sender)) + + if self._sender is None: self._sender = await self._client.get_entity(self._input_sender) + return self._sender @property @@ -556,6 +602,7 @@ class NewMessage(_EventBuilder): return self.message.out +@_name_inner_event class ChatAction(_EventBuilder): """ Represents an action in a chat (such as user joined, left, or new pin). @@ -621,6 +668,7 @@ class ChatAction(_EventBuilder): else: return + event._entities = update.entities return self._filter_event(event) class Event(_EventCommon): @@ -764,7 +812,13 @@ class ChatAction(_EventBuilder): The user who added ``users``, if applicable (``None`` otherwise). """ if self._added_by and not isinstance(self._added_by, types.User): - self._added_by = await self._client.get_entity(self._added_by) + self._added_by =\ + self._entities.get(utils.get_peer_id(self._added_by)) + + if not self._added_by: + self._added_by =\ + await self._client.get_entity(self._added_by) + return self._added_by @property @@ -773,7 +827,13 @@ class ChatAction(_EventBuilder): The user who kicked ``users``, if applicable (``None`` otherwise). """ if self._kicked_by and not isinstance(self._kicked_by, types.User): - self._kicked_by = await self._client.get_entity(self._kicked_by) + self._kicked_by =\ + self._entities.get(utils.get_peer_id(self._kicked_by)) + + if not self._kicked_by: + self._kicked_by =\ + await self._client.get_entity(self._kicked_by) + return self._kicked_by @property @@ -803,11 +863,24 @@ class ChatAction(_EventBuilder): Might be empty if the information can't be retrieved or there are no users taking part. """ - if self._users is None and self._user_peers: + if not self._user_peers: + return [] + + if self._users is None: + have, missing = [], [] + for peer in self._user_peers: + user = self._entities.get(utils.get_peer_id(peer)) + if user: + have.append(user) + else: + missing.append(peer) + try: - self._users = await self._client.get_entity(self._user_peers) + missing = await self._client.get_entity(missing) except (TypeError, ValueError): - self._users = [] + missing = [] + + self._user_peers = have + missing return self._users @@ -828,6 +901,7 @@ class ChatAction(_EventBuilder): return self._input_users +@_name_inner_event class UserUpdate(_EventBuilder): """ Represents an user update (gone online, offline, joined Telegram). @@ -839,6 +913,7 @@ class UserUpdate(_EventBuilder): else: return + event._entities = update.entities return self._filter_event(event) class Event(_EventCommon): @@ -975,6 +1050,7 @@ class UserUpdate(_EventBuilder): return self.chat +@_name_inner_event class MessageEdited(NewMessage): """ Event fired when a message has been edited. @@ -986,9 +1062,11 @@ class MessageEdited(NewMessage): else: return - return self._filter_event(event) + event._entities = update.entities + return self._message_filter_event(event) +@_name_inner_event class MessageDeleted(_EventBuilder): """ Event fired when one or more messages are deleted. @@ -1007,6 +1085,7 @@ class MessageDeleted(_EventBuilder): else: return + event._entities = update.entities return self._filter_event(event) class Event(_EventCommon): diff --git a/telethon_aio/sessions/__init__.py b/telethon_aio/sessions/__init__.py index a487a4bd..af3423f3 100644 --- a/telethon_aio/sessions/__init__.py +++ b/telethon_aio/sessions/__init__.py @@ -1,4 +1,3 @@ from .abstract import Session from .memory import MemorySession from .sqlite import SQLiteSession -from .sqlalchemy import AlchemySessionContainer, AlchemySession diff --git a/telethon_aio/sessions/sqlalchemy.py b/telethon_aio/sessions/sqlalchemy.py deleted file mode 100644 index a24df485..00000000 --- a/telethon_aio/sessions/sqlalchemy.py +++ /dev/null @@ -1,236 +0,0 @@ -try: - from sqlalchemy.ext.declarative import declarative_base - from sqlalchemy import Column, String, Integer, LargeBinary, orm - import sqlalchemy as sql -except ImportError: - sql = None - -from .memory import MemorySession, _SentFileType -from .. import utils -from ..crypto import AuthKey -from ..tl.types import ( - InputPhoto, InputDocument, PeerUser, PeerChat, PeerChannel -) - -LATEST_VERSION = 1 - - -class AlchemySessionContainer: - def __init__(self, engine=None, session=None, table_prefix='', - table_base=None, manage_tables=True): - if not sql: - raise ImportError('SQLAlchemy not imported') - if isinstance(engine, str): - engine = sql.create_engine(engine) - - self.db_engine = engine - if not session: - db_factory = orm.sessionmaker(bind=self.db_engine) - self.db = orm.scoping.scoped_session(db_factory) - else: - self.db = session - - table_base = table_base or declarative_base() - (self.Version, self.Session, self.Entity, - self.SentFile) = self.create_table_classes(self.db, table_prefix, - table_base) - - if manage_tables: - table_base.metadata.bind = self.db_engine - if not self.db_engine.dialect.has_table(self.db_engine, - self.Version.__tablename__): - table_base.metadata.create_all() - self.db.add(self.Version(version=LATEST_VERSION)) - self.db.commit() - else: - self.check_and_upgrade_database() - - @staticmethod - def create_table_classes(db, prefix, Base): - class Version(Base): - query = db.query_property() - __tablename__ = '{prefix}version'.format(prefix=prefix) - version = Column(Integer, primary_key=True) - - class Session(Base): - query = db.query_property() - __tablename__ = '{prefix}sessions'.format(prefix=prefix) - - session_id = Column(String, primary_key=True) - dc_id = Column(Integer, primary_key=True) - server_address = Column(String) - port = Column(Integer) - auth_key = Column(LargeBinary) - - class Entity(Base): - query = db.query_property() - __tablename__ = '{prefix}entities'.format(prefix=prefix) - - session_id = Column(String, primary_key=True) - id = Column(Integer, primary_key=True) - hash = Column(Integer, nullable=False) - username = Column(String) - phone = Column(Integer) - name = Column(String) - - class SentFile(Base): - query = db.query_property() - __tablename__ = '{prefix}sent_files'.format(prefix=prefix) - - session_id = Column(String, primary_key=True) - md5_digest = Column(LargeBinary, primary_key=True) - file_size = Column(Integer, primary_key=True) - type = Column(Integer, primary_key=True) - id = Column(Integer) - hash = Column(Integer) - - return Version, Session, Entity, SentFile - - def check_and_upgrade_database(self): - row = self.Version.query.all() - version = row[0].version if row else 1 - if version == LATEST_VERSION: - return - - self.Version.query.delete() - - # Implement table schema updates here and increase version - - self.db.add(self.Version(version=version)) - self.db.commit() - - def new_session(self, session_id): - return AlchemySession(self, session_id) - - def list_sessions(self): - return - - def save(self): - self.db.commit() - - -class AlchemySession(MemorySession): - def __init__(self, container, session_id): - super().__init__() - self.container = container - self.db = container.db - self.Version, self.Session, self.Entity, self.SentFile = ( - container.Version, container.Session, container.Entity, - container.SentFile) - self.session_id = session_id - self._load_session() - - def _load_session(self): - sessions = self._db_query(self.Session).all() - session = sessions[0] if sessions else None - if session: - self._dc_id = session.dc_id - self._server_address = session.server_address - self._port = session.port - self._auth_key = AuthKey(data=session.auth_key) - - def clone(self, to_instance=None): - return super().clone(MemorySession()) - - def set_dc(self, dc_id, server_address, port): - super().set_dc(dc_id, server_address, port) - self._update_session_table() - - sessions = self._db_query(self.Session).all() - session = sessions[0] if sessions else None - if session and session.auth_key: - self._auth_key = AuthKey(data=session.auth_key) - else: - self._auth_key = None - - @MemorySession.auth_key.setter - def auth_key(self, value): - self._auth_key = value - self._update_session_table() - - def _update_session_table(self): - self.Session.query.filter( - self.Session.session_id == self.session_id).delete() - new = self.Session(session_id=self.session_id, dc_id=self._dc_id, - server_address=self._server_address, - port=self._port, - auth_key=(self._auth_key.key - if self._auth_key else b'')) - self.db.merge(new) - - def _db_query(self, dbclass, *args): - return dbclass.query.filter( - dbclass.session_id == self.session_id, *args - ) - - def save(self): - self.container.save() - - def close(self): - # Nothing to do here, connection is managed by AlchemySessionContainer. - pass - - def delete(self): - self._db_query(self.Session).delete() - self._db_query(self.Entity).delete() - self._db_query(self.SentFile).delete() - - def _entity_values_to_row(self, id, hash, username, phone, name): - return self.Entity(session_id=self.session_id, id=id, hash=hash, - username=username, phone=phone, name=name) - - def process_entities(self, tlo): - rows = self._entities_to_rows(tlo) - if not rows: - return - - for row in rows: - self.db.merge(row) - self.save() - - def get_entity_rows_by_phone(self, key): - row = self._db_query(self.Entity, - self.Entity.phone == key).one_or_none() - return (row.id, row.hash) if row else None - - def get_entity_rows_by_username(self, key): - row = self._db_query(self.Entity, - self.Entity.username == key).one_or_none() - return (row.id, row.hash) if row else None - - def get_entity_rows_by_name(self, key): - row = self._db_query(self.Entity, - self.Entity.name == key).one_or_none() - return (row.id, row.hash) if row else None - - def get_entity_rows_by_id(self, key, exact=True): - if exact: - query = self._db_query(self.Entity, self.Entity.id == key) - else: - ids = ( - utils.get_peer_id(PeerUser(key)), - utils.get_peer_id(PeerChat(key)), - utils.get_peer_id(PeerChannel(key)) - ) - query = self._db_query(self.Entity, self.Entity.id in ids) - - row = query.one_or_none() - return (row.id, row.hash) if row else None - - def get_file(self, md5_digest, file_size, cls): - row = self._db_query(self.SentFile, - self.SentFile.md5_digest == md5_digest, - self.SentFile.file_size == file_size, - self.SentFile.type == _SentFileType.from_type( - cls).value).one_or_none() - return (row.id, row.hash) if row else None - - def cache_file(self, md5_digest, file_size, instance): - if not isinstance(instance, (InputDocument, InputPhoto)): - raise TypeError('Cannot cache %s instance' % type(instance)) - - self.db.merge( - self.SentFile(session_id=self.session_id, md5_digest=md5_digest, - type=_SentFileType.from_type(type(instance)).value, - id=instance.id, hash=instance.access_hash)) - self.save() diff --git a/telethon_aio/telegram_bare_client.py b/telethon_aio/telegram_bare_client.py index 31a12cb7..f169ae2c 100644 --- a/telethon_aio/telegram_bare_client.py +++ b/telethon_aio/telegram_bare_client.py @@ -70,6 +70,7 @@ class TelegramBareClient: proxy=None, timeout=timedelta(seconds=5), loop=None, + report_errors=True, device_model=None, system_version=None, app_version=None, @@ -102,6 +103,7 @@ class TelegramBareClient: DEFAULT_PORT ) + session.report_errors = report_errors self.session = session self.api_id = int(api_id) self.api_hash = api_hash diff --git a/telethon_aio/telegram_client.py b/telethon_aio/telegram_client.py index 6840787f..6a762217 100644 --- a/telethon_aio/telegram_client.py +++ b/telethon_aio/telegram_client.py @@ -146,9 +146,13 @@ class TelegramClient(TelegramBareClient): instantly, as soon as they arrive. Can still be disabled if you want to run the library without any additional thread. + report_errors (:obj:`bool`, optional): + Whether to report RPC errors or not. Defaults to ``True``, + see :ref:`api-status` for more information. + Kwargs: - Extra parameters will be forwarded to the ``Session`` file. - Most relevant parameters are: + Some extra parameters are required when establishing the first + connection. These are are (along with their default values): .. code-block:: python @@ -157,7 +161,6 @@ class TelegramClient(TelegramBareClient): app_version = TelegramClient.__version__ lang_code = 'en' system_lang_code = lang_code - report_errors = True """ # region Initialization @@ -168,6 +171,7 @@ class TelegramClient(TelegramBareClient): proxy=None, timeout=timedelta(seconds=5), loop=None, + report_errors=True, **kwargs): super().__init__( session, api_id, api_hash, @@ -176,6 +180,7 @@ class TelegramClient(TelegramBareClient): proxy=proxy, timeout=timeout, loop=loop, + report_errors=report_errors, **kwargs ) @@ -190,6 +195,9 @@ class TelegramClient(TelegramBareClient): # Sometimes we need to know who we are, cache the self peer self._self_input_peer = None + # Don't call .get_dialogs() every time a .get_entity() fails + self._called_get_dialogs = False + # endregion # region Telegram requests functions @@ -1156,9 +1164,10 @@ class TelegramClient(TelegramBareClient): raise TypeError('Invalid message type: {}'.format(type(message))) async def iter_participants(self, entity, limit=None, search='', - aggressive=False, _total_box=None): + filter=None, aggressive=False, + _total_box=None): """ - Gets the list of participants from the specified entity. + Iterator over the participants belonging to the specified chat. Args: entity (:obj:`entity`): @@ -1170,6 +1179,12 @@ class TelegramClient(TelegramBareClient): search (:obj:`str`, optional): Look for participants with this string in name/username. + filter (:obj:`ChannelParticipantsFilter`, optional): + The filter to be used, if you want e.g. only admins. See + https://lonamiwebs.github.io/Telethon/types/channel_participants_filter.html. + Note that you might not have permissions for some filter. + This has no effect for normal chats or users. + aggressive (:obj:`bool`, optional): Aggressively looks for all participants in the chat in order to get more than 10,000 members (a hard limit @@ -1178,16 +1193,32 @@ class TelegramClient(TelegramBareClient): participants on groups with 100,000 members. This has no effect for groups or channels with less than - 10,000 members. + 10,000 members, or if a ``filter`` is given. _total_box (:obj:`_Box`, optional): A _Box instance to pass the total parameter by reference. - Returns: - A list of participants with an additional .total variable on the - list indicating the total amount of members in this group/channel. + Yields: + The ``User`` objects returned by ``GetParticipantsRequest`` + with an additional ``.participant`` attribute which is the + matched ``ChannelParticipant`` type for channels/megagroups + or ``ChatParticipants`` for normal chats. """ + if isinstance(filter, type): + filter = filter() + entity = await self.get_input_entity(entity) + if search and (filter or not isinstance(entity, InputPeerChannel)): + # We need to 'search' ourselves unless we have a PeerChannel + search = search.lower() + + def filter_entity(ent): + return search in utils.get_display_name(ent).lower() or\ + search in (getattr(ent, 'username', '') or None).lower() + else: + def filter_entity(ent): + return True + limit = float('inf') if limit is None else int(limit) if isinstance(entity, InputPeerChannel): total = (await self(GetFullChannelRequest( @@ -1200,7 +1231,7 @@ class TelegramClient(TelegramBareClient): return seen = set() - if total > 10000 and aggressive: + if total > 10000 and aggressive and not filter: requests = [GetParticipantsRequest( channel=entity, filter=ChannelParticipantsSearch(search + chr(x)), @@ -1211,7 +1242,7 @@ class TelegramClient(TelegramBareClient): else: requests = [GetParticipantsRequest( channel=entity, - filter=ChannelParticipantsSearch(search), + filter=filter or ChannelParticipantsSearch(search), offset=0, limit=200, hash=0 @@ -1237,31 +1268,47 @@ class TelegramClient(TelegramBareClient): if not participants.users: requests.pop(i) else: - requests[i].offset += len(participants.users) - for user in participants.users: - if user.id not in seen: - seen.add(user.id) - yield user - if len(seen) >= limit: - return + requests[i].offset += len(participants.participants) + users = {user.id: user for user in participants.users} + for participant in participants.participants: + user = users[participant.user_id] + if not filter_entity(user) or user.id in seen: + continue + + seen.add(participant.user_id) + user = users[participant.user_id] + user.participant = participant + yield user + if len(seen) >= limit: + return elif isinstance(entity, InputPeerChat): - users = (await self(GetFullChatRequest(entity.chat_id))).users + # TODO We *could* apply the `filter` here ourselves + full = await self(GetFullChatRequest(entity.chat_id)) if _total_box: - _total_box.x = len(users) + _total_box.x = len(full.full_chat.participants.participants) have = 0 - for user in users: + users = {user.id: user for user in full.users} + for participant in full.full_chat.participants.participants: + user = users[participant.user_id] + if not filter_entity(user): + continue have += 1 if have > limit: break else: + user = users[participant.user_id] + user.participant = participant yield user else: if _total_box: _total_box.x = 1 if limit != 0: - yield await self.get_entity(entity) + user = await self.get_entity(entity) + if filter_entity(user): + user.participant = None + yield user async def get_participants(self, *args, **kwargs): """ @@ -1308,6 +1355,10 @@ class TelegramClient(TelegramBareClient): photo or similar) so that it can be resent without the need to download and re-upload it again. + If a list or similar is provided, the files in it will be + sent as an album in the order in which they appear, sliced + in chunks of 10 if more than 10 are given. + caption (:obj:`str`, optional): Optional caption for the sent media message. @@ -1353,23 +1404,33 @@ class TelegramClient(TelegramBareClient): # First check if the user passed an iterable, in which case # we may want to send as an album if all are photo files. if utils.is_list_like(file): - # Convert to tuple so we can iterate several times - file = tuple(x for x in file) - if all(utils.is_image(x) for x in file): - return await self._send_album( - entity, file, caption=caption, + # TODO Fix progress_callback + images = [] + documents = [] + for x in file: + if utils.is_image(x): + images.append(x) + else: + documents.append(x) + + result = [] + while images: + result += await self._send_album( + entity, images[:10], caption=caption, progress_callback=progress_callback, reply_to=reply_to, parse_mode=parse_mode ) - # Not all are images, so send all the files one by one - return [ + images = images[10:] + + result.extend( await self.send_file( entity, x, allow_cache=False, caption=caption, force_document=force_document, progress_callback=progress_callback, reply_to=reply_to, attributes=attributes, thumb=thumb, **kwargs - ) for x in file - ] + ) for x in documents + ) + return result entity = await self.get_input_entity(entity) reply_to = self._get_message_id(reply_to) @@ -1509,6 +1570,10 @@ class TelegramClient(TelegramBareClient): # we need to produce right now to send albums (uploadMedia), and # cache only makes a difference for documents where the user may # want the attributes used on them to change. + # + # In theory documents can be sent inside the albums but they appear + # as different messages (not inside the album), and the logic to set + # the attributes/avoid cache is already written in .send_file(). entity = await self.get_input_entity(entity) if not utils.is_list_like(caption): caption = (caption,) @@ -2001,7 +2066,7 @@ class TelegramClient(TelegramBareClient): input_location (:obj:`InputFileLocation`): The file location from which the file will be downloaded. - file (:obj:`str` | :obj:`file`, optional): + file (:obj:`str` | :obj:`file`): The output file path, directory, or stream-like object. If the path exists and is a file, it will be overwritten. @@ -2168,22 +2233,46 @@ class TelegramClient(TelegramBareClient): self._event_builders.append((event, callback)) - def add_update_handler(self, handler): - """Adds an update handler (a function which takes a TLObject, - an update, as its parameter) and listens for updates""" + def remove_event_handler(self, callback, event=None): + """ + Inverse operation of :meth:`add_event_handler`. + + If no event is given, all events for this callback are removed. + Returns how many callbacks were removed. + """ + found = 0 + if event and not isinstance(event, type): + event = type(event) + + for i, ec in enumerate(self._event_builders): + ev, cb = ec + if cb == callback and (not event or isinstance(ev, event)): + del self._event_builders[i] + found += 1 + + return found + + def list_event_handlers(self): + """ + Lists all added event handlers, returning a list of pairs + consisting of (callback, event). + """ + return [(callback, event) for event, callback in self._event_builders] + + async def add_update_handler(self, handler): warnings.warn( 'add_update_handler is deprecated, use the @client.on syntax ' 'or add_event_handler(callback, events.Raw) instead (see ' 'https://telethon.rtfd.io/en/latest/extra/basic/working-' 'with-updates.html)' ) - self.add_event_handler(handler, events.Raw) + return await self.add_event_handler(handler, events.Raw) def remove_update_handler(self, handler): - pass + return self.remove_event_handler(handler) def list_update_handlers(self): - return [] + return [callback for callback, _ in self.list_event_handlers()] # endregion @@ -2293,7 +2382,8 @@ class TelegramClient(TelegramBareClient): return await self.get_me() result = await self(ResolveUsernameRequest(username)) for entity in itertools.chain(result.users, result.chats): - if entity.username.lower() == username: + if getattr(entity, 'username', None) or ''\ + .lower() == username: return entity try: # Nobody with this username, maybe it's an exact name/title @@ -2350,16 +2440,18 @@ class TelegramClient(TelegramBareClient): # Add the mark to the peers if the user passed a Peer (not an int), # or said ID is negative. If it's negative it's been marked already. # Look in the dialogs with the hope to find it. - mark = not isinstance(peer, int) or peer < 0 - target_id = utils.get_peer_id(peer) - if mark: - async for dialog in self.iter_dialogs(): - if utils.get_peer_id(dialog.entity) == target_id: - return utils.get_input_peer(dialog.entity) - else: - async for dialog in self.iter_dialogs(): - if dialog.entity.id == target_id: - return utils.get_input_peer(dialog.entity) + if not self._called_get_dialogs: + self._called_get_dialogs = True + mark = not isinstance(peer, int) or peer < 0 + target_id = utils.get_peer_id(peer) + if mark: + async for dialog in self.get_dialogs(100): + if utils.get_peer_id(dialog.entity) == target_id: + return utils.get_input_peer(dialog.entity) + else: + async for dialog in self.get_dialogs(100): + if dialog.entity.id == target_id: + return utils.get_input_peer(dialog.entity) raise TypeError( 'Could not find the input entity corresponding to "{}". ' diff --git a/telethon_aio/utils.py b/telethon_aio/utils.py index daf8c875..286853ad 100644 --- a/telethon_aio/utils.py +++ b/telethon_aio/utils.py @@ -4,10 +4,11 @@ to convert between an entity like an User, Chat, etc. into its Input version) """ import math import mimetypes +import os import re import types from collections import UserList -from mimetypes import add_type, guess_extension +from mimetypes import guess_extension from .tl.types import ( Channel, ChannelForbidden, Chat, ChatEmpty, ChatForbidden, ChatFull, @@ -315,11 +316,13 @@ def get_input_media(media, is_photo=False): def is_image(file): - """Returns True if the file extension looks like an image file""" + """ + Returns True if the file extension looks like an image file to Telegram. + """ if not isinstance(file, str): return False - mime = mimetypes.guess_type(file)[0] or '' - return mime.startswith('image/') and not mime.endswith('/webp') + _, ext = os.path.splitext(file) + return re.match(r'\.(png|jpe?g)', ext, re.IGNORECASE) def is_audio(file): diff --git a/telethon_aio/version.py b/telethon_aio/version.py index 90e8bfe4..90266dbf 100644 --- a/telethon_aio/version.py +++ b/telethon_aio/version.py @@ -1,3 +1,3 @@ # Versions should comply with PEP440. # This line is parsed in setup.py: -__version__ = '0.18' +__version__ = '0.18.1' diff --git a/telethon_generator/parser/tl_object.py b/telethon_generator/parser/tl_object.py index 034cb3c3..0e0045d7 100644 --- a/telethon_generator/parser/tl_object.py +++ b/telethon_generator/parser/tl_object.py @@ -254,7 +254,7 @@ class TLArg: self.generic_definition = generic_definition - def type_hint(self): + def doc_type_hint(self): result = { 'int': 'int', 'long': 'int', @@ -272,6 +272,27 @@ class TLArg: return result + def python_type_hint(self): + type = self.type + if '.' in type: + type = type.split('.')[1] + result = { + 'int': 'int', + 'long': 'int', + 'int128': 'int', + 'int256': 'int', + 'string': 'str', + 'date': 'Optional[datetime]', # None date = 0 timestamp + 'bytes': 'bytes', + 'true': 'bool', + }.get(type, "Type{}".format(type)) + if self.is_vector: + result = 'List[{}]'.format(result) + if self.is_flag and type != 'date': + result = 'Optional[{}]'.format(result) + + return result + def __str__(self): # Find the real type representation by updating it as required real_type = self.type diff --git a/telethon_generator/tl_generator.py b/telethon_generator/tl_generator.py index b2706487..cb5c7700 100644 --- a/telethon_generator/tl_generator.py +++ b/telethon_generator/tl_generator.py @@ -138,6 +138,7 @@ class TLGenerator: builder.writeln( 'from {}.tl.tlobject import TLObject'.format('.' * depth) ) + builder.writeln('from typing import Optional, List, Union, TYPE_CHECKING') # Add the relative imports to the namespaces, # unless we already are in a namespace. @@ -154,13 +155,81 @@ class TLGenerator: # Import struct for the .__bytes__(self) serialization builder.writeln('import struct') + tlobjects.sort(key=lambda x: x.name) + + type_names = set() + type_defs = [] + + # Find all the types in this file and generate type definitions + # based on the types. The type definitions are written to the + # file at the end. + for t in tlobjects: + if not t.is_function: + type_name = t.result + if '.' in type_name: + type_name = type_name[type_name.rindex('.'):] + if type_name in type_names: + continue + type_names.add(type_name) + constructors = type_constructors[type_name] + if not constructors: + pass + elif len(constructors) == 1: + type_defs.append('Type{} = {}'.format( + type_name, constructors[0].class_name())) + else: + type_defs.append('Type{} = Union[{}]'.format( + type_name, ','.join(c.class_name() + for c in constructors))) + + imports = {} + primitives = ('int', 'long', 'int128', 'int256', 'string', + 'date', 'bytes', 'true') + # Find all the types in other files that are used in this file + # and generate the information required to import those types. + for t in tlobjects: + for arg in t.args: + name = arg.type + if not name or name in primitives: + continue + + import_space = '{}.tl.types'.format('.' * depth) + if '.' in name: + namespace = name.split('.')[0] + name = name.split('.')[1] + import_space += '.{}'.format(namespace) + + if name not in type_names: + type_names.add(name) + if name == 'date': + imports['datetime'] = ['datetime'] + continue + elif not import_space in imports: + imports[import_space] = set() + imports[import_space].add('Type{}'.format(name)) + + # Add imports required for type checking. + builder.writeln('if TYPE_CHECKING:') + for namespace, names in imports.items(): + builder.writeln('from {} import {}'.format( + namespace, ', '.join(names))) + else: + builder.writeln('pass') + builder.end_block() + # Generate the class for every TLObject - for t in sorted(tlobjects, key=lambda x: x.name): + for t in tlobjects: TLGenerator._write_source_code( t, builder, depth, type_constructors ) builder.current_indent = 0 + # Write the type definitions generated earlier. + builder.writeln('') + for line in type_defs: + builder.writeln(line) + + @staticmethod def _write_source_code(tlobject, builder, depth, type_constructors): """Writes the source code corresponding to the given TLObject @@ -218,7 +287,7 @@ class TLGenerator: for arg in args: if not arg.flag_indicator: builder.writeln(':param {} {}:'.format( - arg.type_hint(), arg.name + arg.doc_type_hint(), arg.name )) builder.current_indent -= 1 # It will auto-indent (':') @@ -258,7 +327,8 @@ class TLGenerator: for arg in args: if not arg.can_be_inferred: - builder.writeln('self.{0} = {0}'.format(arg.name)) + builder.writeln('self.{0} = {0} # type: {1}'.format( + arg.name, arg.python_type_hint())) continue # Currently the only argument that can be