SFM/app/pipeline/attachments.py
Raykov-MS 721712d86a 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.
2026-09-24 16:57:26 +03:00

199 lines
8.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Сбор 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))