Skip to content

feat: добавлен метод download_bytes_io, download_bytes#112

Open
Pankovea wants to merge 15 commits intolove-apples:mainfrom
Pankovea:main
Open

feat: добавлен метод download_bytes_io, download_bytes#112
Pankovea wants to merge 15 commits intolove-apples:mainfrom
Pankovea:main

Conversation

@Pankovea
Copy link
Copy Markdown

@Pankovea Pankovea commented Apr 14, 2026

Что добавлено

Новые возможности

Скачивание файла в оперативную память для дальнейшей обработки:
download_bytes_io(url, *, chunk_size) -> NamedBytesIO
download_bytes(url, *, chunk_size) -> bytes

Улучшения существующего API

  • download_file: теперь корректно определяет имя файла из:
    • Заголовка Content-Disposition: attachment; filename="..."
    • Парсинга URL (с декодированием %XX-последовательностей)
    • image_YYMMDD_HHMMSS.webp для картинок
  • Защита от коллизий: если файл с таким именем уже существует, автоматически добавляется суффикс (1), (2) и т.д. (Windows like без пробела)
  • если передано имя файла в destination, то использует его. Определяется по наличию расширения файла. Если его нет, то считается что это директория.

Обратная совместимость

  • Сигнатура download_file не изменилась — старый код продолжит работать
  • Новые методы — чисто аддитивное изменение, ничего не ломает

Практические сценарии

зачем в реальных проектах (особенно в ботах и API) нужен метод, возвращающий байты в память:

  1. Мгновенная пересылка или загрузка в другой сервис
    Бот получил файл → сразу отправляет его в Telegram, WhatsApp, на внешний API или в облако (S3, Yandex Cloud).
    Почему не диск: не нужно создавать временный файл, ждать завершения записи, а потом удалять. Байты сразу летят дальше.
  2. Обработка в памяти (AI, OCR, парсинг)
    Распознавание текста, анализ изображений, извлечение данных из PDF/Excel, генерация превью, модерация контента.
    Почему не диск: библиотеки вроде Pillow, pydantic, pdfplumber, transformers умеют работать с bytes или io.BytesIO. Диск здесь только замедляет.
  3. Прокси/Транзитная передача
    Получить файл из одного источника → сразу отправить в очередь задач (RabbitMQ, Redis), в другой микросервис или на CDN.
    Почему не диск: в распределённых системах пути к файлам бессмысленны. Передаются именно байты или base64.

🔧 Технические детали

Архитектура

Единая точка обработки ошибок и retry-логики для всех методов скачивания
_fetch_content_stream()

download_file()
├─> _fetch_response
├─> извлечение имени файла
├─> _fetch_content_stream(response)
└─> Сохранение на диск

download_bytes_io()
├─> _fetch_response
├─> извлечение имени файла
└─> _fetch_content_stream(response) → NamedBytesIO

download_bytes()
├─> download_bytes_io()
└─> bio.read() → bytes

Обработка имён файлов

  1. Content-Disposition: attachment; filename="report.pdf"
  2. Парсинг URL: /path/to/file.png → file.png
  3. Fallback для i.oneme.ru: image_260415_143022.webp
  4. Fallback для остальных: 260415_143022.bin

Защита:

  • Path(filename).name → удаляет path traversal
  • unquote() → декодирует URL-encoded имена (в т.ч. двойное кодирование от серверов Max)
  • check_exists() → автоматическая нумерация при коллизиях

Примеры использования

# Сохранить файл на диск (как раньше)
path = await bot.download_file(
    "https://fd.oneme.ru/getfile?sig=abc123",
    destination="/tmp/downloads"
)
# path: pathlib.Path

# Скачать файл в память (новое!)
data = await bot.download_bytes_io(
    "https://i.oneme.ru/i?r=abc789"
)
# data: NamedBytesIO 
# data.name → имя файла
# data.getbuffer() → представление содержимого буфера без его копирования
# data.getbuffer().nbytes → размер файла
# data.read() → bytes - копия содержания
# Обработка файла в памяти

data = await bot.download_bytes(
    "https://i.oneme.ru/i?r=abc789"
)
# data: bytes
# Имя файла не доступно
# Обработка байтов в памяти


# Обработка ошибок
try:
    content = await bot.download_bytes(url)
except DownloadFileError as e:
    logger.error(f"Не удалось скачать: {e}")

@bish-x
Copy link
Copy Markdown
Contributor

bish-x commented Apr 14, 2026

Привет, спасибо за PR. Функциональная часть (download_file_as_bytes + вынос общего _fetch_content_stream) действительно полезная — разделение транспорта и consumer'а через async generator сделано аккуратно. Прогнал PR локально на чистом клоне против main и собрал развёрнутый фидбек перед ревью мейнтейнера.

🔴 Блокеры: два теста реально падают

tests/test_download_file.py::TestDownloadFile::test_download_file_no_content_disposition FAILED
tests/test_download_file.py::TestDownloadFileAsBytes::test_download_file_name_collision FAILED
========================= 2 failed, 15 passed in 0.13s =========================

1. test_download_file_no_content_disposition — регрессия старого теста

Этот тест защищал старый контракт: если нет Content-Disposition, имя считалось как f"file{ext}" (версия на main). Assert в тесте:

assert result.name.startswith("file")   # tests/test_download_file.py:108

В новой версии capture_filename для non-photo URL без CD берёт basename из URL:

parsed = urlparse(url)
name = unquote(parsed.path) or f"{datetime_str}{ext}"
filename = Path(name).name

