"""Сохранение вложений Streamlit на диск не должно удваивать их в памяти. UploadedFile — это BytesIO: Streamlit уже держит всё содержимое вложения в памяти. getvalue() делает ещё одну полную копию каждого файла перед записью на диск; при 230 вложениях в контейнере на 1 ГБ это лишняя пиковая нагрузка. Копировать нужно потоково, через файловый интерфейс, не трогая getvalue(). Исключение вложений из обработки (без удаления из file_uploader) и сброс загрузчика — по file_id, потому что имена вложений могут совпадать. """ from __future__ import annotations import io from pathlib import Path from main import ( EXCLUDED_FILE_IDS_KEY, UPLOADER_GENERATION_KEY, _active_uploads, _clear_all_attachments, _exclude_checkbox_key, _exclude_selected, _init_attachment_state, _save_attachments, ) class _UploadWithoutGetValue(io.BytesIO): """Вложение, у которого есть только файловый интерфейс и имя.""" def __init__(self, name: str, payload: bytes) -> None: super().__init__(payload) self.name = name def getvalue(self) -> bytes: # type: ignore[override] raise AssertionError("getvalue() копирует всё вложение в память") def test_save_attachments_streams_without_getvalue(tmp_path: Path) -> None: uploads = [ _UploadWithoutGetValue("a.xml", b""), _UploadWithoutGetValue("a.xml", b"" * 1000), ] paths = _save_attachments(uploads, tmp_path) assert [path.name for path in paths] == ["a.xml", "a.xml"] assert paths[0] != paths[1] assert paths[0].read_bytes() == b"" assert paths[1].read_bytes() == b"" * 1000 def test_save_attachments_copies_from_start_even_if_upload_was_read( tmp_path: Path, ) -> None: upload = _UploadWithoutGetValue("f.xml", b"0123456789") upload.read(4) (path,) = _save_attachments([upload], tmp_path) assert path.read_bytes() == b"0123456789" class _Upload: """Минимальный UploadedFile: имя и file_id, по которому его отличаем.""" def __init__(self, name: str, file_id: str, size: int = 10) -> None: self.name = name self.file_id = file_id self.size = size def test_excluded_files_are_matched_by_file_id_not_by_name() -> None: first = _Upload("Файлы.zip", "id-1") second = _Upload("Файлы.zip", "id-2") other = _Upload("a.xml", "id-3") active = _active_uploads([first, second, other], {"id-2"}) assert active == [first, other] def test_exclude_selected_moves_checked_files_to_excluded() -> None: state: dict = {} _init_attachment_state(state) first = _Upload("Файлы.zip", "id-1") second = _Upload("Файлы.zip", "id-2") state[_exclude_checkbox_key(0, "id-2")] = True state[_exclude_checkbox_key(0, "id-1")] = False excluded = _exclude_selected(state, [first, second]) assert excluded == 1 assert state[EXCLUDED_FILE_IDS_KEY] == {"id-2"} assert _active_uploads([first, second], state[EXCLUDED_FILE_IDS_KEY]) == [first] def test_exclude_selected_keeps_previous_exclusions() -> None: state: dict = {EXCLUDED_FILE_IDS_KEY: {"id-1"}, UPLOADER_GENERATION_KEY: 2} state[_exclude_checkbox_key(2, "id-3")] = True # Отметка из прошлого поколения загрузчика не должна ничего исключать. state[_exclude_checkbox_key(1, "id-4")] = True excluded = _exclude_selected( state, [_Upload("c.xml", "id-3"), _Upload("d.xml", "id-4")] ) assert excluded == 1 assert state[EXCLUDED_FILE_IDS_KEY] == {"id-1", "id-3"} def test_exclude_selected_without_checked_files_changes_nothing() -> None: state: dict = {} _init_attachment_state(state) assert _exclude_selected(state, [_Upload("a.xml", "id-1")]) == 0 assert state[EXCLUDED_FILE_IDS_KEY] == set() def test_clear_all_resets_uploader_key_and_exclusions() -> None: state: dict = {} _init_attachment_state(state) state[EXCLUDED_FILE_IDS_KEY] = {"id-1", "id-2"} _clear_all_attachments(state) assert state[UPLOADER_GENERATION_KEY] == 1 assert state[EXCLUDED_FILE_IDS_KEY] == set() def test_init_attachment_state_keeps_existing_values() -> None: state: dict = {UPLOADER_GENERATION_KEY: 5, EXCLUDED_FILE_IDS_KEY: {"id-1"}} _init_attachment_state(state) assert state == {UPLOADER_GENERATION_KEY: 5, EXCLUDED_FILE_IDS_KEY: {"id-1"}}