Skip to content

added download file method#90

Closed
someqst wants to merge 1 commit intolove-apples:mainfrom
someqst:add-download-file
Closed

added download file method#90
someqst wants to merge 1 commit intolove-apples:mainfrom
someqst:add-download-file

Conversation

@someqst
Copy link
Copy Markdown

@someqst someqst commented Apr 7, 2026

Добавил метод для скачивания файлов
closes #64
Протестировано на:

  1. Документах
  2. Аудио
  3. Видео

Очень хотел бы видеть этот метод.

@someqst someqst changed the title added downlod file method added download file method Apr 8, 2026
Comment thread maxapi/connection/base.py
return await response.text()

async def download_file(
self, url: str, destination: Path | str, chunk_size: int | None = 65536
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

65536 - магическое число, лучше вынести в константу

Comment thread maxapi/connection/base.py
Comment on lines +293 to +299
if not bot.session:
bot.session = ClientSession(
base_url=bot.api_url,
timeout=bot.default_connection.timeout,
headers=bot.headers,
**bot.default_connection.kwargs,
)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Эта конструкция несколько раз встречается в коде. По принципам DRY лучше вынести её в отдельный метод Bot, позволяющий установить сессию и на выходе гарантировано её получить. Чтобы эта логика не была разбросана по всему приложению

Comment thread maxapi/connection/base.py
response = await temp_session.post(url=url, data=form)
return await response.text()

async def download_file(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Нужно дописать тесты для покрытия этой функции

Comment thread maxapi/connection/base.py
**bot.default_connection.kwargs,
)

response = await bot.session.get(url=url)
Copy link
Copy Markdown
Collaborator

@Olegt0rr Olegt0rr Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Если посмотреть на пример метода request в Bot, то там реализован backoff на случай отвала. Было бы здорово его здесь поддержать.

И в целом лучше в докстрингах указать явный комментарий почему данный метод работает не через общий request

Copy link
Copy Markdown
Collaborator

@Olegt0rr Olegt0rr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Хороший и чистый код, но требует ещё доработок по комментариям.

@Olegt0rr
Copy link
Copy Markdown
Collaborator

Olegt0rr commented Apr 8, 2026

@someqst добавь, пожалуйста, в описание PR: closes #64, чтобы связать с задачей

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new download_file capability to the MAX API client so bots can download user-sent attachments (docs/audio/video), addressing issue #64.

Changes:

  • Introduces BaseConnection.download_file() to stream-download a file to a local destination.
  • Derives the output filename from Content-Disposition (with URL-decoding) or falls back to a UUID + MIME-based extension.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread maxapi/connection/base.py
Comment on lines +294 to +298
bot.session = ClientSession(
base_url=bot.api_url,
timeout=bot.default_connection.timeout,
headers=bot.headers,
**bot.default_connection.kwargs,
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bot.session создаётся с base_url=bot.api_url, но url для скачивания берётся из payload и может быть абсолютным (https://...). В aiohttp нельзя делать запросы с абсолютным URL через ClientSession с base_url — это приводит к ошибке сборки URL. Решение: для абсолютных URL использовать отдельную ClientSession без base_url (но с теми же headers/timeout), либо приводить url к относительному пути перед get().

Copilot uses AI. Check for mistakes.
Comment thread maxapi/connection/base.py
Comment on lines +301 to +316
response = await bot.session.get(url=url)

cd = response.content_disposition
if cd and cd.filename:
filename = cd.filename
filename = unquote(cd.filename)
else:
ext = mimetypes.guess_extension(response.content_type) or ""
filename = str(uuid4()) + ext

path = Path(destination) / filename

async with aiofiles.open(path, "wb") as f:
async for chunk in response.content.iter_chunked(chunk_size):
await f.write(chunk)

Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ответ GET не закрывается/не освобождается: используется await bot.session.get(...) без async with, и после чтения/записи файла не вызывается response.release()/response.close(). Это может привести к утечке соединений в пуле. Лучше оборачивать запрос в async with ... as response: и гарантировать освобождение ресурсов при исключениях.

Suggested change
response = await bot.session.get(url=url)
cd = response.content_disposition
if cd and cd.filename:
filename = cd.filename
filename = unquote(cd.filename)
else:
ext = mimetypes.guess_extension(response.content_type) or ""
filename = str(uuid4()) + ext
path = Path(destination) / filename
async with aiofiles.open(path, "wb") as f:
async for chunk in response.content.iter_chunked(chunk_size):
await f.write(chunk)
async with bot.session.get(url=url) as response:
cd = response.content_disposition
if cd and cd.filename:
filename = cd.filename
filename = unquote(cd.filename)
else:
ext = mimetypes.guess_extension(response.content_type) or ""
filename = str(uuid4()) + ext
path = Path(destination) / filename
async with aiofiles.open(path, "wb") as f:
async for chunk in response.content.iter_chunked(chunk_size):
await f.write(chunk)

Copilot uses AI. Check for mistakes.
Comment thread maxapi/connection/base.py
Comment on lines +301 to +317
response = await bot.session.get(url=url)

cd = response.content_disposition
if cd and cd.filename:
filename = cd.filename
filename = unquote(cd.filename)
else:
ext = mimetypes.guess_extension(response.content_type) or ""
filename = str(uuid4()) + ext

path = Path(destination) / filename

async with aiofiles.open(path, "wb") as f:
async for chunk in response.content.iter_chunked(chunk_size):
await f.write(chunk)

return path
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Перед сохранением на диск нет проверки response.status/response.ok. Сейчас при 401/404/5xx будет сохранён ответ об ошибке как «файл». Нужна явная обработка не-2xx статусов (например, 401 → InvalidToken, прочие → MaxApiError/NotAvailableForDownload) до начала записи.

Suggested change
response = await bot.session.get(url=url)
cd = response.content_disposition
if cd and cd.filename:
filename = cd.filename
filename = unquote(cd.filename)
else:
ext = mimetypes.guess_extension(response.content_type) or ""
filename = str(uuid4()) + ext
path = Path(destination) / filename
async with aiofiles.open(path, "wb") as f:
async for chunk in response.content.iter_chunked(chunk_size):
await f.write(chunk)
return path
async with bot.session.get(url=url) as response:
if response.status == 401:
message = await response.text()
raise InvalidToken(message or "Invalid token")
if response.status < 200 or response.status >= 300:
message = await response.text()
raise MaxApiError(
message or f"File is not available for download. HTTP status: {response.status}"
)
cd = response.content_disposition
if cd and cd.filename:
filename = cd.filename
filename = unquote(cd.filename)
else:
ext = mimetypes.guess_extension(response.content_type) or ""
filename = str(uuid4()) + ext
path = Path(destination) / filename
async with aiofiles.open(path, "wb") as f:
async for chunk in response.content.iter_chunked(chunk_size):
await f.write(chunk)
return path

Copilot uses AI. Check for mistakes.
Comment thread maxapi/connection/base.py
Comment on lines +277 to +279
async def download_file(
self, url: str, destination: Path | str, chunk_size: int | None = 65536
) -> Path | None:
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

chunk_size объявлен как int | None, но response.content.iter_chunked() требует int. При chunk_size=None скачивание упадёт с исключением. Либо сделайте параметр всегда int (с дефолтом), либо при None используйте другой способ чтения.

Copilot uses AI. Check for mistakes.
Comment thread maxapi/connection/base.py
Comment on lines +305 to +306
filename = cd.filename
filename = unquote(cd.filename)
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Имя файла берётся из Content-Disposition и используется без валидации. Если сервер вернёт filename с ../ или разделителями пути, то Path(destination) / filename позволит записать файл вне destination (path traversal). Нужна нормализация/санитизация имени (например, брать только Path(filename).name, запрещать разделители/нулевые байты) перед формированием итогового пути.

Suggested change
filename = cd.filename
filename = unquote(cd.filename)
raw_filename = unquote(cd.filename)
if "\x00" in raw_filename:
raise MaxApiError("Invalid filename in Content-Disposition")
filename = Path(raw_filename.replace("\\", "/")).name
if filename in {"", ".", ".."}:
ext = mimetypes.guess_extension(response.content_type) or ""
filename = str(uuid4()) + ext

Copilot uses AI. Check for mistakes.
Comment thread maxapi/connection/base.py
Comment on lines +286 to +290
destination (Path | str): место назначения для скачанного файла.
chunk_size (bytes): Размер чанков файла, по умолчанию 64.
Returns:
Path: Ссылка на файл в файловой системе
"""
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Докстринг не совпадает с реальным поведением: chunk_size по умолчанию 65536 (64 KiB), но описан как «по умолчанию 64». Также в Returns указан Path, тогда как аннотация метода — Path | None, хотя None не возвращается. Стоит привести документацию и типы к одному контракту.

Copilot uses AI. Check for mistakes.
Comment thread maxapi/connection/base.py
cd = response.content_disposition
if cd and cd.filename:
filename = cd.filename
filename = unquote(cd.filename)
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Лишнее присваивание: filename = cd.filename, а следующей строкой переменная сразу перезаписывается. Можно оставить одно присваивание (и вызывать unquote от уже присвоенной переменной), чтобы избежать дублирования.

Suggested change
filename = unquote(cd.filename)
filename = unquote(filename)

Copilot uses AI. Check for mistakes.
Comment thread maxapi/connection/base.py
Comment on lines +277 to +281
async def download_file(
self, url: str, destination: Path | str, chunk_size: int | None = 65536
) -> Path | None:
"""
Скачивает файл с сервера MAX.
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

В репозитории есть покрытие тестами для connection/utils, но для нового метода скачивания тестов нет. Желательно добавить тест(ы), которые проверяют: выбор имени файла (с/без Content-Disposition), обработку не-2xx статусов и запись чанками в destination (с мокнутым ClientSession/response).

Copilot uses AI. Check for mistakes.
@bish-x
Copy link
Copy Markdown
Contributor

bish-x commented Apr 12, 2026

Привет! Я реализовал расширенную версию этого функционала в PR #96, где учтены все замечания @Olegt0rr:

  • Магическое число 65536 вынесено в константу DOWNLOAD_CHUNK_SIZE
  • Логика создания сессии — через Bot.ensure_session() (DRY)
  • Retry/backoff через библиотеку backoff вместо ручной реализации
  • Защита от path traversal (Path(filename).name)
  • Тесты наа скачивание, retry, path traversal, ensure_session
  • async with / try/finally для освобождения response

Если этот PR больше не планируется дорабатывать, возможно имеет смысл его закрыть в пользу #96?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Скачивание файлов который отправляет пользователь боту

4 participants