Для URL из теста (https://example.com/img) это даёт filename = "img":

AssertionError: assert False
 +  where False = 'img'.startswith('file')

Что это значит по сути. PR меняет контракт download_file (дефолтное имя), но старый тест, защищавший этот контракт, не обновлён. Нужно либо сохранить прежний дефолт file{ext} (и тогда PR снова становится чисто аддитивным — см. §«Архитектурное предложение»), либо явно переписать тест и зафиксировать breaking change в CHANGELOG/README.

2. test_download_file_name_collision — тест не воспроизводит коллизию

existing = tmp_dir / "240414_120000.tmp"
existing.write_bytes(b"old")
mock_response = _make_mock_response(chunks=[b"new"])    # ← cd_filename=None
mock_session.request = AsyncMock(return_value=mock_response)
result = await bot.download_file(url="https://example.com/file", destination=tmp_dir)
assert result.name.endswith("_1.tmp")

Фикстура хардкодит 240414_120000.tmp, но внутри download_file имя считается как datetime.now().strftime('%y%m%d_%H%M%S') — это текущий момент. При cd_filename=None и URL .../file (parsed.path = "/file") получаем filename = "file". Коллизии с заранее созданным 240414_120000.tmp нет, check_exists(dest/"file") возвращает исходный путь, файл сохраняется как file:

AssertionError: assert False
 +  where False = 'file'.endswith('_1.tmp')

Минимальный фикс — замокать datetime.now():

from unittest.mock import patch

with patch("maxapi.connection.base.datetime") as mock_dt:
    mock_dt.now.return_value.strftime.return_value = "240414_120000"
    ...

или сделать так, чтобы существующий файл совпал с именем, которое сгенерирует код (например cd_filename="file.tmp" + заранее созданный tmp_dir / "file.tmp").


🟠 Логические баги

3. check_exists — дыра при разрывах нумерации (base.py:397–405)

existing = glob(pattern)
num = len(existing) + 1
path = dest / f'{fname}_{num}{ext}'

Если в каталоге есть file_1.tmp и file_3.tmp (нет _2), то len(existing) == 2num = 3 → новый путь совпадает с уже существующим file_3.tmp. Воспроизвёл локально — коллизия.

Варианты фикса (от простого к надёжному):

# A — максимум существующих + 1
import re
pat = re.compile(rf'{re.escape(fname)}_(\d+){re.escape(ext)}$')
nums = [int(m.group(1)) for p in existing if (m := pat.search(p))]
num = (max(nums) + 1) if nums else 1

# B — атомарный к локальным файлам цикл
num = 1
while (dest / f'{fname}_{num}{ext}').exists():
    num += 1

Вариант B заодно убирает glob — сейчас это синхронный вызов в async-функции (base.py:412), который блокирует event loop при большом каталоге.

4. Двойное URL-decoding — эвристика почти никогда не срабатывает там, где нужна

if filename.count('%') >= 2:
    filename = unquote(filename)

В URL-ветке name = unquote(parsed.path) уже выполнен выше, поэтому после первого прохода % в имени либо нет, либо остался один от literal-%. Реально double-encoded имя test%2520file.pdf после первого unquote становится test%20file.pdf (один %) — условие >= 2 не срабатывает, декодирование не произойдёт.

Зато условие триггерится на именах с литеральными %: напр. a%b%c.pdf (ни одной валидной %XX-последовательности) — unquote дёргается впустую.

Предложение: убрать этот блок совсем. Один unquote по parsed.path покрывает стандартный случай. Если двойное кодирование от серверов MAX реально наблюдается — добавить отдельный флаг decode_twice: bool = False или явную проверку if '%25' in parsed.path, а не по count('%').

5. Race между двумя check_exists и лишний rename (base.py:409, 437–438)

temp_path = check_exists(dest / filename)           # filename = дефолтный
# ... скачали весь файл в temp_path ...
final_path = check_exists(dest / filename)          # filename мог измениться
if final_path != temp_path:
    temp_path.replace(final_path)

Между двумя check_exists проходит всё время загрузки. Если параллельно кто-то создаст/удалит файлы в dest, второй check_exists выдаст другой номер. На Windows rename через границы FS — лишний syscall. Чище: вычислить финальное имя внутри on_response (до начала записи — первый чанк идёт после колбэка) и открывать aiofiles.open(final_path, ...) один раз.


🟡 Типизация и стиль

6. Optional / Callable не импортированы (base.py:308)

on_response: Optional[Callable[[ClientResponse], None]] = None,

В рантайме спасает только from __future__ import annotations (аннотации не вычисляются). Для type-checkers и читаемости стоит добавить явно:

from typing import TYPE_CHECKING, Any, AsyncIterator, Optional, Callable

7. except Exception слишком широк (base.py:427)

Внутри capture_filename реально возможны только AttributeError, TypeError, ValueError (от response.content_disposition, Path(...).name, mimetypes, urlparse, unquote). Логичнее:

except (AttributeError, TypeError, ValueError) as e:

8. download_file потерял секцию Raises в docstring

Было:

Raises:
    DownloadFileError: При ошибке скачивания.

В PR секции нет. Верните её, плюс опишите новое поведение имён (CD → basename URL → image_<datetime> для i.oneme.ru<datetime>.tmp).

9. Trailing whitespace и отсутствие финального newline

grep ' +$' maxapi/connection/base.py даёт пробелы в конце строк 312, 317, 320, 359, 445 (в том числе внутри новых docstring-ов). Плюс tests/test_download_file.py оканчивается без \n (\ No newline at end of file в дифе). Вернёт линтеры.

10. on_response задекларирован как sync, но без явной пометки

Тип Callable[[ClientResponse], None] подразумевает синхронный колбэк, но в ревью легко передать async def и получить молчаливое «coroutine was never awaited». Для приватного помощника достаточно одной строки в docstring: «должна быть синхронной». Альтернатива — rigor-check:

if on_response is not None:
    result = on_response(response)
    if inspect.iscoroutine(result):
        raise TypeError("on_response must be synchronous")

🟡 Расхождение описания PR и поведения

  • Про .webp. Описание обещает image_YYMMDD_HHMMSS.webp, но для i.oneme.ru расширение берётся из Content-Type (ваш же тест test_download_file_photo_correct_extension проверяет именно .png). Поправьте описание: image_YYMMDD_HHMMSS.<ext-из-Content-Type>.
  • Про обратную совместимость. Сигнатура download_file — да, не менялась. Но поведение по умолчанию стало другим: раньше file{ext}, теперь URL-basename (или <datetime>.tmp). Для пользователей dev-ветки / автотестов — это наблюдаемое изменение (см. блокер №1). Стабильный релиз v1.0.0 не пострадает — download_file был добавлен в main уже после v1.0.0 (PR feat: добавлен метод download_file для скачивания файлов #96) и в релиз ещё не уезжал; но пользователи main и интеграционные тесты пострадают.

🟢 Архитектурное предложение: разделить PR

PR фактически делает две независимые задачи в одном коммите. Предлагаю развести:

  1. Этот PR (feat-only, аддитивный):

    • новый _fetch_content_stream (общий retry/backoff-генератор);
    • новый download_file_as_bytes;
    • download_file рефакторится минимально: вызывает _fetch_content_stream, но дефолт имени остаётся file{ext}, без check_exists, без image_<datetime>, без double-unquote;
    • старые тесты TestDownloadFile не меняются и проходят;
    • новые тесты только для download_file_as_bytes.
  2. Отдельный PR (feat!: smarter filename):

    • basename из URL + unquote;
    • image_<datetime>.<ext> для i.oneme.ru;
    • защита от коллизий (с фикшенным check_exists);
    • запись в CHANGELOG и миграционный пункт в README.

Это упростит ревью, снизит риск регрессий и разметит ответственность в git-истории. Если разделять неудобно — хотя бы явно перечислите breaking changes в описании и приведите таблицу «было → стало» для имён файлов.


Покрытие (ориентир на 100% patch от codecov)

Не покрыто новыми тестами:

  • except Exception в capture_filename — нужен сценарий, где response.content_disposition бросает.
  • Ветка filename.count('%') >= 2 — если оставляете логику, нужен тест с именем %2520….
  • Content-Disposition с path traversal — test_download_file_path_traversal_protection покрывает CD-ветку, но не URL-ветку (parsed.path = "/../etc/passwd").
  • Ветка i.oneme.ru с пустым Content-Type — fallback image_<datetime> без расширения.
  • Явный тест на on_response=None у _fetch_content_stream (сейчас вызывается только через download_file).
  • Ветка «второй check_exists даёт имя отличное от temp_path» (реальный rename) — скорее всего уйдёт сама после §5.

TL;DR — что сделать

  1. Починить два падающих теста — ИЛИ убрать breaking-часть и оставить PR чисто аддитивным (см. архитектурное предложение).
  2. Починить check_exists (max(nums)+1 или while-loop, заодно без glob).
  3. Убрать/пересмотреть эвристику count('%') >= 2.
  4. Импорт Optional, Callable; сузить except Exception; вернуть Raises: в docstring; прибрать trailing whitespace и финальный newline.
  5. Актуализировать описание PR (про .webp и про breaking change).
  6. Добавить тесты на непокрытые ветки.

Ещё раз — идея фичи правильная, _fetch_content_stream — аккуратное разделение транспорта и consumer'ов, спасибо. Готов перепроверить после правок.

@Pankovea
Copy link
Copy Markdown
Author

Pankovea commented Apr 14, 2026

Упс. Напортачил. Хотел сделать отдельный пулл-реквест. Попробую исправить

@bish-x
Copy link
Copy Markdown
Contributor

bish-x commented Apr 14, 2026

если нужно, могу исправить

@Pankovea
Copy link
Copy Markdown
Author

Вернул как было.

@Pankovea
Copy link
Copy Markdown
Author

Постарался подбить неисправности.

@bish-x
Copy link
Copy Markdown
Contributor

bish-x commented Apr 15, 2026

Спасибо за правки, часть пунктов закрыта. Давай по итогам второго прохода по существу.

✅ Что исправлено корректно

  • check_exists переписан на max(num) + 1 по regex ^{fname}\((\d+)\){ext}$ — это правильный фикс, дыра с разрывами нумерации (file(1).tmp + file(3).tmpfile(4).tmp) закрыта. Мой прошлый пункт №3 снимаю — алгоритм сейчас корректный (base.py:404-425).
  • Двойной check_exists + лишний rename убраны, остался один проход (base.py:477-480).
  • Эвристика count('%') >= 2 заменена на re.search(r'%[0-9A-Fa-f]{2}', filename) — теперь триггерится на реально закодированные %XX, а не на литеральные % (base.py:446).
  • Импорты Optional, Callable добавлены (base.py:6).

🔴 Блокер 1: ветка отстала от upstream и при мёрже откатит чужие изменения

PR-ветка (Pankovea:main) базируется на 17e21e9 (PR #110). Upstream после этого ушёл на 552ca45 (PR #94 — надёжность загрузки файлов) и 691e5d2 (bump 1.0.0). Поскольку head PR-а — это main твоего форка, при мёрже этот PR физически удалит чужие изменения.

Фактический out-of-scope diff (git diff --stat origin/main..HEAD):

Файл Что будет при мёрже
tests/test_upload_file.py удалён целиком (−172 строки из PR #94)
tests/test_types.py −34 строки: удалены test_get_ids_ignores_inviter_id, test_get_ids_ignores_admin_id
maxapi/methods/send_message.py:158 self.format.valueself.format (реверт коммита 0e8fefe)
maxapi/methods/edit_message.py то же изменение
pyproject.toml:3 version = "1.0.0""0.9.18" (откат релиза)

Это не злой умысел — это следствие того, что PR сделан из самого main форка, а он просто отстал. Фикс:

git remote add upstream https://github.com/love-apples/maxapi.git   # если ещё нет
git fetch upstream
git checkout main
git rebase upstream/main                # или git merge upstream/main
git push --force-with-lease origin main

После этого out-of-scope-шум исчезнет, и можно будет смотреть дельту download_file_as_bytes чисто.

🔴 Блокер 2: test_download_file_no_content_disposition по-прежнему красный

tests/test_download_file.py::TestDownloadFile::test_download_file_no_content_disposition FAILED
========================= 1 failed, 16 passed in 0.12s =========================

Assert обновили с startswith("file") на startswith(<datetime>), но код для URL https://example.com/img без Content-Disposition всё ещё возвращает img.jpg: parsed.path = "/img"filename = "img"ext = ".jpg" из mime → filename = "img.jpg". Ветка с datetime (base.py:463-475) срабатывает только когда filename is None, а она тут не None.

Нужно согласовать контракт. Вариант, который я бы оставил: приоритет URL-basename — это ок и ближе к пользовательскому ожиданию, просто assert переписать:

async def test_download_file_no_content_disposition(self, bot, tmp_dir, mock_session):
    """Без Content-Disposition имя генерируется из basename URL + mime-ext."""
    mock_response = _make_mock_response(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", destination=tmp_dir)
    assert result.name == "img.jpg"
    assert result.parent == tmp_dir

И тогда стоит добавить отдельный тест на datetime-fallback — когда URL без внятного path, например https://example.com/ + пустой CD: имя должно быть <datetime>.bin (покрытие ветки в base.py:468-474).

🟠 Ещё не исправлено (мелочи, чтобы не возвращаться)

  • Raises: в docstring download_file (base.py:375-396) — секция по-прежнему отсутствует. Добавь описание DownloadFileError + поведение дефолтных имён.
  • except Exception в двух местах:
    • base.py:450 (внутри capture_filename) — сузить до (AttributeError, TypeError, ValueError). Реально только они возможны от response.content_disposition, Path(...).name, mimetypes, urlparse, unquote.
    • base.py:276 (внутри upload_file_buffer) — сузить до (OSError, ValueError).
  • Trailing whitespace на строках 313, 318, 321, 360, 404, 425, 461, 480 в maxapi/connection/base.py — линтеры вернут.
  • Нет финального newline в tests/test_download_file.py (tail -c1 = ), не \n).
  • on_response — синхронный колбэк. В docstring _fetch_content_stream (base.py:311-324) явно не сказано, что он должен быть sync. Легко передать async def и получить тихое «coroutine was never awaited». Одна строка в docstring достаточна, либо runtime-проверка inspect.iscoroutine(result).

🟢 Что снято из первого ревью

  • Пункт №3 (check_exists gap) — снят, см. выше.
  • Пункт №5 (race + лишний rename) — снят.
  • Пункт №4 (count('%')>=2) — теперь это nit, можно оставить как есть.

Короткий чек-лист

  1. Ребейз на upstream/main + force-push.
  2. Починить test_download_file_no_content_disposition (или обновить assert под URL-basename, или изменить логику кода — нужно определиться с контрактом).
  3. Добавить тест на datetime-fallback (URL без path + без CD).
  4. Докинуть Raises: в docstring, сузить except Exception, прибрать whitespace/newline, пометить on_response как sync-only.

Если что-то из этого неудобно или не хочется — скажи, я сам могу закоммитить в твою ветку (или прислать диф отдельно). Не хочу, чтобы объём замечаний блокировал PR — по сути осталось немного.

@Pankovea Pankovea force-pushed the main branch 2 times, most recently from d5a4e86 to 527583f Compare April 15, 2026 21:19
@Pankovea
Copy link
Copy Markdown
Author

Кажется всё внёс. Тесты проходят.
Сделал ребейз чтобы ничего не затиралось.

@Pankovea
Copy link
Copy Markdown
Author

Вот такое предложение как вариант.
Не использовать коллбэк. Это сложно.
Я предлагаю передавать словарь и из него извлекать данные.
Теперь можно в download_file_as_bytes тоже получать имя файла
Но может тогда не bytes, а BytesIO. И у него тогда прописать bio.name
Чтобы не возвращать кортеж, а обойтись одним объектом

@Pankovea
Copy link
Copy Markdown
Author

Кажется теперь идеально. Жду ревью.
Спасибо за поддержку.

@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 17, 2026

Codecov Report

❌ Patch coverage is 99.24242% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
maxapi/connection/base.py 99.23% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@Pankovea
Copy link
Copy Markdown
Author

Pankovea commented Apr 17, 2026

Смотрю ruff правки. Сделаю.
А вот это не понятно.

❌ Patch coverage is 88.42105% with 11 lines

@love-apples
Copy link
Copy Markdown
Owner

@Pankovea это процент покрытия тестами

@Pankovea
Copy link
Copy Markdown
Author

В смысле не понятно что с этим делать. Неужели 100% должно быть. Я думаю, что 100 это скорее вред, чем польза. Тесты они должны быть не для галочки и как можно более высокоуровневыми, чтобы не закреплять реализацию.
Ну, например, проверять текст ошибки как-то не правильно. Нужно только тип проверять.
Вот там он предлагает сделать проверку на тип ошибки OSError. Что там тестировать? Я вот этого не понимаю.

@bish-x
Copy link
Copy Markdown
Contributor

bish-x commented Apr 18, 2026

@Pankovea да, я говорил об этом) нужно 100% покрытия для любого нового кода. Это потенциальная регрессия в будущем, если упустить сейчас

@Pankovea Pankovea deleted the branch love-apples:main April 18, 2026 22:01
@Pankovea Pankovea closed this Apr 18, 2026
@Pankovea Pankovea deleted the main branch April 18, 2026 22:01
@Pankovea Pankovea restored the main branch April 18, 2026 22:04
@Pankovea Pankovea reopened this Apr 18, 2026
@Pankovea
Copy link
Copy Markdown
Author

@bish-x Кажется разобрался в чём дело. Не очень понимал эти покрытия.
Всё добавил. Должно теперь мои новые функции покрывать.

@Pankovea
Copy link
Copy Markdown
Author

И не разобрался как у меня в форке переименовать ветку main -> feat, чтобы PR не пересоздавать.

@Pankovea
Copy link
Copy Markdown
Author

Может быть логичным продолжением этой ветки будет внедрение высокоуровнего метода в сообщения?
Message.attachments.download(destination) -> list[Path]
Message.attachments.download_as_bytes() -> list[BinaryIO]

Метод обнаруживает все файловые вложения (фото и документы) в сообщении и скачивает их.

Можно еще для получения списка вложений такие фильтрационные методы:

Message.attachments.get_images()
... get_files()
Или даже их оформить как пропертис:
Message.attachments.images
... files
Которые будут возвращать list[str] список ссылок для скачивания.

Такие же проперти сделать для получения кнопок: messge.attachments.buttons
Так можно будет легко определять:
if files:=messge.attachments.files:
await message.reply(f"Вы прислали {len(files)}")

Или вот случай: нужно отредактировать сообщение с вложениями. Вложения содержат кнопки и картинку. Надо заменить картинку, но оставить кнопки. Если мы отправим attachments с картинкой, но без кнопок, то кнопки потеряются.
await message.edit("Новое фото", attachments=new_image_list + message.attachments.buttons)

Как вам идеи?

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

Добавляет возможность скачивать файлы в оперативную память и рефакторит логику скачивания/определения имени файла в BaseConnection, чтобы переиспользовать общую retry/error-handling логику.

Changes:

  • Вынесена общая логика скачивания в асинхронный генератор _fetch_content_stream() с retry/backoff.
  • Обновлена логика определения имени файла (_capture_filename) и добавлена защита от коллизий имён (_check_file_exists) при сохранении на диск.
  • Добавлен новый публичный метод download_file_as_bytes() и расширены тесты для новых/изменённых сценариев.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 9 comments.

File Description
maxapi/connection/base.py Рефакторинг скачивания, определение имени файла, защита от коллизий, добавление download_file_as_bytes()
tests/test_download_file.py Новые тесты для in-memory скачивания и расширение покрытия логики имени файла/коллизий

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

Comment thread maxapi/connection/base.py Outdated
filename = response["filename"]
final_path = self._check_file_exists(dest / filename)
if final_path != temp_path:
temp_path.replace(final_path)
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

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

download_file() использует синхронный Path.replace() для финального перемещения файла. Это выполняет блокирующую файловую операцию в event loop. Лучше использовать await aiofiles.os.replace(...) (или вынести в asyncio.to_thread) для более корректного async-поведения.

Suggested change
temp_path.replace(final_path)
await aiofiles.os.replace(temp_path, final_path)

Copilot uses AI. Check for mistakes.
Comment thread maxapi/connection/base.py Outdated
Comment on lines +542 to +548
async def download_file_as_bytes(
self,
url: str,
*,
chunk_size: int = DOWNLOAD_CHUNK_SIZE,
) -> BinaryIO:
"""
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

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

download_file_as_bytes() по названию и описанию PR заявлен как возвращающий bytes, но сейчас возвращается NamedBytesIO/BinaryIO. Это создаёт путаницу для пользователей и типизации. Либо верните именно bytes (и при необходимости добавьте отдельный способ получить имя файла), либо переименуйте метод/докстринг так, чтобы он явно возвращал file-like объект.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

думаю лучше второе:
download_as_file_like()
или просто
download_file_like()

А может просто вот так дописать:
download_as_bytes_io()
или короче
download_bytes_io()

Последний вариант нравится.

Comment thread tests/test_download_file.py Outdated
Comment on lines +456 to +460
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(
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

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

В тестах есть нарушения форматирования/линтинга, которые, судя по конфигу, будут ловиться ruff format/ruff check: например, отсутствуют пробелы вокруг = в присваиваниях (url="..."), и присутствует сверхдлинная строка URL (будет триггерить E501). Приведите файл к формату Ruff (и разбейте длинные литералы на несколько строк).

Copilot uses AI. Check for mistakes.
Comment thread maxapi/connection/base.py Outdated
Comment on lines +448 to +453
"""Проверяет, если файл существует, то возвращает новый свободный путь для сохранения
Windows style:
- file_name.ext
- file_name(2).ext
- file_name(3).ext

Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

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

Описание в PR говорит про суффиксы _1, _2 и fallback-расширение .tmp, но текущая реализация/тесты используют Windows-стиль (... ) начиная с (2) и fallback .bin. Если это ожидаемое поведение, стоит обновить PR description/публичную документацию, чтобы не было расхождений для пользователей.

Copilot uses AI. Check for mistakes.
Comment thread maxapi/connection/base.py
Comment on lines 376 to 379
if not response.ok:
raise DownloadFileError(
f"Ошибка при скачивании файла: HTTP {response.status}"
)
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

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

В _fetch_content_stream() при not response.ok выбрасывается DownloadFileError, но ответ не освобождается (release()/close() не вызываются). Это может оставить соединение занятым в пуле aiohttp. Освобождайте response перед raise (или оберните проверку статуса и дальнейшую логику в try/finally с await response.release()).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

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 Outdated
Comment on lines +381 to +385
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)}")
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

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

Если response_dict передан неверного типа, сейчас выбрасывается ValueError после получения response, но до входа в try/finally, поэтому response.release() не будет вызван. Проверьте тип response_dict до выполнения запроса или гарантируйте освобождение response в этом раннем выходе.

Copilot uses AI. Check for mistakes.
Comment thread maxapi/connection/base.py Outdated
Comment on lines +337 to +339
response_dict: Опциональный словарь в который будет сохраненs заголовки до начала чтения тела
и имя файла. Формат:
- response_dict['response']
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

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

Докстринг _fetch_content_stream() расходится с реализацией: в тексте указаны ключи response_dict['response'], а код пишет resp; также есть опечатка "сохраненs". Стоит синхронизировать названия ключей/описание, чтобы не вводить в заблуждение пользователей внутреннего API.

Suggested change
response_dict: Опциональный словарь в который будет сохраненs заголовки до начала чтения тела
и имя файла. Формат:
- response_dict['response']
response_dict: Опциональный словарь, в который будут сохранены объект ответа
до начала чтения тела и имя файла. Формат:
- response_dict['resp']

Copilot uses AI. Check for mistakes.
Comment thread maxapi/connection/base.py Outdated
filename = f"image_{datetime_str}{ext}"

except (AttributeError, TypeError, ValueError) as e:
logger_bot.warning("Не удалось определить имя файла из заголовков: %s", e)
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

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

_capture_filename() в except возвращает пустую строку. В таком случае download_file() дальше делает dest / filename, что превращается в директорию и может привести к некорректному final_path/перезаписи. Лучше гарантировать безопасный fallback-результат (например, timestamp/uuid + расширение) даже при ошибке парсинга.

Suggested change
logger_bot.warning("Не удалось определить имя файла из заголовков: %s", e)
logger_bot.warning("Не удалось определить имя файла из заголовков: %s", e)
filename = f"{uuid.uuid4().hex}.bin"

Copilot uses AI. Check for mistakes.
Comment thread maxapi/connection/base.py Outdated
Comment on lines +465 to +486
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

Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

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

_check_file_exists() при коллизии синхронно сканирует всю директорию через dest.iterdir(). В больших каталогах это может заметно тормозить и блокировать event loop. Рассмотрите более дешёвую стратегию (инкрементально проверять path.with_name(...) в цикле, либо выполнять сканирование/iterdir() в отдельном потоке).

Suggested change
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
if not path.exists():
return path
dest = path.parent
fname, ext = path.stem, path.suffix
next_num = 2
while True:
candidate = dest / f"{fname}({next_num}){ext}"
if not candidate.exists():
return candidate
next_num += 1

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Я уже советовался с нейронкой по этому поводу. И получил обратную рекомендацию:
Мол того тут один запрос в файловую систему - список получен, потом его можно быстро пробежаться.

Тут предлагается для каждого случая запрашивать существование. У палки два конца. Удачный метод будет зависеть от случая.
Если в папке множество файлов и 1 дубликат
или же если множество дубликатов и мало файлов.

Какое событие менее вероятно?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Специально протестировал оба метода. на реальных данных. Вот итог анализа данных ИИ:

dest.iterdir() выполняет один системный вызов (getdents64/readdir), после чего вся обработка (парсинг имён, regex, сравнение) происходит в userspace. Альтернативный подход с path.exists() в цикле делает N отдельных вызовов statx().

Чтобы перевести дискуссию из теоретической плоскости в практическую, мы провели нагрузочный тест (50 000 итераций на метод) в двух режимах:

  • ✅ С активным кэшем ОС (реальный production-воркфлоу)
  • ✅ С принудительным сбросом кэша (drop_caches, worst-case сценарий)

📊 Результаты бенчмарка (TrueNAS / ZFS / Linux)

Метод CPU / вызов I/O Wait / вызов Wall Time / вызов I/O%
iterdir() ~23.8 мкс ~0.3 мкс ~24.1 мкс ~1.2%
incremental() ~23.5 мкс ~0.4 мкс ~23.9 мкс ~1.5%

🔍 Выводы

  1. Разница < 2 микросекунд на вызов. Это находится в пределах погрешности системного таймера и джиттера планировщика ОС.
  2. Даже в режиме «холодного» кэша I/O-ожидание не превышает 3 мкс, так как метаданные быстро подтягиваются в RAM (ZFS ARC + Linux page cache).
  3. Нагрузка на CPU и диск практически идентична для обоих подходов.

✅ Почему оставляем iterdir()

  • 📖 Читаемость: логика «найти максимальный номер за один проход» очевидна и не требует дополнительных комментариев.
  • 🔒 Предсказуемость: всегда O(N) без скрытых циклов. Время выполнения не зависит от количества коллизий или разрывов в нумерации.
  • 🛡️ Устойчивость: если в папке остались только file(5).ext и file(42).ext, iterdir() сразу вернёт 43, тогда как инкрементальный подход сделает 37 лишних statx() вызовов впустую.

💡 Бонус-оптимизация: @lru_cache для компиляции регулярного выражения даёт ~10–15% выигрыша при частых вызовах с одинаковыми расширениями, не усложняя логику.

from functools import lru_cache

@lru_cache(maxsize=64)
def _collision_pattern(fname: str, ext: str) -> re.Pattern:
    """Кэшируем компиляцию регулярок для ускорения."""
    return re.compile(rf"^{re.escape(fname)}\((\d+)\){re.escape(ext)}$")

Полные скрипты бенчмарка и сырые логи могу приложить по запросу.

@Pankovea
Copy link
Copy Markdown
Author

В общем можно бесконечно допиливать.
Но некоторые моменты действительно стоит определить до того как всё это пойдёт в работу, например название метода и возвращаемый тип.
Это только у меня столько косяков или так у многих?

@Olegt0rr
Copy link
Copy Markdown
Collaborator

В общем можно бесконечно допиливать. Но некоторые моменты действительно стоит определить до того как всё это пойдёт в работу, например название метода и возвращаемый тип. Это только у меня столько косяков или так у многих?

У кого как. Зависит от уровня. Большинство ошибок можно локально закрыть, запуская тесты и линтеры. Другую часть ошибок можно не допускать, когда уже был опыт их ловли где-нибудь в боевых проектах.

В целом - ничего страшного! Все ошибаются! В этом и есть развитие)

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.

