Steps to Reproduce
- Install
python-telegram-bot,pytestandpytest-asyncio. - Prepare a photo (e.g.,
photo.png). - Use the following minimal
pytesttest case, which enablesResourceWarningas an error.
from pathlib import Path import pytest from telegram.ext import ApplicationBuilder @pytest.mark.filterwarnings("error::ResourceWarning") @pytest.mark.asyncio async def test_send_photo_with_path_generates_warning(tmp_path): async with ( ApplicationBuilder() .token("******:************") .build() .bot as bot ): await bot.send_photo(chat_id='*********', photo=Path(r"C:\Users\vivodi\Downloads\logo.png"))
- Run the test with
pytest.
Expected behaviour
The method should internally manage the file handle it creates. It should open the file from the provided path, read its contents, and then immediately close the file handle.
No ResourceWarning should be emitted. The pytest test above should pass without errors.
Actual behaviour
When passing a pathlib.Path object to bot.send_photo() or bot.send_document(), the underlying file is opened but is not subsequently closed. This results in a ResourceWarning when the file object is garbage collected.
This becomes a critical issue in environments that treat warnings as errors, such as in a CI/CD pipeline using pytest with the -W error flag, causing tests to fail.
The file handle created from the pathlib.Path object is not closed. This generates a ResourceWarning.
The pytest test fails with the following traceback, pointing to the unclosed file resource.
E ResourceWarning: unclosed file <_io.BufferedReader name='...\\photo.png'>
.../venv/lib/site-packages/telegram/_utils/files.py:148: ResourceWarning
=========================== short test summary info ===========================
FAILED test_file_handling.py::test_send_photo_with_path_generates_warning
The issue originates in telegram._utils.files.parse_file_input, where path.open() is called but the returned file object is never closed.
| if isinstance(file_input, (str, Path)): | |
| if is_local_file(file_input): | |
| path = Path(file_input) | |
| if local_mode: | |
| return path.absolute().as_uri() | |
| return InputFile(path.open(mode="rb"), filename=filename, attach=attach) |
Operating System
Windows 11
Version of Python, python-telegram-bot & dependencies
22.3
Relevant log output
============================= test session starts ============================= platform win32 -- Python 3.14.0rc1, pytest-8.4.1, pluggy-1.6.0 -- C:\Users\vivodi\Projects\Flexget\.venv\Scripts\python.exe cachedir: .pytest_cache rootdir: C:\Users\vivodi\Projects\Flexget configfile: pyproject.toml plugins: anyio-4.10.0, asyncio-1.1.0, cov-6.2.1, xdist-3.8.0 asyncio: mode=Mode.STRICT, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function created: 12/12 workers 12 workers [1 item] scheduling tests via LoadGroupScheduling tests/test_t.py::test_send_photo_with_path_generates_warning ================================== FAILURES =================================== _________________ test_send_photo_with_path_generates_warning _________________ [gw0] win32 -- Python 3.14.0 C:\Users\vivodi\Projects\Flexget\.venv\Scripts\python.exe file_input = WindowsPath('C:/Users/vivodi/Downloads/logo.png') tg_type = <class 'telegram._files.photosize.PhotoSize'>, filename = None attach = False, local_mode = False def parse_file_input( # pylint: disable=too-many-return-statements file_input: Union[FileInput, "TelegramObject"], tg_type: Optional[type["TelegramObject"]] = None, filename: Optional[str] = None, attach: bool = False, local_mode: bool = False, ) -> Union[str, "InputFile", Any]: """ Parses input for sending files: * For string input, if the input is an absolute path of a local file: * if ``local_mode`` is ``True``, adds the ``file://`` prefix. If the input is a relative path of a local file, computes the absolute path and adds the ``file://`` prefix. * if ``local_mode`` is ``False``, loads the file as binary data and builds an :class:`InputFile` from that Returns the input unchanged, otherwise. * :class:`pathlib.Path` objects are treated the same way as strings. * For IO and bytes input, returns an :class:`telegram.InputFile`. * If :attr:`tg_type` is specified and the input is of that type, returns the ``file_id`` attribute. Args: file_input (:obj:`str` | :obj:`bytes` | :term:`file object` | :class:`~telegram.InputFile`\ | Telegram media object): The input to parse. tg_type (:obj:`type`, optional): The Telegram media type the input can be. E.g. :class:`telegram.Animation`. filename (:obj:`str`, optional): The filename. Only relevant in case an :class:`telegram.InputFile` is returned. attach (:obj:`bool`, optional): Pass :obj:`True` if the parameter this file belongs to in the request to Telegram should point to the multipart data via an ``attach://`` URI. Defaults to `False`. Only relevant if an :class:`telegram.InputFile` is returned. local_mode (:obj:`bool`, optional): Pass :obj:`True` if the bot is running an api server in ``--local`` mode. Returns: :obj:`str` | :class:`telegram.InputFile` | :obj:`object`: The parsed input or the untouched :attr:`file_input`, in case it's no valid file input. """ # Importing on file-level yields cyclic Import Errors from telegram import InputFile # pylint: disable=import-outside-toplevel # noqa: PLC0415 if isinstance(file_input, str) and file_input.startswith("file://"): if not local_mode: raise ValueError("Specified file input is a file URI, but local mode is not enabled.") return file_input if isinstance(file_input, (str, Path)): if is_local_file(file_input): path = Path(file_input) if local_mode: return path.absolute().as_uri() > return InputFile(path.open(mode="rb"), filename=filename, attach=attach) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ E ResourceWarning: unclosed file <_io.BufferedReader name='C:\\Users\\vivodi\\Downloads\\logo.png'> C:\Users\vivodi\Projects\Flexget\.venv\Lib\site-packages\telegram\_utils\files.py:148: ResourceWarning The above exception was the direct cause of the following exception: cls = <class '_pytest.runner.CallInfo'> func = <function call_and_report.<locals>.<lambda> at 0x0000014645816C40> when = 'call' reraise = (<class '_pytest.outcomes.Exit'>, <class 'KeyboardInterrupt'>) @classmethod def from_call( cls, func: Callable[[], TResult], when: Literal["collect", "setup", "call", "teardown"], reraise: type[BaseException] | tuple[type[BaseException], ...] | None = None, ) -> CallInfo[TResult]: """Call func, wrapping the result in a CallInfo. :param func: The function to call. Called without arguments. :type func: Callable[[], _pytest.runner.TResult] :param when: The phase in which the function is called. :param reraise: Exception or exceptions that shall propagate if raised by the function, instead of being wrapped in the CallInfo. """ excinfo = None instant = timing.Instant() try: > result: TResult | None = func() ^^^^^^ C:\Users\vivodi\Projects\Flexget\.venv\Lib\site-packages\_pytest\runner.py:344: _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ C:\Users\vivodi\Projects\Flexget\.venv\Lib\site-packages\_pytest\runner.py:246: in <lambda> lambda: runtest_hook(item=item, **kwds), when=when, reraise=reraise ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ C:\Users\vivodi\Projects\Flexget\.venv\Lib\site-packages\pluggy\_hooks.py:512: in __call__ return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ C:\Users\vivodi\Projects\Flexget\.venv\Lib\site-packages\pluggy\_manager.py:120: in _hookexec return self._inner_hookexec(hook_name, methods, kwargs, firstresult) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ C:\Users\vivodi\Projects\Flexget\.venv\Lib\site-packages\_pytest\logging.py:850: in pytest_runtest_call yield C:\Users\vivodi\Projects\Flexget\.venv\Lib\site-packages\_pytest\capture.py:900: in pytest_runtest_call return (yield) ^^^^^ C:\Users\vivodi\Projects\Flexget\.venv\Lib\site-packages\_pytest\skipping.py:263: in pytest_runtest_call return (yield) ^^^^^ C:\Users\vivodi\Projects\Flexget\.venv\Lib\site-packages\_pytest\unraisableexception.py:158: in pytest_runtest_call collect_unraisable(item.config) C:\Users\vivodi\Projects\Flexget\.venv\Lib\site-packages\_pytest\unraisableexception.py:79: in collect_unraisable raise errors[0] _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ config = <_pytest.config.Config object at 0x000001463C1C2120> def collect_unraisable(config: Config) -> None: pop_unraisable = config.stash[unraisable_exceptions].pop errors: list[pytest.PytestUnraisableExceptionWarning | RuntimeError] = [] meta = None hook_error = None try: while True: try: meta = pop_unraisable() except IndexError: break if isinstance(meta, BaseException): hook_error = RuntimeError("Failed to process unraisable exception") hook_error.__cause__ = meta errors.append(hook_error) continue msg = meta.msg try: > warnings.warn(pytest.PytestUnraisableExceptionWarning(msg)) E pytest.PytestUnraisableExceptionWarning: Exception ignored while finalizing file <_io.FileIO name='C:\\Users\\vivodi\\Downloads\\logo.png' mode='rb' closefd=True>: None C:\Users\vivodi\Projects\Flexget\.venv\Lib\site-packages\_pytest\unraisableexception.py:67: PytestUnraisableExceptionWarning ----------------------------- Captured log setup ------------------------------ DEBUG asyncio:proactor_events.py:631 Using proactor: IocpProactor ------------------------------ Captured log call ------------------------------ DEBUG telegram.ext.ExtBot:_bot.py:333 Set Bot API URL: https://api.telegram.org/bot************:************************ DEBUG telegram.ext.ExtBot:_bot.py:334 Set Bot API File URL: https://api.telegram.org/file/bot************:************************ DEBUG telegram.ext.ExtBot:_bot.py:726 Calling Bot API endpoint `getMe` with parameters `{}` DEBUG httpcore.connection:_trace.py:87 connect_tcp.started host='api.telegram.org' port=443 local_address=None timeout=5.0 socket_options=None DEBUG httpcore.connection:_trace.py:87 connect_tcp.complete return_value=<httpcore._backends.anyio.AnyIOStream object at 0x0000014645C1E900> DEBUG httpcore.connection:_trace.py:87 start_tls.started ssl_context=<ssl.SSLContext object at 0x0000014645A13110> server_hostname='api.telegram.org' timeout=5.0 DEBUG httpcore.connection:_trace.py:87 start_tls.complete return_value=<httpcore._backends.anyio.AnyIOStream object at 0x0000014645C225D0> DEBUG httpcore.http11:_trace.py:87 send_request_headers.started request=<Request [b'POST']> DEBUG httpcore.http11:_trace.py:87 send_request_headers.complete DEBUG httpcore.http11:_trace.py:87 send_request_body.started request=<Request [b'POST']> DEBUG httpcore.http11:_trace.py:87 send_request_body.complete DEBUG httpcore.http11:_trace.py:87 receive_response_headers.started request=<Request [b'POST']> DEBUG httpcore.http11:_trace.py:87 receive_response_headers.complete return_value=(b'HTTP/1.1', 200, b'OK', [(b'Server', b'nginx/1.18.0'), (b'Date', b'Wed, 13 Aug 2025 12:33:36 GMT'), (b'Content-Type', b'application/json'), (b'Content-Length', b'251'), (b'Connection', b'keep-alive'), (b'Strict-Transport-Security', b'max-age=31536000; includeSubDomains; preload'), (b'Access-Control-Allow-Origin', b'*'), (b'Access-Control-Allow-Methods', b'GET, POST, OPTIONS'), (b'Access-Control-Expose-Headers', b'Content-Length,Content-Type,Date,Server,Connection')]) INFO httpx:_client.py:1740 HTTP Request: POST https://api.telegram.org/bot************:************************/getMe "HTTP/1.1 200 OK" DEBUG httpcore.http11:_trace.py:87 receive_response_body.started request=<Request [b'POST']> DEBUG httpcore.http11:_trace.py:87 receive_response_body.complete DEBUG httpcore.http11:_trace.py:87 response_closed.started DEBUG httpcore.http11:_trace.py:87 response_closed.complete DEBUG telegram.ext.ExtBot:_bot.py:735 Call to Bot API endpoint `getMe` finished with return value `{'id': 5187592617, 'is_bot': True, 'first_name': 'oneofptbots', 'username': 'oneofptbot', 'can_join_groups': True, 'can_read_all_group_messages': False, 'supports_inline_queries': False, 'can_connect_to_business': False, 'has_main_web_app': False}` DEBUG telegram.ext.ExtBot:_bot.py:726 Calling Bot API endpoint `sendPhoto` with parameters `{'chat_id': '12345678', 'photo': <telegram._files.inputfile.InputFile object at 0x0000014645C13B00>}` DEBUG httpcore.http11:_trace.py:87 send_request_headers.started request=<Request [b'POST']> DEBUG httpcore.http11:_trace.py:87 send_request_headers.complete DEBUG httpcore.http11:_trace.py:87 send_request_body.started request=<Request [b'POST']> DEBUG httpcore.http11:_trace.py:87 send_request_body.complete DEBUG httpcore.http11:_trace.py:87 receive_response_headers.started request=<Request [b'POST']> DEBUG httpcore.http11:_trace.py:87 receive_response_headers.complete return_value=(b'HTTP/1.1', 200, b'OK', [(b'Server', b'nginx/1.18.0'), (b'Date', b'Wed, 13 Aug 2025 12:33:37 GMT'), (b'Content-Type', b'application/json'), (b'Content-Length', b'778'), (b'Connection', b'keep-alive'), (b'Strict-Transport-Security', b'max-age=31536000; includeSubDomains; preload'), (b'Access-Control-Allow-Origin', b'*'), (b'Access-Control-Allow-Methods', b'GET, POST, OPTIONS'), (b'Access-Control-Expose-Headers', b'Content-Length,Content-Type,Date,Server,Connection')]) INFO httpx:_client.py:1740 HTTP Request: POST https://api.telegram.org/bot************:************************/sendPhoto "HTTP/1.1 200 OK" DEBUG httpcore.http11:_trace.py:87 receive_response_body.started request=<Request [b'POST']> DEBUG httpcore.http11:_trace.py:87 receive_response_body.complete DEBUG httpcore.http11:_trace.py:87 response_closed.started DEBUG httpcore.http11:_trace.py:87 response_closed.complete DEBUG telegram.ext.ExtBot:_bot.py:735 Call to Bot API endpoint `sendPhoto` finished with return value `{'message_id': 3095, 'from': {'id': 5187592617, 'is_bot': True, 'first_name': 'oneofptbots', 'username': 'oneofptbot'}, 'chat': {'id': 12345678, 'first_name': 'o', 'last_name': '0', 'username': 'chehxlpp', 'type': 'private'}, 'date': 1755088417, 'photo': [{'file_id': 'AgACAgUAAxkDAAIMFGichK1MJVfAlG8CFpMCpXAcaJF2AAIPyjEbvBroVBfWvmfZKAfVAQADAgADcwADNgQ', 'file_unique_id': 'AQADD8oxG7wa6FR4', 'file_size': 780, 'width': 90, 'height': 18}, {'file_id': 'AgACAgUAAxkDAAIMFGichK1MJVfAlG8CFpMCpXAcaJF2AAIPyjEbvBroVBfWvmfZKAfVAQADAgADbQADNgQ', 'file_unique_id': 'AQADD8oxG7wa6FRy', 'file_size': 5446, 'width': 320, 'height': 65}, {'file_id': 'AgACAgUAAxkDAAIMFGichK1MJVfAlG8CFpMCpXAcaJF2AAIPyjEbvBroVBfWvmfZKAfVAQADAgADeAADNgQ', 'file_unique_id': 'AQADD8oxG7wa6FR9', 'file_size': 5767, 'width': 331, 'height': 67}]}` DEBUG httpcore.connection:_trace.py:87 close.started DEBUG httpcore.connection:_trace.py:87 close.complete =========================== short test summary info =========================== FAILED tests/test_t.py::test_send_photo_with_path_generates_warning - pytest.PytestUnraisableExceptionWarning: Exception ignored while finalizing file <_io.FileIO name='C:\\Users\\vivodi\\Downloads\\logo.png' mode='rb' closefd=True>: None ============================= 1 failed in 13.26s ============================== [gw0] [100%] FAILED tests/test_t.py::test_send_photo_with_path_generates_warning tests\test_t.py:7 (test_send_photo_with_path_generates_warning) file_input = WindowsPath('C:/Users/vivodi/Downloads/logo.png') tg_type = <class 'telegram._files.photosize.PhotoSize'>, filename = None attach = False, local_mode = False def parse_file_input( # pylint: disable=too-many-return-statements file_input: Union[FileInput, "TelegramObject"], tg_type: Optional[type["TelegramObject"]] = None, filename: Optional[str] = None, attach: bool = False, local_mode: bool = False, ) -> Union[str, "InputFile", Any]: """ Parses input for sending files: * For string input, if the input is an absolute path of a local file: * if ``local_mode`` is ``True``, adds the ``file://`` prefix. If the input is a relative path of a local file, computes the absolute path and adds the ``file://`` prefix. * if ``local_mode`` is ``False``, loads the file as binary data and builds an :class:`InputFile` from that Returns the input unchanged, otherwise. * :class:`pathlib.Path` objects are treated the same way as strings. * For IO and bytes input, returns an :class:`telegram.InputFile`. * If :attr:`tg_type` is specified and the input is of that type, returns the ``file_id`` attribute. Args: file_input (:obj:`str` | :obj:`bytes` | :term:`file object` | :class:`~telegram.InputFile`\ | Telegram media object): The input to parse. tg_type (:obj:`type`, optional): The Telegram media type the input can be. E.g. :class:`telegram.Animation`. filename (:obj:`str`, optional): The filename. Only relevant in case an :class:`telegram.InputFile` is returned. attach (:obj:`bool`, optional): Pass :obj:`True` if the parameter this file belongs to in the request to Telegram should point to the multipart data via an ``attach://`` URI. Defaults to `False`. Only relevant if an :class:`telegram.InputFile` is returned. local_mode (:obj:`bool`, optional): Pass :obj:`True` if the bot is running an api server in ``--local`` mode. Returns: :obj:`str` | :class:`telegram.InputFile` | :obj:`object`: The parsed input or the untouched :attr:`file_input`, in case it's no valid file input. """ # Importing on file-level yields cyclic Import Errors from telegram import InputFile # pylint: disable=import-outside-toplevel # noqa: PLC0415 if isinstance(file_input, str) and file_input.startswith("file://"): if not local_mode: raise ValueError("Specified file input is a file URI, but local mode is not enabled.") return file_input if isinstance(file_input, (str, Path)): if is_local_file(file_input): path = Path(file_input) if local_mode: return path.absolute().as_uri() > return InputFile(path.open(mode="rb"), filename=filename, attach=attach) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ E ResourceWarning: unclosed file <_io.BufferedReader name='C:\\Users\\vivodi\\Downloads\\logo.png'> C:\Users\vivodi\Projects\Flexget\.venv\Lib\site-packages\telegram\_utils\files.py:148: ResourceWarning The above exception was the direct cause of the following exception: cls = <class '_pytest.runner.CallInfo'> func = <function call_and_report.<locals>.<lambda> at 0x0000014645816C40> when = 'call' reraise = (<class '_pytest.outcomes.Exit'>, <class 'KeyboardInterrupt'>) @classmethod def from_call( cls, func: Callable[[], TResult], when: Literal["collect", "setup", "call", "teardown"], reraise: type[BaseException] | tuple[type[BaseException], ...] | None = None, ) -> CallInfo[TResult]: """Call func, wrapping the result in a CallInfo. :param func: The function to call. Called without arguments. :type func: Callable[[], _pytest.runner.TResult] :param when: The phase in which the function is called. :param reraise: Exception or exceptions that shall propagate if raised by the function, instead of being wrapped in the CallInfo. """ excinfo = None instant = timing.Instant() try: > result: TResult | None = func() ^^^^^^ C:\Users\vivodi\Projects\Flexget\.venv\Lib\site-packages\_pytest\runner.py:344: _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ C:\Users\vivodi\Projects\Flexget\.venv\Lib\site-packages\_pytest\runner.py:246: in <lambda> lambda: runtest_hook(item=item, **kwds), when=when, reraise=reraise ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ C:\Users\vivodi\Projects\Flexget\.venv\Lib\site-packages\pluggy\_hooks.py:512: in __call__ return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ C:\Users\vivodi\Projects\Flexget\.venv\Lib\site-packages\pluggy\_manager.py:120: in _hookexec return self._inner_hookexec(hook_name, methods, kwargs, firstresult) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ C:\Users\vivodi\Projects\Flexget\.venv\Lib\site-packages\_pytest\logging.py:850: in pytest_runtest_call yield C:\Users\vivodi\Projects\Flexget\.venv\Lib\site-packages\_pytest\capture.py:900: in pytest_runtest_call return (yield) ^^^^^ C:\Users\vivodi\Projects\Flexget\.venv\Lib\site-packages\_pytest\skipping.py:263: in pytest_runtest_call return (yield) ^^^^^ C:\Users\vivodi\Projects\Flexget\.venv\Lib\site-packages\_pytest\unraisableexception.py:158: in pytest_runtest_call collect_unraisable(item.config) C:\Users\vivodi\Projects\Flexget\.venv\Lib\site-packages\_pytest\unraisableexception.py:79: in collect_unraisable raise errors[0] _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ config = <_pytest.config.Config object at 0x000001463C1C2120> def collect_unraisable(config: Config) -> None: pop_unraisable = config.stash[unraisable_exceptions].pop errors: list[pytest.PytestUnraisableExceptionWarning | RuntimeError] = [] meta = None hook_error = None try: while True: try: meta = pop_unraisable() except IndexError: break if isinstance(meta, BaseException): hook_error = RuntimeError("Failed to process unraisable exception") hook_error.__cause__ = meta errors.append(hook_error) continue msg = meta.msg try: > warnings.warn(pytest.PytestUnraisableExceptionWarning(msg)) E pytest.PytestUnraisableExceptionWarning: Exception ignored while finalizing file <_io.FileIO name='C:\\Users\\vivodi\\Downloads\\logo.png' mode='rb' closefd=True>: None C:\Users\vivodi\Projects\Flexget\.venv\Lib\site-packages\_pytest\unraisableexception.py:67: PytestUnraisableExceptionWarning
Additional Context
Workaround
The current workaround is to manually manage the file handle in the user's code, which prevents the library from opening the file itself.
# This works correctly and does not generate a warning image_path = Path("photo.png") with open(image_path, "rb") as f: await bot.send_photo(chat_id=chat_id, photo=f)
While this workaround is effective, the library should ideally handle this gracefully when given a path.