Тесты и инфраструктура
- tests/: 104 теста — lessons (все форматы ячеек), xlsx (мержи/скрытые столбцы/красный текст/даты), parsing (группы/недели/индексы), API (TestClient, TTL/modified/stale-while-error, 404/400/502) - xlsx_builder.py: генерация xlsx в памяти для тестов без openpyxl - docker-compose: healthz-хелскчеки (не блокируют старт), SCHEDULE_LINK - Makefile: target test - requirements: закреплённые версии - .gitignore: __pycache__/, .venv/ и т.д.
This commit is contained in:
164
tests/xlsx_builder.py
Normal file
164
tests/xlsx_builder.py
Normal file
@@ -0,0 +1,164 @@
|
||||
"""Минимальный сборщик xlsx в памяти для тестов (без openpyxl).
|
||||
|
||||
Поддерживает: shared strings (включая красные ранки), цифры, мержи,
|
||||
скрытые столбцы. Формат достаточно строгий, чтобы покрывать реальный
|
||||
парсер (он читает именно этот поднабор OOXML).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import zipfile
|
||||
from dataclasses import dataclass, field
|
||||
from xml.sax.saxutils import escape
|
||||
|
||||
|
||||
def col_letter(idx: int) -> str:
|
||||
"""0-based индекс столбца -> 'A', 'B', ..., 'AA'."""
|
||||
s = ""
|
||||
idx += 1
|
||||
while idx:
|
||||
idx, rem = divmod(idx - 1, 26)
|
||||
s = chr(ord("A") + rem) + s
|
||||
return s
|
||||
|
||||
|
||||
@dataclass
|
||||
class SheetSpec:
|
||||
rows: list[list]
|
||||
merges: list[str] = field(default_factory=list)
|
||||
hidden_cols: list[tuple[int, int]] = field(default_factory=list) # 0-based, инклязивно
|
||||
|
||||
|
||||
def build_workbook(sheets: dict[str, SheetSpec]) -> bytes:
|
||||
shared: list[str] = []
|
||||
shared_index: dict[str, int] = {}
|
||||
red_runs: dict[int, list[tuple[str, bool]]] = {}
|
||||
|
||||
def intern(value) -> tuple[str, str, object]:
|
||||
"""-> (тип ячейки, атрибут t, значение v / None)"""
|
||||
if value is None or value == "":
|
||||
return None, "", None
|
||||
if isinstance(value, (int, float)):
|
||||
return "n", "", str(value)
|
||||
if isinstance(value, list):
|
||||
idx = len(shared)
|
||||
shared.append(None)
|
||||
red_runs[idx] = value
|
||||
return "s", "s", idx
|
||||
idx = shared_index.get(value)
|
||||
if idx is None:
|
||||
idx = len(shared)
|
||||
shared_index[value] = idx
|
||||
shared.append(value)
|
||||
return "s", "s", idx
|
||||
|
||||
sheet_xml: dict[str, str] = {}
|
||||
rels = []
|
||||
sheet_defs = []
|
||||
for i, (name, spec) in enumerate(sheets.items(), start=1):
|
||||
rel_id = f"rId{i}"
|
||||
rels.append(
|
||||
f'<Relationship Id="{rel_id}" '
|
||||
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" '
|
||||
f'Target="worksheets/sheet{i}.xml"/>'
|
||||
)
|
||||
sheet_defs.append(
|
||||
f'<sheet name="{escape(name)}" sheetId="{i}" r:id="{rel_id}"/>'
|
||||
)
|
||||
|
||||
cols = "".join(
|
||||
f'<col min="{a + 1}" max="{b + 1}" hidden="1"/>'
|
||||
for a, b in spec.hidden_cols
|
||||
)
|
||||
cols_xml = f"<cols>{cols}</cols>" if cols else ""
|
||||
|
||||
row_xml = []
|
||||
for r, row in enumerate(spec.rows, start=1):
|
||||
cells = []
|
||||
for c, value in enumerate(row):
|
||||
kind, t_attr, v = intern(value)
|
||||
if kind is None:
|
||||
continue
|
||||
t = f' t="{t_attr}"' if t_attr else ""
|
||||
cells.append(f'<c r="{col_letter(c)}{r}"{t}><v>{escape(str(v))}</v></c>')
|
||||
if cells:
|
||||
row_xml.append(f'<row r="{r}">{"".join(cells)}</row>')
|
||||
|
||||
merges_xml = ""
|
||||
if spec.merges:
|
||||
mc = "".join(f'<mergeCell ref="{m}"/>' for m in spec.merges)
|
||||
merges_xml = f'<mergeCells count="{len(spec.merges)}">{mc}</mergeCells>'
|
||||
|
||||
sheet_xml[f"sheet{i}.xml"] = (
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
|
||||
f"{cols_xml}<sheetData>{''.join(row_xml)}</sheetData>{merges_xml}"
|
||||
"</worksheet>"
|
||||
)
|
||||
|
||||
sst_parts = []
|
||||
for i, value in enumerate(shared):
|
||||
if i in red_runs:
|
||||
runs = "".join(
|
||||
f'<r>{("<rPr><color rgb=\"FFFF0000\"/></rPr>" if red else "")}'
|
||||
f"<t xml:space='preserve'>{escape(text)}</t></r>"
|
||||
for text, red in red_runs[i]
|
||||
)
|
||||
sst_parts.append(f"<si>{runs}</si>")
|
||||
else:
|
||||
sst_parts.append(f"<si><t xml:space='preserve'>{escape(value)}</t></si>")
|
||||
sst = (
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" '
|
||||
f'count="{len(shared)}" uniqueCount="{len(shared)}">'
|
||||
f"{''.join(sst_parts)}</sst>"
|
||||
)
|
||||
|
||||
content_types = ["<Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/>",
|
||||
"<Default Extension=\"xml\" ContentType=\"application/xml\"/>",
|
||||
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>']
|
||||
for i in range(1, len(sheets) + 1):
|
||||
content_types.append(
|
||||
f'<Override PartName="/xl/worksheets/sheet{i}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
|
||||
)
|
||||
if shared:
|
||||
content_types.append(
|
||||
'<Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/>'
|
||||
)
|
||||
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr(
|
||||
"[Content_Types].xml",
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
||||
+ "".join(content_types)
|
||||
+ "</Types>",
|
||||
)
|
||||
zf.writestr(
|
||||
"_rels/.rels",
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>'
|
||||
"</Relationships>",
|
||||
)
|
||||
zf.writestr(
|
||||
"xl/workbook.xml",
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" '
|
||||
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
|
||||
f"<sheets>{''.join(sheet_defs)}</sheets></workbook>",
|
||||
)
|
||||
zf.writestr(
|
||||
"xl/_rels/workbook.xml.rels",
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
+ "".join(rels)
|
||||
+ "</Relationships>",
|
||||
)
|
||||
for fname, xml in sheet_xml.items():
|
||||
zf.writestr(f"xl/worksheets/{fname}", xml)
|
||||
if shared:
|
||||
zf.writestr("xl/sharedStrings.xml", sst)
|
||||
return buf.getvalue()
|
||||
Reference in New Issue
Block a user