Идея полезная, но реализация требует доработки — есть несколько реальных проблем.

  1. ResponseDict в TYPE_CHECKING — потенциальный NameError в рантайме
    ResponseDict определён внутри if TYPE_CHECKING:, то есть при запуске недоступен. Но он используется в сигнатуре _fetch_content_stream как аннотация параметра. Без from future import annotations Python вычисляет аннотации при объявлении — и получится NameError. Либо добавь from future import annotations в начало файла, либо вынеси ResponseDict за блок TYPE_CHECKING.

  2. Пустое имя файла не обрабатывается
    _capture_filename может вернуть пустую строку (например, когда URL — просто /, или при исключении). Тогда в download_file получается dest / "" == dest — в _check_file_exists передаётся директория вместо файла. Нужен фолбэк-имя.

  3. Временный файл не удаляется при ошибке
    Если _fetch_content_stream бросит исключение после создания temp_path, файл остаётся на диске. Нужен try/finally с удалением.

  4. Поле resp в TypedDict нигде не читается
    response_dict["resp"] = response устанавливается, но ни в download_file, ни в download_file_as_bytes не используется. Если не нужно — убрать, чтобы не путало.

  5. Моржовый оператор в _capture_filename
    if not ext and (ext := mimetypes.guess_extension(...)): — работает, но читается тяжеловато. Лучше обычным присваиванием на отдельной строке.

