"""Лимиты фазы чтения (ruling R-23, находка I-1 финального ревью): распакованный размер zip-архива и суммарная
площадь объединённых ячеек проверяются до библиотеки чтения — бомба из нескольких килобайт не доходит до openpyxl.
Книги собираются в памяти: в fixtures ничего не пишется.
"""
import io
import time
import zipfile
from collections.abc import Callable, Iterator
from typing import Any
import openpyxl
import pytest
from arm_statements import default_rules
from src.contract import ErrorCode, ResultStatus
from src.parser.book import BookParser
from src.parser.errors import UnreadableError, UnsupportedFormatError
from src.parser.grid import MAX_INDEXED_AREA, MergedRange, Sheet
from src.parser.osv import OsvParser
from src.parser.readers import _READERS, read_book
from src.parser.readers._limits import (
LARGE_MERGED_COUNT_LIMIT,
MERGED_AREA_LIMIT,
ZIP_UNCOMPRESSED_LIMIT_FACTOR,
check_merged_area,
check_xlsx_merged_area,
merged_area_of_xml,
)
from src.parser.rules import Rules, load_rules
from src.parser.statements.pipeline import StatementPipeline
FAST_SECONDS = 1.0
SHEET_PART = "xl/worksheets/sheet1.xml"
ZEROS_BYTES = 10 * 1024 * 1024 # «десятки МБ» нулей сжимаются в ~10 КБ
BOMB_MAX_FILE_BYTES = 10_000 # бомба — несколько килобайт файла
SMALL_MAX_BYTES = 100_000 # лимит размера файла в тесте: распакованный лимит = 10 × 100 000 байт
MAIN_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
_PACKAGE_RELS_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
_OFFICE_RELS_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
_STYLES = (
''
''
''
''
)
@pytest.fixture(scope="module")
def rules() -> Rules:
return load_rules()
def _plain_xlsx() -> bytes:
book = openpyxl.Workbook()
sheet = book.active
assert sheet is not None
sheet.title = "ОСВ"
sheet["A1"] = "Оборотно-сальдовая ведомость по счету 60"
buffer = io.BytesIO()
book.save(buffer)
return buffer.getvalue()
def _rewrite(data: bytes, patch: Callable[[str, bytes], bytes], extra: dict[str, bytes] | None = None) -> bytes:
"""Копия zip-архива книги: содержимое частей через `patch(имя, байты)` и дополнительные части `extra`."""
source = zipfile.ZipFile(io.BytesIO(data))
out = io.BytesIO()
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as target:
for info in source.infolist():
target.writestr(info.filename, patch(info.filename, source.read(info.filename)))
for name, content in (extra or {}).items():
target.writestr(name, content)
return out.getvalue()
def _xlsx_with_merges(*refs: str, tag: str = "mergeCell") -> bytes:
cells = "".join(f'<{tag} ref="{ref}"/>' for ref in refs).encode()
block = b'' % len(refs) + cells + b""
def patch(name: str, content: bytes) -> bytes:
return content.replace(b"", b"" + block) if name == SHEET_PART else content
return _rewrite(_plain_xlsx(), patch)
def _xlsx_with_zeros(size: int) -> bytes:
return _rewrite(_plain_xlsx(), lambda _name, content: content, {"xl/media/zeros.bin": bytes(size)})
@pytest.fixture
def openpyxl_forbidden(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
"""openpyxl не должен вызываться: бомба отбрасывается до него (и до выделения памяти под ячейки).
Вызов не только падает, но и запоминается: `read_book` превращает любое исключение читателя в `UnreadableError`,
и тест на UNREADABLE без проверки после теста прошёл бы и при вызове openpyxl.
"""
calls: list[str] = []
def forbidden(*_args: Any, **_kwargs: Any) -> None:
calls.append("load_workbook")
raise AssertionError("openpyxl.load_workbook вызван: лимит должен сработать до чтения книги")
monkeypatch.setattr(openpyxl, "load_workbook", forbidden)
yield
assert calls == [], "openpyxl.load_workbook вызван: лимит должен сработать до чтения книги"
# --- Площадь объединённых ячеек (xlsx) ---
@pytest.mark.usefixtures("openpyxl_forbidden")
def test_huge_merged_range_is_rejected_before_openpyxl() -> None:
data = _xlsx_with_merges("A1:Z200000") # 5,2 млн ячеек в нескольких килобайтах файла
assert len(data) < BOMB_MAX_FILE_BYTES
started = time.monotonic()
with pytest.raises(UnsupportedFormatError) as caught:
read_book(data, "XLSX")
assert time.monotonic() - started < FAST_SECONDS
assert caught.value.code is ErrorCode.UNSUPPORTED_FORMAT
assert "лимит объединённых ячеек" in caught.value.message
assert str(MERGED_AREA_LIMIT) in caught.value.message
def _book(rules: Rules, max_bytes: int) -> BookParser:
statement_rules = default_rules()
return BookParser(
rules,
osv=OsvParser(rules),
statements=StatementPipeline(statement_rules),
statement_rules=statement_rules,
max_bytes=max_bytes,
timeout_seconds=120,
)
@pytest.mark.usefixtures("openpyxl_forbidden")
def test_many_small_merged_ranges_are_summed() -> None:
per_range = 1000 # A1:A1000 и т. д.: каждый диапазон по отдельности невелик
count = MERGED_AREA_LIMIT // per_range + 1
refs = [f"B{start}:B{start + per_range - 1}" for start in range(1, count * per_range, per_range)]
with pytest.raises(UnsupportedFormatError, match="лимит объединённых ячеек"):
read_book(_xlsx_with_merges(*refs), "XLSX")
@pytest.mark.usefixtures("openpyxl_forbidden")
def test_huge_merged_range_gives_unsupported_format_from_parser(rules: Rules) -> None:
outcome = _book(rules, 50 * 1024 * 1024).parse(_xlsx_with_merges("A1:Z200000"), "XLSX")
assert outcome.status is ResultStatus.ERROR
assert outcome.error is not None
assert outcome.error.code is ErrorCode.UNSUPPORTED_FORMAT
def test_normal_merges_are_read() -> None:
sheets, _ = read_book(_xlsx_with_merges("A3:A4", "B3:C3"), "XLSX")
assert set(sheets[0].merged) == {MergedRange(3, 1, 4, 1), MergedRange(3, 2, 3, 3)}
@pytest.mark.parametrize("chunk_size", [1, 7, 64, 65_536])
def test_scan_is_independent_of_chunk_boundaries(chunk_size: int) -> None:
xml = (
b'1
'
b''
b''
) % (MAIN_NS.encode(), MAIN_NS.encode())
expected = 4 + 1 + 10 + 4
assert merged_area_of_xml(io.BytesIO(xml), limit=MERGED_AREA_LIMIT, chunk_size=chunk_size) == expected
def test_scan_limit_is_inclusive() -> None:
xml = b''
area = 100
assert merged_area_of_xml(io.BytesIO(xml), limit=area) == area
with pytest.raises(UnsupportedFormatError, match="лимит объединённых ячеек"):
merged_area_of_xml(io.BytesIO(xml), limit=area - 1)
def test_scan_ignores_merge_cells_container_and_other_elements() -> None:
xml = b''
assert merged_area_of_xml(io.BytesIO(xml), limit=MERGED_AREA_LIMIT, chunk_size=5) == 0
def test_overlong_merge_element_is_rejected() -> None:
"""Раздутый атрибут не прячет диапазон от сканера: слишком длинный элемент mergeCell — отказ, а не пропуск."""
xml = b''
with pytest.raises(UnsupportedFormatError, match="лимит объединённых ячеек"):
merged_area_of_xml(io.BytesIO(xml), limit=MERGED_AREA_LIMIT, chunk_size=4096)
# --- Обходы скана (повторное ревью I-1б): книги собираются вручную, `zipfile`, без openpyxl ---
BOMB_REF = "A1:Z200000" # 5,2 млн ячеек
SMALL_REF = "A1:B2"
SMALL_SPAN = MergedRange(1, 1, 2, 2)
XML_DECLARATION = ''
def _hand_xlsx(
merge_cells: str,
*,
sheet_part: str = SHEET_PART,
extra: dict[str, bytes] | None = None,
sheet_head: str = XML_DECLARATION,
sheet_encoding: str = "utf-8",
) -> bytes:
"""Минимальная книга xlsx из одного листа: `merge_cells` вставляется после `sheetData`, лист лежит в `sheet_part`
(цель связи в `xl/_rels/workbook.xml.rels` — openpyxl идёт по ней, имя части не проверяет), начинается с
`sheet_head` (по умолчанию XML-декларация) и записан в кодировке `sheet_encoding`."""
target = sheet_part.removeprefix("xl/")
parts = {
"[Content_Types].xml": (
''
''
''
''
''
),
"_rels/.rels": (
f''
f''
""
),
"xl/workbook.xml": (
f''
''
),
"xl/_rels/workbook.xml.rels": (
f''
f''
f''
),
"xl/styles.xml": f'{_STYLES}',
}
sheet = (
f'{sheet_head}'
'Оборотно-сальдовая ведомость по счету 60
'
f"{merge_cells}"
)
out = io.BytesIO()
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as archive:
for name, content in parts.items():
archive.writestr(name, content)
archive.writestr(sheet_part, sheet.encode(sheet_encoding))
for name, raw in (extra or {}).items():
archive.writestr(name, raw)
return out.getvalue()
def _merges(element: str) -> str:
return f'{element}'
# Вариант → (элемент mergeCell с диапазоном SMALL_REF, тот же с BOMB_REF, часть листа)
_BYPASSES: dict[str, tuple[str, str, str]] = {
"исходная бомба": (f'', f'', SHEET_PART),
"лист вне xl/worksheets": (f'', f'', "xl/data/s.xml"),
"лист без расширения .xml": (f'', f'', "xl/data/s.dat"),
"десятичная ссылка на символ": ('', '', SHEET_PART),
"шестнадцатеричная ссылка на символ": (
'',
'',
SHEET_PART,
),
"атрибут a:ref перед ref": (
f'',
f'',
SHEET_PART,
),
"«>» в значении атрибута": (
f'',
f'',
SHEET_PART,
),
"элемент с префиксом основного пространства": (
f'',
f'',
SHEET_PART,
),
"элемент чужого пространства имён": (
f'',
f'',
SHEET_PART,
),
}
@pytest.mark.parametrize("variant", list(_BYPASSES))
def test_bypass_variant_is_honoured_by_openpyxl(variant: str) -> None:
"""Каждый вариант — не выдумка: openpyxl на малом диапазоне действительно читает объединение (значит, на
большом разложил бы его по ячейкам), а скан пропускает книгу в пределах лимита и видит площадь диапазона."""
small, _, part = _BYPASSES[variant]
data = _hand_xlsx(_merges(small), sheet_part=part)
sheets, _ = read_book(data, "XLSX")
assert sheets[0].merged == (SMALL_SPAN,)
with zipfile.ZipFile(io.BytesIO(data)) as archive, pytest.raises(UnsupportedFormatError):
check_xlsx_merged_area(archive, limit=SMALL_SPAN.area - 1)
@pytest.mark.usefixtures("openpyxl_forbidden")
@pytest.mark.parametrize("variant", list(_BYPASSES))
def test_bypass_variant_bomb_is_rejected_before_openpyxl(variant: str) -> None:
_, bomb, part = _BYPASSES[variant]
data = _hand_xlsx(_merges(bomb), sheet_part=part)
assert len(data) < BOMB_MAX_FILE_BYTES
started = time.monotonic()
with pytest.raises(UnsupportedFormatError) as caught:
read_book(data, "XLSX")
assert time.monotonic() - started < FAST_SECONDS
assert caught.value.code is ErrorCode.UNSUPPORTED_FORMAT
assert "лимит объединённых ячеек" in caught.value.message
@pytest.mark.usefixtures("openpyxl_forbidden")
def test_whole_sheet_merge_costs_one_step() -> None:
"""Площадь считается по строке ref, ячейки не перебираются: диапазон на весь лист отвергается сразу."""
started = time.monotonic()
with pytest.raises(UnsupportedFormatError, match="лимит объединённых ячеек"):
read_book(_hand_xlsx(_merges('')), "XLSX")
assert time.monotonic() - started < FAST_SECONDS
def test_unprefixed_ref_only_is_counted() -> None:
"""Считается только атрибут `ref` без префикса (его читает openpyxl); `a:ref` с бомбой — чужой атрибут."""
xml = (
b''
b""
) % MAIN_NS.encode()
assert merged_area_of_xml(io.BytesIO(xml), limit=MERGED_AREA_LIMIT) == SMALL_SPAN.area
def test_scan_stops_reading_as_soon_as_limit_is_exceeded() -> None:
"""Ранний отказ: поток после бомбы не дочитывается (и даже битый XML за ней не важен)."""
head = b''
stream = io.BytesIO(head + b"x" * 1_000_000 + b"<<<")
with pytest.raises(UnsupportedFormatError, match="лимит объединённых ячеек"):
merged_area_of_xml(stream, limit=MERGED_AREA_LIMIT, chunk_size=len(head))
assert stream.tell() < 2 * len(head)
@pytest.mark.usefixtures("openpyxl_forbidden")
def test_malformed_xml_part_is_unreadable() -> None:
data = _hand_xlsx(_merges('')) # элемент не закрыт
with pytest.raises(UnreadableError) as caught:
read_book(data, "XLSX")
assert caught.value.code is ErrorCode.UNREADABLE
@pytest.mark.usefixtures("openpyxl_forbidden")
@pytest.mark.parametrize("element", ['', '', ""])
def test_unparseable_merge_ref_is_unreadable(element: str) -> None:
"""ref, который openpyxl не разберёт (или его нет), — книга не читается, как и у openpyxl."""
with pytest.raises(UnreadableError, match="mergeCell"):
read_book(_hand_xlsx(_merges(element)), "XLSX")
def test_non_xml_parts_that_are_not_xml_do_not_break_reading() -> None:
"""Части без расширения .xml сканируются все, но мягко: бинарные (картинки, printerSettings), текст и не вполне
корректный VML книгу не ломают — ошибка разбора лишь заканчивает скан части (openpyxl их не разбирает)."""
extra = {
"xl/media/image1.png": b"\x89PNG\r\n\x1a\n" + bytes(64),
"xl/printerSettings/printerSettings1.bin": "\\\\принтер".encode("utf-16-le") + bytes(64),
"xl/drawings/vmlDrawing1.vml": b"
",
"xl/notes.txt": b" " * 100 + b"plain text, not XML",
"xl/media/empty.bin": b"",
}
sheets, _ = read_book(_hand_xlsx(_merges(f''), extra=extra), "XLSX")
assert sheets[0].merged == (SMALL_SPAN,)
@pytest.mark.usefixtures("openpyxl_forbidden")
def test_utf16_part_is_scanned() -> None:
"""Кодировка части не прячет диапазон: expat сам определяет UTF-16 по BOM."""
bomb = _merges(f'')
sheet = f'{bomb}'
data = _hand_xlsx("", extra={"xl/data/u.bin": sheet.encode("utf-16")})
with pytest.raises(UnsupportedFormatError, match="лимит объединённых ячеек"):
read_book(data, "XLSX")
# Обход «sniff» (повторное ревью раунда 2, I-1б): лист в части без `.xml`, ≥ 64 байт ведущих пробелов, без
# XML-декларации — первые 64 байта не начинаются с «<», и проверка «похоже на XML» часть пропускала (27 с / 1,4 ГБ)
CRAFTED_PART = "xl/worksheets/sheet1.dat"
CRAFTED_PADDING = " " * 64
SLOW_READ_SECONDS = 2.0
# Вариант → (начало части листа, кодировка)
_SNIFF_BYPASSES: dict[str, tuple[str, str]] = {
"64 пробела без декларации": (CRAFTED_PADDING, "utf-8"),
"BOM UTF-16 и 64 пробела без декларации": (CRAFTED_PADDING, "utf-16"),
"4096 пробелов без декларации": (" " * 4096, "utf-8"),
}
@pytest.mark.parametrize("variant", list(_SNIFF_BYPASSES))
def test_padded_non_xml_sheet_part_is_honoured_by_openpyxl(variant: str) -> None:
"""Вариант не выдумка: openpyxl читает такой лист и раскладывает его объединения."""
head, encoding = _SNIFF_BYPASSES[variant]
data = _hand_xlsx(
_merges(f''), sheet_part=CRAFTED_PART, sheet_head=head, sheet_encoding=encoding
)
sheets, _ = read_book(data, "XLSX")
assert sheets[0].merged == (SMALL_SPAN,)
with zipfile.ZipFile(io.BytesIO(data)) as archive, pytest.raises(UnsupportedFormatError):
check_xlsx_merged_area(archive, limit=SMALL_SPAN.area - 1)
@pytest.mark.usefixtures("openpyxl_forbidden")
@pytest.mark.parametrize("variant", list(_SNIFF_BYPASSES))
def test_padded_non_xml_sheet_part_bomb_is_rejected_before_openpyxl(variant: str) -> None:
head, encoding = _SNIFF_BYPASSES[variant]
data = _hand_xlsx(
_merges(f''), sheet_part=CRAFTED_PART, sheet_head=head, sheet_encoding=encoding
)
assert len(data) < BOMB_MAX_FILE_BYTES
started = time.monotonic()
with pytest.raises(UnsupportedFormatError) as caught:
read_book(data, "XLSX")
assert time.monotonic() - started < SLOW_READ_SECONDS
assert caught.value.code is ErrorCode.UNSUPPORTED_FORMAT
assert "лимит объединённых ячеек" in caught.value.message
# --- Площадь объединённых ячеек после чтения (xls/xlsb: индекс merged в `Sheet`) ---
def _sheet_with(*spans: MergedRange) -> Sheet:
return Sheet(name="Лист", cells={}, merged=spans, row_outline={}, n_rows=1, n_cols=1)
def _indexable_spans(count: int) -> tuple[MergedRange, ...]:
"""`count` диапазонов площадью ровно `MAX_INDEXED_AREA` (столбец за столбцом): каждый индекс раскладывает."""
return tuple(MergedRange(1, col, MAX_INDEXED_AREA, col) for col in range(1, count + 1))
def test_merged_area_of_read_sheets_is_limited() -> None:
"""xlrd хранит merged списком (8 байт на диапазон), но индекс `Sheet` раскладывает диапазоны по ячейкам:
тысячи средних диапазонов в xls дали бы десятки миллионов записей. Та же сумма площадей — после чтения.
"""
half = _indexable_spans(MERGED_AREA_LIMIT // MAX_INDEXED_AREA // 2)
check_merged_area([_sheet_with(*half), _sheet_with(*half)], limit=MERGED_AREA_LIMIT)
with pytest.raises(UnsupportedFormatError, match="лимит объединённых ячеек"):
check_merged_area([_sheet_with(*half), _sheet_with(*half, MergedRange(1, 99, 1, 99))], limit=MERGED_AREA_LIMIT)
def test_spans_not_indexed_by_sheet_are_not_counted_after_read() -> None:
"""Диапазон больше `MAX_INDEXED_AREA` (merged на весь столбец, на весь лист) `Sheet` по ячейкам не раскладывает —
риска нет, и законная книга xls с таким диапазоном не отвергается (находка N1)."""
whole_column = MergedRange(1, 1, 1_048_576, 1)
whole_sheet = MergedRange(1, 1, 1_048_576, 16_384)
just_above = MergedRange(1, 1, MAX_INDEXED_AREA + 1, 1)
check_merged_area([_sheet_with(whole_column, whole_sheet, just_above)], limit=MERGED_AREA_LIMIT)
with pytest.raises(UnsupportedFormatError, match="лимит объединённых ячеек"):
check_merged_area([_sheet_with(whole_column, *_indexable_spans(MERGED_AREA_LIMIT // MAX_INDEXED_AREA + 1))])
def test_xls_reader_result_is_checked(monkeypatch: pytest.MonkeyPatch) -> None:
many = _sheet_with(*_indexable_spans(MERGED_AREA_LIMIT // MAX_INDEXED_AREA + 1))
monkeypatch.setitem(_READERS, "XLS", ("BIFF8", lambda _data: (many,)))
with pytest.raises(UnsupportedFormatError, match="лимит объединённых ячеек"):
read_book(bytes.fromhex("D0CF11E0A1B11AE1") + b"\x00" * 64, "XLS")
def test_xls_with_whole_column_merge_is_read(monkeypatch: pytest.MonkeyPatch) -> None:
sheet = _sheet_with(MergedRange(1, 1, 1_048_576, 1))
monkeypatch.setitem(_READERS, "XLS", ("BIFF8", lambda _data: (sheet,)))
sheets, _ = read_book(bytes.fromhex("D0CF11E0A1B11AE1") + b"\x00" * 64, "XLS")
assert sheets == (sheet,)
def _large_spans(count: int) -> tuple[MergedRange, ...]:
"""`count` диапазонов площадью `MAX_INDEXED_AREA + 1` (столбец за столбцом): индекс не раскладывает ни один."""
return tuple(MergedRange(1, col, MAX_INDEXED_AREA + 1, col) for col in range(1, count + 1))
def test_large_spans_count_is_limited_per_sheet() -> None:
"""Крупные диапазоны (площадь больше `MAX_INDEXED_AREA`) `Sheet.merged_span` перебирает линейно на каждое
обращение: в xls их 100 000 — ≈ 4,4 мс на обращение (повторное ревью раунда 2, минор #2). Потолок — на лист."""
at_limit = _large_spans(LARGE_MERGED_COUNT_LIMIT)
check_merged_area([_sheet_with(*at_limit), _sheet_with(*at_limit)])
with pytest.raises(UnsupportedFormatError, match="лимит крупных объединённых диапазонов") as caught:
check_merged_area([_sheet_with(*_large_spans(LARGE_MERGED_COUNT_LIMIT + 1))])
assert str(LARGE_MERGED_COUNT_LIMIT) in caught.value.message
def test_xls_with_too_many_large_spans_is_unsupported(monkeypatch: pytest.MonkeyPatch) -> None:
many = _sheet_with(*_large_spans(100_000))
monkeypatch.setitem(_READERS, "XLS", ("BIFF8", lambda _data: (many,)))
started = time.monotonic()
with pytest.raises(UnsupportedFormatError) as caught:
read_book(bytes.fromhex("D0CF11E0A1B11AE1") + b"\x00" * 64, "XLS")
assert time.monotonic() - started < FAST_SECONDS
assert caught.value.code is ErrorCode.UNSUPPORTED_FORMAT
assert "лимит крупных объединённых диапазонов" in caught.value.message
# --- Распакованный размер zip ---
@pytest.mark.usefixtures("openpyxl_forbidden")
def test_zip_expanding_beyond_limit_is_rejected() -> None:
data = _xlsx_with_zeros(ZEROS_BYTES)
assert len(data) < SMALL_MAX_BYTES
with pytest.raises(UnsupportedFormatError) as caught:
read_book(data, "XLSX", max_uncompressed_bytes=ZIP_UNCOMPRESSED_LIMIT_FACTOR * SMALL_MAX_BYTES)
assert caught.value.code is ErrorCode.UNSUPPORTED_FORMAT
assert "распакованный размер" in caught.value.message
assert str(ZIP_UNCOMPRESSED_LIMIT_FACTOR * SMALL_MAX_BYTES) in caught.value.message
@pytest.mark.usefixtures("openpyxl_forbidden")
def test_parser_passes_ten_times_max_bytes_as_zip_limit(rules: Rules) -> None:
outcome = _book(rules, SMALL_MAX_BYTES).parse(_xlsx_with_zeros(ZEROS_BYTES), "XLSX")
assert outcome.error is not None
assert outcome.error.code is ErrorCode.UNSUPPORTED_FORMAT
assert "распакованный размер" in outcome.error.message
def test_zip_limit_applies_to_xlsb_too() -> None:
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
archive.writestr("xl/workbook.bin", bytes(ZEROS_BYTES))
with pytest.raises(UnsupportedFormatError, match="распакованный размер"):
read_book(buffer.getvalue(), "XLSB", max_uncompressed_bytes=SMALL_MAX_BYTES)
def test_zip_within_limit_is_read() -> None:
data = _xlsx_with_zeros(1000)
sheets, _ = read_book(data, "XLSX", max_uncompressed_bytes=ZIP_UNCOMPRESSED_LIMIT_FACTOR * SMALL_MAX_BYTES)
assert [sheet.name for sheet in sheets] == ["ОСВ"]