Accept several XML files and archives as attachments

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.
This commit is contained in:
Raykov-MS 2026-09-24 16:57:26 +03:00
parent 880fa5811d
commit 721712d86a
7 changed files with 651 additions and 56 deletions

198
app/pipeline/attachments.py Normal file
View File

@ -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))

View File

@ -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,

View File

@ -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:])

35
main.py
View File

@ -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), без

View File

@ -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,

View File

@ -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

View File

@ -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 = "<?xml version='1.0' encoding='UTF-8'?><СообщОперКО/>"
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