Тесты хорошие, особенно freeze_datetime-декоратор — аккуратно сделано. Но пока проблемы не поправлены, мёрджить рано.

@Pankovea
Copy link
Copy Markdown
Author

Pankovea commented Apr 23, 2026

Спасибо за комментарии. Есть дополнения относительно автоматизированных. Всё внёс кроме:

#112 (review)

4. response_dict["resp"] = response устанавливается, но ни в download_file, ни в download_file_as_bytes не используется.

Я его передал на всякий случай, если пользователю захочется самому посмотреть исходные заголовки.
А не полагаться на автоматику и fallback имя файла. Может ещё чего захочет узнать. В общем закладка на будущее.
Могу и убрать, тогда словарь будет нагружен только именем файла. И это будет использовано только для внутренней логики и никогда не выйдет к пользователю....
Хотя, я соглашусь, что т.к. метод _fetch_content_stream приватный, то он и предназначен для внутренней работы. Поэтому если понадобится просто потом добавим.
Принято. Спасибо ещё раз.

@Pankovea
Copy link
Copy Markdown
Author

Pankovea commented Apr 23, 2026

В общем произвёл рефакторинг и избавился от response_dict совсем.
В итоге более чистая архитектура.
Разделённая ответственность методов.
При сохранении файла не создаются временные файлы.

