From c1036d79fcfdcf7f5ec14151dd6f4e9fe9b0a5b6 Mon Sep 17 00:00:00 2001 From: Pankovea <114323250+Pankovea@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:12:04 +0300 Subject: [PATCH 01/15] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=20=D0=BC=D0=B5=D1=82=D0=BE=D0=B4=20download?= =?UTF-8?q?=5Ffile=5Fas=5Fbytes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Добавлен метод download_file_as_bytes() для скачивания файлов в память - Рефакторинг _fetch_content_stream: добавлен коллбек on_response для извлечения заголовков - Исправлено определение имени файла в download_file: приоритет Content-Disposition → парсинг URL → fallback - Реализована защита от коллизий имён: таймстемп + нумерация при конфликте - Добавлена защита от path traversal через Path(filename).name - Добавлены юнит-тесты для download_file_as_bytes --- maxapi/connection/base.py | 204 +++++++++++++++++++++++++----- tests/test_download_file.py | 246 +++++++++++++++++++++++++++++++++++- 2 files changed, 418 insertions(+), 32 deletions(-) diff --git a/maxapi/connection/base.py b/maxapi/connection/base.py index 7324f58..0f32bd1 100644 --- a/maxapi/connection/base.py +++ b/maxapi/connection/base.py @@ -1,15 +1,20 @@ from __future__ import annotations import asyncio +import inspect import mimetypes from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, AsyncIterator, Awaitable, Callable, Optional +from urllib.parse import urlparse, unquote +from datetime import datetime +import re +import uuid import aiofiles import aiofiles.os import backoff import puremagic -from aiohttp import ClientConnectionError, ClientSession, FormData +from aiohttp import ClientConnectionError, ClientSession, FormData, ClientResponse from ..enums.api_path import ApiPath from ..enums.update import UpdateType @@ -267,7 +272,7 @@ async def upload_file_buffer( else: mime_type = f"{type.value}/*" ext = "" - except Exception: + except (OSError, ValueError): mime_type = f"{type.value}/*" ext = "" @@ -294,30 +299,29 @@ async def upload_file_buffer( response = await temp_session.post(url=url, data=form) return await response.text() - async def download_file( + + async def _fetch_content_stream( self, url: str, - destination: Path | str, *, chunk_size: int = DOWNLOAD_CHUNK_SIZE, - ) -> Path: + on_response: Optional[Callable[[ClientResponse], None | Awaitable[None]]] = None, + ) -> AsyncIterator[bytes]: """ - Скачивает файл по URL и сохраняет на диск. - - Метод работает не через общий ``request()``, поскольку - ответом является бинарный поток, а не JSON. + Асинхронный генератор, который отдаёт чанки файла по мере скачивания. Args: - url: URL файла для скачивания (из payload.url вложения). - destination: Путь к директории для сохранения файла. - chunk_size: Размер чанка при потоковом чтении - (по умолчанию 64 КБ). + url: URL файла. + on_response: Опциональный коллбек, вызываемый с объектом ответа + до начала чтения тела. Позволяет извлечь заголовки. + Поддерживаются как синхронные функции, так и async def. + Если передана асинхронная функция, она будет автоматически awaited - Returns: - Path: Полный путь к скачанному файлу. + Yields: + bytes: Чанки данных файла. Raises: - DownloadFileError: При ошибке скачивания. + DownloadFileError: при ошибке запроса или недопустимом статусе. """ bot = self._ensure_bot() conn = bot.default_connection @@ -351,22 +355,160 @@ async def _do_download() -> Any: f"Ошибка при скачивании файла: HTTP {response.status}" ) - cd = response.content_disposition - if cd and cd.filename: - filename = Path(cd.filename).name - else: - ext = mimetypes.guess_extension(response.content_type or "") or "" - filename = f"file{ext}" - - dest = Path(destination) - await aiofiles.os.makedirs(destination, exist_ok=True) - path = dest / filename + if on_response is not None: + result = on_response(response) + if inspect.iscoroutine(result): + await result try: - async with aiofiles.open(path, "wb") as f: - async for chunk in response.content.iter_chunked(chunk_size): - await f.write(chunk) + async for chunk in response.content.iter_chunked(chunk_size): + yield chunk finally: await response.release() - return path + + async def download_file( + self, + url: str, + destination: Path | str, + *, + chunk_size: int = DOWNLOAD_CHUNK_SIZE, + ) -> Path: + """ + Скачивает файл по URL и сохраняет на диск. + + Метод работает не через общий ``request()``, поскольку + ответом является бинарный поток, а не JSON. + + Если файл существует, то возвращает новый свободный путь для сохранения + + Windows style: + - file_name.ext + - file_name(2).ext + - file_name(3).ext + + Args: + url: URL файла для скачивания (из payload.url вложения). + destination: Путь к директории для сохранения файла. + chunk_size: Размер чанка при потоковом чтении + (по умолчанию 64 КБ). + + Returns: + Path: Полный путь к скачанному файлу. + + Raises: + DownloadFileError: при ошибке скачивания. + """ + dest = Path(destination) + filename: Optional[str] = None # Переменная для хранения итогового имени + ext: Optional[str] = None # расширение файла из заголовков + + await aiofiles.os.makedirs(destination, exist_ok=True) + temp_filename = f"tmp_{uuid.uuid4().hex}.part" + temp_path = dest / temp_filename + + def check_exists(path: Path) -> Path: + """Проверяет, если файл существует, то возвращает новый свободный путь для сохранения""" + + if path.exists(): + max_num = 1 # Один уже существует + fname, ext = path.stem, path.suffix + pattern = re.compile(rf"^{re.escape(fname)}\((\d+)\){re.escape(ext)}$") + + # Сканируем директорию + for existing_path in dest.iterdir(): + if existing_path.suffix == '.part': + continue + + match = pattern.match(existing_path.name) + if match: + num = int(match.group(1)) + if num > max_num: + max_num = num + + path = dest / f"{fname}({max_num+1}){ext}" + + return path + + + def capture_filename(response: Any) -> None: + """Получает имя файла из заголовков""" + nonlocal filename, ext + try: + cd = response.content_disposition + if cd and cd.filename: + filename = Path(cd.filename).name + ext = Path(filename).suffix + else: + parsed = urlparse(url) + name = unquote(parsed.path, encoding='utf-8', errors='replace') + filename = Path(name).name # Защита от path traversal + ext = Path(filename).suffix + if not ext: + ext = mimetypes.guess_extension(response.content_type or "") + filename = f"{filename}{ext}" + + if re.search(r'%[0-9A-Fa-f]{2}', filename): + # Сервера Max возвращают имя файла дважды закодированное. Проверяем + filename = unquote(filename, encoding='utf-8', errors='replace') + + except (AttributeError, TypeError, ValueError) as e: + logger_bot.warning("Не удалось определить имя файла из заголовков: %s. Используется дефолт", e) + + + async with aiofiles.open(temp_path, "wb") as f: + async for chunk in self._fetch_content_stream( + url, + chunk_size=chunk_size, + on_response=capture_filename + ): + await f.write(chunk) + + # Если имя не определилось + datetime_str = datetime.now().strftime("%y%m%d_%H%M%S") + is_photo = url.startswith("https://i.oneme.ru/") + if not filename or filename.startswith("."): + if is_photo: + if not ext: + ext = '.webp' + filename = f"image_{datetime_str}{ext}" + else: + if not ext: + ext = '.bin' + filename = f"{datetime_str}.bin" + elif is_photo: + filename = f"image_{datetime_str}{Path(filename).suffix}" + + final_path = check_exists(dest / filename) + if final_path != temp_path: + temp_path.replace(final_path) + + return final_path + + + async def download_file_as_bytes( + self, + url: str, + *, + chunk_size: int = DOWNLOAD_CHUNK_SIZE, + ) -> bytes: + """ + Скачивает файл по URL и возвращает его содержимое как bytes. + + Внимание: весь файл загружается в оперативную память. + Не используйте для файлов >100–200 МБ без контроля. + + Args: + url: URL файла. + chunk_size: Размер чанка при потоковом чтении. + + Returns: + bytes: Содержимое файла. + + Raises: + DownloadFileError: при ошибке скачивания. + """ + chunks: list[bytes] = [] + async for chunk in self._fetch_content_stream(url, chunk_size=chunk_size): + chunks.append(chunk) + return b"".join(chunks) diff --git a/tests/test_download_file.py b/tests/test_download_file.py index 5d5912c..dccb809 100644 --- a/tests/test_download_file.py +++ b/tests/test_download_file.py @@ -104,8 +104,47 @@ async def test_download_file_no_content_disposition( url="https://example.com/img", destination=tmp_dir, ) + assert result.name == "img.jpg" + assert result.parent == tmp_dir + + async def test_download_file_no_content_disposition_no_path( + self, bot, tmp_dir, mock_session + ): + """Скачивание без Content-Disposition и без MIME и без внятного пути""" + from datetime import datetime + + mock_response = _make_mock_response( + chunks=[b"imagedata"], + ) + mock_session.request = AsyncMock(return_value=mock_response) + + result = await bot.download_file( + url="https://example.com/", + destination=tmp_dir, + ) + expected = f"{datetime.now().strftime("%y%m%d_%H%M%S")}.bin" + assert result.name == expected + assert result.parent == tmp_dir + + + async def test_download_photo( + self, bot, tmp_dir, mock_session + ): + """Скачивание фложения-фото по ссылке выда https://i.oneme.ru/i?r=photo_token""" + from datetime import datetime + + mock_response = _make_mock_response( + content_type="image/jpeg", + chunks=[b"imagedata"], + ) + mock_session.request = AsyncMock(return_value=mock_response) - assert result.name.startswith("file") + result = await bot.download_file( + url="https://i.oneme.ru/i?r=photo_token", + destination=tmp_dir, + ) + expected = f"image_{datetime.now().strftime("%y%m%d_%H%M%S")}.jpg" + assert result.name == expected assert result.parent == tmp_dir async def test_download_file_path_traversal_protection( @@ -203,3 +242,208 @@ async def test_ensure_session_reuses_existing(self, bot, mock_session): """ensure_session возвращает существующую сессию.""" session = await bot.ensure_session() assert session is mock_session + +# tests/test_download_file.py + +class TestDownloadFileAsBytes: + """ + Тесты для метода download_file_as_bytes. + + Примеры реальных URL для ручного тестирования: + - Файл с подписью: + https://fd.oneme.ru/getfile?sig=...&expires=...&clientType=3&id=... + - Изображение: + https://i.oneme.ru/i?r=BTGBPUwtwgYUeoFhO7rESmr81n-DnwjHYFhx5_EAhKk... + """ + + async def test_download_file_as_bytes_success(self, bot, mock_session): + """ + Успешное скачивание файла в память. + + Эмулирует поведение реального эндпоинта типа: + GET https://fd.oneme.ru/getfile?sig=...&expires=... + """ + chunks = [b"chunk1", b"chunk2", b"chunk3"] + mock_response = _make_mock_response( + content_type="application/octet-stream", + cd_filename="document.pdf", + chunks=chunks, + ) + mock_session.request = AsyncMock(return_value=mock_response) + + result = await bot.download_file_as_bytes( + url="https://fd.oneme.ru/getfile?sig=test&expires=123", + ) + + assert result == b"chunk1chunk2chunk3" + mock_response.release.assert_called_once() + + async def test_download_file_as_bytes_image_url(self, bot, mock_session): + """ + Скачивание изображения с i.oneme.ru. + + Пример реального URL: + https://i.oneme.ru/i?r=BTGBPUwtwgYUeoFhO7rESmr81n-DnwjHYFhx5_EAhKk... + """ + # Эмулируем PNG-изображение (минимальный валидный заголовок) + png_header = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100 + mock_response = _make_mock_response( + content_type="image/png", + chunks=[png_header], + ) + mock_session.request = AsyncMock(return_value=mock_response) + + result = await bot.download_file_as_bytes( + url="https://i.oneme.ru/i?r=test_token", + ) + + assert result.startswith(b"\x89PNG") + assert len(result) > 0 + + + async def test_download_file_as_bytes_http_error(self, bot, mock_session): + """DownloadFileError при HTTP 404.""" + mock_response = _make_mock_response(ok=False, status=404) + mock_session.request = AsyncMock(return_value=mock_response) + + with pytest.raises(DownloadFileError, match="HTTP 404"): + await bot.download_file_as_bytes( + url="https://example.com/missing", + ) + + async def test_download_file_as_bytes_connection_error( + self, bot, mock_session + ): + """DownloadFileError при ошибке соединения.""" + from aiohttp import ClientConnectionError + + mock_session.request = AsyncMock( + side_effect=ClientConnectionError("timeout") + ) + bot.default_connection.max_retries = 0 + + with pytest.raises(DownloadFileError, match="Ошибка при скачивании"): + await bot.download_file_as_bytes( + url="https://example.com/file", + ) + + async def test_download_file_as_bytes_retry_on_503( + self, bot, mock_session + ): + """Retry при 503, затем успех.""" + retry_response = _make_mock_response(ok=False, status=503) + retry_response.read = AsyncMock() + + success_response = _make_mock_response( + content_type="text/plain", + chunks=[b"success"], + ) + + mock_session.request = AsyncMock( + side_effect=[retry_response, success_response] + ) + bot.default_connection.max_retries = 1 + bot.default_connection.retry_backoff_factor = 0.0 + + with patch("asyncio.sleep", new_callable=AsyncMock): + result = await bot.download_file_as_bytes( + url="https://example.com/file", + ) + assert result == b"success" + + async def test_download_file_as_bytes_empty_file(self, bot, mock_session): + """Скачивание пустого файла.""" + mock_response = _make_mock_response( + content_type="application/octet-stream", + chunks=[], # Пустой итератор + ) + mock_session.request = AsyncMock(return_value=mock_response) + + result = await bot.download_file_as_bytes( + url="https://example.com/empty", + ) + + assert result == b"" + + async def test_download_file_vs_as_bytes_same_content( + self, bot, tmp_dir, mock_session + ): + """download_file и download_file_as_bytes возвращают одинаковые данные.""" + content = b"test content for comparison" + chunks = [content[i:i+10] for i in range(0, len(content), 10)] + + # Для download_file + mock_response_disk = _make_mock_response( + cd_filename="test.txt", + chunks=chunks.copy(), + ) + # Для download_file_as_bytes + mock_response_bytes = _make_mock_response( + chunks=chunks.copy(), + ) + + # Мокаем request дважды: первый вызов — для disk, второй — для bytes + mock_session.request = AsyncMock( + side_effect=[mock_response_disk, mock_response_bytes] + ) + + # Скачиваем на диск + path = await bot.download_file( + url="https://example.com/file", + destination=tmp_dir, + ) + disk_content = path.read_bytes() + + # Скачиваем в память + bytes_content = await bot.download_file_as_bytes( + url="https://example.com/file", + ) + + assert disk_content == bytes_content == content + + + async def test_download_file_name_collision(self, bot, tmp_dir, mock_session): + """Проверка, что при коллизии имён добавляется (2), (3) и т.д.""" + from typing import List + from pathlib import Path + + # Пытаемся скачать сразу 5 файлов + results: List[Path] = [] + for i in range(5): + mock_response = _make_mock_response(chunks=[f"new {i+1}".encode()]) + mock_session.request = AsyncMock(return_value=mock_response) + results.append( + await bot.download_file( + url=f"https://i.oneme.ru/i?r=file{i+1}", + destination=tmp_dir, + ) + ) + + for i, result in enumerate(results): + if i == 0: # Первый файл не проверяем + # Первый файл должен быть без суффикса _N + # Только image_date_time + assert '(' not in result.stem and ')' not in result.stem + else: + # Ожидаем, что файлы сохранится с суффиксами + assert result.stem.endswith(f"({i+1})") + assert result.read_bytes() == f"new {i+1}".encode() + + + async def test_download_file_photo_correct_extension( + self, bot, tmp_dir, mock_session + ): + """Для i.oneme.ru расширение определяется по Content-Type, а не .webp.""" + mock_response = _make_mock_response( + content_type="image/png", + chunks=[b"\x89PNG\r\n\x1a\n"], + ) + mock_session.request = AsyncMock(return_value=mock_response) + + result = await bot.download_file( + url="https://i.oneme.ru/i?r=test", + destination=tmp_dir, + ) + + assert result.suffix == ".png" # не .webp! + assert result.name.startswith("image_") From f8b48c46a34ffb1fe4c65a3f732c419966bf1b28 Mon Sep 17 00:00:00 2001 From: Pankovea <114323250+Pankovea@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:12:05 +0300 Subject: [PATCH 02/15] refactor: download_file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Избавились от коллбэка on_response. Теперь просто передаём словарь и получаем значения из него через .get('filename') На свякий случай тудаже записываtncz сам Respone --- maxapi/connection/base.py | 167 ++++++++++++++++++++++-------------- tests/test_download_file.py | 22 ++++- 2 files changed, 122 insertions(+), 67 deletions(-) diff --git a/maxapi/connection/base.py b/maxapi/connection/base.py index 0f32bd1..7ce0e03 100644 --- a/maxapi/connection/base.py +++ b/maxapi/connection/base.py @@ -4,7 +4,7 @@ import inspect import mimetypes from pathlib import Path -from typing import TYPE_CHECKING, Any, AsyncIterator, Awaitable, Callable, Optional +from typing import TYPE_CHECKING, Any, AsyncIterator, Optional from urllib.parse import urlparse, unquote from datetime import datetime import re @@ -305,17 +305,17 @@ async def _fetch_content_stream( url: str, *, chunk_size: int = DOWNLOAD_CHUNK_SIZE, - on_response: Optional[Callable[[ClientResponse], None | Awaitable[None]]] = None, + response_dict: Optional[dict[str, str]] = None, ) -> AsyncIterator[bytes]: """ Асинхронный генератор, который отдаёт чанки файла по мере скачивания. Args: url: URL файла. - on_response: Опциональный коллбек, вызываемый с объектом ответа - до начала чтения тела. Позволяет извлечь заголовки. - Поддерживаются как синхронные функции, так и async def. - Если передана асинхронная функция, она будет автоматически awaited + response_dict: Опциональный словарь в который будет сохраненs заголовки до начала чтения тела + и имя файла. Формат: + - response_dict['response'] + - response_dict['filename'] Yields: bytes: Чанки данных файла. @@ -355,10 +355,11 @@ async def _do_download() -> Any: f"Ошибка при скачивании файла: HTTP {response.status}" ) - if on_response is not None: - result = on_response(response) - if inspect.iscoroutine(result): - await result + if isinstance(response_dict, dict): + response_dict['resp'] = response + response_dict['filename'] = self._capture_filename(response) + elif response_dict is not None: + raise ValueError(f"response_dict должен быть словарём, получен {type(response_dict)}") try: async for chunk in response.content.iter_chunked(chunk_size): @@ -366,6 +367,83 @@ async def _do_download() -> Any: finally: await response.release() + @staticmethod + def _capture_filename(response: ClientResponse) -> str: + """ + Получает имя файла из заголовков + Используется в _fetch_content_stream + + Args: + response: Ответ сервера с заголовками файла + + Returns: + str: Имя файла. В зависимости от содержиния заголовков может быть вида: + "filename.doc", ".doc", "filename" + """ + filename = ext = None + try: + cd = response.content_disposition + if cd and cd.filename: + filename = Path(cd.filename).name + ext = Path(filename).suffix + else: + parsed = urlparse(response.url) + name = unquote(parsed.path, encoding='utf-8', errors='replace') + filename = Path(name).name # Защита от path traversal + ext = Path(filename).suffix + if not ext: + ext = mimetypes.guess_extension(response.content_type or "") + filename = f"{filename}{ext}" + + if re.search(r'%[0-9A-Fa-f]{2}', filename): + # Сервера Max возвращают имя файла дважды закодированное. Проверяем + filename = unquote(filename, encoding='utf-8', errors='replace') + + except (AttributeError, TypeError, ValueError) as e: + logger_bot.warning("Не удалось определить имя файла из заголовков: %s", e) + + return filename + + @staticmethod + def _check_file_exists(path: Path|str) -> Path: + """Проверяет, если файл существует, то возвращает новый свободный путь для сохранения + Windows style: + - file_name.ext + - file_name(2).ext + - file_name(3).ext + + Args: + path (pathlib.Path): Путь к файлу + + Returns: + pathlib.Path: Свободное имя файла с путём для сохранения + + Raises: + ValueError: Non-encodable path. + """ + if isinstance(path, str): + path = Path(path) + dest = path.parent + + if path.exists(): + max_num = 1 # Один уже существует + fname, ext = path.stem, path.suffix + pattern = re.compile(rf"^{re.escape(fname)}\((\d+)\){re.escape(ext)}$") + + # Сканируем директорию + for existing_path in dest.iterdir(): + if existing_path.suffix == '.part': + continue + + match = pattern.match(existing_path.name) + if match: + num = int(match.group(1)) + if num > max_num: + max_num = num + + path = dest / f"{fname}({max_num+1}){ext}" + + return path async def download_file( self, @@ -400,70 +478,27 @@ async def download_file( DownloadFileError: при ошибке скачивания. """ dest = Path(destination) - filename: Optional[str] = None # Переменная для хранения итогового имени - ext: Optional[str] = None # расширение файла из заголовков await aiofiles.os.makedirs(destination, exist_ok=True) temp_filename = f"tmp_{uuid.uuid4().hex}.part" temp_path = dest / temp_filename - def check_exists(path: Path) -> Path: - """Проверяет, если файл существует, то возвращает новый свободный путь для сохранения""" - - if path.exists(): - max_num = 1 # Один уже существует - fname, ext = path.stem, path.suffix - pattern = re.compile(rf"^{re.escape(fname)}\((\d+)\){re.escape(ext)}$") - - # Сканируем директорию - for existing_path in dest.iterdir(): - if existing_path.suffix == '.part': - continue - - match = pattern.match(existing_path.name) - if match: - num = int(match.group(1)) - if num > max_num: - max_num = num - - path = dest / f"{fname}({max_num+1}){ext}" - - return path - - - def capture_filename(response: Any) -> None: - """Получает имя файла из заголовков""" - nonlocal filename, ext - try: - cd = response.content_disposition - if cd and cd.filename: - filename = Path(cd.filename).name - ext = Path(filename).suffix - else: - parsed = urlparse(url) - name = unquote(parsed.path, encoding='utf-8', errors='replace') - filename = Path(name).name # Защита от path traversal - ext = Path(filename).suffix - if not ext: - ext = mimetypes.guess_extension(response.content_type or "") - filename = f"{filename}{ext}" - - if re.search(r'%[0-9A-Fa-f]{2}', filename): - # Сервера Max возвращают имя файла дважды закодированное. Проверяем - filename = unquote(filename, encoding='utf-8', errors='replace') - - except (AttributeError, TypeError, ValueError) as e: - logger_bot.warning("Не удалось определить имя файла из заголовков: %s. Используется дефолт", e) - - + response = {} async with aiofiles.open(temp_path, "wb") as f: async for chunk in self._fetch_content_stream( url, chunk_size=chunk_size, - on_response=capture_filename + response_dict=response ): await f.write(chunk) - + + filename = response.get('filename') + ext = None + if filename: + name_ext = filename.rsplit(".", maxsplit=1) + if len(name_ext) == 2: + ext = f".{name_ext[1]}" + # Если имя не определилось datetime_str = datetime.now().strftime("%y%m%d_%H%M%S") is_photo = url.startswith("https://i.oneme.ru/") @@ -475,11 +510,11 @@ def capture_filename(response: Any) -> None: else: if not ext: ext = '.bin' - filename = f"{datetime_str}.bin" + filename = f"{datetime_str}{ext}" elif is_photo: - filename = f"image_{datetime_str}{Path(filename).suffix}" + filename = f"image_{datetime_str}{ext}" - final_path = check_exists(dest / filename) + final_path = self._check_file_exists(dest / filename) if final_path != temp_path: temp_path.replace(final_path) diff --git a/tests/test_download_file.py b/tests/test_download_file.py index dccb809..d9e41c3 100644 --- a/tests/test_download_file.py +++ b/tests/test_download_file.py @@ -40,6 +40,7 @@ def _make_mock_response( content_type="application/octet-stream", cd_filename=None, chunks=None, + url=None, ): """Создаёт мок aiohttp-ответа для скачивания.""" mock_response = AsyncMock() @@ -54,6 +55,9 @@ def _make_mock_response( else: mock_response.content_disposition = None + if url is not None: + mock_response.url = url + if chunks is not None: mock_response.content.iter_chunked = MagicMock( return_value=AsyncIterator(chunks) @@ -76,6 +80,7 @@ async def test_download_file_success(self, bot, tmp_dir, mock_session): """Скачивание файла с корректным Content-Disposition.""" chunks = [b"chunk1", b"chunk2", b"chunk3"] mock_response = _make_mock_response( + url="https://example.com/file.pdf", content_type="application/pdf", cd_filename="document.pdf", chunks=chunks, @@ -95,6 +100,7 @@ async def test_download_file_no_content_disposition( ): """Скачивание без Content-Disposition — имя генерируется по MIME.""" mock_response = _make_mock_response( + url="https://example.com/img", content_type="image/jpeg", chunks=[b"imagedata"], ) @@ -114,6 +120,7 @@ async def test_download_file_no_content_disposition_no_path( from datetime import datetime mock_response = _make_mock_response( + url="https://example.com/", chunks=[b"imagedata"], ) mock_session.request = AsyncMock(return_value=mock_response) @@ -134,6 +141,7 @@ async def test_download_photo( from datetime import datetime mock_response = _make_mock_response( + url="https://i.oneme.ru/i?r=photo_token", content_type="image/jpeg", chunks=[b"imagedata"], ) @@ -152,6 +160,7 @@ async def test_download_file_path_traversal_protection( ): """Защита от path traversal в filename.""" mock_response = _make_mock_response( + url="https://example.com/file", content_type="text/plain", cd_filename="../../etc/passwd", chunks=[b"data"], @@ -203,6 +212,7 @@ async def test_download_file_retry_on_server_error( retry_response.read = AsyncMock() success_response = _make_mock_response( + url="https://example.com/file", content_type="text/plain", cd_filename="result.txt", chunks=[b"ok"], @@ -265,6 +275,7 @@ async def test_download_file_as_bytes_success(self, bot, mock_session): """ chunks = [b"chunk1", b"chunk2", b"chunk3"] mock_response = _make_mock_response( + url="https://fd.oneme.ru/getfile?sig=test&expires=123", content_type="application/octet-stream", cd_filename="document.pdf", chunks=chunks, @@ -288,6 +299,7 @@ async def test_download_file_as_bytes_image_url(self, bot, mock_session): # Эмулируем PNG-изображение (минимальный валидный заголовок) png_header = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100 mock_response = _make_mock_response( + url="https://i.oneme.ru/i?r=test_token", content_type="image/png", chunks=[png_header], ) @@ -335,6 +347,7 @@ async def test_download_file_as_bytes_retry_on_503( retry_response.read = AsyncMock() success_response = _make_mock_response( + url="https://example.com/file", content_type="text/plain", chunks=[b"success"], ) @@ -354,6 +367,7 @@ async def test_download_file_as_bytes_retry_on_503( async def test_download_file_as_bytes_empty_file(self, bot, mock_session): """Скачивание пустого файла.""" mock_response = _make_mock_response( + url="https://example.com/empty", content_type="application/octet-stream", chunks=[], # Пустой итератор ) @@ -374,11 +388,13 @@ async def test_download_file_vs_as_bytes_same_content( # Для download_file mock_response_disk = _make_mock_response( + url="https://example.com/file", cd_filename="test.txt", chunks=chunks.copy(), ) # Для download_file_as_bytes mock_response_bytes = _make_mock_response( + url="https://example.com/file", chunks=chunks.copy(), ) @@ -410,7 +426,10 @@ async def test_download_file_name_collision(self, bot, tmp_dir, mock_session): # Пытаемся скачать сразу 5 файлов results: List[Path] = [] for i in range(5): - mock_response = _make_mock_response(chunks=[f"new {i+1}".encode()]) + mock_response = _make_mock_response( + url=f"https://i.oneme.ru/i?r=file{i+1}", + chunks=[f"new {i+1}".encode()] + ) mock_session.request = AsyncMock(return_value=mock_response) results.append( await bot.download_file( @@ -435,6 +454,7 @@ async def test_download_file_photo_correct_extension( ): """Для i.oneme.ru расширение определяется по Content-Type, а не .webp.""" mock_response = _make_mock_response( + url="https://i.oneme.ru/i?r=test", content_type="image/png", chunks=[b"\x89PNG\r\n\x1a\n"], ) From 3e6d86ff90190e651b6d3225775f11c3f9de4d71 Mon Sep 17 00:00:00 2001 From: Pankovea <114323250+Pankovea@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:12:05 +0300 Subject: [PATCH 03/15] =?UTF-8?q?download=5Ffile=5Fas=5Fbytes=20=D1=82?= =?UTF-8?q?=D0=B5=D0=BF=D0=B5=D1=80=D1=8C=20=D0=B2=D0=BE=D0=B7=D0=B2=D1=80?= =?UTF-8?q?=D0=B0=D1=89=D0=B0=D0=B5=D1=82=20tuple[bytes,=20str],=20=D0=B3?= =?UTF-8?q?=D0=B4=D0=B5=20str=20=D0=B8=D0=BC=D1=8F=20=D1=84=D0=B0=D0=B9?= =?UTF-8?q?=D0=BB=D0=B0=20=D0=B8=D0=B7=20=D0=B7=D0=B0=D0=B3=D0=BE=D0=BB?= =?UTF-8?q?=D0=BE=D0=B2=D0=BA=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- maxapi/connection/base.py | 51 +++++++++++++++++-------------------- tests/test_download_file.py | 10 ++++---- 2 files changed, 29 insertions(+), 32 deletions(-) diff --git a/maxapi/connection/base.py b/maxapi/connection/base.py index 7ce0e03..2ab8351 100644 --- a/maxapi/connection/base.py +++ b/maxapi/connection/base.py @@ -377,17 +377,17 @@ def _capture_filename(response: ClientResponse) -> str: response: Ответ сервера с заголовками файла Returns: - str: Имя файла. В зависимости от содержиния заголовков может быть вида: - "filename.doc", ".doc", "filename" + str: Имя файла из заголовков. Если не удалось определить, то возвращается default """ filename = ext = None + url = response.url try: cd = response.content_disposition if cd and cd.filename: filename = Path(cd.filename).name ext = Path(filename).suffix else: - parsed = urlparse(response.url) + parsed = urlparse(url) name = unquote(parsed.path, encoding='utf-8', errors='replace') filename = Path(name).name # Защита от path traversal ext = Path(filename).suffix @@ -399,6 +399,21 @@ def _capture_filename(response: ClientResponse) -> str: # Сервера Max возвращают имя файла дважды закодированное. Проверяем filename = unquote(filename, encoding='utf-8', errors='replace') + # Если имя не определилось + datetime_str = datetime.now().strftime("%y%m%d_%H%M%S") + is_photo = url.startswith("https://i.oneme.ru/") + if not filename or filename.startswith("."): + if is_photo: + if not ext: + ext = '.webp' + filename = f"image_{datetime_str}{ext}" + else: + if not ext: + ext = '.bin' + filename = f"{datetime_str}{ext}" + elif is_photo: + filename = f"image_{datetime_str}{ext}" + except (AttributeError, TypeError, ValueError) as e: logger_bot.warning("Не удалось определить имя файла из заголовков: %s", e) @@ -493,27 +508,6 @@ async def download_file( await f.write(chunk) filename = response.get('filename') - ext = None - if filename: - name_ext = filename.rsplit(".", maxsplit=1) - if len(name_ext) == 2: - ext = f".{name_ext[1]}" - - # Если имя не определилось - datetime_str = datetime.now().strftime("%y%m%d_%H%M%S") - is_photo = url.startswith("https://i.oneme.ru/") - if not filename or filename.startswith("."): - if is_photo: - if not ext: - ext = '.webp' - filename = f"image_{datetime_str}{ext}" - else: - if not ext: - ext = '.bin' - filename = f"{datetime_str}{ext}" - elif is_photo: - filename = f"image_{datetime_str}{ext}" - final_path = self._check_file_exists(dest / filename) if final_path != temp_path: temp_path.replace(final_path) @@ -526,7 +520,7 @@ async def download_file_as_bytes( url: str, *, chunk_size: int = DOWNLOAD_CHUNK_SIZE, - ) -> bytes: + ) -> tuple[bytes, str]: """ Скачивает файл по URL и возвращает его содержимое как bytes. @@ -539,11 +533,14 @@ async def download_file_as_bytes( Returns: bytes: Содержимое файла. + str: Имя файла из заголовков или default Raises: DownloadFileError: при ошибке скачивания. """ chunks: list[bytes] = [] - async for chunk in self._fetch_content_stream(url, chunk_size=chunk_size): + response = {} + async for chunk in self._fetch_content_stream(url, chunk_size=chunk_size, response_dict=response): chunks.append(chunk) - return b"".join(chunks) + filename = response.get('filename') + return b"".join(chunks), filename diff --git a/tests/test_download_file.py b/tests/test_download_file.py index d9e41c3..1c12db7 100644 --- a/tests/test_download_file.py +++ b/tests/test_download_file.py @@ -282,7 +282,7 @@ async def test_download_file_as_bytes_success(self, bot, mock_session): ) mock_session.request = AsyncMock(return_value=mock_response) - result = await bot.download_file_as_bytes( + result, filename = await bot.download_file_as_bytes( url="https://fd.oneme.ru/getfile?sig=test&expires=123", ) @@ -305,7 +305,7 @@ async def test_download_file_as_bytes_image_url(self, bot, mock_session): ) mock_session.request = AsyncMock(return_value=mock_response) - result = await bot.download_file_as_bytes( + result, filename = await bot.download_file_as_bytes( url="https://i.oneme.ru/i?r=test_token", ) @@ -359,7 +359,7 @@ async def test_download_file_as_bytes_retry_on_503( bot.default_connection.retry_backoff_factor = 0.0 with patch("asyncio.sleep", new_callable=AsyncMock): - result = await bot.download_file_as_bytes( + result, filename = await bot.download_file_as_bytes( url="https://example.com/file", ) assert result == b"success" @@ -373,7 +373,7 @@ async def test_download_file_as_bytes_empty_file(self, bot, mock_session): ) mock_session.request = AsyncMock(return_value=mock_response) - result = await bot.download_file_as_bytes( + result, filename = await bot.download_file_as_bytes( url="https://example.com/empty", ) @@ -411,7 +411,7 @@ async def test_download_file_vs_as_bytes_same_content( disk_content = path.read_bytes() # Скачиваем в память - bytes_content = await bot.download_file_as_bytes( + bytes_content, filename = await bot.download_file_as_bytes( url="https://example.com/file", ) From 351ef90acaaae0e8cf08bc9ed386337a2324b08a Mon Sep 17 00:00:00 2001 From: Pankovea <114323250+Pankovea@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:12:05 +0300 Subject: [PATCH 04/15] =?UTF-8?q?download=5Ffile=5Fas=5Fbytes=20=D0=B2?= =?UTF-8?q?=D0=BE=D0=B7=D0=B2=D1=80=D0=B0=D1=89=D0=B0=D0=B5=D1=82=20=D0=BE?= =?UTF-8?q?=D0=B4=D0=B8=D0=BD=20=D0=BE=D0=B1=D1=8A=D0=B5=D0=BA=D1=82=20Byt?= =?UTF-8?q?esIO=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20=D0=BA=D0=BE?= =?UTF-8?q?=D1=80=D1=82=D0=B5=D0=B6=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Таким образом мы заранее не создаём дополнительны объект bytes, а продолжаем хранить сырые чанки. Это экономнее по памяти и белее ассинхронно Так же в BytesIO.name хранится имя файла, что более удобно и типобезопасно чем работа возврат кортежа. --- maxapi/connection/base.py | 21 +++++++++++++-------- tests/test_download_file.py | 18 ++++++++++++------ 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/maxapi/connection/base.py b/maxapi/connection/base.py index 2ab8351..70eac9b 100644 --- a/maxapi/connection/base.py +++ b/maxapi/connection/base.py @@ -2,6 +2,7 @@ import asyncio import inspect +from io import BytesIO import mimetypes from pathlib import Path from typing import TYPE_CHECKING, Any, AsyncIterator, Optional @@ -520,9 +521,9 @@ async def download_file_as_bytes( url: str, *, chunk_size: int = DOWNLOAD_CHUNK_SIZE, - ) -> tuple[bytes, str]: + ) -> BytesIO: """ - Скачивает файл по URL и возвращает его содержимое как bytes. + Скачивает файл и возвращает file-like объект в памяти. Внимание: весь файл загружается в оперативную память. Не используйте для файлов >100–200 МБ без контроля. @@ -532,15 +533,19 @@ async def download_file_as_bytes( chunk_size: Размер чанка при потоковом чтении. Returns: - bytes: Содержимое файла. - str: Имя файла из заголовков или default + BytesIO: Содержимое файла. Атрибут .name содержит имя файла Raises: DownloadFileError: при ошибке скачивания. """ - chunks: list[bytes] = [] + bio = BytesIO() + response = {} async for chunk in self._fetch_content_stream(url, chunk_size=chunk_size, response_dict=response): - chunks.append(chunk) - filename = response.get('filename') - return b"".join(chunks), filename + bio.write(chunk) + bio.seek(0) # обязательно переходим в начало + + if filename := response.get('filename'): + bio.name = filename + + return bio diff --git a/tests/test_download_file.py b/tests/test_download_file.py index 1c12db7..8e9ae96 100644 --- a/tests/test_download_file.py +++ b/tests/test_download_file.py @@ -282,9 +282,10 @@ async def test_download_file_as_bytes_success(self, bot, mock_session): ) mock_session.request = AsyncMock(return_value=mock_response) - result, filename = await bot.download_file_as_bytes( + bio = await bot.download_file_as_bytes( url="https://fd.oneme.ru/getfile?sig=test&expires=123", ) + result = bio.read() assert result == b"chunk1chunk2chunk3" mock_response.release.assert_called_once() @@ -305,9 +306,10 @@ async def test_download_file_as_bytes_image_url(self, bot, mock_session): ) mock_session.request = AsyncMock(return_value=mock_response) - result, filename = await bot.download_file_as_bytes( + bio = await bot.download_file_as_bytes( url="https://i.oneme.ru/i?r=test_token", ) + result = bio.read() assert result.startswith(b"\x89PNG") assert len(result) > 0 @@ -359,9 +361,10 @@ async def test_download_file_as_bytes_retry_on_503( bot.default_connection.retry_backoff_factor = 0.0 with patch("asyncio.sleep", new_callable=AsyncMock): - result, filename = await bot.download_file_as_bytes( + bio = await bot.download_file_as_bytes( url="https://example.com/file", ) + result = bio.read() assert result == b"success" async def test_download_file_as_bytes_empty_file(self, bot, mock_session): @@ -373,10 +376,10 @@ async def test_download_file_as_bytes_empty_file(self, bot, mock_session): ) mock_session.request = AsyncMock(return_value=mock_response) - result, filename = await bot.download_file_as_bytes( + bio = await bot.download_file_as_bytes( url="https://example.com/empty", ) - + result = bio.read() assert result == b"" async def test_download_file_vs_as_bytes_same_content( @@ -395,6 +398,7 @@ async def test_download_file_vs_as_bytes_same_content( # Для download_file_as_bytes mock_response_bytes = _make_mock_response( url="https://example.com/file", + cd_filename="test.txt", chunks=chunks.copy(), ) @@ -411,10 +415,12 @@ async def test_download_file_vs_as_bytes_same_content( disk_content = path.read_bytes() # Скачиваем в память - bytes_content, filename = await bot.download_file_as_bytes( + bio = await bot.download_file_as_bytes( url="https://example.com/file", ) + bytes_content = bio.read() + assert path.name == bio.name assert disk_content == bytes_content == content From 308fa8b4913b20adeeea04f9e6e9224a1b14e402 Mon Sep 17 00:00:00 2001 From: Pankovea <114323250+Pankovea@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:12:06 +0300 Subject: [PATCH 05/15] =?UTF-8?q?fix=20AttributeError:=20'=5Fio.BytesIO'?= =?UTF-8?q?=20object=20has=20no=20attribute=20'name'=20=D0=A1=D0=BE=D0=B7?= =?UTF-8?q?=D0=B4=D0=B0=D0=BD=20=D0=BD=D0=B0=D1=81=D0=BB=D0=B5=D0=B4=D0=BD?= =?UTF-8?q?=D0=B8=D0=BA=20=D1=81=20=D0=BF=D0=BE=D0=B4=D0=B4=D0=B5=D1=80?= =?UTF-8?q?=D0=B6=D0=BA=D0=BE=D0=B9=20=D0=B0=D1=82=D1=80=D0=B8=D0=B1=D1=83?= =?UTF-8?q?=D1=82=D0=B0=20name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- maxapi/connection/base.py | 40 ++++++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/maxapi/connection/base.py b/maxapi/connection/base.py index 70eac9b..45499dc 100644 --- a/maxapi/connection/base.py +++ b/maxapi/connection/base.py @@ -1,11 +1,10 @@ from __future__ import annotations import asyncio -import inspect from io import BytesIO import mimetypes from pathlib import Path -from typing import TYPE_CHECKING, Any, AsyncIterator, Optional +from typing import TYPE_CHECKING, Any, AsyncIterator, Optional, BinaryIO from urllib.parse import urlparse, unquote from datetime import datetime import re @@ -45,6 +44,16 @@ def __init__(self, status: int) -> None: super().__init__(f"Server error {status}") +class NamedBytesIO(BytesIO): + """BytesIO с поддержкой атрибута .name для единообразия с файловыми объектами.""" + __slots__ = ("name",) + name: Optional[str] + + def __init__(self, buffer: bytes = b"", *, name: Optional[str] = None) -> None: + super().__init__(buffer) + self.name = name # Соответствует протоколу typing.BinaryIO + + def _on_backoff(details: Details) -> None: """Логирование при retry. @@ -507,7 +516,7 @@ async def download_file( response_dict=response ): await f.write(chunk) - + filename = response.get('filename') final_path = self._check_file_exists(dest / filename) if final_path != temp_path: @@ -521,9 +530,9 @@ async def download_file_as_bytes( url: str, *, chunk_size: int = DOWNLOAD_CHUNK_SIZE, - ) -> BytesIO: + ) -> BinaryIO: """ - Скачивает файл и возвращает file-like объект в памяти. + Скачивает файл по URL и возвращает file-like объект в памяти. Внимание: весь файл загружается в оперативную память. Не используйте для файлов >100–200 МБ без контроля. @@ -533,19 +542,24 @@ async def download_file_as_bytes( chunk_size: Размер чанка при потоковом чтении. Returns: - BytesIO: Содержимое файла. Атрибут .name содержит имя файла + BinaryIO: Содержимое файла с атрибутом .name. + Для zero-copy передачи используйте .getbuffer(), + для получения bytes — .read() или .getvalue(). Raises: DownloadFileError: при ошибке скачивания. """ - bio = BytesIO() - + bio = NamedBytesIO() + response = {} - async for chunk in self._fetch_content_stream(url, chunk_size=chunk_size, response_dict=response): + async for chunk in self._fetch_content_stream( + url, + chunk_size=chunk_size, + response_dict=response + ): bio.write(chunk) + bio.seek(0) # обязательно переходим в начало - - if filename := response.get('filename'): - bio.name = filename - + bio.name = response.get('filename') + return bio From b5531c1f0915b0fa4e0e9d38ecd863c7471ef12a Mon Sep 17 00:00:00 2001 From: Pankovea <114323250+Pankovea@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:12:06 +0300 Subject: [PATCH 06/15] =?UTF-8?q?fix=20tests=20=D0=98=D0=BD=D0=BE=D0=B3?= =?UTF-8?q?=D0=B4=D0=B0=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20=D0=BF=D0=B0?= =?UTF-8?q?=D0=B4=D0=B0=D0=BB=D0=B8,=20=D0=BF=D0=BE=D1=82=D0=BE=D0=BC?= =?UTF-8?q?=D1=83=20=D1=87=D1=82=D0=BE=20=D0=B7=D0=B0=D0=BF=D1=83=D1=81?= =?UTF-8?q?=D0=BA=D0=B0=D0=BB=D0=B8=D1=81=D1=8C=20=D0=BD=D0=B0=20=D0=B3?= =?UTF-8?q?=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D0=B5=20=D1=81=D0=B5=D0=BA=D1=83?= =?UTF-8?q?=D0=BD=D0=B4=D1=8B.=20=D0=A2=D0=BE=20=D0=B5=D1=81=D1=82=D1=8C?= =?UTF-8?q?=20=D0=B2=20=D0=BF=D1=80=D0=BE=D1=86=D0=B5=D1=81=D1=81=D0=B5=20?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=D0=B0=20=D0=BC=D0=B5=D0=BD=D1=8F=D0=BB?= =?UTF-8?q?=D0=BE=D1=81=D1=8C=20=D0=B2=D1=80=D0=B5=D0=BC=D1=8F=20=D0=B8=20?= =?UTF-8?q?=D0=BE=D0=B6=D0=B8=D0=B4=D0=B0=D0=B5=D0=BC=D0=BE=D0=B5=20=D0=B8?= =?UTF-8?q?=D0=BC=D1=8F=20=D1=84=D0=B0=D0=B9=D0=BB=D0=B0,=20=D0=BE=D1=81?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D0=BE=D0=B5=20=D0=BD=D0=B0?= =?UTF-8?q?=20=D1=82=D0=B5=D0=BA=D1=83=D1=89=D0=B5=D0=BC=20=D0=B2=D1=80?= =?UTF-8?q?=D0=B5=D0=BC=D0=B5=D0=BD=D0=B8.=20=D0=A2=D0=B5=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D1=8C=20=D0=B2=D1=80=D0=B5=D0=BC=D1=8F=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D1=82=D0=B5=D1=81=D1=82=D0=BE=D0=B2=20=D0=B7=D0=B0?= =?UTF-8?q?=D1=84=D0=B8=D0=BA=D1=81=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D0=BD=D0=BE=20=D0=B4=D0=B5=D0=BA=D0=BE=D1=80=D0=B0=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=BE=D0=BC=20freeze=5Fdatetime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix: ruff downlad_as_bytes, tests --- maxapi/connection/base.py | 47 +++++++++++-------- tests/test_download_file.py | 94 ++++++++++++++++++++++++++++++------- 2 files changed, 104 insertions(+), 37 deletions(-) diff --git a/maxapi/connection/base.py b/maxapi/connection/base.py index 45499dc..0a6aaa7 100644 --- a/maxapi/connection/base.py +++ b/maxapi/connection/base.py @@ -1,20 +1,25 @@ from __future__ import annotations import asyncio -from io import BytesIO import mimetypes -from pathlib import Path -from typing import TYPE_CHECKING, Any, AsyncIterator, Optional, BinaryIO -from urllib.parse import urlparse, unquote -from datetime import datetime import re import uuid +from datetime import datetime +from io import BytesIO +from pathlib import Path +from typing import TYPE_CHECKING, Any, BinaryIO +from urllib.parse import unquote, urlparse import aiofiles import aiofiles.os import backoff import puremagic -from aiohttp import ClientConnectionError, ClientSession, FormData, ClientResponse +from aiohttp import ( + ClientConnectionError, + ClientResponse, + ClientSession, + FormData, +) from ..enums.api_path import ApiPath from ..enums.update import UpdateType @@ -25,6 +30,8 @@ from ..utils.runtime import bind_bot if TYPE_CHECKING: + from collections.abc import AsyncIterator + from backoff.types import Details from pydantic import BaseModel @@ -47,9 +54,9 @@ def __init__(self, status: int) -> None: class NamedBytesIO(BytesIO): """BytesIO с поддержкой атрибута .name для единообразия с файловыми объектами.""" __slots__ = ("name",) - name: Optional[str] + name: str | None - def __init__(self, buffer: bytes = b"", *, name: Optional[str] = None) -> None: + def __init__(self, buffer: bytes = b"", *, name: str | None = None) -> None: super().__init__(buffer) self.name = name # Соответствует протоколу typing.BinaryIO @@ -315,7 +322,7 @@ async def _fetch_content_stream( url: str, *, chunk_size: int = DOWNLOAD_CHUNK_SIZE, - response_dict: Optional[dict[str, str]] = None, + response_dict: dict[str, str] | None = None, ) -> AsyncIterator[bytes]: """ Асинхронный генератор, который отдаёт чанки файла по мере скачивания. @@ -366,8 +373,8 @@ async def _do_download() -> Any: ) if isinstance(response_dict, dict): - response_dict['resp'] = response - response_dict['filename'] = self._capture_filename(response) + response_dict["resp"] = response + response_dict["filename"] = self._capture_filename(response) elif response_dict is not None: raise ValueError(f"response_dict должен быть словарём, получен {type(response_dict)}") @@ -398,16 +405,16 @@ def _capture_filename(response: ClientResponse) -> str: ext = Path(filename).suffix else: parsed = urlparse(url) - name = unquote(parsed.path, encoding='utf-8', errors='replace') + name = unquote(parsed.path, encoding="utf-8", errors="replace") filename = Path(name).name # Защита от path traversal ext = Path(filename).suffix if not ext: ext = mimetypes.guess_extension(response.content_type or "") filename = f"{filename}{ext}" - if re.search(r'%[0-9A-Fa-f]{2}', filename): + if re.search(r"%[0-9A-Fa-f]{2}", filename): # Сервера Max возвращают имя файла дважды закодированное. Проверяем - filename = unquote(filename, encoding='utf-8', errors='replace') + filename = unquote(filename, encoding="utf-8", errors="replace") # Если имя не определилось datetime_str = datetime.now().strftime("%y%m%d_%H%M%S") @@ -415,11 +422,11 @@ def _capture_filename(response: ClientResponse) -> str: if not filename or filename.startswith("."): if is_photo: if not ext: - ext = '.webp' + ext = ".webp" filename = f"image_{datetime_str}{ext}" else: if not ext: - ext = '.bin' + ext = ".bin" filename = f"{datetime_str}{ext}" elif is_photo: filename = f"image_{datetime_str}{ext}" @@ -457,7 +464,7 @@ def _check_file_exists(path: Path|str) -> Path: # Сканируем директорию for existing_path in dest.iterdir(): - if existing_path.suffix == '.part': + if existing_path.suffix == ".part": continue match = pattern.match(existing_path.name) @@ -517,7 +524,7 @@ async def download_file( ): await f.write(chunk) - filename = response.get('filename') + filename = response.get("filename") final_path = self._check_file_exists(dest / filename) if final_path != temp_path: temp_path.replace(final_path) @@ -543,7 +550,7 @@ async def download_file_as_bytes( Returns: BinaryIO: Содержимое файла с атрибутом .name. - Для zero-copy передачи используйте .getbuffer(), + Для zero-copy передачи используйте .getbuffer(), для получения bytes — .read() или .getvalue(). Raises: @@ -560,6 +567,6 @@ async def download_file_as_bytes( bio.write(chunk) bio.seek(0) # обязательно переходим в начало - bio.name = response.get('filename') + bio.name = response.get("filename") return bio diff --git a/tests/test_download_file.py b/tests/test_download_file.py index 8e9ae96..93d0d71 100644 --- a/tests/test_download_file.py +++ b/tests/test_download_file.py @@ -1,11 +1,19 @@ """Тесты для метода download_file.""" +import inspect +from collections.abc import Callable +from datetime import datetime +from functools import wraps +from typing import TYPE_CHECKING from unittest.mock import AsyncMock, MagicMock, patch import pytest from maxapi.bot import Bot from maxapi.exceptions.download_file import DownloadFileError +if TYPE_CHECKING: + from pathlib import Path + @pytest.fixture def bot(): @@ -57,7 +65,7 @@ def _make_mock_response( if url is not None: mock_response.url = url - + if chunks is not None: mock_response.content.iter_chunked = MagicMock( return_value=AsyncIterator(chunks) @@ -75,6 +83,58 @@ def mock_session(bot): return session +def freeze_datetime( + target_module: str, + fixed_dt: datetime | str, + *, + attr: str = "datetime" +) -> Callable: + """ + Декоратор для заморозки datetime.now() в указанном модуле. + Корректно работает с синхронными и асинхронными тестами. + + Args: + target_module: Полный путь к модулю, где вызывается datetime.now() + (например: 'myapp.services.payment', 'tests.conftest') + fixed_dt: Фиксированная дата/время (datetime объект или ISO-строка) + attr: Имя атрибута для патча. + 'datetime' → если в модуле `from datetime import datetime` + 'datetime.datetime' → если в модуле `import datetime` + + Returns: + Декоратор для тестовой функции. + """ + if isinstance(fixed_dt, str): + fixed_dt = datetime.fromisoformat(fixed_dt) + + patch_target = f"{target_module}.{attr}" + + def decorator(func: Callable) -> Callable: + # Синхронная обёртка + @wraps(func) + def _sync_wrapper(*args, **kwargs): + with patch(patch_target) as mock_dt: + mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw) + mock_dt.now.return_value = fixed_dt + return func(*args, **kwargs) + + # Асинхронная обёртка + @wraps(func) + async def _async_wrapper(*args, **kwargs): + with patch(patch_target) as mock_dt: + mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw) + mock_dt.now.return_value = fixed_dt + return await func(*args, **kwargs) + + # Возвращаем нужную обёртку в зависимости от типа функции + if inspect.iscoroutinefunction(func): + return _async_wrapper + else: + return _sync_wrapper + + return decorator + + class TestDownloadFile: async def test_download_file_success(self, bot, tmp_dir, mock_session): """Скачивание файла с корректным Content-Disposition.""" @@ -113,12 +173,11 @@ async def test_download_file_no_content_disposition( assert result.name == "img.jpg" assert result.parent == tmp_dir + @freeze_datetime("maxapi.connection.base", "2026-04-16 10:30:50") async def test_download_file_no_content_disposition_no_path( self, bot, tmp_dir, mock_session ): """Скачивание без Content-Disposition и без MIME и без внятного пути""" - from datetime import datetime - mock_response = _make_mock_response( url="https://example.com/", chunks=[b"imagedata"], @@ -129,17 +188,15 @@ async def test_download_file_no_content_disposition_no_path( url="https://example.com/", destination=tmp_dir, ) - expected = f"{datetime.now().strftime("%y%m%d_%H%M%S")}.bin" + expected = "260416_103050.bin" assert result.name == expected assert result.parent == tmp_dir - + @freeze_datetime("maxapi.connection.base", "2026-04-16 10:30:50") async def test_download_photo( self, bot, tmp_dir, mock_session ): """Скачивание фложения-фото по ссылке выда https://i.oneme.ru/i?r=photo_token""" - from datetime import datetime - mock_response = _make_mock_response( url="https://i.oneme.ru/i?r=photo_token", content_type="image/jpeg", @@ -151,7 +208,7 @@ async def test_download_photo( url="https://i.oneme.ru/i?r=photo_token", destination=tmp_dir, ) - expected = f"image_{datetime.now().strftime("%y%m%d_%H%M%S")}.jpg" + expected = "image_260416_103050.jpg" assert result.name == expected assert result.parent == tmp_dir @@ -260,9 +317,9 @@ class TestDownloadFileAsBytes: Тесты для метода download_file_as_bytes. Примеры реальных URL для ручного тестирования: - - Файл с подписью: + - Файл с подписью: https://fd.oneme.ru/getfile?sig=...&expires=...&clientType=3&id=... - - Изображение: + - Изображение: https://i.oneme.ru/i?r=BTGBPUwtwgYUeoFhO7rESmr81n-DnwjHYFhx5_EAhKk... """ @@ -385,7 +442,9 @@ async def test_download_file_as_bytes_empty_file(self, bot, mock_session): async def test_download_file_vs_as_bytes_same_content( self, bot, tmp_dir, mock_session ): - """download_file и download_file_as_bytes возвращают одинаковые данные.""" + """ + download_file и download_file_as_bytes возвращают одинаковые данные + """ content = b"test content for comparison" chunks = [content[i:i+10] for i in range(0, len(content), 10)] @@ -423,14 +482,12 @@ async def test_download_file_vs_as_bytes_same_content( assert path.name == bio.name assert disk_content == bytes_content == content - + @freeze_datetime("maxapi.connection.base", datetime.now()) async def test_download_file_name_collision(self, bot, tmp_dir, mock_session): """Проверка, что при коллизии имён добавляется (2), (3) и т.д.""" - from typing import List - from pathlib import Path # Пытаемся скачать сразу 5 файлов - results: List[Path] = [] + results: list[Path] = [] for i in range(5): mock_response = _make_mock_response( url=f"https://i.oneme.ru/i?r=file{i+1}", @@ -448,7 +505,8 @@ async def test_download_file_name_collision(self, bot, tmp_dir, mock_session): if i == 0: # Первый файл не проверяем # Первый файл должен быть без суффикса _N # Только image_date_time - assert '(' not in result.stem and ')' not in result.stem + assert "(" not in result.stem + assert ")" not in result.stem else: # Ожидаем, что файлы сохранится с суффиксами assert result.stem.endswith(f"({i+1})") @@ -458,7 +516,9 @@ async def test_download_file_name_collision(self, bot, tmp_dir, mock_session): async def test_download_file_photo_correct_extension( self, bot, tmp_dir, mock_session ): - """Для i.oneme.ru расширение определяется по Content-Type, а не .webp.""" + """ + Для i.oneme.ru расширение определяется по Content-Type, а не .webp + """ mock_response = _make_mock_response( url="https://i.oneme.ru/i?r=test", content_type="image/png", From 3c5b4ad8a31fcdd091bb410a4552905c9d23c93b Mon Sep 17 00:00:00 2001 From: Pankovea <114323250+Pankovea@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:12:06 +0300 Subject: [PATCH 07/15] fix: annotations download_as_bytes --- maxapi/connection/base.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/maxapi/connection/base.py b/maxapi/connection/base.py index 0a6aaa7..8f81416 100644 --- a/maxapi/connection/base.py +++ b/maxapi/connection/base.py @@ -7,7 +7,7 @@ from datetime import datetime from io import BytesIO from pathlib import Path -from typing import TYPE_CHECKING, Any, BinaryIO +from typing import TYPE_CHECKING, Any, BinaryIO, NotRequired, TypedDict from urllib.parse import unquote, urlparse import aiofiles @@ -39,6 +39,9 @@ from ..enums.http_method import HTTPMethod from ..enums.upload_type import UploadType + class ResponseDict(TypedDict): + resp: NotRequired[ClientResponse] + filename: str DOWNLOAD_CHUNK_SIZE = 65536 @@ -322,7 +325,7 @@ async def _fetch_content_stream( url: str, *, chunk_size: int = DOWNLOAD_CHUNK_SIZE, - response_dict: dict[str, str] | None = None, + response_dict: ResponseDict | dict[str, Any] | None = None, ) -> AsyncIterator[bytes]: """ Асинхронный генератор, который отдаёт чанки файла по мере скачивания. @@ -372,7 +375,7 @@ async def _do_download() -> Any: f"Ошибка при скачивании файла: HTTP {response.status}" ) - if isinstance(response_dict, dict): + if isinstance(response_dict, (dict)): response_dict["resp"] = response response_dict["filename"] = self._capture_filename(response) elif response_dict is not None: @@ -396,8 +399,8 @@ def _capture_filename(response: ClientResponse) -> str: Returns: str: Имя файла из заголовков. Если не удалось определить, то возвращается default """ - filename = ext = None - url = response.url + filename = ext = "" + url = str(response.url) try: cd = response.content_disposition if cd and cd.filename: @@ -515,7 +518,7 @@ async def download_file( temp_filename = f"tmp_{uuid.uuid4().hex}.part" temp_path = dest / temp_filename - response = {} + response: ResponseDict = {"filename": ""} async with aiofiles.open(temp_path, "wb") as f: async for chunk in self._fetch_content_stream( url, @@ -524,7 +527,7 @@ async def download_file( ): await f.write(chunk) - filename = response.get("filename") + filename = response["filename"] final_path = self._check_file_exists(dest / filename) if final_path != temp_path: temp_path.replace(final_path) From 2b6c7ed91400b1cd840ccdcb46ee4d1f9b4980c9 Mon Sep 17 00:00:00 2001 From: Pankovea <114323250+Pankovea@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:12:06 +0300 Subject: [PATCH 08/15] fix: download tests coverage --- maxapi/connection/base.py | 15 +-- tests/test_download_file.py | 197 +++++++++++++++++++++++++++--------- 2 files changed, 155 insertions(+), 57 deletions(-) diff --git a/maxapi/connection/base.py b/maxapi/connection/base.py index 8f81416..c70040c 100644 --- a/maxapi/connection/base.py +++ b/maxapi/connection/base.py @@ -375,7 +375,7 @@ async def _do_download() -> Any: f"Ошибка при скачивании файла: HTTP {response.status}" ) - if isinstance(response_dict, (dict)): + if isinstance(response_dict, dict): response_dict["resp"] = response response_dict["filename"] = self._capture_filename(response) elif response_dict is not None: @@ -400,8 +400,8 @@ def _capture_filename(response: ClientResponse) -> str: str: Имя файла из заголовков. Если не удалось определить, то возвращается default """ filename = ext = "" - url = str(response.url) try: + url = str(response.url) cd = response.content_disposition if cd and cd.filename: filename = Path(cd.filename).name @@ -411,8 +411,7 @@ def _capture_filename(response: ClientResponse) -> str: name = unquote(parsed.path, encoding="utf-8", errors="replace") filename = Path(name).name # Защита от path traversal ext = Path(filename).suffix - if not ext: - ext = mimetypes.guess_extension(response.content_type or "") + if not ext and (ext:=mimetypes.guess_extension(response.content_type or "")): filename = f"{filename}{ext}" if re.search(r"%[0-9A-Fa-f]{2}", filename): @@ -424,7 +423,7 @@ def _capture_filename(response: ClientResponse) -> str: is_photo = url.startswith("https://i.oneme.ru/") if not filename or filename.startswith("."): if is_photo: - if not ext: + if not ext or ext == ".bin": ext = ".webp" filename = f"image_{datetime_str}{ext}" else: @@ -432,6 +431,8 @@ def _capture_filename(response: ClientResponse) -> str: ext = ".bin" filename = f"{datetime_str}{ext}" elif is_photo: + if not ext or ext == ".bin": + ext = ".webp" filename = f"image_{datetime_str}{ext}" except (AttributeError, TypeError, ValueError) as e: @@ -456,8 +457,8 @@ def _check_file_exists(path: Path|str) -> Path: Raises: ValueError: Non-encodable path. """ - if isinstance(path, str): - path = Path(path) + path = Path(path) + dest = path.parent if path.exists(): diff --git a/tests/test_download_file.py b/tests/test_download_file.py index 93d0d71..12784b9 100644 --- a/tests/test_download_file.py +++ b/tests/test_download_file.py @@ -136,11 +136,28 @@ async def _async_wrapper(*args, **kwargs): class TestDownloadFile: + async def test_download_file_path_as_str(self, bot, tmp_dir, mock_session): + """Скачивание файла с корректным Content-Disposition.""" + chunks = [b"chunk1", b"chunk2", b"chunk3"] + url="https://example.com/file.pdf" + mock_response = _make_mock_response(url=url, chunks=chunks) + mock_session.request = AsyncMock(return_value=mock_response) + + result = await bot.download_file( + url=url, + destination=str(tmp_dir), + ) + + assert result == tmp_dir / "file.pdf" + assert result.read_bytes() == b"".join(chunks) + + async def test_download_file_success(self, bot, tmp_dir, mock_session): """Скачивание файла с корректным Content-Disposition.""" chunks = [b"chunk1", b"chunk2", b"chunk3"] + url="https://example.com/file.pdf" mock_response = _make_mock_response( - url="https://example.com/file.pdf", + url=url, content_type="application/pdf", cd_filename="document.pdf", chunks=chunks, @@ -148,26 +165,27 @@ async def test_download_file_success(self, bot, tmp_dir, mock_session): mock_session.request = AsyncMock(return_value=mock_response) result = await bot.download_file( - url="https://example.com/file.pdf", + url=url, destination=tmp_dir, ) assert result == tmp_dir / "document.pdf" - assert result.read_bytes() == b"chunk1chunk2chunk3" + assert result.read_bytes() == b"".join(chunks) async def test_download_file_no_content_disposition( self, bot, tmp_dir, mock_session ): """Скачивание без Content-Disposition — имя генерируется по MIME.""" + url="https://example.com/img" mock_response = _make_mock_response( - url="https://example.com/img", + url=url, content_type="image/jpeg", chunks=[b"imagedata"], ) mock_session.request = AsyncMock(return_value=mock_response) result = await bot.download_file( - url="https://example.com/img", + url=url, destination=tmp_dir, ) assert result.name == "img.jpg" @@ -178,14 +196,16 @@ async def test_download_file_no_content_disposition_no_path( self, bot, tmp_dir, mock_session ): """Скачивание без Content-Disposition и без MIME и без внятного пути""" + url="https://example.com/" mock_response = _make_mock_response( - url="https://example.com/", + url=url, + content_type=None, # Без типа chunks=[b"imagedata"], ) mock_session.request = AsyncMock(return_value=mock_response) result = await bot.download_file( - url="https://example.com/", + url=url, destination=tmp_dir, ) expected = "260416_103050.bin" @@ -196,16 +216,17 @@ async def test_download_file_no_content_disposition_no_path( async def test_download_photo( self, bot, tmp_dir, mock_session ): - """Скачивание фложения-фото по ссылке выда https://i.oneme.ru/i?r=photo_token""" + """Скачивание вложения-фото по ссылке выда https://i.oneme.ru/i?r=photo_token""" + url="https://i.oneme.ru/i?r=photo_token" mock_response = _make_mock_response( - url="https://i.oneme.ru/i?r=photo_token", + url=url, content_type="image/jpeg", chunks=[b"imagedata"], ) mock_session.request = AsyncMock(return_value=mock_response) result = await bot.download_file( - url="https://i.oneme.ru/i?r=photo_token", + url=url, destination=tmp_dir, ) expected = "image_260416_103050.jpg" @@ -216,8 +237,9 @@ async def test_download_file_path_traversal_protection( self, bot, tmp_dir, mock_session ): """Защита от path traversal в filename.""" + url="https://example.com/file" mock_response = _make_mock_response( - url="https://example.com/file", + url=url, content_type="text/plain", cd_filename="../../etc/passwd", chunks=[b"data"], @@ -225,7 +247,7 @@ async def test_download_file_path_traversal_protection( mock_session.request = AsyncMock(return_value=mock_response) result = await bot.download_file( - url="https://example.com/file", + url=url, destination=tmp_dir, ) @@ -268,8 +290,9 @@ async def test_download_file_retry_on_server_error( retry_response = _make_mock_response(ok=False, status=503) retry_response.read = AsyncMock() + url="https://example.com/file" success_response = _make_mock_response( - url="https://example.com/file", + url=url, content_type="text/plain", cd_filename="result.txt", chunks=[b"ok"], @@ -283,7 +306,7 @@ async def test_download_file_retry_on_server_error( with patch("asyncio.sleep", new_callable=AsyncMock): result = await bot.download_file( - url="https://example.com/file", + url=url, destination=tmp_dir, ) @@ -331,17 +354,16 @@ async def test_download_file_as_bytes_success(self, bot, mock_session): GET https://fd.oneme.ru/getfile?sig=...&expires=... """ chunks = [b"chunk1", b"chunk2", b"chunk3"] + url="https://fd.oneme.ru/getfile?sig=test&expires=123" mock_response = _make_mock_response( - url="https://fd.oneme.ru/getfile?sig=test&expires=123", + url=url, content_type="application/octet-stream", cd_filename="document.pdf", chunks=chunks, ) mock_session.request = AsyncMock(return_value=mock_response) - bio = await bot.download_file_as_bytes( - url="https://fd.oneme.ru/getfile?sig=test&expires=123", - ) + bio = await bot.download_file_as_bytes(url=url) result = bio.read() assert result == b"chunk1chunk2chunk3" @@ -350,22 +372,18 @@ async def test_download_file_as_bytes_success(self, bot, mock_session): async def test_download_file_as_bytes_image_url(self, bot, mock_session): """ Скачивание изображения с i.oneme.ru. - - Пример реального URL: - https://i.oneme.ru/i?r=BTGBPUwtwgYUeoFhO7rESmr81n-DnwjHYFhx5_EAhKk... """ # Эмулируем PNG-изображение (минимальный валидный заголовок) png_header = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100 + url="https://i.oneme.ru/i?r=BTGBPUwtwgYUeoFhO7rESmr81n-DnwjHYFhx5_EAhKk..." mock_response = _make_mock_response( - url="https://i.oneme.ru/i?r=test_token", + url=url, content_type="image/png", chunks=[png_header], ) mock_session.request = AsyncMock(return_value=mock_response) - bio = await bot.download_file_as_bytes( - url="https://i.oneme.ru/i?r=test_token", - ) + bio = await bot.download_file_as_bytes(url=url) result = bio.read() assert result.startswith(b"\x89PNG") @@ -377,10 +395,9 @@ async def test_download_file_as_bytes_http_error(self, bot, mock_session): mock_response = _make_mock_response(ok=False, status=404) mock_session.request = AsyncMock(return_value=mock_response) + url="https://example.com/missing" with pytest.raises(DownloadFileError, match="HTTP 404"): - await bot.download_file_as_bytes( - url="https://example.com/missing", - ) + await bot.download_file_as_bytes(url=url) async def test_download_file_as_bytes_connection_error( self, bot, mock_session @@ -393,10 +410,9 @@ async def test_download_file_as_bytes_connection_error( ) bot.default_connection.max_retries = 0 + url="https://example.com/file" with pytest.raises(DownloadFileError, match="Ошибка при скачивании"): - await bot.download_file_as_bytes( - url="https://example.com/file", - ) + await bot.download_file_as_bytes(url=url) async def test_download_file_as_bytes_retry_on_503( self, bot, mock_session @@ -405,8 +421,9 @@ async def test_download_file_as_bytes_retry_on_503( retry_response = _make_mock_response(ok=False, status=503) retry_response.read = AsyncMock() + url="https://example.com/file" success_response = _make_mock_response( - url="https://example.com/file", + url=url, content_type="text/plain", chunks=[b"success"], ) @@ -418,27 +435,40 @@ async def test_download_file_as_bytes_retry_on_503( bot.default_connection.retry_backoff_factor = 0.0 with patch("asyncio.sleep", new_callable=AsyncMock): - bio = await bot.download_file_as_bytes( - url="https://example.com/file", - ) + bio = await bot.download_file_as_bytes(url=url) result = bio.read() assert result == b"success" async def test_download_file_as_bytes_empty_file(self, bot, mock_session): """Скачивание пустого файла.""" + url="https://example.com/empty" mock_response = _make_mock_response( - url="https://example.com/empty", + url=url, content_type="application/octet-stream", chunks=[], # Пустой итератор ) mock_session.request = AsyncMock(return_value=mock_response) - bio = await bot.download_file_as_bytes( - url="https://example.com/empty", - ) + bio = await bot.download_file_as_bytes(url=url) result = bio.read() assert result == b"" + async def test_download_file_as_bytes_encoded_filename(self, bot, mock_session): + """Скачивание пустого файла.""" + chunks = [b"chunk1", b"chunk2", b"chunk3"] + url = "https://fd.oneme.ru/getfile?sig=Dm00IcsNNg1fIU1X4CB_R0777_saII2AAtcffL6lmnT3TTiVuBBB95jo-4qfyGElLLh1w4ZdD4QpwliVoW77Kg&expires=1779148580110&clientType=3&id=3100094539&userId=111973341" + mock_response = _make_mock_response( + url=url, + cd_filename="%D0%94%D0%BE%D0%BA%D1%83%D0%BC%D0%B5%D0%BD%D1%82.pdf", + content_type="application/octet-stream", + chunks=chunks + ) + mock_session.request = AsyncMock(return_value=mock_response) + + bio = await bot.download_file_as_bytes(url=url) + result = bio.read() + assert result == b"".join(chunks) + async def test_download_file_vs_as_bytes_same_content( self, bot, tmp_dir, mock_session ): @@ -447,16 +477,17 @@ async def test_download_file_vs_as_bytes_same_content( """ content = b"test content for comparison" chunks = [content[i:i+10] for i in range(0, len(content), 10)] + url="https://example.com/file" # Для download_file mock_response_disk = _make_mock_response( - url="https://example.com/file", + url=url, cd_filename="test.txt", chunks=chunks.copy(), ) # Для download_file_as_bytes mock_response_bytes = _make_mock_response( - url="https://example.com/file", + url=url, cd_filename="test.txt", chunks=chunks.copy(), ) @@ -468,15 +499,13 @@ async def test_download_file_vs_as_bytes_same_content( # Скачиваем на диск path = await bot.download_file( - url="https://example.com/file", - destination=tmp_dir, + url=url, + destination=tmp_dir ) disk_content = path.read_bytes() # Скачиваем в память - bio = await bot.download_file_as_bytes( - url="https://example.com/file", - ) + bio = await bot.download_file_as_bytes(url=url) bytes_content = bio.read() assert path.name == bio.name @@ -489,14 +518,15 @@ async def test_download_file_name_collision(self, bot, tmp_dir, mock_session): # Пытаемся скачать сразу 5 файлов results: list[Path] = [] for i in range(5): + url=f"https://i.oneme.ru/i?r=file{i+1}" mock_response = _make_mock_response( - url=f"https://i.oneme.ru/i?r=file{i+1}", + url=url, chunks=[f"new {i+1}".encode()] ) mock_session.request = AsyncMock(return_value=mock_response) results.append( await bot.download_file( - url=f"https://i.oneme.ru/i?r=file{i+1}", + url=url, destination=tmp_dir, ) ) @@ -519,17 +549,84 @@ async def test_download_file_photo_correct_extension( """ Для i.oneme.ru расширение определяется по Content-Type, а не .webp """ + url="https://i.oneme.ru/i?r=test" mock_response = _make_mock_response( - url="https://i.oneme.ru/i?r=test", + url=url, content_type="image/png", chunks=[b"\x89PNG\r\n\x1a\n"], ) mock_session.request = AsyncMock(return_value=mock_response) result = await bot.download_file( - url="https://i.oneme.ru/i?r=test", + url=url, destination=tmp_dir, ) assert result.suffix == ".png" # не .webp! assert result.name.startswith("image_") + + + async def test_download_file_retryable_server_error(self, + bot, mock_session): + """ + Покрытие ветки: except _RetryableServerError -> DownloadFileError + """ + mock_response = _make_mock_response(status=502) + mock_session.request = AsyncMock(return_value=mock_response) + + with pytest.raises(DownloadFileError) as exc_info: + await bot.download_file_as_bytes(url="https://i.oneme.ru/i?r=test") + + assert "HTTP 502" in str(exc_info.value) + + async def test_fetch_content_stream_invalid_response_dict_type_direct(self, + bot, mock_session): + """Покрытие ветки: elif response_dict is not None -> ValueError""" + # from maxapi.connection.base import BaseConnection # или ваш миксин + + mock_response = _make_mock_response() + # Мокаем итерацию по контенту (пустой поток, чтобы сразу завершиться) + async def empty_iterator(): + return + yield # Пустой генератор + + mock_response.content.iter_chunked = MagicMock(return_value=empty_iterator()) + + # Мокаем session.request + mock_session.request = AsyncMock(return_value=mock_response) + bot._session = mock_session + + # 2. Вызываем приватный метод напрямую с НЕПРАВИЛЬНЫМ типом response_dict + with pytest.raises(ValueError, match="response_dict должен быть словарём"): + async for _ in bot._fetch_content_stream( + url="https://example.com/file.pdf", + response_dict=[] # ❌ list вместо dict + ): + pass + + @freeze_datetime("maxapi.connection.base", "2026-04-16 10:30:50") + async def test_capture_filename_no_extension_fallback(self, + bot, mock_session): + """Покрытие: is_photo=True, ext='', fallback на .webp""" + from maxapi.connection.base import BaseConnection + + # 1. Случай с фото + url="https://i.oneme.ru/" # Нет имени файла в URL + mock_response = _make_mock_response( + url = url, content_type=None) # Нет заголовка и content_type + + filename = BaseConnection._capture_filename(mock_response) + + assert filename == "image_260416_103050.webp" + + + def test_capture_filename_minimal_object(self): + """Покрытие: except (TypeError, AttributeError) при доступе к полям""" + from maxapi.connection.base import BaseConnection + + class BrokenResponse: + # Нет ни content_disposition, ни url, ни content_type + pass + + filename = BaseConnection._capture_filename(BrokenResponse()) + assert filename == "" # Должен вернуться fallback-результат From f6eebe4f9727bf80ca32299a73dff5d7b5ec0b45 Mon Sep 17 00:00:00 2001 From: Pankovea <114323250+Pankovea@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:12:06 +0300 Subject: [PATCH 09/15] =?UTF-8?q?refactor:=20=D0=B2=D1=8B=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BE=D1=82=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BC=D0=B5=D1=82=D0=BE=D0=B4?= =?UTF-8?q?=D0=B0=20=5Ffetch=5Fresponse=20=D0=B4=D0=BB=D1=8F=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D1=83=D1=87=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=B3=D0=BE=D0=BB=D0=BE=D0=B2=D0=BA=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В итоге более чистая архитектура. Разделённая ответственность методов. При сохранении файла не создаются временные файлы. переименован метод download_file_as_bytes. Теперь: download_bytes_io -> NamedBytesIO add: download_bytes -> bytes теперь не нужен response_dict. * ruff правки * _capture_filename возвращает вуафгде значение (не пустую строку) * tests 100% coverage remove: ResponseDict ruff --- maxapi/connection/base.py | 211 +++++++++++++++++----------- tests/test_download_file.py | 267 +++++++++++++++++++++++------------- 2 files changed, 304 insertions(+), 174 deletions(-) diff --git a/maxapi/connection/base.py b/maxapi/connection/base.py index c70040c..5b5f8fa 100644 --- a/maxapi/connection/base.py +++ b/maxapi/connection/base.py @@ -3,12 +3,11 @@ import asyncio import mimetypes import re -import uuid from datetime import datetime from io import BytesIO from pathlib import Path -from typing import TYPE_CHECKING, Any, BinaryIO, NotRequired, TypedDict -from urllib.parse import unquote, urlparse +from typing import TYPE_CHECKING, Any +from urllib.parse import unquote import aiofiles import aiofiles.os @@ -39,9 +38,6 @@ from ..enums.http_method import HTTPMethod from ..enums.upload_type import UploadType - class ResponseDict(TypedDict): - resp: NotRequired[ClientResponse] - filename: str DOWNLOAD_CHUNK_SIZE = 65536 @@ -55,11 +51,15 @@ def __init__(self, status: int) -> None: class NamedBytesIO(BytesIO): - """BytesIO с поддержкой атрибута .name для единообразия с файловыми объектами.""" + """ + BytesIO с поддержкой атрибута .name для единообразия с файловыми объектами. + """ __slots__ = ("name",) name: str | None - def __init__(self, buffer: bytes = b"", *, name: str | None = None) -> None: + def __init__(self, buffer: bytes = b"", + *, + name: str | None = None) -> None: super().__init__(buffer) self.name = name # Соответствует протоколу typing.BinaryIO @@ -118,6 +118,7 @@ def __init__(self) -> None: self.after_input_media_delay: float = self.AFTER_MEDIA_INPUT_DELAY self.api_url = self.API_URL + def set_api_url(self, url: str) -> None: """ Установка API URL для запросов @@ -128,6 +129,7 @@ def set_api_url(self, url: str) -> None: self.api_url = url + async def request( self, method: HTTPMethod, @@ -228,6 +230,7 @@ async def _do_request() -> Any: return bind_bot(model, bot) + async def upload_file(self, url: str, path: str, type: UploadType) -> str: """ Загружает файл на сервер. @@ -268,6 +271,7 @@ async def upload_file(self, url: str, path: str, type: UploadType) -> str: response = await temp_session.post(url=url, data=form) return await response.text() + async def upload_file_buffer( self, filename: str, url: str, buffer: bytes, type: UploadType ) -> str: @@ -320,30 +324,9 @@ async def upload_file_buffer( return await response.text() - async def _fetch_content_stream( - self, - url: str, - *, - chunk_size: int = DOWNLOAD_CHUNK_SIZE, - response_dict: ResponseDict | dict[str, Any] | None = None, - ) -> AsyncIterator[bytes]: - """ - Асинхронный генератор, который отдаёт чанки файла по мере скачивания. - - Args: - url: URL файла. - response_dict: Опциональный словарь в который будет сохраненs заголовки до начала чтения тела - и имя файла. Формат: - - response_dict['response'] - - response_dict['filename'] - - Yields: - bytes: Чанки данных файла. - - Raises: - DownloadFileError: при ошибке запроса или недопустимом статусе. - """ + async def _fetch_response(self, url: str) -> ClientResponse: bot = self._ensure_bot() + session = await bot.ensure_session() conn = bot.default_connection @backoff.on_exception( @@ -353,8 +336,7 @@ async def _fetch_content_stream( factor=conn.retry_backoff_factor, on_backoff=_on_backoff, ) - async def _do_download() -> Any: - session = await bot.ensure_session() + async def _do_request() -> Any: resp = await session.request("GET", url) if resp.status in conn.retry_on_statuses: await resp.read() @@ -362,24 +344,48 @@ async def _do_download() -> Any: return resp try: - response = await _do_download() + response = await _do_request() except ClientConnectionError as e: - raise DownloadFileError(f"Ошибка при скачивании файла: {e}") from e + raise DownloadFileError(f"Network error: {e}") from e except _RetryableServerError as e: raise DownloadFileError( f"Ошибка при скачивании файла: HTTP {e.status}" ) from e if not response.ok: + await response.release() raise DownloadFileError( - f"Ошибка при скачивании файла: HTTP {response.status}" - ) + f"Ошибка при скачивании: HTTP {response.status}") + + return response + - if isinstance(response_dict, dict): - response_dict["resp"] = response - response_dict["filename"] = self._capture_filename(response) - elif response_dict is not None: - raise ValueError(f"response_dict должен быть словарём, получен {type(response_dict)}") + async def _fetch_content_stream( + self, + response: ClientResponse, + *, + chunk_size: int = DOWNLOAD_CHUNK_SIZE, + ) -> AsyncIterator[bytes]: + """ + Асинхронный генератор, который отдаёт чанки файла по мере скачивания. + + Args: + response: Предварительно полученный ClientResponse. + Результат метода self._fetch_response + + Yields: + bytes: Чанки данных файла. + + Raises: + DownloadFileError: при ошибке запроса или недопустимом статусе. + """ + if response.closed: + raise DownloadFileError("response соединение закрыто") + + if not response.ok: + await response.release() + raise DownloadFileError( + f"Ошибка при скачивании: HTTP {response.status}") try: async for chunk in response.content.iter_chunked(chunk_size): @@ -387,6 +393,7 @@ async def _do_download() -> Any: finally: await response.release() + @staticmethod def _capture_filename(response: ClientResponse) -> str: """ @@ -397,30 +404,36 @@ def _capture_filename(response: ClientResponse) -> str: response: Ответ сервера с заголовками файла Returns: - str: Имя файла из заголовков. Если не удалось определить, то возвращается default + str: Имя файла из заголовков. + Если не удалось определить, то возвращается default + в формате %y%m%d_%H%M%S.ext """ filename = ext = "" + datetime_str = datetime.now().strftime("%y%m%d_%H%M%S") + if not isinstance(response, ClientResponse): + raise TypeError( + f"Ожидается ClientResponse, получен {type(response)}") try: - url = str(response.url) cd = response.content_disposition if cd and cd.filename: filename = Path(cd.filename).name ext = Path(filename).suffix else: - parsed = urlparse(url) - name = unquote(parsed.path, encoding="utf-8", errors="replace") + name = response.url.name filename = Path(name).name # Защита от path traversal ext = Path(filename).suffix - if not ext and (ext:=mimetypes.guess_extension(response.content_type or "")): - filename = f"{filename}{ext}" + if not ext: + if response.content_type: + ext = mimetypes.guess_extension(response.content_type) + if ext: + filename = f"{filename}{ext}" + # Сервера Max возвращают имя файла дважды закодированное. Проверяем if re.search(r"%[0-9A-Fa-f]{2}", filename): - # Сервера Max возвращают имя файла дважды закодированное. Проверяем - filename = unquote(filename, encoding="utf-8", errors="replace") + filename = unquote(filename, encoding="utf-8") # Если имя не определилось - datetime_str = datetime.now().strftime("%y%m%d_%H%M%S") - is_photo = url.startswith("https://i.oneme.ru/") + is_photo = response.url.host == "i.oneme.ru" if not filename or filename.startswith("."): if is_photo: if not ext or ext == ".bin": @@ -436,14 +449,19 @@ def _capture_filename(response: ClientResponse) -> str: filename = f"image_{datetime_str}{ext}" except (AttributeError, TypeError, ValueError) as e: - logger_bot.warning("Не удалось определить имя файла из заголовков: %s", e) + logger_bot.warning( + "Не удалось определить имя файла из заголовков: %s", e) + if not filename: + filename = f"{datetime_str}.bin" # fallback return filename + @staticmethod def _check_file_exists(path: Path|str) -> Path: - """Проверяет, если файл существует, то возвращает новый свободный путь для сохранения - Windows style: + """ + Проверяет, если файл существует, то возвращает + новый свободный путь для сохранения Windows style: - file_name.ext - file_name(2).ext - file_name(3).ext @@ -464,13 +482,12 @@ def _check_file_exists(path: Path|str) -> Path: if path.exists(): max_num = 1 # Один уже существует fname, ext = path.stem, path.suffix - pattern = re.compile(rf"^{re.escape(fname)}\((\d+)\){re.escape(ext)}$") + pattern = re.compile( + rf"^{re.escape(fname)}\((\d+)\){re.escape(ext)}$" + ) # Сканируем директорию for existing_path in dest.iterdir(): - if existing_path.suffix == ".part": - continue - match = pattern.match(existing_path.name) if match: num = int(match.group(1)) @@ -481,6 +498,7 @@ def _check_file_exists(path: Path|str) -> Path: return path + async def download_file( self, url: str, @@ -514,34 +532,35 @@ async def download_file( DownloadFileError: при ошибке скачивания. """ dest = Path(destination) - await aiofiles.os.makedirs(destination, exist_ok=True) - temp_filename = f"tmp_{uuid.uuid4().hex}.part" - temp_path = dest / temp_filename - - response: ResponseDict = {"filename": ""} - async with aiofiles.open(temp_path, "wb") as f: - async for chunk in self._fetch_content_stream( - url, - chunk_size=chunk_size, - response_dict=response - ): - await f.write(chunk) - - filename = response["filename"] + + response = await self._fetch_response(url) + + filename = self._capture_filename(response) final_path = self._check_file_exists(dest / filename) - if final_path != temp_path: - temp_path.replace(final_path) + + try: + async with aiofiles.open(final_path, "wb") as f: + async for chunk in self._fetch_content_stream( + response, + chunk_size=chunk_size + ): + await f.write(chunk) + except Exception: + # При любой ошибке удаляем частично записанный файл + if final_path.exists(): + final_path.unlink() + raise return final_path - async def download_file_as_bytes( + async def download_bytes_io( self, url: str, *, chunk_size: int = DOWNLOAD_CHUNK_SIZE, - ) -> BinaryIO: + ) -> NamedBytesIO: """ Скачивает файл по URL и возвращает file-like объект в памяти. @@ -553,7 +572,8 @@ async def download_file_as_bytes( chunk_size: Размер чанка при потоковом чтении. Returns: - BinaryIO: Содержимое файла с атрибутом .name. + NamedBytesIO: Содержимое файла с атрибутом .name. + Наследуется от io.BytesIO Для zero-copy передачи используйте .getbuffer(), для получения bytes — .read() или .getvalue(). @@ -562,15 +582,42 @@ async def download_file_as_bytes( """ bio = NamedBytesIO() - response = {} + response = await self._fetch_response(url) + bio.name = self._capture_filename(response) + async for chunk in self._fetch_content_stream( - url, + response, chunk_size=chunk_size, - response_dict=response ): bio.write(chunk) bio.seek(0) # обязательно переходим в начало - bio.name = response.get("filename") return bio + + + async def download_bytes( + self, + url: str, + *, + chunk_size: int = DOWNLOAD_CHUNK_SIZE, + ) -> bytes: + """ + Скачивает файл по URL и возвращает bytes в памяти. + + Внимание: весь файл загружается в оперативную память. + Не используйте для файлов >100–200 МБ без контроля. + + Args: + url: URL файла. + chunk_size: Размер чанка при потоковом чтении. + + Returns: + bytes: Содержимое файла + + Raises: + DownloadFileError: при ошибке скачивания. + """ + bio = await self.download_bytes_io(url=url, chunk_size=chunk_size) + + return bio.read() diff --git a/tests/test_download_file.py b/tests/test_download_file.py index 12784b9..0143374 100644 --- a/tests/test_download_file.py +++ b/tests/test_download_file.py @@ -4,15 +4,14 @@ from collections.abc import Callable from datetime import datetime from functools import wraps -from typing import TYPE_CHECKING +from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import pytest +from aiohttp import ClientResponse from maxapi.bot import Bot from maxapi.exceptions.download_file import DownloadFileError - -if TYPE_CHECKING: - from pathlib import Path +from yarl import URL @pytest.fixture @@ -49,12 +48,18 @@ def _make_mock_response( cd_filename=None, chunks=None, url=None, + closed=False, ): """Создаёт мок aiohttp-ответа для скачивания.""" - mock_response = AsyncMock() + mock_response = AsyncMock(spec_set=ClientResponse) mock_response.ok = ok + mock_response.release = AsyncMock( + side_effect=lambda: setattr(mock_response, "closed", True) + ) + mock_response.closed = closed mock_response.status = status mock_response.content_type = content_type + mock_response.__class__ = ClientResponse if cd_filename is not None: cd = MagicMock() @@ -64,7 +69,9 @@ def _make_mock_response( mock_response.content_disposition = None if url is not None: - mock_response.url = url + mock_response.url = URL(url) + else: + mock_response.url = None if chunks is not None: mock_response.content.iter_chunked = MagicMock( @@ -98,8 +105,8 @@ def freeze_datetime( (например: 'myapp.services.payment', 'tests.conftest') fixed_dt: Фиксированная дата/время (datetime объект или ISO-строка) attr: Имя атрибута для патча. - 'datetime' → если в модуле `from datetime import datetime` - 'datetime.datetime' → если в модуле `import datetime` + 'datetime' → если в модуле `from datetime import datetime` + 'datetime.datetime' → если в модуле `import datetime` Returns: Декоратор для тестовой функции. @@ -139,7 +146,7 @@ class TestDownloadFile: async def test_download_file_path_as_str(self, bot, tmp_dir, mock_session): """Скачивание файла с корректным Content-Disposition.""" chunks = [b"chunk1", b"chunk2", b"chunk3"] - url="https://example.com/file.pdf" + url = "https://example.com/file.pdf" mock_response = _make_mock_response(url=url, chunks=chunks) mock_session.request = AsyncMock(return_value=mock_response) @@ -155,7 +162,7 @@ async def test_download_file_path_as_str(self, bot, tmp_dir, mock_session): async def test_download_file_success(self, bot, tmp_dir, mock_session): """Скачивание файла с корректным Content-Disposition.""" chunks = [b"chunk1", b"chunk2", b"chunk3"] - url="https://example.com/file.pdf" + url = "https://example.com/file.pdf" mock_response = _make_mock_response( url=url, content_type="application/pdf", @@ -176,7 +183,7 @@ async def test_download_file_no_content_disposition( self, bot, tmp_dir, mock_session ): """Скачивание без Content-Disposition — имя генерируется по MIME.""" - url="https://example.com/img" + url = "https://example.com/img" mock_response = _make_mock_response( url=url, content_type="image/jpeg", @@ -196,11 +203,11 @@ async def test_download_file_no_content_disposition_no_path( self, bot, tmp_dir, mock_session ): """Скачивание без Content-Disposition и без MIME и без внятного пути""" - url="https://example.com/" + url = "https://example.com/" mock_response = _make_mock_response( url=url, - content_type=None, # Без типа - chunks=[b"imagedata"], + content_type=None, # Без типа # type: ignore + chunks=[b"some_binary_data"], ) mock_session.request = AsyncMock(return_value=mock_response) @@ -208,16 +215,17 @@ async def test_download_file_no_content_disposition_no_path( url=url, destination=tmp_dir, ) - expected = "260416_103050.bin" - assert result.name == expected + + assert result.name == "260416_103050.bin" assert result.parent == tmp_dir + @freeze_datetime("maxapi.connection.base", "2026-04-16 10:30:50") async def test_download_photo( self, bot, tmp_dir, mock_session ): """Скачивание вложения-фото по ссылке выда https://i.oneme.ru/i?r=photo_token""" - url="https://i.oneme.ru/i?r=photo_token" + url = "https://i.oneme.ru/i?r=photo_token" mock_response = _make_mock_response( url=url, content_type="image/jpeg", @@ -237,7 +245,7 @@ async def test_download_file_path_traversal_protection( self, bot, tmp_dir, mock_session ): """Защита от path traversal в filename.""" - url="https://example.com/file" + url = "https://example.com/file" mock_response = _make_mock_response( url=url, content_type="text/plain", @@ -262,7 +270,7 @@ async def test_download_file_http_error(self, bot, tmp_dir, mock_session): with pytest.raises(DownloadFileError, match="HTTP 404"): await bot.download_file( - url="https://example.com/missing", + url = "https://example.com/missing", destination=tmp_dir, ) @@ -277,9 +285,10 @@ async def test_download_file_connection_error_raises( ) bot.default_connection.max_retries = 0 - with pytest.raises(DownloadFileError, match="Ошибка при скачивании"): + with pytest.raises(DownloadFileError, + match="Network error: connection refused"): await bot.download_file( - url="https://example.com/file", + url = "https://example.com/file", destination=tmp_dir, ) @@ -290,7 +299,7 @@ async def test_download_file_retry_on_server_error( retry_response = _make_mock_response(ok=False, status=503) retry_response.read = AsyncMock() - url="https://example.com/file" + url = "https://example.com/file" success_response = _make_mock_response( url=url, content_type="text/plain", @@ -337,7 +346,7 @@ async def test_ensure_session_reuses_existing(self, bot, mock_session): class TestDownloadFileAsBytes: """ - Тесты для метода download_file_as_bytes. + Тесты для метода download_bytes. Примеры реальных URL для ручного тестирования: - Файл с подписью: @@ -346,7 +355,7 @@ class TestDownloadFileAsBytes: https://i.oneme.ru/i?r=BTGBPUwtwgYUeoFhO7rESmr81n-DnwjHYFhx5_EAhKk... """ - async def test_download_file_as_bytes_success(self, bot, mock_session): + async def test_download_bytes_success(self, bot, mock_session): """ Успешное скачивание файла в память. @@ -354,7 +363,7 @@ async def test_download_file_as_bytes_success(self, bot, mock_session): GET https://fd.oneme.ru/getfile?sig=...&expires=... """ chunks = [b"chunk1", b"chunk2", b"chunk3"] - url="https://fd.oneme.ru/getfile?sig=test&expires=123" + url = "https://fd.oneme.ru/getfile?sig=test&expires=123" mock_response = _make_mock_response( url=url, content_type="application/octet-stream", @@ -363,19 +372,18 @@ async def test_download_file_as_bytes_success(self, bot, mock_session): ) mock_session.request = AsyncMock(return_value=mock_response) - bio = await bot.download_file_as_bytes(url=url) - result = bio.read() + result = await bot.download_bytes(url=url) assert result == b"chunk1chunk2chunk3" mock_response.release.assert_called_once() - async def test_download_file_as_bytes_image_url(self, bot, mock_session): + async def test_download_bytes_image_url(self, bot, mock_session): """ Скачивание изображения с i.oneme.ru. """ # Эмулируем PNG-изображение (минимальный валидный заголовок) png_header = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100 - url="https://i.oneme.ru/i?r=BTGBPUwtwgYUeoFhO7rESmr81n-DnwjHYFhx5_EAhKk..." + url = "https://i.oneme.ru/i?r=BTGBPUwtwgYUeoFhO7rESmr81n-DnwjHYFhx5_EAhKk..." mock_response = _make_mock_response( url=url, content_type="image/png", @@ -383,23 +391,22 @@ async def test_download_file_as_bytes_image_url(self, bot, mock_session): ) mock_session.request = AsyncMock(return_value=mock_response) - bio = await bot.download_file_as_bytes(url=url) - result = bio.read() + result = await bot.download_bytes(url=url) assert result.startswith(b"\x89PNG") assert len(result) > 0 - async def test_download_file_as_bytes_http_error(self, bot, mock_session): + async def test_download_bytes_http_error(self, bot, mock_session): """DownloadFileError при HTTP 404.""" mock_response = _make_mock_response(ok=False, status=404) mock_session.request = AsyncMock(return_value=mock_response) - url="https://example.com/missing" + url = "https://example.com/missing" with pytest.raises(DownloadFileError, match="HTTP 404"): - await bot.download_file_as_bytes(url=url) + await bot.download_bytes(url=url) - async def test_download_file_as_bytes_connection_error( + async def test_download_bytes_connection_error( self, bot, mock_session ): """DownloadFileError при ошибке соединения.""" @@ -410,18 +417,18 @@ async def test_download_file_as_bytes_connection_error( ) bot.default_connection.max_retries = 0 - url="https://example.com/file" - with pytest.raises(DownloadFileError, match="Ошибка при скачивании"): - await bot.download_file_as_bytes(url=url) + url = "https://example.com/file" + with pytest.raises(DownloadFileError, match="Network error: timeout"): + await bot.download_bytes(url=url) - async def test_download_file_as_bytes_retry_on_503( + async def test_download_bytes_retry_on_503( self, bot, mock_session ): """Retry при 503, затем успех.""" retry_response = _make_mock_response(ok=False, status=503) retry_response.read = AsyncMock() - url="https://example.com/file" + url = "https://example.com/file" success_response = _make_mock_response( url=url, content_type="text/plain", @@ -435,13 +442,12 @@ async def test_download_file_as_bytes_retry_on_503( bot.default_connection.retry_backoff_factor = 0.0 with patch("asyncio.sleep", new_callable=AsyncMock): - bio = await bot.download_file_as_bytes(url=url) - result = bio.read() + result = await bot.download_bytes(url=url) assert result == b"success" - async def test_download_file_as_bytes_empty_file(self, bot, mock_session): + async def test_download_bytes_empty_file(self, bot, mock_session): """Скачивание пустого файла.""" - url="https://example.com/empty" + url = "https://example.com/empty" mock_response = _make_mock_response( url=url, content_type="application/octet-stream", @@ -449,14 +455,16 @@ async def test_download_file_as_bytes_empty_file(self, bot, mock_session): ) mock_session.request = AsyncMock(return_value=mock_response) - bio = await bot.download_file_as_bytes(url=url) - result = bio.read() + result = await bot.download_bytes(url=url) assert result == b"" - async def test_download_file_as_bytes_encoded_filename(self, bot, mock_session): + async def test_download_bytes_encoded_filename(self, bot, mock_session): """Скачивание пустого файла.""" chunks = [b"chunk1", b"chunk2", b"chunk3"] - url = "https://fd.oneme.ru/getfile?sig=Dm00IcsNNg1fIU1X4CB_R0777_saII2AAtcffL6lmnT3TTiVuBBB95jo-4qfyGElLLh1w4ZdD4QpwliVoW77Kg&expires=1779148580110&clientType=3&id=3100094539&userId=111973341" + url = ("https://fd.oneme.ru/getfile?sig=Dm00IcsNNg1fIU1X4CB_R0777" + "_saII2AAtcffL6lmnT3TTiVuBBB95jo-4qfyGElLLh1w4ZdD4QpwliVoW77Kg" + "&expires=1779148580110&clientType=3&id=3100094539&userId=111973341" + ) mock_response = _make_mock_response( url=url, cd_filename="%D0%94%D0%BE%D0%BA%D1%83%D0%BC%D0%B5%D0%BD%D1%82.pdf", @@ -465,19 +473,18 @@ async def test_download_file_as_bytes_encoded_filename(self, bot, mock_session): ) mock_session.request = AsyncMock(return_value=mock_response) - bio = await bot.download_file_as_bytes(url=url) - result = bio.read() + result = await bot.download_bytes(url=url) assert result == b"".join(chunks) async def test_download_file_vs_as_bytes_same_content( self, bot, tmp_dir, mock_session ): """ - download_file и download_file_as_bytes возвращают одинаковые данные + download_file и download_bytes возвращают одинаковые данные """ content = b"test content for comparison" chunks = [content[i:i+10] for i in range(0, len(content), 10)] - url="https://example.com/file" + url = "https://example.com/file" # Для download_file mock_response_disk = _make_mock_response( @@ -485,7 +492,7 @@ async def test_download_file_vs_as_bytes_same_content( cd_filename="test.txt", chunks=chunks.copy(), ) - # Для download_file_as_bytes + # Для download_bytes mock_response_bytes = _make_mock_response( url=url, cd_filename="test.txt", @@ -505,20 +512,22 @@ async def test_download_file_vs_as_bytes_same_content( disk_content = path.read_bytes() # Скачиваем в память - bio = await bot.download_file_as_bytes(url=url) + bio = await bot.download_bytes_io(url=url) bytes_content = bio.read() assert path.name == bio.name assert disk_content == bytes_content == content @freeze_datetime("maxapi.connection.base", datetime.now()) - async def test_download_file_name_collision(self, bot, tmp_dir, mock_session): + async def test_download_file_name_collision( + self, bot, tmp_dir, mock_session + ): """Проверка, что при коллизии имён добавляется (2), (3) и т.д.""" # Пытаемся скачать сразу 5 файлов results: list[Path] = [] for i in range(5): - url=f"https://i.oneme.ru/i?r=file{i+1}" + url = f"https://i.oneme.ru/i?r=file{i+1}" mock_response = _make_mock_response( url=url, chunks=[f"new {i+1}".encode()] @@ -549,7 +558,7 @@ async def test_download_file_photo_correct_extension( """ Для i.oneme.ru расширение определяется по Content-Type, а не .webp """ - url="https://i.oneme.ru/i?r=test" + url = "https://i.oneme.ru/i?r=test" mock_response = _make_mock_response( url=url, content_type="image/png", @@ -566,8 +575,9 @@ async def test_download_file_photo_correct_extension( assert result.name.startswith("image_") - async def test_download_file_retryable_server_error(self, - bot, mock_session): + async def test_download_file_retryable_server_error( + self, bot, mock_session + ): """ Покрытие ветки: except _RetryableServerError -> DownloadFileError """ @@ -575,58 +585,131 @@ async def test_download_file_retryable_server_error(self, mock_session.request = AsyncMock(return_value=mock_response) with pytest.raises(DownloadFileError) as exc_info: - await bot.download_file_as_bytes(url="https://i.oneme.ru/i?r=test") + await bot.download_bytes(url = "https://i.oneme.ru/i?r=test") assert "HTTP 502" in str(exc_info.value) - async def test_fetch_content_stream_invalid_response_dict_type_direct(self, - bot, mock_session): - """Покрытие ветки: elif response_dict is not None -> ValueError""" - # from maxapi.connection.base import BaseConnection # или ваш миксин - - mock_response = _make_mock_response() - # Мокаем итерацию по контенту (пустой поток, чтобы сразу завершиться) - async def empty_iterator(): - return - yield # Пустой генератор - - mock_response.content.iter_chunked = MagicMock(return_value=empty_iterator()) - - # Мокаем session.request - mock_session.request = AsyncMock(return_value=mock_response) - bot._session = mock_session - - # 2. Вызываем приватный метод напрямую с НЕПРАВИЛЬНЫМ типом response_dict - with pytest.raises(ValueError, match="response_dict должен быть словарём"): - async for _ in bot._fetch_content_stream( - url="https://example.com/file.pdf", - response_dict=[] # ❌ list вместо dict - ): - pass @freeze_datetime("maxapi.connection.base", "2026-04-16 10:30:50") - async def test_capture_filename_no_extension_fallback(self, - bot, mock_session): + async def test_capture_filename_no_extension_fallback(self): """Покрытие: is_photo=True, ext='', fallback на .webp""" from maxapi.connection.base import BaseConnection # 1. Случай с фото - url="https://i.oneme.ru/" # Нет имени файла в URL + url = "https://i.oneme.ru/" # Нет имени файла в URL mock_response = _make_mock_response( - url = url, content_type=None) # Нет заголовка и content_type + url = url, content_type=None) # type: ignore # Нет заголовка и content_type filename = BaseConnection._capture_filename(mock_response) assert filename == "image_260416_103050.webp" - def test_capture_filename_minimal_object(self): + def test_capture_filename_wrong_response(self): """Покрытие: except (TypeError, AttributeError) при доступе к полям""" from maxapi.connection.base import BaseConnection class BrokenResponse: - # Нет ни content_disposition, ни url, ни content_type pass - filename = BaseConnection._capture_filename(BrokenResponse()) - assert filename == "" # Должен вернуться fallback-результат + with pytest.raises(TypeError, match="Ожидается ClientResponse"): + BaseConnection._capture_filename(BrokenResponse()) # type: ignore + + + @freeze_datetime("maxapi.connection.base", "2026-04-16 10:30:50") + def test_capture_filename_minimal_object(self): + """Покрытие: except (TypeError, AttributeError) при доступе к полям""" + from maxapi.connection.base import BaseConnection + + # Нет ни content_disposition, ни url, ни content_type + mock_response = _make_mock_response( + content_type=None, # type: ignore + ) + + filename = BaseConnection._capture_filename(mock_response) + assert filename == "260416_103050.bin" # fallback-результат + + + +class FailingAsyncStream: + """ + Имитирует async generator, который падает на первой итерации. + Именно это вызывает срабатывание блока except Exception в download_file. + """ + def __aiter__(self): + return self + async def __anext__(self): + raise RuntimeError("Ошибка сети при чтении потока") + + +class TestInternalUncoveredParts: + + async def test_fetch_content_stream_closed_response(self, bot): + """Проверка ветки: response.closed == True""" + mock_response = _make_mock_response( + ok=True, + closed=True, + ) + + with pytest.raises(DownloadFileError, + match="response соединение закрыто"): + async for _ in bot._fetch_content_stream(mock_response): + pass + + mock_response.release.assert_not_called() + + async def test_fetch_content_stream_http_error(self, bot): + """Проверка ветки: response.ok == False""" + mock_response = _make_mock_response( + ok=False, + closed=False, + status=403, # любой не-2xx статус + ) + + with pytest.raises(DownloadFileError, + match="Ошибка при скачивании: HTTP 403"): + async for _ in bot._fetch_content_stream(mock_response): + pass + + mock_response.release.assert_awaited_once() + + + async def test_download_file_cleanup_partial_file_on_error(self, bot): + """Проверка download_file ветки: + except Exception: + # При любой ошибке удаляем частично записанный файл + if final_path.exists(): + final_path.unlink() + raise + """ + + # Мокаем цепочку, чтобы дойти до try...except + bot._fetch_response = AsyncMock() + bot._capture_filename = MagicMock(return_value="260416_103000.bin") + + # Файл уже частично создан + # (например, записался первый чанк, потом ошибка) + mock_final_path = MagicMock(spec=Path) + mock_final_path.exists.return_value = True + bot._check_file_exists = MagicMock(return_value=mock_final_path) + + # 3. Ломаем поток именно на этапе async for chunk in ... + bot._fetch_content_stream = MagicMock( + return_value=FailingAsyncStream()) + + # 4. Мокаем aiofiles.open как контекстный менеджер + mock_file = AsyncMock() + mock_cm = MagicMock() + mock_cm.__aenter__ = AsyncMock(return_value=mock_file) + mock_cm.__aexit__ = AsyncMock(return_value=False) + + # Важно: путь в patch должен совпадать с тем, + # как aiofiles импортирован в вашем модуле + with patch("aiofiles.open", return_value=mock_cm), \ + pytest.raises(RuntimeError, match="Ошибка сети при чтении потока"): + await bot.download_file("http://example.com/file", "/tmp/dl") + + # ✅ 5. Проверяем покрытие целевой ветки + mock_final_path.exists.assert_called_once() + mock_final_path.unlink.assert_called_once() + From a6676315f9d7be1258106a75afc1f1e3f41b9f1f Mon Sep 17 00:00:00 2001 From: Pankovea <114323250+Pankovea@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:12:51 +0300 Subject: [PATCH 10/15] ruff reformat --- maxapi/connection/base.py | 41 +++++++--------- tests/test_download_file.py | 97 ++++++++++++++++--------------------- 2 files changed, 58 insertions(+), 80 deletions(-) diff --git a/maxapi/connection/base.py b/maxapi/connection/base.py index 5b5f8fa..b6ca536 100644 --- a/maxapi/connection/base.py +++ b/maxapi/connection/base.py @@ -54,12 +54,13 @@ class NamedBytesIO(BytesIO): """ BytesIO с поддержкой атрибута .name для единообразия с файловыми объектами. """ + __slots__ = ("name",) name: str | None - def __init__(self, buffer: bytes = b"", - *, - name: str | None = None) -> None: + def __init__( + self, buffer: bytes = b"", *, name: str | None = None + ) -> None: super().__init__(buffer) self.name = name # Соответствует протоколу typing.BinaryIO @@ -118,7 +119,6 @@ def __init__(self) -> None: self.after_input_media_delay: float = self.AFTER_MEDIA_INPUT_DELAY self.api_url = self.API_URL - def set_api_url(self, url: str) -> None: """ Установка API URL для запросов @@ -129,7 +129,6 @@ def set_api_url(self, url: str) -> None: self.api_url = url - async def request( self, method: HTTPMethod, @@ -230,7 +229,6 @@ async def _do_request() -> Any: return bind_bot(model, bot) - async def upload_file(self, url: str, path: str, type: UploadType) -> str: """ Загружает файл на сервер. @@ -271,7 +269,6 @@ async def upload_file(self, url: str, path: str, type: UploadType) -> str: response = await temp_session.post(url=url, data=form) return await response.text() - async def upload_file_buffer( self, filename: str, url: str, buffer: bytes, type: UploadType ) -> str: @@ -323,7 +320,6 @@ async def upload_file_buffer( response = await temp_session.post(url=url, data=form) return await response.text() - async def _fetch_response(self, url: str) -> ClientResponse: bot = self._ensure_bot() session = await bot.ensure_session() @@ -355,11 +351,11 @@ async def _do_request() -> Any: if not response.ok: await response.release() raise DownloadFileError( - f"Ошибка при скачивании: HTTP {response.status}") + f"Ошибка при скачивании: HTTP {response.status}" + ) return response - async def _fetch_content_stream( self, response: ClientResponse, @@ -385,7 +381,8 @@ async def _fetch_content_stream( if not response.ok: await response.release() raise DownloadFileError( - f"Ошибка при скачивании: HTTP {response.status}") + f"Ошибка при скачивании: HTTP {response.status}" + ) try: async for chunk in response.content.iter_chunked(chunk_size): @@ -393,7 +390,6 @@ async def _fetch_content_stream( finally: await response.release() - @staticmethod def _capture_filename(response: ClientResponse) -> str: """ @@ -412,7 +408,8 @@ def _capture_filename(response: ClientResponse) -> str: datetime_str = datetime.now().strftime("%y%m%d_%H%M%S") if not isinstance(response, ClientResponse): raise TypeError( - f"Ожидается ClientResponse, получен {type(response)}") + f"Ожидается ClientResponse, получен {type(response)}" + ) try: cd = response.content_disposition if cd and cd.filename: @@ -450,15 +447,15 @@ def _capture_filename(response: ClientResponse) -> str: except (AttributeError, TypeError, ValueError) as e: logger_bot.warning( - "Не удалось определить имя файла из заголовков: %s", e) + "Не удалось определить имя файла из заголовков: %s", e + ) if not filename: - filename = f"{datetime_str}.bin" # fallback + filename = f"{datetime_str}.bin" # fallback return filename - @staticmethod - def _check_file_exists(path: Path|str) -> Path: + def _check_file_exists(path: Path | str) -> Path: """ Проверяет, если файл существует, то возвращает новый свободный путь для сохранения Windows style: @@ -480,7 +477,7 @@ def _check_file_exists(path: Path|str) -> Path: dest = path.parent if path.exists(): - max_num = 1 # Один уже существует + max_num = 1 # Один уже существует fname, ext = path.stem, path.suffix pattern = re.compile( rf"^{re.escape(fname)}\((\d+)\){re.escape(ext)}$" @@ -494,11 +491,10 @@ def _check_file_exists(path: Path|str) -> Path: if num > max_num: max_num = num - path = dest / f"{fname}({max_num+1}){ext}" + path = dest / f"{fname}({max_num + 1}){ext}" return path - async def download_file( self, url: str, @@ -542,8 +538,7 @@ async def download_file( try: async with aiofiles.open(final_path, "wb") as f: async for chunk in self._fetch_content_stream( - response, - chunk_size=chunk_size + response, chunk_size=chunk_size ): await f.write(chunk) except Exception: @@ -554,7 +549,6 @@ async def download_file( return final_path - async def download_bytes_io( self, url: str, @@ -595,7 +589,6 @@ async def download_bytes_io( return bio - async def download_bytes( self, url: str, diff --git a/tests/test_download_file.py b/tests/test_download_file.py index 0143374..6dba066 100644 --- a/tests/test_download_file.py +++ b/tests/test_download_file.py @@ -91,10 +91,7 @@ def mock_session(bot): def freeze_datetime( - target_module: str, - fixed_dt: datetime | str, - *, - attr: str = "datetime" + target_module: str, fixed_dt: datetime | str, *, attr: str = "datetime" ) -> Callable: """ Декоратор для заморозки datetime.now() в указанном модуле. @@ -158,7 +155,6 @@ async def test_download_file_path_as_str(self, bot, tmp_dir, mock_session): assert result == tmp_dir / "file.pdf" assert result.read_bytes() == b"".join(chunks) - async def test_download_file_success(self, bot, tmp_dir, mock_session): """Скачивание файла с корректным Content-Disposition.""" chunks = [b"chunk1", b"chunk2", b"chunk3"] @@ -206,7 +202,7 @@ async def test_download_file_no_content_disposition_no_path( url = "https://example.com/" mock_response = _make_mock_response( url=url, - content_type=None, # Без типа # type: ignore + content_type=None, # Без типа # type: ignore chunks=[b"some_binary_data"], ) mock_session.request = AsyncMock(return_value=mock_response) @@ -219,11 +215,8 @@ async def test_download_file_no_content_disposition_no_path( assert result.name == "260416_103050.bin" assert result.parent == tmp_dir - @freeze_datetime("maxapi.connection.base", "2026-04-16 10:30:50") - async def test_download_photo( - self, bot, tmp_dir, mock_session - ): + async def test_download_photo(self, bot, tmp_dir, mock_session): """Скачивание вложения-фото по ссылке выда https://i.oneme.ru/i?r=photo_token""" url = "https://i.oneme.ru/i?r=photo_token" mock_response = _make_mock_response( @@ -270,7 +263,7 @@ async def test_download_file_http_error(self, bot, tmp_dir, mock_session): with pytest.raises(DownloadFileError, match="HTTP 404"): await bot.download_file( - url = "https://example.com/missing", + url="https://example.com/missing", destination=tmp_dir, ) @@ -285,10 +278,11 @@ async def test_download_file_connection_error_raises( ) bot.default_connection.max_retries = 0 - with pytest.raises(DownloadFileError, - match="Network error: connection refused"): + with pytest.raises( + DownloadFileError, match="Network error: connection refused" + ): await bot.download_file( - url = "https://example.com/file", + url="https://example.com/file", destination=tmp_dir, ) @@ -342,8 +336,10 @@ async def test_ensure_session_reuses_existing(self, bot, mock_session): session = await bot.ensure_session() assert session is mock_session + # tests/test_download_file.py + class TestDownloadFileAsBytes: """ Тесты для метода download_bytes. @@ -396,7 +392,6 @@ async def test_download_bytes_image_url(self, bot, mock_session): assert result.startswith(b"\x89PNG") assert len(result) > 0 - async def test_download_bytes_http_error(self, bot, mock_session): """DownloadFileError при HTTP 404.""" mock_response = _make_mock_response(ok=False, status=404) @@ -406,9 +401,7 @@ async def test_download_bytes_http_error(self, bot, mock_session): with pytest.raises(DownloadFileError, match="HTTP 404"): await bot.download_bytes(url=url) - async def test_download_bytes_connection_error( - self, bot, mock_session - ): + async def test_download_bytes_connection_error(self, bot, mock_session): """DownloadFileError при ошибке соединения.""" from aiohttp import ClientConnectionError @@ -421,9 +414,7 @@ async def test_download_bytes_connection_error( with pytest.raises(DownloadFileError, match="Network error: timeout"): await bot.download_bytes(url=url) - async def test_download_bytes_retry_on_503( - self, bot, mock_session - ): + async def test_download_bytes_retry_on_503(self, bot, mock_session): """Retry при 503, затем успех.""" retry_response = _make_mock_response(ok=False, status=503) retry_response.read = AsyncMock() @@ -461,7 +452,8 @@ async def test_download_bytes_empty_file(self, bot, mock_session): async def test_download_bytes_encoded_filename(self, bot, mock_session): """Скачивание пустого файла.""" chunks = [b"chunk1", b"chunk2", b"chunk3"] - url = ("https://fd.oneme.ru/getfile?sig=Dm00IcsNNg1fIU1X4CB_R0777" + url = ( + "https://fd.oneme.ru/getfile?sig=Dm00IcsNNg1fIU1X4CB_R0777" "_saII2AAtcffL6lmnT3TTiVuBBB95jo-4qfyGElLLh1w4ZdD4QpwliVoW77Kg" "&expires=1779148580110&clientType=3&id=3100094539&userId=111973341" ) @@ -469,7 +461,7 @@ async def test_download_bytes_encoded_filename(self, bot, mock_session): url=url, cd_filename="%D0%94%D0%BE%D0%BA%D1%83%D0%BC%D0%B5%D0%BD%D1%82.pdf", content_type="application/octet-stream", - chunks=chunks + chunks=chunks, ) mock_session.request = AsyncMock(return_value=mock_response) @@ -483,7 +475,7 @@ async def test_download_file_vs_as_bytes_same_content( download_file и download_bytes возвращают одинаковые данные """ content = b"test content for comparison" - chunks = [content[i:i+10] for i in range(0, len(content), 10)] + chunks = [content[i : i + 10] for i in range(0, len(content), 10)] url = "https://example.com/file" # Для download_file @@ -505,10 +497,7 @@ async def test_download_file_vs_as_bytes_same_content( ) # Скачиваем на диск - path = await bot.download_file( - url=url, - destination=tmp_dir - ) + path = await bot.download_file(url=url, destination=tmp_dir) disk_content = path.read_bytes() # Скачиваем в память @@ -527,10 +516,9 @@ async def test_download_file_name_collision( # Пытаемся скачать сразу 5 файлов results: list[Path] = [] for i in range(5): - url = f"https://i.oneme.ru/i?r=file{i+1}" + url = f"https://i.oneme.ru/i?r=file{i + 1}" mock_response = _make_mock_response( - url=url, - chunks=[f"new {i+1}".encode()] + url=url, chunks=[f"new {i + 1}".encode()] ) mock_session.request = AsyncMock(return_value=mock_response) results.append( @@ -541,16 +529,15 @@ async def test_download_file_name_collision( ) for i, result in enumerate(results): - if i == 0: # Первый файл не проверяем + if i == 0: # Первый файл не проверяем # Первый файл должен быть без суффикса _N # Только image_date_time assert "(" not in result.stem assert ")" not in result.stem else: # Ожидаем, что файлы сохранится с суффиксами - assert result.stem.endswith(f"({i+1})") - assert result.read_bytes() == f"new {i+1}".encode() - + assert result.stem.endswith(f"({i + 1})") + assert result.read_bytes() == f"new {i + 1}".encode() async def test_download_file_photo_correct_extension( self, bot, tmp_dir, mock_session @@ -574,7 +561,6 @@ async def test_download_file_photo_correct_extension( assert result.suffix == ".png" # не .webp! assert result.name.startswith("image_") - async def test_download_file_retryable_server_error( self, bot, mock_session ): @@ -585,11 +571,10 @@ async def test_download_file_retryable_server_error( mock_session.request = AsyncMock(return_value=mock_response) with pytest.raises(DownloadFileError) as exc_info: - await bot.download_bytes(url = "https://i.oneme.ru/i?r=test") + await bot.download_bytes(url="https://i.oneme.ru/i?r=test") assert "HTTP 502" in str(exc_info.value) - @freeze_datetime("maxapi.connection.base", "2026-04-16 10:30:50") async def test_capture_filename_no_extension_fallback(self): """Покрытие: is_photo=True, ext='', fallback на .webp""" @@ -597,14 +582,12 @@ async def test_capture_filename_no_extension_fallback(self): # 1. Случай с фото url = "https://i.oneme.ru/" # Нет имени файла в URL - mock_response = _make_mock_response( - url = url, content_type=None) # type: ignore # Нет заголовка и content_type + mock_response = _make_mock_response(url=url, content_type=None) # type: ignore # Нет заголовка и content_type filename = BaseConnection._capture_filename(mock_response) assert filename == "image_260416_103050.webp" - def test_capture_filename_wrong_response(self): """Покрытие: except (TypeError, AttributeError) при доступе к полям""" from maxapi.connection.base import BaseConnection @@ -613,8 +596,7 @@ class BrokenResponse: pass with pytest.raises(TypeError, match="Ожидается ClientResponse"): - BaseConnection._capture_filename(BrokenResponse()) # type: ignore - + BaseConnection._capture_filename(BrokenResponse()) # type: ignore @freeze_datetime("maxapi.connection.base", "2026-04-16 10:30:50") def test_capture_filename_minimal_object(self): @@ -623,27 +605,27 @@ def test_capture_filename_minimal_object(self): # Нет ни content_disposition, ни url, ни content_type mock_response = _make_mock_response( - content_type=None, # type: ignore + content_type=None, # type: ignore ) filename = BaseConnection._capture_filename(mock_response) assert filename == "260416_103050.bin" # fallback-результат - class FailingAsyncStream: """ Имитирует async generator, который падает на первой итерации. Именно это вызывает срабатывание блока except Exception в download_file. """ + def __aiter__(self): return self + async def __anext__(self): raise RuntimeError("Ошибка сети при чтении потока") class TestInternalUncoveredParts: - async def test_fetch_content_stream_closed_response(self, bot): """Проверка ветки: response.closed == True""" mock_response = _make_mock_response( @@ -651,8 +633,9 @@ async def test_fetch_content_stream_closed_response(self, bot): closed=True, ) - with pytest.raises(DownloadFileError, - match="response соединение закрыто"): + with pytest.raises( + DownloadFileError, match="response соединение закрыто" + ): async for _ in bot._fetch_content_stream(mock_response): pass @@ -663,17 +646,17 @@ async def test_fetch_content_stream_http_error(self, bot): mock_response = _make_mock_response( ok=False, closed=False, - status=403, # любой не-2xx статус + status=403, # любой не-2xx статус ) - with pytest.raises(DownloadFileError, - match="Ошибка при скачивании: HTTP 403"): + with pytest.raises( + DownloadFileError, match="Ошибка при скачивании: HTTP 403" + ): async for _ in bot._fetch_content_stream(mock_response): pass mock_response.release.assert_awaited_once() - async def test_download_file_cleanup_partial_file_on_error(self, bot): """Проверка download_file ветки: except Exception: @@ -695,7 +678,8 @@ async def test_download_file_cleanup_partial_file_on_error(self, bot): # 3. Ломаем поток именно на этапе async for chunk in ... bot._fetch_content_stream = MagicMock( - return_value=FailingAsyncStream()) + return_value=FailingAsyncStream() + ) # 4. Мокаем aiofiles.open как контекстный менеджер mock_file = AsyncMock() @@ -705,11 +689,12 @@ async def test_download_file_cleanup_partial_file_on_error(self, bot): # Важно: путь в patch должен совпадать с тем, # как aiofiles импортирован в вашем модуле - with patch("aiofiles.open", return_value=mock_cm), \ - pytest.raises(RuntimeError, match="Ошибка сети при чтении потока"): + with ( + patch("aiofiles.open", return_value=mock_cm), + pytest.raises(RuntimeError, match="Ошибка сети при чтении потока"), + ): await bot.download_file("http://example.com/file", "/tmp/dl") # ✅ 5. Проверяем покрытие целевой ветки mock_final_path.exists.assert_called_once() mock_final_path.unlink.assert_called_once() - From dd348b002f4ecc06d733b9910d72244acf32cfa4 Mon Sep 17 00:00:00 2001 From: Pankovea <114323250+Pankovea@users.noreply.github.com> Date: Fri, 24 Apr 2026 19:38:03 +0300 Subject: [PATCH 11/15] =?UTF-8?q?feat:=20download=5Ffile=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B4=D0=B0=D1=87=D0=B5=20?= =?UTF-8?q?=D0=B8=D0=BC=D0=B5=D0=BD=D0=B8=20=D1=84=D0=B0=D0=B9=D0=BB=D0=B0?= =?UTF-8?q?=20=D0=B1=D1=83=D0=B4=D0=B5=D1=82=20=D1=81=D0=BE=D1=85=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D1=8F=D1=82=D1=8C=20=D1=81=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D0=BC=20=D0=B8=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D0=B5=D0=BC=20=D1=84=D0=B0=D0=B9=D0=BB=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit если в переданном пути destination не можержится имени файла, то будет использовано имя файла от сервера или по умочанию --- maxapi/bot.py | 7 +- maxapi/connection/base.py | 21 +++++- tests/test_download_file.py | 132 +++++++++++++++++++++++++++++++++++- 3 files changed, 155 insertions(+), 5 deletions(-) diff --git a/maxapi/bot.py b/maxapi/bot.py index 24e0850..dcf33f0 100644 --- a/maxapi/bot.py +++ b/maxapi/bot.py @@ -1108,7 +1108,12 @@ async def download_file( Args: url: URL файла для скачивания. - destination: Путь к директории для сохранения. + destination: Путь к директории для сохранения файла. + Если путь не содержит имя файла (не имеет расширения), + то будет использовано имя, предоставляемое сервером + или значение по умолчанию. + Если путь содержит расширение, он трактуется как + полное имя файла для сохранения. chunk_size: Размер чанка (по умолчанию 64 КБ). Returns: diff --git a/maxapi/connection/base.py b/maxapi/connection/base.py index b6ca536..a601c2d 100644 --- a/maxapi/connection/base.py +++ b/maxapi/connection/base.py @@ -518,6 +518,11 @@ async def download_file( Args: url: URL файла для скачивания (из payload.url вложения). destination: Путь к директории для сохранения файла. + Если путь не содержит имя файла (не имеет расширения), + то будет использовано имя, предоставляемое сервером + или значение по умолчанию. + Если путь содержит расширение, он трактуется как + полное имя файла для сохранения. chunk_size: Размер чанка при потоковом чтении (по умолчанию 64 КБ). @@ -528,12 +533,22 @@ async def download_file( DownloadFileError: при ошибке скачивания. """ dest = Path(destination) - await aiofiles.os.makedirs(destination, exist_ok=True) + # Получаем ответ для определения имени файла из заголовков response = await self._fetch_response(url) - filename = self._capture_filename(response) - final_path = self._check_file_exists(dest / filename) + # Определяем конечный путь для сохранения: + # - если destination имеет расширение (суффикс) → это имя файла + # - иначе → это директория, добавляем имя из ответа + if dest.suffix: + # destination содержит имя файла с расширением + await aiofiles.os.makedirs(dest.parent, exist_ok=True) + final_path = self._check_file_exists(dest) + else: + # destination - это директория, добавляем имя файла из ответа + await aiofiles.os.makedirs(dest, exist_ok=True) + filename = self._capture_filename(response) + final_path = self._check_file_exists(dest / filename) try: async with aiofiles.open(final_path, "wb") as f: diff --git a/tests/test_download_file.py b/tests/test_download_file.py index 6dba066..6fab7e8 100644 --- a/tests/test_download_file.py +++ b/tests/test_download_file.py @@ -336,8 +336,138 @@ async def test_ensure_session_reuses_existing(self, bot, mock_session): session = await bot.ensure_session() assert session is mock_session + async def test_download_file_destination_with_filename( + self, bot, tmp_dir, mock_session + ): + """Скачивание файла когда destination содержит имя файла.""" + chunks = [b"chunk1", b"chunk2"] + url = "https://example.com/remote.pdf" + mock_response = _make_mock_response( + url=url, + content_type="application/pdf", + cd_filename="server_name.pdf", # Имя от сервера + chunks=chunks, + ) + mock_session.request = AsyncMock(return_value=mock_response) + + # destination содержит своё имя файла + result = await bot.download_file( + url=url, + destination=tmp_dir / "my_custom_name.pdf", + ) + + # Должно использоваться имя из destination, а не от сервера + assert result == tmp_dir / "my_custom_name.pdf" + assert result.read_bytes() == b"".join(chunks) + + async def test_download_file_destination_with_filename_collision( + self, bot, tmp_dir, mock_session + ): + """Проверка коллизии имён когда destination содержит имя файла.""" + # Создаём существующий файл + existing_file = tmp_dir / "report.pdf" + existing_file.write_bytes(b"old content") + + chunks = [b"new content"] + url = "https://example.com/file" + mock_response = _make_mock_response( + url=url, + chunks=chunks, + ) + mock_session.request = AsyncMock(return_value=mock_response) + + # Пытаемся скачать в тот же путь + result = await bot.download_file( + url=url, + destination=tmp_dir / "report.pdf", + ) + + # Должен быть создан новый файл с суффиксом (2) + assert result == tmp_dir / "report(2).pdf" + assert result.read_bytes() == b"".join(chunks) + # Старый файл не должен быть перезаписан + assert existing_file.read_bytes() == b"old content" + + async def test_download_file_destination_directory_uses_server_filename( + self, bot, tmp_dir, mock_session + ): + """Проверка, что при указании директории используется имя от сервера.""" + chunks = [b"data"] + url = "https://example.com/download" + mock_response = _make_mock_response( + url=url, + content_type="text/plain", + cd_filename="server_file.txt", + chunks=chunks, + ) + mock_session.request = AsyncMock(return_value=mock_response) + + # destination - только директория (без имени файла) + result = await bot.download_file( + url=url, + destination=tmp_dir, + ) + + # Должно использоваться имя от сервера + assert result == tmp_dir / "server_file.txt" + assert result.read_bytes() == b"".join(chunks) + + async def test_download_file_destination_without_extension_uses_server_name( + self, bot, tmp_dir, mock_session + ): + """Проверка: путь без расширения трактуется как директория.""" + chunks = [b"binary"] + url = "https://example.com/data" + mock_response = _make_mock_response( + url=url, + content_type="application/octet-stream", + cd_filename="data.bin", + chunks=chunks, + ) + mock_session.request = AsyncMock(return_value=mock_response) + + # Путь без расширения → трактуется как директория + result = await bot.download_file( + url=url, + destination=tmp_dir / "downloads", # Нет расширения + ) + + # Файл должен быть сохранён внутри директории с именем от сервера + assert result == tmp_dir / "downloads" / "data.bin" + assert result.read_bytes() == b"".join(chunks) + + async def test_download_file_destination_relative_filename( + self, bot, tmp_dir, mock_session + ): + """Скачивание с относительным путём к файлу.""" + import os + + original_cwd = os.getcwd() + try: + os.chdir(tmp_dir) + + chunks = [b"relative"] + url = "https://example.com/file" + mock_response = _make_mock_response( + url=url, + cd_filename="ignored.txt", + chunks=chunks, + ) + mock_session.request = AsyncMock(return_value=mock_response) + + # Относительный путь с расширением + destination = "subdir/my_file.txt" + result = await bot.download_file( + url=url, + destination=destination, + ) -# tests/test_download_file.py + # Приводим оба пути к абсолютным для сравнения + assert result.resolve() == Path(destination).resolve() + assert result.read_bytes() == b"".join(chunks) + assert result.exists() + finally: + os.chdir(original_cwd) class TestDownloadFileAsBytes: From 63d8fdb9b33a5efd85bcbbbf46e169d045613298 Mon Sep 17 00:00:00 2001 From: Pankovea <114323250+Pankovea@users.noreply.github.com> Date: Sun, 26 Apr 2026 22:45:29 +0300 Subject: [PATCH 12/15] =?UTF-8?q?add:=20=D0=A0=D0=B5=D0=B0=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D1=81=D0=BB=D1=83=D1=87=D0=B0=D0=B8=20?= =?UTF-8?q?=D1=81=D1=81=D1=8B=D0=BB=D0=BE=D0=BA=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?=D1=81=D0=BA=D0=B0=D1=87=D0=B8=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=B2=20=D1=82=D0=B5=D1=81=D1=82=D0=B0=D1=85=20audio,=20image,?= =?UTF-8?q?=20sticker,=20file,=20video?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Именование скаченных stickers по данным из ссылки smileId Именование скаченных images по данным из ссылки image_token fallback на datetime_str в случае неудачи определения имени. --- maxapi/connection/base.py | 70 +++++++-- tests/test_download_file.py | 306 ++++++++++++++++++++++++++++++------ 2 files changed, 310 insertions(+), 66 deletions(-) diff --git a/maxapi/connection/base.py b/maxapi/connection/base.py index a601c2d..9671e08 100644 --- a/maxapi/connection/base.py +++ b/maxapi/connection/base.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import base64 import mimetypes import re from datetime import datetime @@ -391,7 +392,38 @@ async def _fetch_content_stream( await response.release() @staticmethod - def _capture_filename(response: ClientResponse) -> str: + def _get_image_id(r: str) -> str | None: + """ + Извлекает уникальную часть из токена изображения ссылки вида + https://i.oneme.ru/i?r=image_token_base64url + Args: + r: Параметр из url + + Returns: + str: Уникальная часть токена + None: В случае ошибки ил ине верного формата + """ + # Добавляем паддинг и конвертируем base64url + r += "=" * (-len(r) % 4) + # Конвертируем base64url в стандартный base64 + r = r.replace("-", "+").replace("_", "/") + try: + data = base64.b64decode(r) + except Exception: + return None + + if len(data) < 50: + return None + + # Заголовок и хвост одинаковы для ссылок одного бота + # head = base64.urlsafe_b64encode(data[0:16]).rstrip(b'=').decode() + # tail = base64.urlsafe_b64encode(data[:-16]).rstrip(b'=').decode() + + # уникальный идентификатор изобраения для текущего бота + image_id = base64.urlsafe_b64encode(data[18:-16]).rstrip(b"=").decode() + return image_id + + def _capture_filename(self, response: ClientResponse) -> str: """ Получает имя файла из заголовков Используется в _fetch_content_stream @@ -429,21 +461,31 @@ def _capture_filename(response: ClientResponse) -> str: if re.search(r"%[0-9A-Fa-f]{2}", filename): filename = unquote(filename, encoding="utf-8") - # Если имя не определилось - is_photo = response.url.host == "i.oneme.ru" - if not filename or filename.startswith("."): - if is_photo: + if response.url.host == "i.oneme.ru": + # is_sticker + if response.url.name == "getSmile": + if not ext or ext == ".bin": + ext = ".png" + if smileId := response.url.query.get("smileId"): + filename = f"sticker_{smileId}{ext}" + else: + filename = f"sticker_{datetime_str}{ext}" + # is_image + if response.url.name == "i": if not ext or ext == ".bin": ext = ".webp" - filename = f"image_{datetime_str}{ext}" - else: - if not ext: - ext = ".bin" - filename = f"{datetime_str}{ext}" - elif is_photo: - if not ext or ext == ".bin": - ext = ".webp" - filename = f"image_{datetime_str}{ext}" + if (r_value := response.url.query.get("r")) and ( + image_id := self._get_image_id(r_value) + ): + filename = f"image_{image_id}{ext}" + else: + filename = f"image_{datetime_str}{ext}" + + # Если имя не определилось + if not filename or filename.startswith("."): + if not ext: + ext = ".bin" + filename = f"{datetime_str}{ext}" except (AttributeError, TypeError, ValueError) as e: logger_bot.warning( diff --git a/tests/test_download_file.py b/tests/test_download_file.py index 6fab7e8..f3fc116 100644 --- a/tests/test_download_file.py +++ b/tests/test_download_file.py @@ -13,6 +13,77 @@ from maxapi.exceptions.download_file import DownloadFileError from yarl import URL +REAL_URL_LINKS = { + "audio": { + "url": ( + "http://vd624.okcdn.ru/?expires=1777235877381&srcIp=10.205.180.43" + "&pr=96&srcAg=UNKNOWN&ms=185.180.203.12&type=2&sig=fZchtK7v5ww" + "&ct=2&urls=176.112.172.22&clientType=11&appId=1248243456&" + "id=15115318397640&scl=2" + ), + "cd_filename": "15115318397640.mp3", + "content_type": "audio/mpeg", + "expected": "15115318397640.mp3", + }, + "image": { + "url": ( + "https://i.oneme.ru/i?r=" + "BTGBPUwtwgYUeoFhO7rESmr8" # head + "1n-DnwjHYFhx5_EAhKk7Np" # unique_part + "BwxbPWZMl-nt3whnrS81A" # tail + ), + "cd_filename": None, + "content_type": "image/webp", + "expected": "image_1n-DnwjHYFhx5_EAhKk7Ng.webp", + }, + "image_user_avatar": { + "url": ( + "https://i.oneme.ru/i?r=" + "BUFglOvkF6bn--g5U-BFgIkJ" # head + "K6mx6ae5OiOa8c66MUn6oXkSMPFAFZx509DvRP7Cxt1" # unique_part + "44dcdJWD0pBaSRiPxZ0Ss" # tail + ), + "cd_filename": None, + "content_type": "image/webp", + "expected": "image_K6mx6ae5OiOa8c66MUn6oXkSMPFAFZx509DvRP7Cxt0.webp", + }, + "sticker": { + "url": "https://i.oneme.ru/getSmile?smileId=c1453bbb&smileType=4", + "cd_filename": None, + "content_type": "image/png", + "expected": "sticker_c1453bbb.png", + }, + "file": { + "url": ( + "https://fd.oneme.ru/getfile?sig=DmSN4pnkY6CxxF2-" + "VDxpsKJfw7AZy8m9qV2ynnU6IqIAS6kiJIV39Bq3D8XZ9Ut4WOhDSRfyhSCmvNhzHZDpGg" + "&expires=1778011573929&clientType=3&id=3118979750&userId=251973343" + ), + "cd_filename": "205046_55821186.jpeg", + "content_type": "application/octet-stream", + "expected": "205046_55821186.jpeg", + }, + "video": { + "url": ( + "https://vd545.okcdn.ru/?expires=1777181558195&srcIp=127.0.0.1" + "&pr=95&srcAg=UNKNOWN&ms=123.456.78.90&type=3&sig=mJM_Fry0PSY" + "&ct=0&urls=10.145.67.89&clientType=11&appId=1234567890" + "&id=12345678901234&scl=1" + ), + "cd_filename": "12345678901234.mp4", + "content_type": "video/mp4", + "expected": "12345678901234.mp4", + }, + # "thumbnail": ( + # "https://pimg.mycdn.me/getImage?disableStub=true" + # "&type=PREPARE&url=https%3A%2F%2Fiv.okcdn.ru%2F" + # "videoPreview%3Fid%3D15054635666120%26type%3D39%26idx" + # "%3D0%26scl%3D2%26tkn%3Dt-XIJ6RzOp2je0aLFQX3rkMuTkY" + # "&signatureToken=xH6_Hq_03SyJsP_ZsL_UAQ" + # ), + # url link not works yet +} + @pytest.fixture def bot(): @@ -59,7 +130,7 @@ def _make_mock_response( mock_response.closed = closed mock_response.status = status mock_response.content_type = content_type - mock_response.__class__ = ClientResponse + mock_response.__class__ = ClientResponse # type: ignore if cd_filename is not None: cd = MagicMock() @@ -143,8 +214,12 @@ class TestDownloadFile: async def test_download_file_path_as_str(self, bot, tmp_dir, mock_session): """Скачивание файла с корректным Content-Disposition.""" chunks = [b"chunk1", b"chunk2", b"chunk3"] - url = "https://example.com/file.pdf" - mock_response = _make_mock_response(url=url, chunks=chunks) + url = REAL_URL_LINKS["file"]["url"] + mock_response = _make_mock_response( + url=url, + chunks=chunks, + cd_filename=REAL_URL_LINKS["file"]["cd_filename"], + ) mock_session.request = AsyncMock(return_value=mock_response) result = await bot.download_file( @@ -152,27 +227,27 @@ async def test_download_file_path_as_str(self, bot, tmp_dir, mock_session): destination=str(tmp_dir), ) - assert result == tmp_dir / "file.pdf" + assert result == tmp_dir / REAL_URL_LINKS["file"]["cd_filename"] assert result.read_bytes() == b"".join(chunks) async def test_download_file_success(self, bot, tmp_dir, mock_session): """Скачивание файла с корректным Content-Disposition.""" chunks = [b"chunk1", b"chunk2", b"chunk3"] - url = "https://example.com/file.pdf" + mock_response = _make_mock_response( - url=url, - content_type="application/pdf", - cd_filename="document.pdf", + url=REAL_URL_LINKS["file"]["url"], + content_type=REAL_URL_LINKS["file"]["content_type"], + cd_filename=REAL_URL_LINKS["file"]["cd_filename"], chunks=chunks, ) mock_session.request = AsyncMock(return_value=mock_response) result = await bot.download_file( - url=url, + url=REAL_URL_LINKS["file"]["url"], destination=tmp_dir, ) - assert result == tmp_dir / "document.pdf" + assert result == tmp_dir / REAL_URL_LINKS["file"]["cd_filename"] assert result.read_bytes() == b"".join(chunks) async def test_download_file_no_content_disposition( @@ -216,22 +291,99 @@ async def test_download_file_no_content_disposition_no_path( assert result.parent == tmp_dir @freeze_datetime("maxapi.connection.base", "2026-04-16 10:30:50") - async def test_download_photo(self, bot, tmp_dir, mock_session): - """Скачивание вложения-фото по ссылке выда https://i.oneme.ru/i?r=photo_token""" - url = "https://i.oneme.ru/i?r=photo_token" + async def test_download_image(self, bot, tmp_dir, mock_session): + """Скачивание вложения-изображения""" + url_case = REAL_URL_LINKS["image"] mock_response = _make_mock_response( - url=url, - content_type="image/jpeg", + url=url_case["url"], + content_type=url_case["content_type"], chunks=[b"imagedata"], ) mock_session.request = AsyncMock(return_value=mock_response) result = await bot.download_file( - url=url, + url=url_case["url"], + destination=tmp_dir, + ) + + assert result.name == url_case["expected"] + assert result.parent == tmp_dir + + async def test_download_image_user_avatar( + self, bot, tmp_dir, mock_session + ): + """Скачивание вложения-изображения""" + url_case = REAL_URL_LINKS["image_user_avatar"] + mock_response = _make_mock_response( + url=url_case["url"], + content_type=url_case["content_type"], + chunks=[b"imagedata"], + ) + mock_session.request = AsyncMock(return_value=mock_response) + + result = await bot.download_file( + url=url_case["url"], + destination=tmp_dir, + ) + + assert result.name == url_case["expected"] + assert result.parent == tmp_dir + + async def test_download_video(self, bot, tmp_dir, mock_session): + """Скачивание вложения-видео""" + url_case = REAL_URL_LINKS["video"] + mock_response = _make_mock_response( + url=url_case["url"], + cd_filename=url_case["cd_filename"], + content_type=url_case["content_type"], + chunks=[b"mp4videodata"], + ) + mock_session.request = AsyncMock(return_value=mock_response) + + result = await bot.download_file( + url=url_case["url"], + destination=tmp_dir, + ) + + assert result.name == url_case["cd_filename"] + assert result.parent == tmp_dir + + async def test_download_audio(self, bot, tmp_dir, mock_session): + """Скачивание вложения-аудио""" + url_case = REAL_URL_LINKS["audio"] + mock_response = _make_mock_response( + url=url_case["url"], + cd_filename=url_case["cd_filename"], + content_type=url_case["content_type"], + chunks=[b"mp3audiodata"], + ) + mock_session.request = AsyncMock(return_value=mock_response) + + result = await bot.download_file( + url=url_case["url"], destination=tmp_dir, ) - expected = "image_260416_103050.jpg" - assert result.name == expected + + assert result.name == url_case["cd_filename"] + assert result.parent == tmp_dir + + async def test_download_sticker(self, bot, tmp_dir, mock_session): + """Скачивание вложения-аудио""" + url_case = REAL_URL_LINKS["sticker"] + mock_response = _make_mock_response( + url=url_case["url"], + cd_filename=url_case["cd_filename"], + content_type=url_case["content_type"], + chunks=[b"PNGdata"], + ) + mock_session.request = AsyncMock(return_value=mock_response) + + result = await bot.download_file( + url=url_case["url"], + destination=tmp_dir, + ) + + assert result.name == url_case["expected"] assert result.parent == tmp_dir async def test_download_file_path_traversal_protection( @@ -391,7 +543,9 @@ async def test_download_file_destination_with_filename_collision( async def test_download_file_destination_directory_uses_server_filename( self, bot, tmp_dir, mock_session ): - """Проверка, что при указании директории используется имя от сервера.""" + """ + Проверка, что при указании директории используется имя от сервера. + """ chunks = [b"data"] url = "https://example.com/download" mock_response = _make_mock_response( @@ -412,7 +566,7 @@ async def test_download_file_destination_directory_uses_server_filename( assert result == tmp_dir / "server_file.txt" assert result.read_bytes() == b"".join(chunks) - async def test_download_file_destination_without_extension_uses_server_name( + async def test_download_file_destination_without_extension( self, bot, tmp_dir, mock_session ): """Проверка: путь без расширения трактуется как директория.""" @@ -442,7 +596,7 @@ async def test_download_file_destination_relative_filename( """Скачивание с относительным путём к файлу.""" import os - original_cwd = os.getcwd() + original_cwd = Path.cwd() try: os.chdir(tmp_dir) @@ -463,7 +617,7 @@ async def test_download_file_destination_relative_filename( ) # Приводим оба пути к абсолютным для сравнения - assert result.resolve() == Path(destination).resolve() + assert result.resolve() == Path(destination).resolve() # noqa: ASYNC240 assert result.read_bytes() == b"".join(chunks) assert result.exists() finally: @@ -489,16 +643,15 @@ async def test_download_bytes_success(self, bot, mock_session): GET https://fd.oneme.ru/getfile?sig=...&expires=... """ chunks = [b"chunk1", b"chunk2", b"chunk3"] - url = "https://fd.oneme.ru/getfile?sig=test&expires=123" mock_response = _make_mock_response( - url=url, - content_type="application/octet-stream", - cd_filename="document.pdf", + url=REAL_URL_LINKS["file"]["url"], + content_type=REAL_URL_LINKS["file"]["content_type"], + cd_filename=REAL_URL_LINKS["file"]["cd_filename"], chunks=chunks, ) mock_session.request = AsyncMock(return_value=mock_response) - result = await bot.download_bytes(url=url) + result = await bot.download_bytes(url=REAL_URL_LINKS["file"]["url"]) assert result == b"chunk1chunk2chunk3" mock_response.release.assert_called_once() @@ -509,15 +662,15 @@ async def test_download_bytes_image_url(self, bot, mock_session): """ # Эмулируем PNG-изображение (минимальный валидный заголовок) png_header = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100 - url = "https://i.oneme.ru/i?r=BTGBPUwtwgYUeoFhO7rESmr81n-DnwjHYFhx5_EAhKk..." + mock_response = _make_mock_response( - url=url, - content_type="image/png", + url=REAL_URL_LINKS["sticker"]["url"], + content_type=REAL_URL_LINKS["sticker"]["content_type"], chunks=[png_header], ) mock_session.request = AsyncMock(return_value=mock_response) - result = await bot.download_bytes(url=url) + result = await bot.download_bytes(url=REAL_URL_LINKS["sticker"]["url"]) assert result.startswith(b"\x89PNG") assert len(result) > 0 @@ -598,7 +751,7 @@ async def test_download_bytes_encoded_filename(self, bot, mock_session): result = await bot.download_bytes(url=url) assert result == b"".join(chunks) - async def test_download_file_vs_as_bytes_same_content( + async def test_download_file_vs_bytes_same_content( self, bot, tmp_dir, mock_session ): """ @@ -669,27 +822,26 @@ async def test_download_file_name_collision( assert result.stem.endswith(f"({i + 1})") assert result.read_bytes() == f"new {i + 1}".encode() - async def test_download_file_photo_correct_extension( + async def test_download_file_image_correct_extension( self, bot, tmp_dir, mock_session ): """ Для i.oneme.ru расширение определяется по Content-Type, а не .webp """ - url = "https://i.oneme.ru/i?r=test" mock_response = _make_mock_response( - url=url, - content_type="image/png", + url=REAL_URL_LINKS["sticker"]["url"], + content_type=REAL_URL_LINKS["sticker"]["content_type"], chunks=[b"\x89PNG\r\n\x1a\n"], ) mock_session.request = AsyncMock(return_value=mock_response) result = await bot.download_file( - url=url, + url=REAL_URL_LINKS["sticker"]["url"], destination=tmp_dir, ) assert result.suffix == ".png" # не .webp! - assert result.name.startswith("image_") + assert result.name.startswith("sticker_") async def test_download_file_retryable_server_error( self, bot, mock_session @@ -706,39 +858,34 @@ async def test_download_file_retryable_server_error( assert "HTTP 502" in str(exc_info.value) @freeze_datetime("maxapi.connection.base", "2026-04-16 10:30:50") - async def test_capture_filename_no_extension_fallback(self): - """Покрытие: is_photo=True, ext='', fallback на .webp""" - from maxapi.connection.base import BaseConnection - - # 1. Случай с фото + async def test_capture_filename_no_extension_fallback(self, bot): + """Покрытие: is_image=True, ext='', fallback на .webp""" + # 1. Случай с изображением url = "https://i.oneme.ru/" # Нет имени файла в URL mock_response = _make_mock_response(url=url, content_type=None) # type: ignore # Нет заголовка и content_type - filename = BaseConnection._capture_filename(mock_response) + filename = bot._capture_filename(mock_response) - assert filename == "image_260416_103050.webp" + assert filename == "260416_103050.bin" - def test_capture_filename_wrong_response(self): + def test_capture_filename_wrong_response(self, bot): """Покрытие: except (TypeError, AttributeError) при доступе к полям""" - from maxapi.connection.base import BaseConnection class BrokenResponse: pass with pytest.raises(TypeError, match="Ожидается ClientResponse"): - BaseConnection._capture_filename(BrokenResponse()) # type: ignore + bot._capture_filename(BrokenResponse()) # type: ignore @freeze_datetime("maxapi.connection.base", "2026-04-16 10:30:50") - def test_capture_filename_minimal_object(self): + def test_capture_filename_minimal_object(self, bot): """Покрытие: except (TypeError, AttributeError) при доступе к полям""" - from maxapi.connection.base import BaseConnection - # Нет ни content_disposition, ни url, ни content_type mock_response = _make_mock_response( content_type=None, # type: ignore ) - filename = BaseConnection._capture_filename(mock_response) + filename = bot._capture_filename(mock_response) assert filename == "260416_103050.bin" # fallback-результат @@ -828,3 +975,58 @@ async def test_download_file_cleanup_partial_file_on_error(self, bot): # ✅ 5. Проверяем покрытие целевой ветки mock_final_path.exists.assert_called_once() mock_final_path.unlink.assert_called_once() + + @freeze_datetime("maxapi.connection.base", "2026-04-16 10:30:50") + async def test_download_image_broken_image_id(self, bot, mock_session): + """ + Проверяем определение имени файла изображения в случае невозможности + выделить уникальную часть токена изображения. Блок: + def _get_image_id(r: str): + ... + try: + data = base64.b64decode(r) + """ + url_case = REAL_URL_LINKS["image"] + mock_response = _make_mock_response( + url=url_case["url"][:-30], # отрезаем данные + content_type=url_case["content_type"], + chunks=[b"imagedata"], + ) + mock_session.request = AsyncMock(return_value=mock_response) + + result = await bot.download_bytes_io(url=url_case["url"]) + + assert result.name != url_case["expected"] + assert result.name == "image_260416_103050.webp" + + """ + Блок: + def _get_image_id(r: str): + ... + if len(data) < 50: + return None + """ + mock_response.url = URL(url_case["url"][:-31]) # отрезаем данные + mock_response.closed = False + # mock_session.request = AsyncMock(return_value=mock_response) + + result = await bot.download_bytes_io(url=url_case["url"]) + + assert result.name != url_case["expected"] + assert result.name == "image_260416_103050.webp" + + @freeze_datetime("maxapi.connection.base", "2026-04-16 10:30:50") + async def test_download_sticker_broken_id(self, bot, mock_session): + """Скачивание вложения-аудио""" + url_case = REAL_URL_LINKS["sticker"] + mock_response = _make_mock_response( + url="https://i.oneme.ru/getSmile?brokensmileId=None&smileType=4", + cd_filename=url_case["cd_filename"], + chunks=[b"PNGdata"], + ) + mock_session.request = AsyncMock(return_value=mock_response) + + result = await bot.download_bytes_io(url=url_case["url"]) + + assert result.name != url_case["expected"] + assert result.name == "sticker_260416_103050.png" From 6e40fc4654eb1f7f61c224f375a92d30dbcd2b7b Mon Sep 17 00:00:00 2001 From: Pankovea <114323250+Pankovea@users.noreply.github.com> Date: Sun, 26 Apr 2026 22:59:59 +0300 Subject: [PATCH 13/15] =?UTF-8?q?remove:=20=D0=BD=D0=B5=D0=BD=D1=83=D0=B6?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20@freeze=5Fdatetime=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?test=5Fdownload=5Fimage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_download_file.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_download_file.py b/tests/test_download_file.py index f3fc116..6e6a425 100644 --- a/tests/test_download_file.py +++ b/tests/test_download_file.py @@ -290,7 +290,6 @@ async def test_download_file_no_content_disposition_no_path( assert result.name == "260416_103050.bin" assert result.parent == tmp_dir - @freeze_datetime("maxapi.connection.base", "2026-04-16 10:30:50") async def test_download_image(self, bot, tmp_dir, mock_session): """Скачивание вложения-изображения""" url_case = REAL_URL_LINKS["image"] From 7df557f93dd9b16b5649547baf0de4e0d84cfcda Mon Sep 17 00:00:00 2001 From: Pankovea <114323250+Pankovea@users.noreply.github.com> Date: Wed, 29 Apr 2026 00:20:49 +0300 Subject: [PATCH 14/15] =?UTF-8?q?bugfix:=20=D1=83=20aiohttp.ClientResponse?= =?UTF-8?q?=20=D0=BC=D0=B5=D1=82=D0=BE=D0=B4=20release()=20=D1=81=D0=B8?= =?UTF-8?q?=D0=BD=D1=85=D1=80=D0=BE=D0=BD=D0=BD=D1=8B=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- maxapi/connection/base.py | 6 +++--- tests/test_download_file.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/maxapi/connection/base.py b/maxapi/connection/base.py index 9671e08..203b053 100644 --- a/maxapi/connection/base.py +++ b/maxapi/connection/base.py @@ -350,7 +350,7 @@ async def _do_request() -> Any: ) from e if not response.ok: - await response.release() + response.release() raise DownloadFileError( f"Ошибка при скачивании: HTTP {response.status}" ) @@ -380,7 +380,7 @@ async def _fetch_content_stream( raise DownloadFileError("response соединение закрыто") if not response.ok: - await response.release() + response.release() raise DownloadFileError( f"Ошибка при скачивании: HTTP {response.status}" ) @@ -389,7 +389,7 @@ async def _fetch_content_stream( async for chunk in response.content.iter_chunked(chunk_size): yield chunk finally: - await response.release() + response.release() @staticmethod def _get_image_id(r: str) -> str | None: diff --git a/tests/test_download_file.py b/tests/test_download_file.py index 6e6a425..be02fb9 100644 --- a/tests/test_download_file.py +++ b/tests/test_download_file.py @@ -931,7 +931,7 @@ async def test_fetch_content_stream_http_error(self, bot): async for _ in bot._fetch_content_stream(mock_response): pass - mock_response.release.assert_awaited_once() + mock_response.release.assert_called_once() async def test_download_file_cleanup_partial_file_on_error(self, bot): """Проверка download_file ветки: From 0db3e5b79caf91fb4671fb5279cd9c5d0b8c059c Mon Sep 17 00:00:00 2001 From: Pankovea <114323250+Pankovea@users.noreply.github.com> Date: Wed, 29 Apr 2026 01:38:20 +0300 Subject: [PATCH 15/15] =?UTF-8?q?fix:=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D0=BE=D0=B5=20=D1=83=D0=BA=D0=B0=D0=B7=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=83=D1=82=D0=B8=20=D0=BD=D0=B0?= =?UTF-8?q?=D0=B7=D0=BD=D0=B0=D1=87=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B8=20?= =?UTF-8?q?=D0=B8=D0=BC=D0=B5=D0=BD=D0=B8=20=D1=84=D0=B0=D0=B9=D0=BB=D0=B0?= =?UTF-8?q?=20bot.download=5Ffile(url=3D...,=20destination=3D...,=20filena?= =?UTF-8?q?me=3D...)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit remove: дублирующий метод download_file в bot.py --- maxapi/bot.py | 38 +------------------- maxapi/connection/base.py | 44 ++++++++++++++--------- tests/test_download_file.py | 70 ++++++++++++++++++++++++++++--------- 3 files changed, 82 insertions(+), 70 deletions(-) diff --git a/maxapi/bot.py b/maxapi/bot.py index dcf33f0..840cd21 100644 --- a/maxapi/bot.py +++ b/maxapi/bot.py @@ -2,13 +2,12 @@ import os import warnings -from pathlib import Path from typing import TYPE_CHECKING, Any from aiohttp import ClientSession from .client.default import DefaultConnectionProperties -from .connection.base import DOWNLOAD_CHUNK_SIZE, BaseConnection +from .connection.base import BaseConnection from .enums.sender_action import SenderAction from .exceptions.max import InvalidToken from .loggers import logger_bot @@ -1090,41 +1089,6 @@ async def upload_media( att=media, ) - async def download_file( - self, - url: str, - destination: str | Path, - *, - chunk_size: int = DOWNLOAD_CHUNK_SIZE, - ) -> Path: - """ - Скачивает файл по URL и сохраняет на диск. - - URL можно получить из payload вложения: - - Изображение: ``attachment.payload.url`` - - Видео: ``attachment.urls.mp4_720`` (или другое разрешение) - - Аудио/Файл: ``attachment.payload.url`` - - Стикер: ``attachment.payload.url`` - - Args: - url: URL файла для скачивания. - destination: Путь к директории для сохранения файла. - Если путь не содержит имя файла (не имеет расширения), - то будет использовано имя, предоставляемое сервером - или значение по умолчанию. - Если путь содержит расширение, он трактуется как - полное имя файла для сохранения. - chunk_size: Размер чанка (по умолчанию 64 КБ). - - Returns: - Path: Полный путь к скачанному файлу. - """ - return await super().download_file( - url=url, - destination=Path(destination), - chunk_size=chunk_size, - ) - async def set_my_commands(self, *commands: BotCommand) -> User: """ Устанавливает список команд бота. diff --git a/maxapi/connection/base.py b/maxapi/connection/base.py index 203b053..c1a482f 100644 --- a/maxapi/connection/base.py +++ b/maxapi/connection/base.py @@ -516,8 +516,6 @@ def _check_file_exists(path: Path | str) -> Path: """ path = Path(path) - dest = path.parent - if path.exists(): max_num = 1 # Один уже существует fname, ext = path.stem, path.suffix @@ -526,6 +524,7 @@ def _check_file_exists(path: Path | str) -> Path: ) # Сканируем директорию + dest = path.parent for existing_path in dest.iterdir(): match = pattern.match(existing_path.name) if match: @@ -541,12 +540,19 @@ async def download_file( self, url: str, destination: Path | str, + filename: Path | str | None = None, *, chunk_size: int = DOWNLOAD_CHUNK_SIZE, ) -> Path: """ Скачивает файл по URL и сохраняет на диск. + URL можно получить из payload вложения: + - Изображение: ``attachment.payload.url`` + - Видео: ``attachment.urls.mp4_720`` (или другое разрешение) + - Аудио/Файл: ``attachment.payload.url`` + - Стикер: ``attachment.payload.url`` + Метод работает не через общий ``request()``, поскольку ответом является бинарный поток, а не JSON. @@ -560,11 +566,9 @@ async def download_file( Args: url: URL файла для скачивания (из payload.url вложения). destination: Путь к директории для сохранения файла. - Если путь не содержит имя файла (не имеет расширения), + filename: Имя файла для сохранения. Если не указано, то будет использовано имя, предоставляемое сервером или значение по умолчанию. - Если путь содержит расширение, он трактуется как - полное имя файла для сохранения. chunk_size: Размер чанка при потоковом чтении (по умолчанию 64 КБ). @@ -573,26 +577,32 @@ async def download_file( Raises: DownloadFileError: при ошибке скачивания. + FileExistsError, NotADirectoryError, PermissionError, OSError: + при ошибках файловой системы """ dest = Path(destination) + final_path = None # Получаем ответ для определения имени файла из заголовков response = await self._fetch_response(url) - # Определяем конечный путь для сохранения: - # - если destination имеет расширение (суффикс) → это имя файла - # - иначе → это директория, добавляем имя из ответа - if dest.suffix: - # destination содержит имя файла с расширением - await aiofiles.os.makedirs(dest.parent, exist_ok=True) - final_path = self._check_file_exists(dest) - else: - # destination - это директория, добавляем имя файла из ответа + try: await aiofiles.os.makedirs(dest, exist_ok=True) - filename = self._capture_filename(response) - final_path = self._check_file_exists(dest / filename) + except (FileExistsError, NotADirectoryError, PermissionError, OSError): + # Если передан файл вместо директории, путь ошибочен + # или нет прав доступа + response.release() + raise try: + if filename: + # Выделяем только имя файла, + # в случае если переменная содержит путь + filename = Path(filename).name + else: + filename = self._capture_filename(response) + + final_path = self._check_file_exists(dest / filename) async with aiofiles.open(final_path, "wb") as f: async for chunk in self._fetch_content_stream( response, chunk_size=chunk_size @@ -600,7 +610,7 @@ async def download_file( await f.write(chunk) except Exception: # При любой ошибке удаляем частично записанный файл - if final_path.exists(): + if final_path and final_path.exists(): final_path.unlink() raise diff --git a/tests/test_download_file.py b/tests/test_download_file.py index be02fb9..9c8d3f1 100644 --- a/tests/test_download_file.py +++ b/tests/test_download_file.py @@ -490,7 +490,7 @@ async def test_ensure_session_reuses_existing(self, bot, mock_session): async def test_download_file_destination_with_filename( self, bot, tmp_dir, mock_session ): - """Скачивание файла когда destination содержит имя файла.""" + """Если destination содержит имя файла.""" chunks = [b"chunk1", b"chunk2"] url = "https://example.com/remote.pdf" mock_response = _make_mock_response( @@ -502,16 +502,27 @@ async def test_download_file_destination_with_filename( mock_session.request = AsyncMock(return_value=mock_response) # destination содержит своё имя файла + filename = "my_custom_name.pdf" + dist_with_filename = tmp_dir / filename result = await bot.download_file( - url=url, - destination=tmp_dir / "my_custom_name.pdf", + url=url, destination=dist_with_filename, filename=filename ) - # Должно использоваться имя из destination, а не от сервера - assert result == tmp_dir / "my_custom_name.pdf" + # Создастся папка с именем файла и внутри файл + assert result == dist_with_filename / filename assert result.read_bytes() == b"".join(chunks) - async def test_download_file_destination_with_filename_collision( + (dist_with_filename / filename).unlink() + dist_with_filename.rmdir() + + # Теперь если файл существует, то будет ошибка + dist_with_filename.write_text("test") + with pytest.raises(FileExistsError): + result = await bot.download_file( + url=url, destination=dist_with_filename, filename=filename + ) + + async def test_download_file_destination_and_filename_collision( self, bot, tmp_dir, mock_session ): """Проверка коллизии имён когда destination содержит имя файла.""" @@ -530,7 +541,8 @@ async def test_download_file_destination_with_filename_collision( # Пытаемся скачать в тот же путь result = await bot.download_file( url=url, - destination=tmp_dir / "report.pdf", + destination=tmp_dir, + filename="report.pdf", ) # Должен быть создан новый файл с суффиксом (2) @@ -565,31 +577,55 @@ async def test_download_file_destination_directory_uses_server_filename( assert result == tmp_dir / "server_file.txt" assert result.read_bytes() == b"".join(chunks) - async def test_download_file_destination_without_extension( + async def test_download_file_filename_with_path( self, bot, tmp_dir, mock_session ): - """Проверка: путь без расширения трактуется как директория.""" + """Проверка: файл соержить путь""" + chunks = [b"binary"] + url = "https://example.com/data" + mock_response = _make_mock_response( + url=url, + cd_filename="data.bin", + chunks=chunks, + ) + mock_session.request = AsyncMock(return_value=mock_response) + + destination = tmp_dir / "downloads" + result = await bot.download_file( + url=url, + destination=destination, + filename=destination / "filename.pdf", + ) + + # Файл должен быть сохранён внутри директории с именем от сервера + assert result == destination / "filename.pdf" + assert result.read_bytes() == b"".join(chunks) + + async def test_download_file_destination_with_filname( + self, bot, tmp_dir, mock_session + ): + """Проверка: файл соержить путь""" chunks = [b"binary"] url = "https://example.com/data" mock_response = _make_mock_response( url=url, - content_type="application/octet-stream", cd_filename="data.bin", chunks=chunks, ) mock_session.request = AsyncMock(return_value=mock_response) - # Путь без расширения → трактуется как директория + destination = tmp_dir / "downloads" result = await bot.download_file( url=url, - destination=tmp_dir / "downloads", # Нет расширения + destination=destination, + filename=destination / "filename.pdf", ) # Файл должен быть сохранён внутри директории с именем от сервера - assert result == tmp_dir / "downloads" / "data.bin" + assert result == destination / "filename.pdf" assert result.read_bytes() == b"".join(chunks) - async def test_download_file_destination_relative_filename( + async def test_download_file_destination_relative_plus_filename( self, bot, tmp_dir, mock_session ): """Скачивание с относительным путём к файлу.""" @@ -609,14 +645,16 @@ async def test_download_file_destination_relative_filename( mock_session.request = AsyncMock(return_value=mock_response) # Относительный путь с расширением - destination = "subdir/my_file.txt" + destination = "subdir" + filename = "my_file.txt" result = await bot.download_file( url=url, destination=destination, + filename=filename, ) # Приводим оба пути к абсолютным для сравнения - assert result.resolve() == Path(destination).resolve() # noqa: ASYNC240 + assert result.resolve() == (Path(destination) / filename).resolve() assert result.read_bytes() == b"".join(chunks) assert result.exists() finally: