Выгрузка XLSX из Python: как сохранить пользовательский текст без формул

Сотрудник скачивает отчёт по обращениям. Колонка «Комментарий» должна показывать сообщения клиентов ровно в том виде, в котором они пришли. В одной строке вместо =2+2 виден результат 4. При этом файл сформирован Python-скриптом, а обработчик комментариев нигде не вычисляет выражения. Значение стало формулой во время записи ячейки или при открытии файла в табличном редакторе.

В CSV кавычки нужны, чтобы сохранить запятые и переносы внутри поля. После разбора кавычки исчезают, а редактор сам решает, как интерпретировать оставшийся текст. Если файл предназначен для людей, которые открывают отчёт в Excel, удобнее передать XLSX и задать тип каждой колонки. Тогда комментарий хранится как строка, а сумма остаётся числом, которое можно сложить.

Выберите метод записи для каждой колонки

У XlsxWriter есть универсальный write(). Он определяет, как записать переданное значение, и по умолчанию может распознать строку с начальным = как формулу, а строку с адресом как гиперссылку. Для полей, где нужна буквальная запись, вызывайте write_string(). У номера обращения тоже текстовый тип: у 00042 ведущие нули входят в идентификатор. Количество и целые суммы в копейках пишите через write_number().

В примере тест сам открывает созданный XLSX как ZIP и читает XML листа. Проверяем именно то, что получит редактор: ссылку на таблицу строк для комментария, числовую ячейку для суммы и отсутствие формул и гиперссылок. Сохраните пример в export_demo.py и установите зависимость командой python -m pip install XlsxWriter.

from dataclasses import dataclass
from pathlib import Path
from tempfile import TemporaryDirectory
from xml.etree import ElementTree
from zipfile import ZipFile

import xlsxwriter


@dataclass(frozen=True)
class Ticket:
    code: str
    comment: str
    amount_minor: int


def export_tickets(path: Path, tickets: list[Ticket]) -> None:
    if len(tickets) > 1_048_575:
        raise ValueError("Too many tickets for a single worksheet")
    for ticket in tickets:
        if max(len(ticket.code), len(ticket.comment)) > 32_767:
            raise ValueError(f"Text exceeds cell limit for ticket {ticket.code}")
    with xlsxwriter.Workbook(path) as workbook:
        sheet = workbook.add_worksheet("Tickets")
        sheet.write_row(0, 0, ["Code", "Comment", "Amount (minor units)"])
        for row, ticket in enumerate(tickets, start=1):
            sheet.write_string(row, 0, ticket.code)
            sheet.write_string(row, 1, ticket.comment)
            sheet.write_number(row, 2, ticket.amount_minor)


namespace = {"x": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"}
tickets = [
    Ticket("00042", "=2+2", -1200),
    Ticket("00043", "https://example.org", 2500),
]
with TemporaryDirectory() as directory:
    path = Path(directory) / "tickets.xlsx"
    export_tickets(path, tickets)
    with ZipFile(path) as archive:
        worksheet = ElementTree.fromstring(archive.read("xl/worksheets/sheet1.xml"))
        strings_xml = ElementTree.fromstring(archive.read("xl/sharedStrings.xml"))
    strings = [element.text for element in strings_xml.findall("x:si/x:t", namespace)]
    cells = {cell.attrib["r"]: cell for cell in worksheet.findall(".//x:c", namespace)}

    def text_at(address: str) -> str:
        cell = cells[address]
        assert cell.attrib["t"] == "s"
        return strings[int(cell.findtext("x:v", namespaces=namespace))]

    assert (text_at("A2"), text_at("B2"), text_at("B3")) == ("00042", "=2+2", "https://example.org")
    assert cells["C2"].get("t", "n") == "n"
    assert int(cells["C2"].findtext("x:v", namespaces=namespace)) == -1200
    assert worksheet.find(".//x:f", namespace) is None
    assert worksheet.find(".//x:hyperlink", namespace) is None
    print(text_at("A2"), text_at("B2"), cells["C2"].findtext("x:v", namespaces=namespace))

Получившийся файл содержит текст =2+2 и исходный идентификатор 00042. Число -1200 остаётся отрицательным числом. Это важно для сумм и сверок. Если экранировать данные одинаково во всех колонках, тип можно потерять там, где он нужен для работы с отчётом.

Решите, как обрабатывать ограничения таблицы

В книге под один лист выделено ограниченное число строк, а write_string() может обрезать слишком длинное значение. Поэтому пример проверяет вход до создания книги. В отчёте с длинными обращениями можно вынести полные тексты в отдельное приложение к выгрузке и оставить в XLSX ссылку или идентификатор. Выберите способ, при котором оператор сможет восстановить содержание обращения.

Оставьте в тестовых данных хотя бы одну строку, похожую на формулу, одну похожую на ссылку и идентификатор с ведущими нулями. Проверяйте типы после записи файла. Если отчёт для импорта в другую систему, согласуйте отдельный CSV-контракт с её парсером. Повторное сохранение XLSX в CSV убирает сведения о типах и возвращает вопрос интерпретации ячеек на сторону редактора.

На бесплатном курсе Latorn «Python для продвинутых» вы отработаете разбор JSON и CSV, типизированные модели и тесты для файловых операций. Итоговый трекер задач даст практику в том, как отделить сохранение данных от бизнес-логики. Открыть программу курса по продвинутому Python.

Продолжить на практике

  • Python для продвинутых: ООП, типизация и тестирование ↗

    Бесплатный курс после основ Python: превратите набор скриптов в приложение с понятной структурой, типами и тестами. ООП, mypy, pytest, работа с файлами и логирование. Итоговый проект — трекер задач; практические решения проверяются автоматически.