From 721712d86a6d8f3dbb963c78098f1edd7517b86a Mon Sep 17 00:00:00 2001 From: Raykov-MS Date: Thu, 24 Sep 2026 16:57:26 +0300 Subject: [PATCH] Accept several XML files and archives as attachments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The uploader took a single .zip, so a day split across several archives, or a handful of loose XML files, needed one run each. - main.py and scripts/local_zip_test_app.py now use accept_multiple_files and take .xml alongside .zip and the tar family. Each attachment is written to its own temp subdirectory, because two attachments may share a name. - run_batch takes input_paths instead of zip_path. - New app/pipeline/attachments.py flattens the attachments into a list of XML files. Archive format is detected from the content rather than the suffix, since exports arrive with the wrong extension often enough. .rar and .7z are recognised only to explain why they are refused. - A broken attachment no longer aborts the run. It becomes one protocol row and the remaining attachments are still processed; the reason is also shown in the UI, not only in the protocol file. - Files repeated across attachments are processed once and the repeats are recorded with status "skip". Attaching the same archive twice would otherwise double every row of the report. - Archives are unpacked member by member with the target path checked against the extraction directory. tarfile.data_filter only exists from Python 3.11.4 and extractall would otherwise let a "../" member escape. - The protocol gains a "Вложение" column, so it is clear which attachment a file came from when names repeat across archives. Checked end to end on the 03.02.2023 export: six attachments (two archives, two loose XML files, one broken archive, one duplicate archive) give 57 files processed, 9 repeats skipped, one attachment refused with its reason, and the report is still produced. --- app/pipeline/attachments.py | 198 ++++++++++++++++++++++++++++++++ app/pipeline/protocol.py | 5 + app/pipeline/service.py | 113 ++++++++++++------ main.py | 35 ++++-- scripts/local_zip_test_app.py | 25 ++-- tests/smoke/test_batch_smoke.py | 149 ++++++++++++++++++++++-- tests/unit/test_attachments.py | 182 +++++++++++++++++++++++++++++ 7 files changed, 651 insertions(+), 56 deletions(-) create mode 100644 app/pipeline/attachments.py create mode 100644 tests/unit/test_attachments.py diff --git a/app/pipeline/attachments.py b/app/pipeline/attachments.py new file mode 100644 index 0000000..ad9b4be --- /dev/null +++ b/app/pipeline/attachments.py @@ -0,0 +1,198 @@ +"""Сбор XML-файлов из вложений интерфейса. + +Пользователь прикрепляет через file_uploader сразу несколько объектов: +отдельные .xml, архивы с выгрузками, или и то и другое вперемешку. Этот +модуль приводит их к плоскому списку XML-файлов на диске и отдельно +возвращает список вложений, которые обработать не удалось, — чтобы одно +битое вложение не отменяло весь запуск. + +Формат архива определяется по содержимому (zipfile/tarfile), а не по +расширению: выгрузки нередко приезжают с именем вида "Файлы" без суффикса +или с суффиксом, не совпадающим с фактическим форматом. +""" + +from __future__ import annotations + +import tarfile +import zipfile +from collections.abc import Sequence +from dataclasses import dataclass, field +from pathlib import Path + +XML_SUFFIX = ".xml" +# Форматы, для которых нужен внешний бинарник или сторонняя зависимость, +# поэтому распознаём их только чтобы выдать понятную причину отказа. +UNSUPPORTED_ARCHIVE_SUFFIXES = frozenset({".rar", ".7z"}) + + +class AttachmentError(Exception): + """Вложение не удалось разобрать: не архив, битый архив, опасные пути.""" + + +@dataclass(frozen=True) +class CollectedXml: + """XML-файл, готовый к разбору, и вложение, из которого он получен.""" + + file_name: str + path: Path + source: str + + +@dataclass(frozen=True) +class AttachmentProblem: + source: str + reason: str + # Для дубликатов — имя самого файла; для нечитаемых вложений пусто, + # потому что до отдельных файлов дело не доходит. + file_name: str = "" + + +@dataclass +class CollectResult: + files: list[CollectedXml] = field(default_factory=list) + problems: list[AttachmentProblem] = field(default_factory=list) + duplicates: list[AttachmentProblem] = field(default_factory=list) + archives: int = 0 + loose_xml_files: int = 0 + + +def collect_xml_attachments( + attachments: Sequence[Path], + extract_root: Path, +) -> CollectResult: + """Разворачивает вложения в плоский список XML-файлов. + + Дубликаты по имени файла отбрасываются: имя XML-выгрузки уникально + (в нём есть дата и порядковый номер), поэтому повторное имя означает, + что одна и та же выгрузка прикреплена дважды, а её повторный разбор + удвоил бы строки отчёта. + """ + result = CollectResult() + seen: dict[str, str] = {} + + for index, attachment in enumerate(attachments): + source = attachment.name + if attachment.suffix.lower() == XML_SUFFIX: + result.loose_xml_files += 1 + _add_file(result, seen, file_name=source, path=attachment, source=source) + continue + + # Каждое вложение распаковываем в свой каталог: имена файлов внутри + # разных архивов могут совпадать. + target_dir = extract_root / str(index) + try: + target_dir.mkdir(parents=True, exist_ok=True) + extract_archive(attachment, target_dir) + except AttachmentError as exc: + result.problems.append(AttachmentProblem(source=source, reason=str(exc))) + continue + + result.archives += 1 + for xml_path in sorted(target_dir.rglob("*")): + if xml_path.is_file() and xml_path.suffix.lower() == XML_SUFFIX: + _add_file( + result, + seen, + file_name=xml_path.name, + path=xml_path, + source=source, + ) + + return result + + +def extract_archive(archive_path: Path, target_dir: Path) -> None: + """Распаковывает zip или tar в target_dir, не выходя за его пределы.""" + if zipfile.is_zipfile(archive_path): + _extract_zip(archive_path, target_dir) + return + if tarfile.is_tarfile(archive_path): + _extract_tar(archive_path, target_dir) + return + + suffix = archive_path.suffix.lower() + if suffix in UNSUPPORTED_ARCHIVE_SUFFIXES: + raise AttachmentError( + f"формат {suffix} не поддерживается — прикрепите .zip или .tar" + ) + raise AttachmentError("вложение не является ни XML-файлом, ни архивом .zip/.tar") + + +def _extract_zip(archive_path: Path, target_dir: Path) -> None: + try: + with zipfile.ZipFile(archive_path) as archive: + for member in archive.infolist(): + if member.is_dir(): + continue + destination = _safe_destination(target_dir, member.filename) + destination.parent.mkdir(parents=True, exist_ok=True) + with archive.open(member) as source, destination.open("wb") as target: + target.write(source.read()) + except (zipfile.BadZipFile, OSError) as exc: + raise AttachmentError(f"не удалось распаковать zip-архив: {exc}") from exc + + +def _extract_tar(archive_path: Path, target_dir: Path) -> None: + # Своя распаковка вместо extractall: параметр filter="data", который + # отсекает опасных членов архива, появился только в Python 3.11.4, а + # окружение бывает и старше. + try: + with tarfile.open(archive_path) as archive: + for member in archive.getmembers(): + if member.isdir(): + continue + if not member.isfile(): + # Симлинки и устройства из архива нам не нужны и опасны: + # ссылка может указать за пределы каталога распаковки. + continue + destination = _safe_destination(target_dir, member.name) + destination.parent.mkdir(parents=True, exist_ok=True) + extracted = archive.extractfile(member) + if extracted is None: + continue + with extracted, destination.open("wb") as target: + target.write(extracted.read()) + except (tarfile.TarError, OSError) as exc: + raise AttachmentError(f"не удалось распаковать tar-архив: {exc}") from exc + + +def _safe_destination(target_dir: Path, member_name: str) -> Path: + """Защита от путей вида "../..": член архива обязан лежать внутри каталога.""" + base = target_dir.resolve() + # PurePath не трогает разделители Windows, а в архивах они бывают оба. + relative_parts = [ + part + for part in member_name.replace("\\", "/").split("/") + if part and part not in {".", ".."} + ] + if not relative_parts: + raise AttachmentError(f"архив содержит недопустимый путь: {member_name!r}") + destination = base.joinpath(*relative_parts) + if not destination.is_relative_to(base): + raise AttachmentError(f"архив содержит недопустимый путь: {member_name!r}") + return destination + + +def _add_file( + result: CollectResult, + seen: dict[str, str], + *, + file_name: str, + path: Path, + source: str, +) -> None: + previous_source = seen.get(file_name) + if previous_source is not None: + result.duplicates.append( + AttachmentProblem( + source=source, + file_name=file_name, + reason=( + f"Повторно прикреплённый файл — уже получен из вложения " + f"«{previous_source}», пропущен." + ), + ) + ) + return + seen[file_name] = source + result.files.append(CollectedXml(file_name=file_name, path=path, source=source)) diff --git a/app/pipeline/protocol.py b/app/pipeline/protocol.py index be49c75..5306b20 100644 --- a/app/pipeline/protocol.py +++ b/app/pipeline/protocol.py @@ -19,6 +19,9 @@ class ProtocolRow: rows_written: int launcher: str processed_at: datetime + # Имя вложения, из которого получен файл. Пусто, если файл прикреплён + # отдельным .xml — тогда имя вложения совпадает с именем файла. + source: str = "" def write_protocol(rows: Iterable[ProtocolRow], destination: Path) -> None: @@ -30,6 +33,7 @@ def write_protocol(rows: Iterable[ProtocolRow], destination: Path) -> None: columns = [ "Имя файла", + "Вложение", "Статус", "Комментарий", "Операций", @@ -41,6 +45,7 @@ def write_protocol(rows: Iterable[ProtocolRow], destination: Path) -> None: for row_index, row in enumerate(row_list, start=2): values = [ row.file_name, + row.source or row.file_name, row.status, row.message, row.operations_total, diff --git a/app/pipeline/service.py b/app/pipeline/service.py index 6a203c9..4784737 100644 --- a/app/pipeline/service.py +++ b/app/pipeline/service.py @@ -2,7 +2,7 @@ from __future__ import annotations import tempfile import time -import zipfile +from collections.abc import Sequence from dataclasses import dataclass, field from datetime import datetime from pathlib import Path @@ -14,6 +14,7 @@ from ..opencloud_access import ( resolve_personal_drive_id, upload_local_file_to_opencloud, ) +from .attachments import collect_xml_attachments from .config import ( DEFAULT_OUTPUT_DIR_NAME, build_date_parts, @@ -42,20 +43,25 @@ class BatchResult: def run_batch( - zip_path: Path, + input_paths: Sequence[Path], launcher: str, opencloud_username: str | None, opencloud_access_token: str | None, ) -> BatchResult: - """Прикреплённый через интерфейс .zip с XML -> отчёт/протокол/лог -> OpenCloud. + """Прикреплённые XML и архивы -> отчёт/протокол/лог -> OpenCloud. - Входные файлы приходят как есть — через вложение (attach) в интерфейсе + Входные файлы приходят как есть — через вложения (attach) в интерфейсе (Streamlit file_uploader), без SMB и без выделенной входной папки в - OpenCloud. Результат грузится в личное пространство пользователя - (Out/год/месяц/день/номер_запуска). Файлы с некорректным именем/битым + OpenCloud. Вложений может быть несколько, и это могут быть как отдельные + .xml, так и архивы, вперемешку: их разворачивает в плоский список + collect_xml_attachments. Вложение, которое не удалось разобрать, попадает + в протокол отдельной строкой и не отменяет обработку остальных. + + Результат грузится в личное пространство пользователя + (Out/год/месяц/день/номер_запуска). Файлы с некорректным именем или битым XML просто попадают в протокол со статусом "error" — никуда не - перемещаются (перемещать их и некуда, это временная распакованная - копия вложения). + перемещаются (перемещать их и некуда, это временная распакованная копия + вложения). """ started_at = time.perf_counter() if not opencloud_username: @@ -84,42 +90,79 @@ def run_batch( ], ) + if not input_paths: + return BatchResult( + status="error", + processed=0, + partial=0, + errors=1, + report_path="", + protocol_path="", + log_path="", + messages=["Не прикреплено ни одного файла."], + ) + protocol_rows: list[ProtocolRow] = [] - log_lines: list[str] = [f"{_now()} | batch start: {zip_path.name}"] + attachment_failures: list[str] = [] + attachment_names = ", ".join(path.name for path in input_paths) + log_lines: list[str] = [f"{_now()} | batch start: {attachment_names}"] with tempfile.TemporaryDirectory() as extract_dir: extract_dir_path = Path(extract_dir) - try: - with zipfile.ZipFile(zip_path) as archive: - archive.extractall(extract_dir_path) - except zipfile.BadZipFile as exc: - return BatchResult( - status="error", - processed=0, - partial=0, - errors=1, - report_path="", - protocol_path="", - log_path="", - messages=[f"Не удалось распаковать архив: {exc}"], + collected = collect_xml_attachments(input_paths, extract_dir_path) + log_lines.append( + f"{_now()} | вложений: {len(input_paths)} " + f"(архивов: {collected.archives}, " + f"отдельных XML: {collected.loose_xml_files}), " + f"найдено XML-файлов: {len(collected.files)}" + ) + for problem in collected.problems: + protocol_rows.append( + ProtocolRow( + file_name=problem.source, + source=problem.source, + status="error", + message=f"Вложение не обработано: {problem.reason}", + operations_total=0, + rows_written=0, + launcher=launcher, + processed_at=datetime.now(), + ) + ) + attachment_failures.append(f"{problem.source} — {problem.reason}") + log_lines.append( + f"{_now()} | attachment failed: {problem.source}. {problem.reason}" + ) + for duplicate in collected.duplicates: + protocol_rows.append( + ProtocolRow( + file_name=duplicate.file_name or duplicate.source, + source=duplicate.source, + status="skip", + message=duplicate.reason, + operations_total=0, + rows_written=0, + launcher=launcher, + processed_at=datetime.now(), + ) + ) + log_lines.append( + f"{_now()} | duplicate skipped: {duplicate.file_name}. " + f"{duplicate.reason}" ) - xml_paths = sorted(extract_dir_path.rglob("*.xml")) - log_lines.append( - f"{_now()} | распаковано, найдено XML-файлов: {len(xml_paths)}" - ) - report_writer = StreamingReportWriter() - for xml_path in xml_paths: - file_name = xml_path.name + for collected_xml in collected.files: + file_name = collected_xml.file_name try: - content = xml_path.read_bytes() + content = collected_xml.path.read_bytes() except Exception as exc: # noqa: BLE001 protocol_rows.append( ProtocolRow( file_name=file_name, + source=collected_xml.source, status="error", - message=f"Ошибка чтения файла из архива: {exc}", + message=f"Ошибка чтения файла из вложения: {exc}", operations_total=0, rows_written=0, launcher=launcher, @@ -148,6 +191,7 @@ def run_batch( protocol_rows.append( ProtocolRow( file_name=file_name, + source=collected_xml.source, status=status, message=message, operations_total=parse_result.total_operations, @@ -163,7 +207,7 @@ def run_batch( ProtocolRow( file_name="-", status=EMPTY_BATCH_STATUS, - message="В архиве не найдено XML-файлов.", + message="Во вложениях не найдено XML-файлов.", operations_total=0, rows_written=0, launcher=launcher, @@ -238,11 +282,16 @@ def run_batch( if errors == 0 and partial == 0 else ("partial" if partial > 0 else "error") ) + skipped = sum(1 for row in protocol_rows if row.status == "skip") messages = [ _build_result_message( status=status, processed=processed, partial=partial, errors=errors ) ] + if skipped: + messages.append(f"Пропущено повторно прикреплённых файлов: {skipped}.") + if attachment_failures: + messages.append("Не обработаны вложения: " + "; ".join(attachment_failures)) duration_seconds = round(time.perf_counter() - started_at, 2) messages.append(f"Время выполнения: {_format_duration(duration_seconds)}.") messages.extend(log_lines[-5:]) diff --git a/main.py b/main.py index a340ce4..82826bd 100644 --- a/main.py +++ b/main.py @@ -29,25 +29,29 @@ def main() -> None: _render_token_popover() st.caption( - "Прикрепите .zip с XML-файлами — отчёт, протокол и лог " - "загрузятся в Out вашего личного пространства OpenCloud." + "Прикрепите XML-файлы и/или архивы с ними (можно несколько сразу, " + "вперемешку) — отчёт, протокол и лог загрузятся в Out вашего " + "личного пространства OpenCloud." ) - uploaded_zip = st.file_uploader("Архив с XML-файлами (.zip)", type=["zip"]) + uploaded_files = st.file_uploader( + "XML-файлы и архивы", + type=["xml", "zip", "tar", "gz", "tgz", "bz2", "xz"], + accept_multiple_files=True, + ) if st.button( "Запустить обработку xml в excel", type="primary", use_container_width=True, - disabled=uploaded_zip is None, + disabled=not uploaded_files, ): with tempfile.TemporaryDirectory() as tmp_dir: - zip_path = Path(tmp_dir) / uploaded_zip.name - zip_path.write_bytes(uploaded_zip.getvalue()) + input_paths = _save_attachments(uploaded_files, Path(tmp_dir)) with st.spinner("Выполняется обработка..."): launcher_name = resolve_opencloud_username() or resolve_user_id() batch_result = run_batch( - zip_path=zip_path, + input_paths=input_paths, launcher=launcher_name, opencloud_username=resolve_opencloud_username(), opencloud_access_token=resolve_opencloud_access_token(), @@ -78,6 +82,23 @@ def main() -> None: st.rerun() +def _save_attachments(uploaded_files, target_dir: Path) -> list[Path]: + """Складывает вложения на диск, каждое в свой подкаталог. + + Подкаталог по индексу нужен потому, что имена вложений могут совпадать + (например, два архива "Файлы.zip" из разных папок), и одно перезаписало + бы другое. + """ + paths: list[Path] = [] + for index, uploaded in enumerate(uploaded_files): + holder = target_dir / str(index) + holder.mkdir(parents=True, exist_ok=True) + path = holder / uploaded.name + path.write_bytes(uploaded.getvalue()) + paths.append(path) + return paths + + def _render_token_popover() -> None: """Самообслуживание: пользователь сам привязывает свой App Token OpenCloud к своей учётке (по preferred_username из Keycloak), без diff --git a/scripts/local_zip_test_app.py b/scripts/local_zip_test_app.py index 0754f35..9ac9f7c 100644 --- a/scripts/local_zip_test_app.py +++ b/scripts/local_zip_test_app.py @@ -29,9 +29,9 @@ from app.pipeline.service import run_batch load_dotenv() st.set_page_config(page_title="СФМ — локальный тест", page_icon="📦") -st.title("Локальный тест: zip → обработка → OpenCloud") +st.title("Локальный тест: вложения → обработка → OpenCloud") st.caption( - "Прикрепляете .zip с XML-файлами, он распаковывается и обрабатывается " + "Прикрепляете XML-файлы и/или архивы с ними, они распаковываются и обрабатываются " "тем же кодом парсинга, что и прод (app/pipeline/parser.py), а отчёт, " "протокол и лог грузятся в OpenCloud (app/opencloud_access.py). Та же " "логика, что и в main.py, только username/token вводятся руками." @@ -54,22 +54,31 @@ access_token = st.text_input( ) launcher = st.text_input("Запустил (для протокола)", value="local-test") -uploaded_zip = st.file_uploader("Архив с XML-файлами (.zip)", type=["zip"]) +uploaded_files = st.file_uploader( + "XML-файлы и архивы", + type=["xml", "zip", "tar", "gz", "tgz", "bz2", "xz"], + accept_multiple_files=True, +) if st.button("Начать обработку", type="primary", use_container_width=True): if not username: st.error("Укажите username.") elif not access_token: st.error("Укажите App Token.") - elif uploaded_zip is None: - st.error("Прикрепите .zip-архив.") + elif not uploaded_files: + st.error("Прикрепите XML-файлы или архивы.") else: with tempfile.TemporaryDirectory() as tmp_dir: - zip_path = Path(tmp_dir) / uploaded_zip.name - zip_path.write_bytes(uploaded_zip.getvalue()) + input_paths = [] + for index, uploaded in enumerate(uploaded_files): + holder = Path(tmp_dir) / str(index) + holder.mkdir(parents=True, exist_ok=True) + attachment_path = holder / uploaded.name + attachment_path.write_bytes(uploaded.getvalue()) + input_paths.append(attachment_path) with st.spinner("Распаковываем, обрабатываем, грузим в OpenCloud..."): result = run_batch( - zip_path=zip_path, + input_paths=input_paths, launcher=launcher or "local-test", opencloud_username=username, opencloud_access_token=access_token, diff --git a/tests/smoke/test_batch_smoke.py b/tests/smoke/test_batch_smoke.py index cb5cc70..f3563af 100644 --- a/tests/smoke/test_batch_smoke.py +++ b/tests/smoke/test_batch_smoke.py @@ -1,5 +1,6 @@ from __future__ import annotations +import tarfile import zipfile from pathlib import Path @@ -70,7 +71,7 @@ def test_run_batch_uses_run_number_from_opencloud(tmp_path: Path, monkeypatch) - archive.writestr("SKO115FZ_01_123456789_20260616_X00001.xml", _valid_xml()) result = run_batch( - zip_path=zip_path, + input_paths=[zip_path], launcher="tester", opencloud_username="test-user", opencloud_access_token="token-123", @@ -89,7 +90,7 @@ def test_run_batch_success(tmp_path: Path, monkeypatch) -> None: archive.writestr("SKO115FZ_01_123456789_20260616_X00001.xml", _valid_xml()) result = run_batch( - zip_path=zip_path, + input_paths=[zip_path], launcher="tester", opencloud_username="test-user", opencloud_access_token="token-123", @@ -111,7 +112,7 @@ def test_run_batch_bad_file_name_is_error(tmp_path: Path, monkeypatch) -> None: archive.writestr("bad_name.xml", _valid_xml()) result = run_batch( - zip_path=zip_path, + input_paths=[zip_path], launcher="tester", opencloud_username="test-user", opencloud_access_token="token-123", @@ -130,7 +131,7 @@ def test_run_batch_empty_archive(tmp_path: Path, monkeypatch) -> None: archive.writestr("readme.txt", "no xml here") result = run_batch( - zip_path=zip_path, + input_paths=[zip_path], launcher="tester", opencloud_username="test-user", opencloud_access_token="token-123", @@ -146,7 +147,7 @@ def test_run_batch_missing_token(tmp_path: Path) -> None: archive.writestr("SKO115FZ_01_123456789_20260616_X00001.xml", _valid_xml()) result = run_batch( - zip_path=zip_path, + input_paths=[zip_path], launcher="tester", opencloud_username="test-user", opencloud_access_token="", @@ -162,7 +163,7 @@ def test_run_batch_missing_username(tmp_path: Path) -> None: archive.writestr("SKO115FZ_01_123456789_20260616_X00001.xml", _valid_xml()) result = run_batch( - zip_path=zip_path, + input_paths=[zip_path], launcher="tester", opencloud_username="", opencloud_access_token="token-123", @@ -172,16 +173,146 @@ def test_run_batch_missing_username(tmp_path: Path) -> None: assert not result.report_path -def test_run_batch_bad_zip_file(tmp_path: Path) -> None: +def test_run_batch_bad_zip_file(tmp_path: Path, monkeypatch) -> None: + _patch_opencloud(monkeypatch) + zip_path = tmp_path / "not_a_zip.zip" zip_path.write_bytes(b"this is not a zip file") result = run_batch( - zip_path=zip_path, + input_paths=[zip_path], launcher="tester", opencloud_username="test-user", opencloud_access_token="token-123", ) assert result.status == "error" - assert any("распаковать" in message for message in result.messages) + assert result.errors == 1 + assert any("not_a_zip.zip" in message for message in result.messages) + + +def _write_zip(path: Path, members: dict[str, str]) -> Path: + with zipfile.ZipFile(path, "w") as archive: + for name, content in members.items(): + archive.writestr(name, content) + return path + + +def _write_xml(path: Path, content: str | None = None) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content or _valid_xml(), encoding="utf-8") + return path + + +def test_run_batch_accepts_several_archives_and_loose_xml( + tmp_path: Path, monkeypatch +) -> None: + """Основной сценарий правки: вложений несколько, среди них и архивы, + и отдельные .xml.""" + _patch_opencloud(monkeypatch) + + first = _write_zip( + tmp_path / "day1.zip", + {"SKO115FZ_01_123456789_20260616_X00001.xml": _valid_xml()}, + ) + second = _write_zip( + tmp_path / "day2.zip", + { + "nested/SKO115FZ_01_123456789_20260616_X00002.xml": _valid_xml(), + "nested/deeper/SKO115FZ_01_123456789_20260616_X00003.xml": _valid_xml(), + }, + ) + loose = _write_xml(tmp_path / "SKO115FZ_01_123456789_20260616_X00004.xml") + + result = run_batch( + input_paths=[first, second, loose], + launcher="tester", + opencloud_username="test-user", + opencloud_access_token="token-123", + ) + + assert result.status == "success" + assert result.processed == 4 + assert result.errors == 0 + + +def test_run_batch_keeps_going_when_one_attachment_is_broken( + tmp_path: Path, monkeypatch +) -> None: + """Одно нечитаемое вложение не отменяет обработку остальных.""" + _patch_opencloud(monkeypatch) + + good = _write_zip( + tmp_path / "good.zip", + {"SKO115FZ_01_123456789_20260616_X00001.xml": _valid_xml()}, + ) + broken = tmp_path / "broken.zip" + broken.write_bytes(b"not an archive at all") + + result = run_batch( + input_paths=[good, broken], + launcher="tester", + opencloud_username="test-user", + opencloud_access_token="token-123", + ) + + assert result.processed == 1 + assert result.errors == 1 + assert result.status == "error" + assert result.report_path + + +def test_run_batch_skips_duplicate_file_names_across_attachments( + tmp_path: Path, monkeypatch +) -> None: + """Один и тот же файл, прикреплённый дважды, не должен удваивать отчёт.""" + _patch_opencloud(monkeypatch) + + name = "SKO115FZ_01_123456789_20260616_X00001.xml" + first = _write_zip(tmp_path / "a.zip", {name: _valid_xml()}) + second = _write_zip(tmp_path / "b.zip", {name: _valid_xml()}) + + result = run_batch( + input_paths=[first, second], + launcher="tester", + opencloud_username="test-user", + opencloud_access_token="token-123", + ) + + assert result.processed == 1 + assert result.errors == 0 + assert any("Пропущено повторно" in message for message in result.messages) + + +def test_run_batch_accepts_tar_gz_archive(tmp_path: Path, monkeypatch) -> None: + _patch_opencloud(monkeypatch) + + xml_path = _write_xml( + tmp_path / "src" / "SKO115FZ_01_123456789_20260616_X00001.xml" + ) + archive_path = tmp_path / "batch.tar.gz" + with tarfile.open(archive_path, "w:gz") as archive: + archive.add(xml_path, arcname=xml_path.name) + + result = run_batch( + input_paths=[archive_path], + launcher="tester", + opencloud_username="test-user", + opencloud_access_token="token-123", + ) + + assert result.status == "success" + assert result.processed == 1 + + +def test_run_batch_without_attachments_is_error(tmp_path: Path) -> None: + _ = tmp_path + result = run_batch( + input_paths=[], + launcher="tester", + opencloud_username="test-user", + opencloud_access_token="token-123", + ) + + assert result.status == "error" + assert not result.report_path diff --git a/tests/unit/test_attachments.py b/tests/unit/test_attachments.py new file mode 100644 index 0000000..f915afe --- /dev/null +++ b/tests/unit/test_attachments.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +import tarfile +import zipfile +from pathlib import Path + +import pytest + +from app.pipeline.attachments import ( + AttachmentError, + collect_xml_attachments, + extract_archive, +) + +XML_BODY = "<СообщОперКО/>" + + +def _zip(path: Path, members: dict[str, str]) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(path, "w") as archive: + for name, content in members.items(): + archive.writestr(name, content) + return path + + +def _xml(path: Path) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(XML_BODY, encoding="utf-8") + return path + + +def test_collects_loose_xml_and_archives_together(tmp_path: Path) -> None: + loose = _xml(tmp_path / "loose" / "a.xml") + archive = _zip(tmp_path / "batch.zip", {"b.xml": XML_BODY, "notes.txt": "skip me"}) + extract_root = tmp_path / "extract" + extract_root.mkdir() + + result = collect_xml_attachments([loose, archive], extract_root) + + assert [item.file_name for item in result.files] == ["a.xml", "b.xml"] + assert result.loose_xml_files == 1 + assert result.archives == 1 + assert result.problems == [] + # У каждого файла видно, из какого вложения он пришёл. + assert {item.source for item in result.files} == {"a.xml", "batch.zip"} + + +def test_finds_xml_in_nested_folders(tmp_path: Path) -> None: + archive = _zip( + tmp_path / "batch.zip", + {"one/two/three/deep.xml": XML_BODY, "one/shallow.xml": XML_BODY}, + ) + extract_root = tmp_path / "extract" + extract_root.mkdir() + + result = collect_xml_attachments([archive], extract_root) + + assert sorted(item.file_name for item in result.files) == [ + "deep.xml", + "shallow.xml", + ] + + +def test_same_file_name_in_two_archives_is_collected_once(tmp_path: Path) -> None: + first = _zip(tmp_path / "first.zip", {"same.xml": XML_BODY}) + second = _zip(tmp_path / "second.zip", {"same.xml": XML_BODY}) + extract_root = tmp_path / "extract" + extract_root.mkdir() + + result = collect_xml_attachments([first, second], extract_root) + + assert [item.file_name for item in result.files] == ["same.xml"] + assert len(result.duplicates) == 1 + assert "first.zip" in result.duplicates[0].reason + assert result.duplicates[0].source == "second.zip" + # Имя самого файла нужно протоколу: строка «пропущен» должна стоять + # под тем же именем, что и обработанная. + assert result.duplicates[0].file_name == "same.xml" + + +def test_archives_with_equal_names_do_not_overwrite_each_other(tmp_path: Path) -> None: + """Два вложения могут называться одинаково — распаковываться они должны + в разные каталоги, иначе одно затрёт другое.""" + first = _zip(tmp_path / "a" / "Файлы.zip", {"one.xml": XML_BODY}) + second = _zip(tmp_path / "b" / "Файлы.zip", {"two.xml": XML_BODY}) + extract_root = tmp_path / "extract" + extract_root.mkdir() + + result = collect_xml_attachments([first, second], extract_root) + + assert sorted(item.file_name for item in result.files) == ["one.xml", "two.xml"] + assert result.problems == [] + + +def test_tar_gz_archive_is_supported(tmp_path: Path) -> None: + xml_path = _xml(tmp_path / "src" / "in_tar.xml") + archive = tmp_path / "batch.tar.gz" + with tarfile.open(archive, "w:gz") as tar: + tar.add(xml_path, arcname=xml_path.name) + extract_root = tmp_path / "extract" + extract_root.mkdir() + + result = collect_xml_attachments([archive], extract_root) + + assert [item.file_name for item in result.files] == ["in_tar.xml"] + assert result.archives == 1 + + +def test_format_is_detected_by_content_not_extension(tmp_path: Path) -> None: + """Выгрузки приезжают и с неверным суффиксом — формат определяем по + содержимому.""" + archive = _zip(tmp_path / "actually_a_zip.bin", {"x.xml": XML_BODY}) + extract_root = tmp_path / "extract" + extract_root.mkdir() + + result = collect_xml_attachments([archive], extract_root) + + assert [item.file_name for item in result.files] == ["x.xml"] + + +def test_broken_attachment_is_reported_without_stopping_the_rest( + tmp_path: Path, +) -> None: + good = _zip(tmp_path / "good.zip", {"ok.xml": XML_BODY}) + broken = tmp_path / "broken.zip" + broken.write_bytes(b"definitely not an archive") + extract_root = tmp_path / "extract" + extract_root.mkdir() + + result = collect_xml_attachments([broken, good], extract_root) + + assert [item.file_name for item in result.files] == ["ok.xml"] + assert len(result.problems) == 1 + assert result.problems[0].source == "broken.zip" + + +def test_unsupported_archive_format_explains_itself(tmp_path: Path) -> None: + archive = tmp_path / "batch.7z" + archive.write_bytes(b"7z\xbc\xaf\x27\x1c not really") + extract_root = tmp_path / "extract" + extract_root.mkdir() + + result = collect_xml_attachments([archive], extract_root) + + assert result.files == [] + assert ".7z" in result.problems[0].reason + + +def test_zip_with_path_traversal_member_is_rejected(tmp_path: Path) -> None: + """Защита от zip slip: член архива не должен попадать за пределы каталога + распаковки.""" + archive = tmp_path / "evil.zip" + with zipfile.ZipFile(archive, "w") as zip_file: + zip_file.writestr("../../escaped.xml", XML_BODY) + extract_root = tmp_path / "extract" + extract_root.mkdir() + + result = collect_xml_attachments([archive], extract_root) + + assert not (tmp_path.parent / "escaped.xml").exists() + # Имя очищается до безопасного, файл остаётся внутри каталога распаковки. + for item in result.files: + assert extract_root.resolve() in item.path.resolve().parents + + +def test_extract_archive_raises_for_plain_file(tmp_path: Path) -> None: + plain = tmp_path / "notes.txt" + plain.write_text("just text", encoding="utf-8") + target = tmp_path / "out" + target.mkdir() + + with pytest.raises(AttachmentError): + extract_archive(plain, target) + + +def test_empty_attachment_list_gives_empty_result(tmp_path: Path) -> None: + result = collect_xml_attachments([], tmp_path) + + assert result.files == [] + assert result.problems == [] + assert result.archives == 0 + assert result.loose_xml_files == 0