Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion maxapi/connection/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ async def upload_file_buffer(
try:
matches = puremagic.magic_string(buffer[:4096])
if matches:
mime_type = matches[0][1]
mime_type = matches[0].mime_type
ext = mimetypes.guess_extension(mime_type) or ""
else:
mime_type = f"{type.value}/*"
Expand Down
125 changes: 66 additions & 59 deletions maxapi/types/input_media.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,53 @@

from ..enums.upload_type import UploadType

READ_FILE_CHUNK_SIZE = 4096


def detect_file_type(data: bytes) -> UploadType:
"""
Определяет тип файла на основе его содержимого (MIME-типа).

Args:
data (bytes): Буфер с содержимым файла.
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.

В detect_file_type докстринг описывает аргументы, но не документирует возвращаемое значение (и возможные исключения). В этом репозитории обычно указываются секции Returns: (и часто Raises:) — стоит добавить их, чтобы поведение функции было однозначно описано.

Suggested change
data (bytes): Буфер с содержимым файла.
data (bytes): Буфер с содержимым файла.
Returns:
UploadType: Определенный тип файла. Если MIME-тип не удалось
определить или при определении произошла ошибка, возвращается
``UploadType.FILE``.

Copilot uses AI. Check for mistakes.
Returns:
UploadType: Определенный тип файла. Если MIME-тип не удалось
определить или при определении произошла ошибка,
возвращается ``UploadType.FILE``.
"""
try:
matches = puremagic.magic_string(data)
if matches:
mime_type = matches[0].mime_type
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

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

puremagic.magic_string() results are treated as tuples elsewhere in the codebase (e.g. maxapi/connection/base.py:265-268 uses matches[0][1] for MIME). Here matches[0].mime_type will raise AttributeError when matches[0] is a tuple. Please extract MIME consistently (e.g. tuple indexing) or normalize the result before accessing it.

Suggested change
mime_type = matches[0].mime_type
match = matches[0]
if isinstance(match, (tuple, list)) and len(match) > 1:
mime_type = match[1]
else:
mime_type = getattr(match, "mime_type", None)

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.

Здесь matches - это list из namedtuple. Луче обратиться по атрибуту. Исправил поведение в maxapi/connection/base.py, там обращение в принципе было не к mime_type по индексу элемента в кортеже.

else:
mime_type = None
except Exception:
mime_type = None

if mime_type is None:
return UploadType.FILE
if mime_type.startswith("video/"):
return UploadType.VIDEO
elif mime_type.startswith("image/"):
return UploadType.IMAGE
elif mime_type.startswith("audio/"):
return UploadType.AUDIO
else:
return UploadType.FILE


def validate_uploading_type(type: UploadType | str) -> UploadType:
if not isinstance(type, UploadType):
try:
return UploadType(type)
except ValueError as e:
allowed = ", ".join(item.value for item in UploadType)
raise ValueError(
f"Неверный тип загружаемого файла: {type!r}. Ожидается: {allowed}" # noqa: E501
) from e

return type


class InputMedia:
"""
Expand All @@ -14,56 +61,32 @@ class InputMedia:
Attributes:
path (str): Путь к файлу.
type (UploadType): Тип файла, определенный на основе содержимого
(MIME-типа).
(MIME-типа) или указанный вручную.
"""

def __init__(self, path: str, type: UploadType | None = None):
def __init__(self, path: str, type: UploadType | str | None = None):
"""
Инициализирует объект медиафайла.

Args:
path (str): Путь к файлу.
type (UploadType, optional): Тип файла. Если не указан,
type (UploadType | str | None): Тип файла. Если не указан,
определяется автоматически.
"""

self.path = path
self.type = type or self.__detect_file_type(path)

def __detect_file_type(self, path: str) -> UploadType:
"""
Определяет тип файла на основе его содержимого (MIME-типа).

Args:
path (str): Путь к файлу.

Returns:
UploadType: Тип файла (VIDEO, IMAGE, AUDIO или FILE).
"""
if type is not None:
self.type = validate_uploading_type(type)
else:
self.type = detect_file_type(InputMedia._read_file_sample(path))
Comment on lines +67 to +82
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.

Добавлена новая функциональность: принимать type как строку и выбрасывать ValueError с перечислением допустимых значений. В тестах сейчас нет проверок этого сценария (особенно: строка валидная → корректный UploadType, строка невалидная → ожидаемое сообщение ошибки). Стоит добавить/расширить тесты для InputMedia и InputMediaBuffer, чтобы зафиксировать контракт.

Copilot uses AI. Check for mistakes.

@staticmethod
def _read_file_sample(
path: str, size: int = READ_FILE_CHUNK_SIZE
) -> bytes:
with Path(path).open("rb") as f:
sample = f.read(4096)

try:
matches = puremagic.magic_string(sample)
if matches:
mime_type = matches[0].mime_type
else:
mime_type = None
except Exception:
mime_type = None

if mime_type is None:
return UploadType.FILE

if mime_type.startswith("video/"):
return UploadType.VIDEO
elif mime_type.startswith("image/"):
return UploadType.IMAGE
elif mime_type.startswith("audio/"):
return UploadType.AUDIO
else:
return UploadType.FILE
return f.read(size)


class InputMediaBuffer:
Expand All @@ -72,14 +95,15 @@ class InputMediaBuffer:

Attributes:
buffer (bytes): Буфер с содержимым файла.
type (UploadType): Тип файла, определенный по содержимому.
type (UploadType): Тип файла, определенный на основе содержимого
(MIME-типа) или указанный вручную.
"""
Comment on lines 96 to 100
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

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

InputMediaBuffer.type is always set to an UploadType instance after initialization, but the docstring/types currently describe the attribute as UploadType | str | None. Consider documenting/annotating the attribute as UploadType (while keeping the constructor parameter as UploadType | str | None) so consumers and type-checkers don't assume str/None are possible post-init.

Copilot uses AI. Check for mistakes.

def __init__(
self,
buffer: bytes,
filename: str | None = None,
type: UploadType | None = None,
type: UploadType | str | None = None,
):
"""
Инициализирует объект медиафайла из буфера.
Expand All @@ -88,31 +112,14 @@ def __init__(
buffer (bytes): Буфер с содержимым файла.
filename (str, optional): Название файла (по умолчанию
присваивается uuid4).
type (UploadType, optional): Тип файла. Если не указан,
type (UploadType | str | None): Тип файла. Если не указан,
определяется автоматически.
"""

self.filename = filename
self.buffer = buffer
self.type = type or self.__detect_file_type(buffer)

def __detect_file_type(self, buffer: bytes) -> UploadType:
try:
matches = puremagic.magic_string(buffer)
if matches:
mime_type = matches[0].mime_type
else:
mime_type = None
except Exception:
mime_type = None

if mime_type is None:
return UploadType.FILE
if mime_type.startswith("video/"):
return UploadType.VIDEO
elif mime_type.startswith("image/"):
return UploadType.IMAGE
elif mime_type.startswith("audio/"):
return UploadType.AUDIO
if type is not None:
self.type = validate_uploading_type(type)
else:
return UploadType.FILE
self.type = detect_file_type(buffer)
186 changes: 186 additions & 0 deletions tests/test_upload_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from maxapi.client.default import DefaultConnectionProperties
from maxapi.connection.base import BaseConnection
from maxapi.enums.upload_type import UploadType
from maxapi.types.input_media import InputMedia, InputMediaBuffer


def _make_connection_with_bot(*, session=None):
Expand Down Expand Up @@ -170,3 +171,188 @@ async def test_uses_existing_session_when_open(self, tmp_path):

mock_cs_cls.assert_not_called()
mock_session.post.assert_awaited_once()


def assert_invalid_type_error(exc_info, invalid_value: str) -> None:
"""Проверяет текст ошибки для невалидного type."""
message = str(exc_info.value)
assert "Неверный тип загружаемого файла" in message
assert repr(invalid_value) in message
assert "file" in message
assert "image" in message
assert "video" in message
assert "audio" in message


class TestInputMediaTypeValidation:
"""Тесты валидации type в InputMedia."""

@pytest.mark.parametrize(
("value", "expected"),
[
("file", UploadType.FILE),
("image", UploadType.IMAGE),
("video", UploadType.VIDEO),
("audio", UploadType.AUDIO),
],
)
def test_accepts_valid_string_type(
self, tmp_path, monkeypatch, value, expected
):
"""Явно переданный строковый type валидируется без autodetect."""
test_file = tmp_path / "sample.bin"
test_file.write_bytes(b"fake-data")

mock_detect = Mock(return_value=UploadType.FILE)
monkeypatch.setattr(
"maxapi.types.input_media.detect_file_type",
mock_detect,
)

media = InputMedia(path=str(test_file), type=value)

assert media.path == str(test_file)
assert media.type == expected
mock_detect.assert_not_called()

def test_invalid_string_type_raises_value_error(self, tmp_path):
"""Невалидный строковый type вызывает ValueError со списком значений.""" # noqa: E501
test_file = tmp_path / "sample.bin"
test_file.write_bytes(b"fake-data")

with pytest.raises(ValueError) as exc_info: # noqa: PT011
InputMedia(path=str(test_file), type="document")

assert_invalid_type_error(exc_info, "document")

def test_none_type_detects_from_file(self, tmp_path, monkeypatch):
"""Если type не передан, тип определяется автоматически."""
test_file = tmp_path / "sample.bin"
test_file.write_bytes(b"fake-data")

mock_detect = Mock(return_value=UploadType.VIDEO)
monkeypatch.setattr(
"maxapi.types.input_media.detect_file_type",
mock_detect,
)

media = InputMedia(path=str(test_file))

assert media.path == str(test_file)
assert media.type == UploadType.VIDEO
mock_detect.assert_called_once()

def test_accepts_enum_type_without_autodetect(self, tmp_path, monkeypatch):
"""Явно переданный UploadType используется без autodetect."""
test_file = tmp_path / "sample.bin"
test_file.write_bytes(b"fake-data")

mock_detect = Mock(return_value=UploadType.FILE)
monkeypatch.setattr(
"maxapi.types.input_media.detect_file_type",
mock_detect,
)

media = InputMedia(path=str(test_file), type=UploadType.IMAGE)

assert media.type == UploadType.IMAGE
mock_detect.assert_not_called()


class TestInputMediaBufferTypeValidation:
"""Тесты валидации type в InputMediaBuffer."""

@pytest.mark.parametrize(
("value", "expected"),
[
("file", UploadType.FILE),
("image", UploadType.IMAGE),
("video", UploadType.VIDEO),
("audio", UploadType.AUDIO),
],
)
def test_accepts_valid_string_type(self, monkeypatch, value, expected):
"""Явно переданный строковый type валидируется без autodetect."""
mock_detect = Mock(return_value=UploadType.FILE)
monkeypatch.setattr(
"maxapi.types.input_media.detect_file_type",
mock_detect,
)

media = InputMediaBuffer(
buffer=b"fake-bytes",
filename="sample.bin",
type=value,
)

assert media.filename == "sample.bin"
assert media.buffer == b"fake-bytes"
assert media.type == expected
mock_detect.assert_not_called()

def test_invalid_string_type_raises_value_error(self):
"""Невалидный строковый type вызывает ValueError со списком значений.""" # noqa: E501
with pytest.raises(ValueError) as exc_info: # noqa: PT011
InputMediaBuffer(
buffer=b"fake-bytes",
filename="sample.bin",
type="document",
)

assert_invalid_type_error(exc_info, "document")

def test_none_type_detects_from_buffer(self, monkeypatch):
"""Если type не передан, тип определяется автоматически."""
mock_detect = Mock(return_value=UploadType.IMAGE)
monkeypatch.setattr(
"maxapi.types.input_media.detect_file_type",
mock_detect,
)

media = InputMediaBuffer(buffer=b"fake-bytes")

assert media.filename is None
assert media.buffer == b"fake-bytes"
assert media.type == UploadType.IMAGE
mock_detect.assert_called_once_with(b"fake-bytes")

def test_accepts_enum_type_without_autodetect(self, monkeypatch):
"""Явно переданный UploadType используется без autodetect."""
mock_detect = Mock(return_value=UploadType.FILE)
monkeypatch.setattr(
"maxapi.types.input_media.detect_file_type",
mock_detect,
)

media = InputMediaBuffer(
buffer=b"fake-bytes",
filename="sample.bin",
type=UploadType.AUDIO,
)

assert media.type == UploadType.AUDIO
mock_detect.assert_not_called()

def test_default_upload_type_input_media_buffer(self, tmp_path):
"""
Если mimetype не определился (None),
для файла должен вернуться тип UploadType.FILE
"""
media = InputMediaBuffer(
buffer=b"fake-bytes",
filename="sample.bin",
)

assert media.type == UploadType.FILE

def test_default_upload_type_input_media(self, tmp_path):
"""
Если mimetype не определился (None),
для файла должен вернуться тип UploadType.FILE
"""
test_file = tmp_path / "sample.bin"
test_file.write_bytes(b"fake-data")

media = InputMedia(path=test_file)

assert media.type == UploadType.FILE
Loading