Это для меня уникальный опыт. С такими требованиями я ещё не сталкивался. Трудно пришлось, но результатом доволен. Кажется теперь, если не обнаружатся ошибки, то всё готово для мерджа.

Там upstream опять ускакал на пару коммитов. Нужно ли мне делать rebase?
Или если не затронуты редактируемые файлы, то не обязательно.

И нужно ли мне изменить описание методов в шапке PR?

@Pankovea
Copy link
Copy Markdown
Author

Pankovea commented Apr 23, 2026

Произвёл rebase на upstream/main
Не пойму почему у меня локально не было конфликтов
а здесь пишется This branch has conflicts that must be resolved

UPD: Ага понял. Не сделал FETCH
Свожу ...

- Добавлен метод download_file_as_bytes() для скачивания файлов в память
- Рефакторинг _fetch_content_stream: добавлен коллбек on_response для извлечения заголовков
- Исправлено определение имени файла в download_file: приоритет Content-Disposition → парсинг URL → fallback
- Реализована защита от коллизий имён: таймстемп + нумерация при конфликте
- Добавлена защита от path traversal через Path(filename).name
- Добавлены юнит-тесты для download_file_as_bytes
Избавились от коллбэка on_response.
Теперь просто передаём словарь
и получаем значения из него через .get('filename')

