diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index a15a7a75..179ea2bd 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -25,15 +25,17 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@v4 + uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4 with: languages: ${{ matrix.language }} - name: Autobuild - uses: github/codeql-action/autobuild@v4 + uses: github/codeql-action/autobuild@c10b8064de6f491fea524254123dbe5e09572f13 # v4 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 + uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 13e8698e..1fdffa13 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,6 +2,9 @@ name: python-telegram tests on: [push, pull_request] +permissions: + contents: read + jobs: tests: runs-on: ubuntu-latest @@ -11,10 +14,12 @@ jobs: python-version: ["3.9", "3.10", "3.11", "3.12"] steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false - name: Set up python ${{ matrix.python-version }} - uses: actions/setup-python@v6.1.0 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: ${{ matrix.python-version }} diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 00000000..681311eb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..cfa8d50d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,44 @@ +## What This Is + +Python wrapper around Telegram's TDLib C library. Provides a synchronous API over TDLib's async backend using ctypes, threads, and a promise-like `AsyncResult` pattern. + +## Commands + +```bash +# Run full test suite + linting + type checks +tox + +# Run tests for a specific Python version +tox -e py312 + +# Run a single test +tox -e py312 -- -k test_send_message + +# Lint +tox -e ruff + +# Format check +tox -e ruff-format + +# Auto-fix lint/format +ruff check --fix && ruff format + +# Type check (strict mode) +tox -e mypy + +# Build Docker image +make docker/build + +# Build PyPI package +make build-pypi +``` + +## Architecture + +**TDJson** (`telegram/tdjson.py`) — ctypes binding to `libtdjson`. Handles library discovery (system path → bundled precompiled in `telegram/lib/{darwin,linux}/`), creation/destruction of TDLib client instances, and JSON send/receive/execute. + +**Telegram** (`telegram/client.py`) — high-level client. Manages login flow via an `AuthorizationState` state machine (NONE → WAIT_TDLIB_PARAMETERS → ... → READY). All API calls return an `AsyncResult`; a background `_listen_to_td` thread receives TDLib responses and matches them to pending results by `@extra` request ID. + +**AsyncResult** (`telegram/utils.py`) — promise-like wrapper. `wait(timeout, raise_exc)` blocks until TDLib responds. Special-cased for `updateAuthorizationState` (doesn't resolve on bare "ok" responses). + +**Worker** (`telegram/worker.py`) — processes message/update handlers in a daemon thread. `add_message_handler()` and `add_update_handler()` register callbacks that the worker dispatches from a queue. diff --git a/telegram/client.py b/telegram/client.py index c2161f67..adbcd9a0 100644 --- a/telegram/client.py +++ b/telegram/client.py @@ -254,8 +254,9 @@ def send_message( updated_text: str if isinstance(text, Element): result = self.parse_text_entities(text.to_html(), parse_mode="HTML") - result.wait() - assert result.update is not None + result.wait(raise_exc=True) + if result.update is None: + raise RuntimeError(f"Failed to parse text entities: {result.error_info}") update: dict = result.update entities = update["entities"] updated_text = update["text"] @@ -524,11 +525,16 @@ def _listen_to_td(self) -> None: logger.info("[Telegram.td_listener] started") while not self._stopped.is_set(): - update = self._tdjson.receive() + try: + update = self._tdjson.receive() - if update: - self._update_async_result(update) - self._run_handlers(update) + if update: + self._update_async_result(update) + self._run_handlers(update) + except Exception: + if self._stopped.is_set(): + break + logger.exception("[Telegram.td_listener] error processing update") def _update_async_result(self, update: Dict[Any, Any]) -> typing.Optional[AsyncResult]: async_result = None @@ -559,7 +565,10 @@ def _run_handlers(self, update: Dict[Any, Any]) -> None: update_type: str = update.get("@type", "unknown") for handler in self._update_handlers[update_type]: - self._workers_queue.put((handler, update), timeout=self._queue_put_timeout) + try: + self._workers_queue.put((handler, update), timeout=self._queue_put_timeout) + except queue.Full: + logger.error("Handler queue full, dropping update %s for handler %s", update_type, handler) def remove_update_handler(self, handler_type: str, func: Callable) -> None: """ diff --git a/telegram/tdjson.py b/telegram/tdjson.py index 2ad4694d..485e11b8 100644 --- a/telegram/tdjson.py +++ b/telegram/tdjson.py @@ -32,7 +32,7 @@ def __init__(self, library_path: Optional[str] = None, verbosity: int = 2) -> No self._build_client(library_path, verbosity) def __del__(self) -> None: - if hasattr(self, "_tdjson") and hasattr(self._tdjson, "_td_json_client_destroy"): + if hasattr(self, "_td_json_client_destroy"): self.stop() def _build_client(self, library_path: str, verbosity: int) -> None: @@ -85,8 +85,8 @@ def _build_client(self, library_path: str, verbosity: int) -> None: def on_fatal_error_callback(error_message: str) -> None: logger.error("TDLib fatal error: %s", error_message) - c_on_fatal_error_callback = fatal_error_callback_type(on_fatal_error_callback) - self._td_set_log_fatal_error_callback(c_on_fatal_error_callback) + self._c_on_fatal_error_callback = fatal_error_callback_type(on_fatal_error_callback) + self._td_set_log_fatal_error_callback(self._c_on_fatal_error_callback) def send(self, query: Dict[Any, Any]) -> None: dumped_query = json.dumps(query).encode("utf-8") @@ -116,4 +116,7 @@ def td_execute(self, query: Dict[Any, Any]) -> Union[Dict[Any, Any], Any]: return None def stop(self) -> None: + if self.td_json_client is None: + return self._td_json_client_destroy(self.td_json_client) + self.td_json_client = None diff --git a/telegram/worker.py b/telegram/worker.py index 2d102040..e7dcbfc4 100644 --- a/telegram/worker.py +++ b/telegram/worker.py @@ -41,7 +41,10 @@ def _run_thread(self) -> None: except Empty: continue - handler(update) + try: + handler(update) + except Exception: + logger.exception("Error in update handler %s", handler) self._queue.task_done() def stop(self) -> None: diff --git a/tests/requirements.txt b/tests/requirements.txt index 13542c17..a4d8b403 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1,4 +1,4 @@ -ruff==0.14.10 +ruff==0.15.10 pytest==8.4.2 ipdb==0.13.13 mypy==1.19.1 diff --git a/tests/test_tdjson.py b/tests/test_tdjson.py index 16447d51..7f3f08c4 100644 --- a/tests/test_tdjson.py +++ b/tests/test_tdjson.py @@ -1,6 +1,6 @@ from unittest.mock import Mock, patch -from telegram.tdjson import _get_tdjson_lib_path +from telegram.tdjson import TDJson, _get_tdjson_lib_path class TestGetTdjsonTdlibPath: @@ -42,3 +42,40 @@ def test_unknown(self): mocked_files.assert_called_once_with("telegram") mocked_joinpath.assert_called_once_with("lib/linux/libtdjson.so") + + +class TestTDJson: + def _make_tdjson(self): + with patch("telegram.tdjson.CDLL") as mocked_cdll: + mocked_cdll.return_value.td_json_client_create.return_value = 12345 + tdjson = TDJson(library_path="/fake/lib.so", verbosity=0) + return tdjson + + def test_del_calls_stop(self): + tdjson = self._make_tdjson() + with patch.object(tdjson, "stop") as mocked_stop: + tdjson.__del__() + mocked_stop.assert_called_once() + + def test_del_skips_stop_if_build_incomplete(self): + tdjson = TDJson.__new__(TDJson) + with patch.object(TDJson, "stop") as mocked_stop: + tdjson.__del__() + mocked_stop.assert_not_called() + + def test_stop_nulls_client_handle(self): + tdjson = self._make_tdjson() + assert tdjson.td_json_client is not None + tdjson.stop() + assert tdjson.td_json_client is None + + def test_stop_is_idempotent(self): + tdjson = self._make_tdjson() + tdjson.stop() + tdjson.stop() + tdjson._td_json_client_destroy.assert_called_once() + + def test_fatal_error_callback_stored_on_instance(self): + tdjson = self._make_tdjson() + assert hasattr(tdjson, "_c_on_fatal_error_callback") + assert tdjson._c_on_fatal_error_callback is not None diff --git a/tests/test_telegram_methods.py b/tests/test_telegram_methods.py index ddbd347b..c45abb64 100644 --- a/tests/test_telegram_methods.py +++ b/tests/test_telegram_methods.py @@ -1,3 +1,5 @@ +import queue + import pytest from unittest.mock import patch @@ -478,3 +480,97 @@ def _get_async_result(data, request_id=None): assert state == telegram.authorization_state == AuthorizationState.READY assert telegram._tdjson.send.call_count == 0 + + +class TestWorkerExceptionHandling: + def test_worker_thread_survives_handler_exception(self): + import time + from queue import Queue + from telegram.worker import SimpleWorker + + q = Queue() + worker = SimpleWorker(queue=q) + worker.run() + + results = [] + + def bad_handler(update): + raise RuntimeError("boom") + + def good_handler(update): + results.append(update) + + q.put((bad_handler, {"@type": "test"})) + q.put((good_handler, {"@type": "test"})) + + time.sleep(0.5) + worker.stop() + + assert results == [{"@type": "test"}] + + +class TestListenerExceptionHandling: + def test_listener_survives_receive_exception(self, telegram): + import threading + + telegram._stopped = threading.Event() + call_count = 0 + + def exploding_receive(): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise RuntimeError("receive failed") + if call_count == 2: + return {"@type": "ok", "@extra": {"request_id": "test123"}} + telegram._stopped.set() + return None + + telegram._tdjson.receive = exploding_receive + telegram._listen_to_td() + + assert call_count == 3 + + def test_listener_exits_on_exception_when_stopped(self, telegram): + import threading + + telegram._stopped = threading.Event() + + def exploding_receive(): + telegram._stopped.set() + raise RuntimeError("error during shutdown") + + telegram._tdjson.receive = exploding_receive + telegram._listen_to_td() + + +class TestRunHandlersQueueFull: + def test_queue_full_does_not_propagate(self, telegram): + def my_handler(update): + pass + + telegram.add_update_handler("testUpdate", my_handler) + + with patch.object(telegram._workers_queue, "put", side_effect=queue.Full): + telegram._run_handlers({"@type": "testUpdate"}) + + +class TestSendMessageElementError: + def test_raises_on_parse_error(self, telegram): + error_result = AsyncResult(client=telegram) + error_result.error = True + error_result.error_info = {"@type": "error", "message": "Bad HTML"} + error_result._ready.set() + + with patch.object(telegram, "parse_text_entities", return_value=error_result): + with pytest.raises(RuntimeError): + telegram.send_message(chat_id=1, text=Spoiler("test")) + + def test_raises_on_none_update(self, telegram): + result = AsyncResult(client=telegram) + result.update = None + result._ready.set() + + with patch.object(telegram, "parse_text_entities", return_value=result): + with pytest.raises(RuntimeError, match="Failed to parse text entities"): + telegram.send_message(chat_id=1, text=Spoiler("test"))