На свякий случай тудаже записываtncz сам Respone
Таким образом мы заранее не создаём
дополнительны объект bytes,
а продолжаем хранить сырые чанки.
Это экономнее по памяти и белее ассинхронно

Так же в BytesIO.name хранится имя файла,
что более удобно и типобезопасно чем работа возврат кортежа.
Создан наследник с поддержкой атрибута name
Иногда тесты падали, потому что запускались на границе секунды.
То есть в процессе теста менялось время и ожидаемое имя файла,
основанное на текущем времени.
Теперь время для тестов зафиксированно декоратором freeze_datetime

fix: ruff downlad_as_bytes, tests
для получения заголовков

В итоге более чистая архитектура.
Разделённая ответственность  методов.
При сохранении файла не создаются временные файлы.

переименован метод download_file_as_bytes. Теперь:
download_bytes_io -> NamedBytesIO
add: download_bytes -> bytes
теперь не нужен response_dict.

* ruff правки
* _capture_filename возвращает вуафгде значение (не пустую строку)
* tests 100% coverage
remove: ResponseDict

ruff
@Pankovea
Copy link
Copy Markdown
Author

Описание PR обновлено

…нным именем файла

если в переданном пути destination не можержится имени файла,
то будет использовано имя файла от сервера или по умочанию
@Pankovea
Copy link
Copy Markdown
Author

Кто-нибудь подскажите. А зачем вообще мы дублируем метод
download_file в классе class Bot(BaseConnection)
ведь в нём никакой полезной нагрузки. Пусть просто наследуется от BaseConnection и там внутри ве нужные мотоды есть: download_file, download_bytes_io, download_bytes.
Либо надо все три метода пробрасывать ещё наружу. Только не понятно зачем... Документация?

@Olegt0rr
Copy link
Copy Markdown
Collaborator

Произвёл rebase на upstream/main Не пойму почему у меня локально не было конфликтов а здесь пишется This branch has conflicts that must be resolved

UPD: Ага понял. Не сделал FETCH Свожу ...

Делать рибейзы с форс-пушем - плохая практика.
Держите main форка синхронизированным с орининалом и не делайте в него коммиты
От него делайте фича-ветку и ещё отправляйте на PR. Если она будет влита - она же к вам приедете при синхронизации форка.

@Pankovea
Copy link
Copy Markdown
Author

Pankovea commented Apr 24, 2026

Делать рибейзы с форс-пушем - плохая практика.

Я сначала поторопился, открыл PR. А потом уже понял, что надо передалиь, но не получилось. При переименовывании ветки PR закрывался.
В общем следующий PR я сделал как надо.
Сейчас это критично настолько чтобы пересоздать PR?

@Pankovea Pankovea changed the title feat: добавлен метод download_file_as_bytes feat: добавлен метод download_bytes_io, download_bytes Apr 24, 2026
audio, image, sticker, file, video

Именование скаченных stickers по данным из ссылки smileId
Именование скаченных images по данным из ссылки image_token
fallback на datetime_str в случае неудачи определения имени.
@Pankovea
Copy link
Copy Markdown
Author

Pankovea commented Apr 26, 2026

Именование скаченных images по данным из ссылки image_token
Провёл целое исследование.
Image_token в ссылках вида https://i.oneme.ru/i?r=...
можно декодировать как base64url
Он содержит в себе статичные элементы для одного бота:
18 байт - статичный head
16 байт - уникальны для картинки
16 байт - статичный tail
Всего: 50 байт

Более того если отправлять одну и туже картинку, то она получит разные token и photo_id, но одну и туже ссылку для скачивания. Это очень важно. Таким образом мы сможем видеть что мы скачиваем ровно туже самую картинку по имени файла.

В итоге я извлекаю эти данные и использую для имени файла при сохранении картинки. Получается:
"image_1n-DnwjHYFhx5_EAhKk7Ng.webp"
То есть уже не так длинно и сохранено минимальное количество данных для идентификации.

Но есть подвох. Например ссылки на аватары пользователей.

  1. У них будут другие head и tail. Тоже статичные
  2. Они могут быть немного длиннее (+16 байт = 66 байт), чем картинки в чатах. Связь не ясна. Может быть и так и так. Но статичные 18 байт сначала и 16 байт в конце сохраняются
    Я решил пока так:
    Если токен 50 и более байт, то отрезаем первые 18 и последние 16 байт и получаем имя файла для картинки. Метод _get_image_id(r)
    во всех остальных случаях используем fallback datetime_str

У меня получилось три группы ссылок:
Пример (убрал персонализацию в данных троеточием):

  1. Avatars 50 байт
type head - 18 байт 16 байт tail - 16 байт
User1-A BTFjO43w8Yr...J4tcurq5Hi KPy_mE5g...GGlA9CBbXRw Y0qIRv13O8h...C_WepFfg
User1-B BTFjO43w8Yr...J4tcurq5Hi gUQ1d12-z...wismgfBvxw Y0qIRv13O8h...C_WepFfg
Bot-A BTFjO43w8Yr...J4tcurq5Hi t5TRmy45Rn9...2EDcmocw Y0qIRv13O8h...C_WepFfg
Bot-B BTFjO43w8Yr...J4tcurq5Hi O4Gu04kqxz...gj4xK7YeA Y0qIRv13O8h...C_WepFfg
User2-B BTFjO43w8Yr...J4tcurq5Hi 7-ZKHEL6z...UTaY2jXMZQ Y0qIRv13O8h...C_WepFfg
User3-B BTFjO43w8Yr...J4tcurq5Hi 4vNMlLr_3T...LW0LbmCSg Y0qIRv13O8h...C_WepFfg
  1. Avatars 66 байт
type head - 18 байт 16 байт 16 байт tail - 16 байт
User2-A BUFglOvkF6bn...5U-BFgIkJ K6mx6ae5...a8c66MUn6oQ eRIw8UAV...T0O9E_sLG3Q eOHXHS...KQWkkYj8WdErA
User3-A BUFglOvkF6bn...5U-BFgIkJ kt2V65MO...pjuClwjQ9Jw fr8Ad00v3m...2MdZTy9DA eOHXHS...KQWkkYj8WdErA
  1. В чатах 50 байт
type head - 18 байт 16 байт tail - 16 байт
Img User1 BTGBPUwtwgYU...hO7rESmr8 mvKfB1OJ...1QIk8Qfz6qhQ kHDFs9ZkyX...fCGetLzUA
Img Bot BTGBPUwtwgYU...hO7rESmr8 1n-DnwjHYFh...EAhKk7Ng kHDFs9ZkyX...fCGetLzUA
Img User2 BTGBPUwtwgYU...hO7rESmr8 fXkmJRqtUD...RRa9tagKQ kHDFs9ZkyX...fCGetLzUA
Img User2 BTGBPUwtwgYU...hO7rESmr8 gFrMph4z_I...OEWcSetgg kHDFs9ZkyX...fCGetLzUA

Comment thread maxapi/connection/base.py Outdated
) from e

if not response.ok:
await response.release()
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Вызывается await response.release(), но у настоящего aiohttp.ClientResponse метод release() синхронный

Copy link
Copy Markdown
Author

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 Outdated
raise DownloadFileError("response соединение закрыто")

if not response.ok:
await response.release()
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Вызывается await response.release(), но у настоящего aiohttp.ClientResponse метод release() синхронный

Copy link
Copy Markdown
Author

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 Outdated
async for chunk in response.content.iter_chunked(chunk_size):
yield chunk
finally:
await response.release()
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Вызывается await response.release(), но у настоящего aiohttp.ClientResponse метод release() синхронный

Copy link
Copy Markdown
Author

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 Outdated
# Определяем конечный путь для сохранения:
# - если destination имеет расширение (суффикс) → это имя файла
# - иначе → это директория, добавляем имя из ответа
if dest.suffix:
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Код определяет, файл это или директория, только по Path.suffix.

Это ломает валидные директории с точкой в имени, например downloads.v1: такой путь будет ошибочно считаться файлом. Если директория уже существует, файл может сохраниться рядом как downloads(2).v1; если не существует, код создаст файл вместо директории

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Исправил в следующих коммитах. Теперь передаём отдельно filename.

bot.download_file(url=..., destination=..., filename=...)

remove: дублирующий метод download_file в bot.py
@Pankovea Pankovea requested a review from love-apples April 29, 2026 08:32
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.

5 participants