Heavy computation is completed.
This commit is contained in:
parent
3585c75b2a
commit
eeb9c31455
30 changed files with 193638 additions and 105489 deletions
660
automation/clean_wolfram_notebook.py
Normal file
660
automation/clean_wolfram_notebook.py
Normal file
|
|
@ -0,0 +1,660 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Удаляет из текстовых Wolfram Notebook (.nb) ячейки с Manipulate/CellPrint.
|
||||
|
||||
Удаляется вся вычислительная группа CellGroupData, если одна из её Input-ячеек
|
||||
содержит Manipulate[...] или CellPrint[...]. Поэтому вместе с исходной ячейкой
|
||||
исчезают связанные Output/Message/Print-ячейки.
|
||||
|
||||
Служебные блоки Notebook (CacheID, NotebookFileOutline, CellTagsIndex и весь
|
||||
Internal cache information) не анализируются и не изменяются. Все нетронутые
|
||||
части списка ячеек также сохраняются посимвольно.
|
||||
|
||||
Примеры:
|
||||
python clean_wolfram_notebook.py notebook.nb
|
||||
python clean_wolfram_notebook.py a.nb b.nb --output-dir cleaned
|
||||
python clean_wolfram_notebook.py notebook.nb --in-place
|
||||
python clean_wolfram_notebook.py notebook.nb --dry-run
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Optional
|
||||
|
||||
|
||||
RESULT_STYLES = {
|
||||
"Output",
|
||||
"Print",
|
||||
"Message",
|
||||
"Echo",
|
||||
"GeneratedCell",
|
||||
}
|
||||
|
||||
|
||||
class NotebookFormatError(RuntimeError):
|
||||
"""Файл не похож на корректный текстовый Wolfram Notebook."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Stats:
|
||||
manipulate_groups: int = 0
|
||||
cellprint_groups: int = 0
|
||||
manipulate_cells: int = 0
|
||||
cellprint_cells: int = 0
|
||||
result_cells: int = 0
|
||||
|
||||
@property
|
||||
def removed_groups(self) -> int:
|
||||
return self.manipulate_groups + self.cellprint_groups
|
||||
|
||||
@property
|
||||
def removed_input_cells(self) -> int:
|
||||
return self.manipulate_cells + self.cellprint_cells
|
||||
|
||||
def add(self, other: "Stats") -> None:
|
||||
self.manipulate_groups += other.manipulate_groups
|
||||
self.cellprint_groups += other.cellprint_groups
|
||||
self.manipulate_cells += other.manipulate_cells
|
||||
self.cellprint_cells += other.cellprint_cells
|
||||
self.result_cells += other.result_cells
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParsedCall:
|
||||
open_index: int
|
||||
close_index: int
|
||||
args: list[str]
|
||||
arg_ranges: list[tuple[int, int]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParsedList:
|
||||
open_index: int
|
||||
close_index: int
|
||||
items: list[str]
|
||||
item_ranges: list[tuple[int, int]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InputTarget:
|
||||
manipulate: bool = False
|
||||
cellprint: bool = False
|
||||
|
||||
@property
|
||||
def any(self) -> bool:
|
||||
return self.manipulate or self.cellprint
|
||||
|
||||
|
||||
def _scan_top_level_ranges(text: str, start: int, end: int) -> list[tuple[int, int]]:
|
||||
"""Разбивает диапазон по запятым верхнего уровня.
|
||||
|
||||
Учитываются строки, экранирование, вложенные (), [] и {}, а также
|
||||
вложенные комментарии Wolfram вида (* ... *).
|
||||
"""
|
||||
ranges: list[tuple[int, int]] = []
|
||||
item_start = start
|
||||
stack: list[str] = []
|
||||
pairs = {")": "(", "]": "[", "}": "{"}
|
||||
in_string = False
|
||||
escaped = False
|
||||
comment_depth = 0
|
||||
i = start
|
||||
|
||||
while i < end:
|
||||
ch = text[i]
|
||||
nxt = text[i + 1] if i + 1 < end else ""
|
||||
|
||||
if comment_depth:
|
||||
if ch == "(" and nxt == "*":
|
||||
comment_depth += 1
|
||||
i += 2
|
||||
continue
|
||||
if ch == "*" and nxt == ")":
|
||||
comment_depth -= 1
|
||||
i += 2
|
||||
continue
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if in_string:
|
||||
if escaped:
|
||||
escaped = False
|
||||
elif ch == "\\":
|
||||
escaped = True
|
||||
elif ch == '"':
|
||||
in_string = False
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if ch == "(" and nxt == "*":
|
||||
comment_depth = 1
|
||||
i += 2
|
||||
continue
|
||||
if ch == '"':
|
||||
in_string = True
|
||||
i += 1
|
||||
continue
|
||||
if ch in "([{":
|
||||
stack.append(ch)
|
||||
i += 1
|
||||
continue
|
||||
if ch in ")]}" :
|
||||
if not stack or stack[-1] != pairs[ch]:
|
||||
raise NotebookFormatError(
|
||||
f"Несогласованная скобка {ch!r} в позиции {i}"
|
||||
)
|
||||
stack.pop()
|
||||
i += 1
|
||||
continue
|
||||
if ch == "," and not stack:
|
||||
ranges.append((item_start, i))
|
||||
item_start = i + 1
|
||||
i += 1
|
||||
|
||||
if in_string:
|
||||
raise NotebookFormatError("Незакрытая строка в Notebook")
|
||||
if comment_depth:
|
||||
raise NotebookFormatError("Незакрытый комментарий в Notebook")
|
||||
if stack:
|
||||
raise NotebookFormatError("Незакрытая скобка в Notebook")
|
||||
|
||||
ranges.append((item_start, end))
|
||||
return ranges
|
||||
|
||||
|
||||
def _find_matching(text: str, open_index: int) -> int:
|
||||
opener = text[open_index]
|
||||
closer = {"[": "]", "{": "}", "(": ")"}.get(opener)
|
||||
if closer is None:
|
||||
raise ValueError(f"Ожидалась открывающая скобка, получено {opener!r}")
|
||||
|
||||
stack: list[str] = [opener]
|
||||
pairs = {")": "(", "]": "[", "}": "{"}
|
||||
in_string = False
|
||||
escaped = False
|
||||
comment_depth = 0
|
||||
i = open_index + 1
|
||||
|
||||
while i < len(text):
|
||||
ch = text[i]
|
||||
nxt = text[i + 1] if i + 1 < len(text) else ""
|
||||
|
||||
if comment_depth:
|
||||
if ch == "(" and nxt == "*":
|
||||
comment_depth += 1
|
||||
i += 2
|
||||
continue
|
||||
if ch == "*" and nxt == ")":
|
||||
comment_depth -= 1
|
||||
i += 2
|
||||
continue
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if in_string:
|
||||
if escaped:
|
||||
escaped = False
|
||||
elif ch == "\\":
|
||||
escaped = True
|
||||
elif ch == '"':
|
||||
in_string = False
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if ch == "(" and nxt == "*":
|
||||
comment_depth = 1
|
||||
i += 2
|
||||
continue
|
||||
if ch == '"':
|
||||
in_string = True
|
||||
i += 1
|
||||
continue
|
||||
if ch in "([{":
|
||||
stack.append(ch)
|
||||
i += 1
|
||||
continue
|
||||
if ch in ")]}" :
|
||||
if not stack or stack[-1] != pairs[ch]:
|
||||
raise NotebookFormatError(
|
||||
f"Несогласованная скобка {ch!r} в позиции {i}"
|
||||
)
|
||||
stack.pop()
|
||||
if not stack:
|
||||
return i
|
||||
i += 1
|
||||
continue
|
||||
i += 1
|
||||
|
||||
raise NotebookFormatError(
|
||||
f"Не найдена закрывающая скобка {closer!r} для позиции {open_index}"
|
||||
)
|
||||
|
||||
|
||||
def _skip_space_and_comments(text: str, pos: int, end: Optional[int] = None) -> int:
|
||||
limit = len(text) if end is None else end
|
||||
while pos < limit:
|
||||
if text[pos].isspace():
|
||||
pos += 1
|
||||
continue
|
||||
if pos + 1 < limit and text[pos : pos + 2] == "(*":
|
||||
depth = 1
|
||||
pos += 2
|
||||
while pos < limit and depth:
|
||||
if pos + 1 < limit and text[pos : pos + 2] == "(*":
|
||||
depth += 1
|
||||
pos += 2
|
||||
elif pos + 1 < limit and text[pos : pos + 2] == "*)":
|
||||
depth -= 1
|
||||
pos += 2
|
||||
else:
|
||||
pos += 1
|
||||
if depth:
|
||||
raise NotebookFormatError("Незакрытый комментарий")
|
||||
continue
|
||||
break
|
||||
return pos
|
||||
|
||||
|
||||
def _parse_call(expr: str, head: str) -> Optional[ParsedCall]:
|
||||
pos = _skip_space_and_comments(expr, 0)
|
||||
if not expr.startswith(head, pos):
|
||||
return None
|
||||
pos += len(head)
|
||||
pos = _skip_space_and_comments(expr, pos)
|
||||
if pos >= len(expr) or expr[pos] != "[":
|
||||
return None
|
||||
close = _find_matching(expr, pos)
|
||||
tail = _skip_space_and_comments(expr, close + 1)
|
||||
if tail != len(expr):
|
||||
return None
|
||||
ranges = _scan_top_level_ranges(expr, pos + 1, close)
|
||||
return ParsedCall(
|
||||
open_index=pos,
|
||||
close_index=close,
|
||||
args=[expr[a:b] for a, b in ranges],
|
||||
arg_ranges=ranges,
|
||||
)
|
||||
|
||||
|
||||
def _parse_list(expr: str) -> Optional[ParsedList]:
|
||||
pos = _skip_space_and_comments(expr, 0)
|
||||
if pos >= len(expr) or expr[pos] != "{":
|
||||
return None
|
||||
close = _find_matching(expr, pos)
|
||||
tail = _skip_space_and_comments(expr, close + 1)
|
||||
if tail != len(expr):
|
||||
return None
|
||||
ranges = _scan_top_level_ranges(expr, pos + 1, close)
|
||||
# Пустой список даёт один пустой диапазон — нормализуем его.
|
||||
if len(ranges) == 1 and not expr[ranges[0][0] : ranges[0][1]].strip():
|
||||
ranges = []
|
||||
return ParsedList(
|
||||
open_index=pos,
|
||||
close_index=close,
|
||||
items=[expr[a:b] for a, b in ranges],
|
||||
item_ranges=ranges,
|
||||
)
|
||||
|
||||
|
||||
def _quoted_string(expr: str) -> Optional[str]:
|
||||
s = expr.strip()
|
||||
if len(s) < 2 or s[0] != '"' or s[-1] != '"':
|
||||
return None
|
||||
# Для стилей Notebook обычно нет escape-последовательностей.
|
||||
return s[1:-1]
|
||||
|
||||
|
||||
def _cell_style(cell_expr: str) -> Optional[str]:
|
||||
cell = _parse_call(cell_expr.strip(), "Cell")
|
||||
if cell is None or len(cell.args) < 2:
|
||||
return None
|
||||
return _quoted_string(cell.args[1])
|
||||
|
||||
|
||||
def _input_target(cell_expr: str) -> InputTarget:
|
||||
if _cell_style(cell_expr) != "Input":
|
||||
return InputTarget()
|
||||
|
||||
# Стандартное коробочное представление: RowBox[{"Manipulate", "[", ...}]
|
||||
manipulate = bool(
|
||||
re.search(r'"Manipulate"\s*,\s*"\\?\["', cell_expr)
|
||||
or re.search(r'(?<![A-Za-z0-9_$`])Manipulate\s*\[', cell_expr)
|
||||
)
|
||||
cellprint = bool(
|
||||
re.search(r'"CellPrint"\s*,\s*"\\?\["', cell_expr)
|
||||
or re.search(r'(?<![A-Za-z0-9_$`])CellPrint\s*\[', cell_expr)
|
||||
)
|
||||
return InputTarget(manipulate=manipulate, cellprint=cellprint)
|
||||
|
||||
|
||||
def _join_modified_items(original_content: str, items: Iterable[str]) -> str:
|
||||
"""Соединяет оставшиеся элементы, не форматируя их заново.
|
||||
|
||||
Каждый элемент передаётся как точный срез исходного файла вместе с его
|
||||
пробелами и переводами строк. Меняются только удалённые элементы и
|
||||
разделяющие их запятые.
|
||||
"""
|
||||
kept = [item for item in items if item.strip()]
|
||||
if not kept:
|
||||
# Пустое содержимое списка допустимо. Намеренно не переносим сюда
|
||||
# пробелы удалённых ячеек, чтобы не оставлять огромные пустые области.
|
||||
return ""
|
||||
return ",".join(kept)
|
||||
|
||||
|
||||
def _clean_cell_group(cell_expr: str) -> tuple[Optional[str], Stats]:
|
||||
stats = Stats()
|
||||
cell = _parse_call(cell_expr.strip(), "Cell")
|
||||
if cell is None or not cell.args:
|
||||
return cell_expr, stats
|
||||
|
||||
group_arg = cell.args[0]
|
||||
group = _parse_call(group_arg.strip(), "CellGroupData")
|
||||
if group is None or not group.args:
|
||||
return cell_expr, stats
|
||||
|
||||
children_expr = group.args[0]
|
||||
children_stripped = children_expr.strip()
|
||||
children = _parse_list(children_stripped)
|
||||
if children is None:
|
||||
return cell_expr, stats
|
||||
|
||||
# Если непосредственно в этой вычислительной группе есть целевой Input,
|
||||
# удаляем всю группу: так гарантированно уходят все её Output/Message/Print.
|
||||
direct_target = InputTarget()
|
||||
for child in children.items:
|
||||
target = _input_target(child.strip())
|
||||
direct_target = InputTarget(
|
||||
manipulate=direct_target.manipulate or target.manipulate,
|
||||
cellprint=direct_target.cellprint or target.cellprint,
|
||||
)
|
||||
|
||||
if direct_target.any:
|
||||
if direct_target.manipulate:
|
||||
stats.manipulate_groups += 1
|
||||
elif direct_target.cellprint:
|
||||
stats.cellprint_groups += 1
|
||||
# В редком случае обе конструкции находятся в одной группе, считаем
|
||||
# группу один раз, с приоритетом Manipulate, но ячейки ниже не нужны.
|
||||
return None, stats
|
||||
|
||||
cleaned_items, child_stats, changed = _clean_cell_list(
|
||||
children_stripped[children.open_index + 1 : children.close_index]
|
||||
)
|
||||
stats.add(child_stats)
|
||||
if not changed:
|
||||
return cell_expr, stats
|
||||
|
||||
new_children_stripped = (
|
||||
children_stripped[: children.open_index + 1]
|
||||
+ cleaned_items
|
||||
+ children_stripped[children.close_index :]
|
||||
)
|
||||
children_leading = children_expr[: len(children_expr) - len(children_expr.lstrip())]
|
||||
children_trailing = children_expr[len(children_expr.rstrip()) :]
|
||||
new_children_expr = children_leading + new_children_stripped + children_trailing
|
||||
|
||||
# Заменяем первый аргумент CellGroupData внутри исходного выражения.
|
||||
group_stripped = group_arg.strip()
|
||||
group_parsed = _parse_call(group_stripped, "CellGroupData")
|
||||
assert group_parsed is not None
|
||||
arg0_start, arg0_end = group_parsed.arg_ranges[0]
|
||||
new_group_stripped = (
|
||||
group_stripped[:arg0_start]
|
||||
+ new_children_expr
|
||||
+ group_stripped[arg0_end:]
|
||||
)
|
||||
group_leading = group_arg[: len(group_arg) - len(group_arg.lstrip())]
|
||||
group_trailing = group_arg[len(group_arg.rstrip()) :]
|
||||
new_group_arg = group_leading + new_group_stripped + group_trailing
|
||||
|
||||
cell_stripped = cell_expr.strip()
|
||||
cell_parsed = _parse_call(cell_stripped, "Cell")
|
||||
assert cell_parsed is not None
|
||||
cell_arg0_start, cell_arg0_end = cell_parsed.arg_ranges[0]
|
||||
new_cell_stripped = (
|
||||
cell_stripped[:cell_arg0_start]
|
||||
+ new_group_arg
|
||||
+ cell_stripped[cell_arg0_end:]
|
||||
)
|
||||
leading = cell_expr[: len(cell_expr) - len(cell_expr.lstrip())]
|
||||
trailing = cell_expr[len(cell_expr.rstrip()) :]
|
||||
return leading + new_cell_stripped + trailing, stats
|
||||
|
||||
|
||||
def _clean_cell_list(content: str) -> tuple[str, Stats, bool]:
|
||||
ranges = _scan_top_level_ranges(content, 0, len(content))
|
||||
if len(ranges) == 1 and not content[ranges[0][0] : ranges[0][1]].strip():
|
||||
return content, Stats(), False
|
||||
|
||||
stats = Stats()
|
||||
kept: list[str] = []
|
||||
changed = False
|
||||
remove_following_results = False
|
||||
|
||||
for start, end in ranges:
|
||||
raw_item = content[start:end]
|
||||
item = raw_item.strip()
|
||||
if not item:
|
||||
continue
|
||||
|
||||
style = _cell_style(item)
|
||||
|
||||
if remove_following_results:
|
||||
if style in RESULT_STYLES:
|
||||
stats.result_cells += 1
|
||||
changed = True
|
||||
continue
|
||||
remove_following_results = False
|
||||
|
||||
target = _input_target(item)
|
||||
if target.any:
|
||||
if target.manipulate:
|
||||
stats.manipulate_cells += 1
|
||||
elif target.cellprint:
|
||||
stats.cellprint_cells += 1
|
||||
changed = True
|
||||
remove_following_results = True
|
||||
continue
|
||||
|
||||
cleaned_group, group_stats = _clean_cell_group(raw_item)
|
||||
stats.add(group_stats)
|
||||
if cleaned_group is None:
|
||||
changed = True
|
||||
continue
|
||||
if cleaned_group != raw_item:
|
||||
changed = True
|
||||
kept.append(cleaned_group)
|
||||
|
||||
if not changed:
|
||||
return content, stats, False
|
||||
return _join_modified_items(content, kept), stats, True
|
||||
|
||||
|
||||
def _find_notebook_cell_list(text: str) -> tuple[int, int]:
|
||||
marker = "(* Beginning of Notebook Content *)"
|
||||
search_from = text.find(marker)
|
||||
if search_from < 0:
|
||||
search_from = 0
|
||||
|
||||
match = re.search(r"\bNotebook\s*\[", text[search_from:])
|
||||
if not match:
|
||||
raise NotebookFormatError("Не найдено выражение Notebook[...]")
|
||||
notebook_start = search_from + match.start()
|
||||
bracket = text.find("[", notebook_start)
|
||||
pos = _skip_space_and_comments(text, bracket + 1)
|
||||
if pos >= len(text) or text[pos] != "{":
|
||||
raise NotebookFormatError("Первый аргумент Notebook не является списком ячеек")
|
||||
close = _find_matching(text, pos)
|
||||
return pos, close
|
||||
|
||||
|
||||
def clean_notebook_text(text: str) -> tuple[str, Stats, bool]:
|
||||
"""Очищает только первый аргумент Notebook[{...}], не трогая кэш.
|
||||
|
||||
Префикс до списка ячеек и суффикс после него вставляются в результат без
|
||||
каких-либо преобразований. Это гарантирует сохранность CacheID и всего
|
||||
блока Internal cache information.
|
||||
"""
|
||||
list_open, list_close = _find_notebook_cell_list(text)
|
||||
prefix = text[: list_open + 1]
|
||||
original_content = text[list_open + 1 : list_close]
|
||||
suffix = text[list_close:]
|
||||
|
||||
cleaned_content, stats, changed = _clean_cell_list(original_content)
|
||||
cleaned = prefix + cleaned_content + suffix if changed else text
|
||||
|
||||
# Проверяем лишь синтаксическую доступность Notebook и списка ячеек.
|
||||
# Служебные блоки после Notebook[...] намеренно не читаются и не меняются.
|
||||
_find_notebook_cell_list(cleaned)
|
||||
return cleaned, stats, changed
|
||||
|
||||
|
||||
def _read_text(path: Path) -> tuple[str, str, bool]:
|
||||
data = path.read_bytes()
|
||||
has_bom = data.startswith(b"\xef\xbb\xbf")
|
||||
if has_bom:
|
||||
data = data[3:]
|
||||
try:
|
||||
text = data.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise NotebookFormatError(
|
||||
f"{path}: ожидается текстовый .nb в UTF-8/ASCII: {exc}"
|
||||
) from exc
|
||||
|
||||
newline = "\r\n" if b"\r\n" in data else "\n"
|
||||
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
||||
return text, newline, has_bom
|
||||
|
||||
|
||||
def _encode_text(text: str, newline: str, has_bom: bool) -> bytes:
|
||||
if newline != "\n":
|
||||
text = text.replace("\n", newline)
|
||||
data = text.encode("utf-8")
|
||||
return (b"\xef\xbb\xbf" + data) if has_bom else data
|
||||
|
||||
|
||||
def _atomic_write(path: Path, data: bytes, mode: int = 0o644) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
||||
temp_path = Path(temp_name)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as fh:
|
||||
fh.write(data)
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
os.chmod(temp_path, mode)
|
||||
os.replace(temp_path, path)
|
||||
except Exception:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def _default_output_path(input_path: Path) -> Path:
|
||||
return input_path.with_name(f"{input_path.stem}_cleaned{input_path.suffix}")
|
||||
|
||||
|
||||
def _resolve_outputs(args: argparse.Namespace, inputs: list[Path]) -> list[Path]:
|
||||
if args.output:
|
||||
if len(inputs) != 1:
|
||||
raise SystemExit("Параметр --output можно использовать только с одним входным файлом")
|
||||
return [Path(args.output)]
|
||||
if args.output_dir:
|
||||
out_dir = Path(args.output_dir)
|
||||
return [out_dir / f"{p.stem}_cleaned{p.suffix}" for p in inputs]
|
||||
if args.in_place:
|
||||
return inputs
|
||||
return [_default_output_path(p) for p in inputs]
|
||||
|
||||
|
||||
def _print_stats(path: Path, stats: Stats, changed: bool, dry_run: bool) -> None:
|
||||
action = "Проверен" if dry_run else "Готов"
|
||||
status = "изменения найдены" if changed else "целевых ячеек нет"
|
||||
print(
|
||||
f"{action}: {path} — {status}; "
|
||||
f"групп Manipulate: {stats.manipulate_groups}, "
|
||||
f"групп CellPrint: {stats.cellprint_groups}, "
|
||||
f"отдельных Input: {stats.removed_input_cells}, "
|
||||
f"отдельных результатов: {stats.result_cells}"
|
||||
)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Удаляет из Wolfram Notebook группы с Manipulate/CellPrint "
|
||||
"вместе с их результатами."
|
||||
)
|
||||
)
|
||||
parser.add_argument("inputs", nargs="+", help="входные .nb-файлы")
|
||||
out = parser.add_mutually_exclusive_group()
|
||||
out.add_argument("-o", "--output", help="выходной файл (только для одного входа)")
|
||||
out.add_argument("--output-dir", help="каталог для очищенных файлов")
|
||||
out.add_argument(
|
||||
"--in-place",
|
||||
action="store_true",
|
||||
help="перезаписать входные файлы; рядом создаётся резервная копия .bak",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-backup",
|
||||
action="store_true",
|
||||
help="не создавать .bak при --in-place",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="только показать, что будет удалено, не записывая файлы",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Optional[list[str]] = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
inputs = [Path(p) for p in args.inputs]
|
||||
|
||||
for path in inputs:
|
||||
if not path.is_file():
|
||||
parser.error(f"файл не найден: {path}")
|
||||
if path.suffix.lower() != ".nb":
|
||||
parser.error(f"ожидался файл .nb: {path}")
|
||||
|
||||
outputs = _resolve_outputs(args, inputs)
|
||||
exit_code = 0
|
||||
|
||||
for input_path, output_path in zip(inputs, outputs):
|
||||
try:
|
||||
text, newline, has_bom = _read_text(input_path)
|
||||
cleaned, stats, changed = clean_notebook_text(text)
|
||||
|
||||
if args.dry_run:
|
||||
_print_stats(input_path, stats, changed, dry_run=True)
|
||||
continue
|
||||
|
||||
if args.in_place and changed and not args.no_backup:
|
||||
backup = input_path.with_suffix(input_path.suffix + ".bak")
|
||||
shutil.copy2(input_path, backup)
|
||||
|
||||
input_mode = input_path.stat().st_mode & 0o777
|
||||
_atomic_write(
|
||||
output_path,
|
||||
_encode_text(cleaned, newline, has_bom),
|
||||
mode=input_mode or 0o644,
|
||||
)
|
||||
_print_stats(output_path, stats, changed, dry_run=False)
|
||||
except (OSError, NotebookFormatError) as exc:
|
||||
print(f"Ошибка: {input_path}: {exc}", file=sys.stderr)
|
||||
exit_code = 1
|
||||
|
||||
return exit_code
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,16 +1,31 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Заполнение Excel-шаблона результатами из Mathematica Notebook (.nb).
|
||||
"""Заполнение одного листа Excel результатами из Mathematica Notebook (.nb).
|
||||
|
||||
Пример:
|
||||
python fill_table_from_nb.py \
|
||||
--template table_template.xlsx \
|
||||
--notebooks h_174-66_Pu240_2-3_osc_Qw1.nb \
|
||||
Все найденные диапазоны размещаются НА ОДНОМ ЛИСТЕ друг под другом:
|
||||
2-3
|
||||
4-5
|
||||
6-7
|
||||
...
|
||||
|
||||
Внутри каждого диапазона собираются шесть notebook-файлов:
|
||||
osc_Qw1, pr_Qw1, gid_Qw1,
|
||||
osc_Qw2, pr_Qw2, gid_Qw2.
|
||||
|
||||
Можно передать отдельные файлы, маски или корневые каталоги. Каталоги
|
||||
просматриваются рекурсивно. Файлы вроде "фитирование.nb" и ".nb.old"
|
||||
автоматически пропускаются.
|
||||
|
||||
Пример для всего дерева каталогов:
|
||||
python convert_precision.py \
|
||||
--template table_template \
|
||||
--notebooks . \
|
||||
--output table_filled.xlsx
|
||||
|
||||
Можно передать сразу несколько notebook-файлов. Имя каждого файла должно содержать:
|
||||
_2-3_ или _4-5_ — блок строк в таблице;
|
||||
_osc_, _pr_ или _gid_ — группу столбцов;
|
||||
_Qw1 или _Qw2 — левую или правую половину таблицы.
|
||||
Пример с маской:
|
||||
python convert_precision.py \
|
||||
--template table_template \
|
||||
--notebooks "**/*_Qw1.nb" "**/*_Qw2.nb" \
|
||||
--output table_filled.xlsx
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -20,16 +35,23 @@ import glob
|
|||
import math
|
||||
import re
|
||||
import sys
|
||||
from copy import copy
|
||||
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
# Границы блоков в конкретном шаблоне.
|
||||
PAIR_START_ROW = {
|
||||
"2-3": 5,
|
||||
"4-5": 19,
|
||||
}
|
||||
SCRIPT_VERSION = "2026.07.29.5-precision"
|
||||
|
||||
|
||||
# В шаблоне один блок занимает 14 строк:
|
||||
# 1-3 — заголовки, 4 — отступ, 5-12 — значения, 13-14 — отступ.
|
||||
TEMPLATE_BLOCK_TOP_ROW = 1
|
||||
BLOCK_HEIGHT = 14
|
||||
DATA_START_OFFSET = 4
|
||||
DATA_ROW_COUNT = 8
|
||||
TEMPLATE_MAX_COLUMN = 22 # A:V
|
||||
|
||||
# Смещение группы относительно первого столбца данных своей половины.
|
||||
MODEL_COLUMN_OFFSET = {
|
||||
|
|
@ -38,6 +60,8 @@ MODEL_COLUMN_OFFSET = {
|
|||
"gid": 6, # Jгид/J
|
||||
}
|
||||
|
||||
MODEL_SORT_ORDER = {"osc": 0, "pr": 1, "gid": 2}
|
||||
|
||||
# Порядок столбцов внутри каждой группы шаблона.
|
||||
METHOD_COLUMN_OFFSET = {
|
||||
"heavy": 0,
|
||||
|
|
@ -67,6 +91,8 @@ GROUP_RE = re.compile(r"Cell\[CellGroupData\[\{(.*?)\},\s*Open\s*\]\]", re.S)
|
|||
INPUT_LABEL_RE = re.compile(r'CellLabel->\s*"In\[(\d+)\]:="')
|
||||
ASSIGNMENT_RE = re.compile(r'RowBox\[\{"([A-Za-z][A-Za-z0-9]*)",\s*"="')
|
||||
SIMPLE_OUTPUT_RE = re.compile(r'Cell\[BoxData\["([^"]+)"\],\s*"Output"', re.S)
|
||||
PAIR_RE = re.compile(r"(?:^|_)(\d+)-(\d+)(?=_|$)")
|
||||
QW_RE = re.compile(r"qw[_-]?([12])(?:[_-]|$)", re.I)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -81,6 +107,8 @@ class NbRecord:
|
|||
@dataclass(frozen=True)
|
||||
class Target:
|
||||
pair: str
|
||||
pair_left: int
|
||||
pair_right: int
|
||||
model: str
|
||||
qw_side: int
|
||||
|
||||
|
|
@ -185,13 +213,40 @@ def first_value_for_assignment(
|
|||
return None
|
||||
|
||||
|
||||
def extract_methods(records: list[NbRecord]) -> dict[str, MethodValues]:
|
||||
def extract_methods(
|
||||
records: list[NbRecord],
|
||||
*,
|
||||
source_path: Path | None = None,
|
||||
source_label: str | None = None,
|
||||
) -> dict[str, MethodValues]:
|
||||
"""Выделяет heavy/new/light без привязки к номерам In[]/Out[]."""
|
||||
q_indexes = [i for i, record in enumerate(records) if is_q_result(record)]
|
||||
if len(q_indexes) < 3:
|
||||
found_inputs = ", ".join(
|
||||
f"In[{records[index].input_number}]" for index in q_indexes
|
||||
) or "нет"
|
||||
|
||||
location_lines: list[str] = []
|
||||
if source_path is not None:
|
||||
try:
|
||||
shown_path = source_path.resolve()
|
||||
except OSError:
|
||||
shown_path = source_path
|
||||
location_lines.append(f"Файл: {shown_path}")
|
||||
if source_label:
|
||||
location_lines.append(f"Позиция: {source_label}")
|
||||
|
||||
location = "\n".join(location_lines)
|
||||
if location:
|
||||
location += "\n"
|
||||
|
||||
raise ValueError(
|
||||
"Найдено меньше трёх выражений q/Sqrt[S1*S2]. "
|
||||
"Проверьте, что notebook полностью вычислен и сохранён с Output-ячейками."
|
||||
location
|
||||
+ "Найдено выражений q/Sqrt[S1*S2]: "
|
||||
+ f"{len(q_indexes)} (ожидалось не меньше 3).\n"
|
||||
+ f"Соответствующие Input-ячейки: {found_inputs}.\n"
|
||||
+ "Проверьте, что notebook полностью вычислен и сохранён "
|
||||
+ "с Output-ячейками."
|
||||
)
|
||||
|
||||
extracted: dict[str, MethodValues] = {}
|
||||
|
|
@ -216,6 +271,7 @@ def extract_methods(records: list[NbRecord]) -> dict[str, MethodValues]:
|
|||
values.Qb = first_value_for_assignment(section, qb_name)
|
||||
values.hb = first_value_for_assignment(section, "hb")
|
||||
values.Q = records[q_index].value
|
||||
|
||||
if values.Q is None:
|
||||
q_value = first_value_for_assignment(section, "q")
|
||||
s1_value = first_value_for_assignment(section, "S1")
|
||||
|
|
@ -304,13 +360,23 @@ def detect_target(nb_path: Path) -> Target:
|
|||
name = nb_path.stem
|
||||
lower = name.lower()
|
||||
|
||||
pair_candidates = re.findall(r"(?:^|_)(\d+-\d+)(?=_|$)", name)
|
||||
pair = next((item for item in pair_candidates if item in PAIR_START_ROW), None)
|
||||
if pair is None:
|
||||
# В имени могут быть две пары чисел: например 174-66 (состав ядра)
|
||||
# и 2-3 (нужный диапазон). Берём пару соседних чисел.
|
||||
pair_candidates = []
|
||||
for match in PAIR_RE.finditer(name):
|
||||
left = int(match.group(1))
|
||||
right = int(match.group(2))
|
||||
if right == left + 1:
|
||||
pair_candidates.append((left, right))
|
||||
|
||||
if not pair_candidates:
|
||||
raise ValueError(
|
||||
f"В имени {nb_path.name} не найден поддерживаемый блок 2-3 или 4-5."
|
||||
f"В имени {nb_path.name} не найден диапазон соседних чисел вида 2-3."
|
||||
)
|
||||
|
||||
pair_left, pair_right = pair_candidates[-1]
|
||||
pair = f"{pair_left}-{pair_right}"
|
||||
|
||||
model: str | None = None
|
||||
for candidate in MODEL_COLUMN_OFFSET:
|
||||
if re.search(rf"(?:^|[_-]){candidate}(?:[_-]|$)", lower):
|
||||
|
|
@ -321,11 +387,63 @@ def detect_target(nb_path: Path) -> Target:
|
|||
f"В имени {nb_path.name} не найден тип расчёта osc, pr или gid."
|
||||
)
|
||||
|
||||
qw_match = re.search(r"qw[_-]?([12])(?:[_-]|$)", lower)
|
||||
qw_match = QW_RE.search(lower)
|
||||
if qw_match is None:
|
||||
raise ValueError(f"В имени {nb_path.name} не найдено Qw1 или Qw2.")
|
||||
|
||||
return Target(pair=pair, model=model, qw_side=int(qw_match.group(1)))
|
||||
return Target(
|
||||
pair=pair,
|
||||
pair_left=pair_left,
|
||||
pair_right=pair_right,
|
||||
model=model,
|
||||
qw_side=int(qw_match.group(1)),
|
||||
)
|
||||
|
||||
|
||||
def looks_like_data_notebook(path: Path) -> bool:
|
||||
if path.suffix.lower() != ".nb":
|
||||
return False
|
||||
try:
|
||||
detect_target(path)
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def pair_sort_key(pair: str) -> tuple[int, int]:
|
||||
left, right = pair.split("-", maxsplit=1)
|
||||
return int(left), int(right)
|
||||
|
||||
|
||||
def data_start_row(block_index: int) -> int:
|
||||
return TEMPLATE_BLOCK_TOP_ROW + block_index * BLOCK_HEIGHT + DATA_START_OFFSET
|
||||
|
||||
|
||||
def round_decimal_places(value: float, places: int = 3) -> float:
|
||||
"""Округляет число до заданного количества знаков методом ROUND_HALF_UP."""
|
||||
if not math.isfinite(value):
|
||||
return value
|
||||
|
||||
quantizer = Decimal(1).scaleb(-places)
|
||||
try:
|
||||
rounded = Decimal(str(value)).quantize(quantizer, rounding=ROUND_HALF_UP)
|
||||
except (InvalidOperation, ValueError):
|
||||
return value
|
||||
return float(rounded)
|
||||
|
||||
|
||||
def round_significant(value: float, digits: int = 3) -> float:
|
||||
"""Округляет число до заданного количества значащих цифр."""
|
||||
if value == 0 or not math.isfinite(value):
|
||||
return value
|
||||
|
||||
decimal_value = Decimal(str(value))
|
||||
quantizer = Decimal(1).scaleb(decimal_value.adjusted() - digits + 1)
|
||||
try:
|
||||
rounded = decimal_value.quantize(quantizer, rounding=ROUND_HALF_UP)
|
||||
except (InvalidOperation, ValueError):
|
||||
return value
|
||||
return float(rounded)
|
||||
|
||||
|
||||
def format_metadata(pair: str, metadata: dict[str, float | None]) -> str:
|
||||
|
|
@ -334,38 +452,147 @@ def format_metadata(pair: str, metadata: dict[str, float | None]) -> str:
|
|||
return ""
|
||||
if float(value).is_integer():
|
||||
return str(int(value))
|
||||
return f"{value:.3g}"
|
||||
return f"{round_decimal_places(value):.3f}".replace(".", ",")
|
||||
|
||||
def compact_decimal(value: float | None) -> str:
|
||||
def three_decimals(value: float | None) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
text = f"{value:.2f}"
|
||||
if text.startswith("0."):
|
||||
text = text[1:]
|
||||
elif text.startswith("-0."):
|
||||
text = "-" + text[2:]
|
||||
return text
|
||||
# b1 и b2 находятся внутри текстовой подписи, поэтому запятую
|
||||
# задаём вручную, а завершающие нули сохраняем.
|
||||
return f"{round_decimal_places(value):.3f}".replace(".", ",")
|
||||
|
||||
return (
|
||||
f"{pair}\n"
|
||||
f"Z1={integer_or_blank(metadata['Z1'])}\n"
|
||||
f"Z2={integer_or_blank(metadata['Z2'])}\n"
|
||||
f"d={integer_or_blank(metadata['d'])}\n"
|
||||
f"b1={compact_decimal(metadata['b1'])}\n"
|
||||
f"b2={compact_decimal(metadata['b2'])}"
|
||||
f"b1={three_decimals(metadata['b1'])}\n"
|
||||
f"b2={three_decimals(metadata['b2'])}"
|
||||
)
|
||||
|
||||
|
||||
def copy_template_block(worksheet, destination_top_row: int) -> None:
|
||||
"""Копирует первый 14-строчный блок шаблона в новую позицию."""
|
||||
from openpyxl.cell.cell import MergedCell
|
||||
|
||||
source_top = TEMPLATE_BLOCK_TOP_ROW
|
||||
source_bottom = source_top + BLOCK_HEIGHT - 1
|
||||
destination_bottom = destination_top_row + BLOCK_HEIGHT - 1
|
||||
row_shift = destination_top_row - source_top
|
||||
|
||||
source_merges = [
|
||||
(
|
||||
merged.min_row,
|
||||
merged.min_col,
|
||||
merged.max_row,
|
||||
merged.max_col,
|
||||
)
|
||||
for merged in worksheet.merged_cells.ranges
|
||||
if merged.min_row >= source_top and merged.max_row <= source_bottom
|
||||
]
|
||||
|
||||
# Удаляем старые объединения во всём целевом блоке, если они там были.
|
||||
for merged in list(worksheet.merged_cells.ranges):
|
||||
if not (
|
||||
merged.max_row < destination_top_row
|
||||
or merged.min_row > destination_bottom
|
||||
):
|
||||
worksheet.unmerge_cells(str(merged))
|
||||
|
||||
for row_offset in range(BLOCK_HEIGHT):
|
||||
source_row = source_top + row_offset
|
||||
destination_row = destination_top_row + row_offset
|
||||
|
||||
source_dimension = worksheet.row_dimensions[source_row]
|
||||
destination_dimension = worksheet.row_dimensions[destination_row]
|
||||
destination_dimension.height = source_dimension.height
|
||||
destination_dimension.hidden = source_dimension.hidden
|
||||
destination_dimension.outlineLevel = source_dimension.outlineLevel
|
||||
|
||||
for column in range(1, TEMPLATE_MAX_COLUMN + 1):
|
||||
source_cell = worksheet.cell(row=source_row, column=column)
|
||||
destination_cell = worksheet.cell(row=destination_row, column=column)
|
||||
|
||||
if isinstance(source_cell, MergedCell):
|
||||
destination_cell.value = None
|
||||
else:
|
||||
destination_cell.value = source_cell.value
|
||||
destination_cell.comment = copy(source_cell.comment)
|
||||
if source_cell.hyperlink is not None:
|
||||
destination_cell._hyperlink = copy(source_cell.hyperlink)
|
||||
|
||||
if getattr(source_cell, "has_style", False):
|
||||
destination_cell._style = copy(source_cell._style)
|
||||
destination_cell.number_format = source_cell.number_format
|
||||
destination_cell.protection = copy(source_cell.protection)
|
||||
destination_cell.alignment = copy(source_cell.alignment)
|
||||
|
||||
for min_row, min_col, max_row, max_col in source_merges:
|
||||
worksheet.merge_cells(
|
||||
start_row=min_row + row_shift,
|
||||
start_column=min_col,
|
||||
end_row=max_row + row_shift,
|
||||
end_column=max_col,
|
||||
)
|
||||
|
||||
|
||||
def prepare_sheet_blocks(worksheet, pairs: list[str]) -> dict[str, int]:
|
||||
"""Создаёт на одном листе одинаковые блоки для всех диапазонов."""
|
||||
from openpyxl.cell.cell import MergedCell
|
||||
|
||||
if not pairs:
|
||||
raise ValueError("Нет диапазонов для создания таблицы.")
|
||||
|
||||
# Первый блок уже является образцом. Все остальные копируются из него.
|
||||
for block_index in range(1, len(pairs)):
|
||||
destination_top = TEMPLATE_BLOCK_TOP_ROW + block_index * BLOCK_HEIGHT
|
||||
copy_template_block(worksheet, destination_top)
|
||||
|
||||
# Удаляем лишние старые блоки шаблона, если диапазон только один.
|
||||
required_last_row = TEMPLATE_BLOCK_TOP_ROW + len(pairs) * BLOCK_HEIGHT - 1
|
||||
if worksheet.max_row > required_last_row:
|
||||
for merged in list(worksheet.merged_cells.ranges):
|
||||
if merged.min_row > required_last_row:
|
||||
worksheet.unmerge_cells(str(merged))
|
||||
worksheet.delete_rows(
|
||||
required_last_row + 1,
|
||||
worksheet.max_row - required_last_row,
|
||||
)
|
||||
|
||||
empty_metadata = {"Z1": None, "Z2": None, "d": None, "b1": None, "b2": None}
|
||||
starts: dict[str, int] = {}
|
||||
|
||||
for block_index, pair in enumerate(pairs):
|
||||
start_row = data_start_row(block_index)
|
||||
starts[pair] = start_row
|
||||
|
||||
# Очищаем старые числовые значения, сохраняя оформление и подписи строк.
|
||||
for row in range(start_row, start_row + DATA_ROW_COUNT):
|
||||
for column in list(range(3, 12)) + list(range(14, 23)):
|
||||
cell = worksheet.cell(row=row, column=column)
|
||||
if not isinstance(cell, MergedCell):
|
||||
cell.value = None
|
||||
|
||||
# Подпись диапазона видна даже при отсутствии одного из файлов Qw.
|
||||
worksheet.cell(row=start_row, column=1).value = format_metadata(
|
||||
pair, empty_metadata
|
||||
)
|
||||
worksheet.cell(row=start_row, column=13).value = format_metadata(
|
||||
pair, empty_metadata
|
||||
)
|
||||
|
||||
return starts
|
||||
|
||||
|
||||
def write_notebook_to_sheet(
|
||||
worksheet,
|
||||
start_row: int,
|
||||
target: Target,
|
||||
methods: dict[str, MethodValues],
|
||||
metadata: dict[str, float | None],
|
||||
written_cells: dict[str, str],
|
||||
source_name: str,
|
||||
) -> list[str]:
|
||||
start_row = PAIR_START_ROW[target.pair]
|
||||
|
||||
# Qw1: подпись в A, данные с C. Qw2: подпись в M, данные с N.
|
||||
if target.qw_side == 1:
|
||||
metadata_column = 1
|
||||
|
|
@ -394,74 +621,227 @@ def write_notebook_to_sheet(
|
|||
coordinate = worksheet.cell(row=row, column=column).coordinate
|
||||
previous_source = written_cells.get(coordinate)
|
||||
if previous_source is not None and previous_source != source_name:
|
||||
print(
|
||||
f"Предупреждение: {coordinate} уже заполнялась из "
|
||||
f"{previous_source}; новое значение берётся из {source_name}.",
|
||||
file=sys.stderr,
|
||||
raise ValueError(
|
||||
f"Ячейка {coordinate} уже заполнялась из {previous_source}; "
|
||||
f"повторная запись из {source_name}."
|
||||
)
|
||||
|
||||
cell = worksheet.cell(row=row, column=column)
|
||||
cell.value = value
|
||||
cell.number_format = "0.000"
|
||||
if value_name == "Q":
|
||||
# Q округляется до трёх значащих цифр, а не до трёх
|
||||
# фиксированных знаков после запятой. Формат без экспоненты:
|
||||
# 0,0688; 0,0000787; 0,112 (разделитель зависит от Excel).
|
||||
written_value = round_significant(value, digits=3)
|
||||
cell.number_format = "0.###############"
|
||||
else:
|
||||
# Все остальные параметры сохраняются и показываются
|
||||
# с точностью до трёх знаков после запятой.
|
||||
written_value = round_decimal_places(value, places=3)
|
||||
cell.number_format = "0.000"
|
||||
|
||||
cell.value = written_value
|
||||
written_cells[coordinate] = source_name
|
||||
report.append(f"{coordinate}={value:.12g}")
|
||||
report.append(f"{coordinate}={written_value:.15g}")
|
||||
|
||||
return report
|
||||
|
||||
|
||||
def expand_notebook_arguments(patterns: list[str]) -> list[Path]:
|
||||
paths: list[Path] = []
|
||||
"""Разворачивает файлы, glob-маски и каталоги; каталоги ищутся рекурсивно."""
|
||||
candidates: list[Path] = []
|
||||
|
||||
def add_candidate(path: Path) -> None:
|
||||
if path.is_dir():
|
||||
candidates.extend(path.rglob("*.nb"))
|
||||
elif path.is_file():
|
||||
candidates.append(path)
|
||||
|
||||
for pattern in patterns:
|
||||
matches = [Path(p) for p in glob.glob(pattern)]
|
||||
if matches:
|
||||
paths.extend(matches)
|
||||
if glob.has_magic(pattern):
|
||||
matches = [Path(item) for item in glob.glob(pattern, recursive=True)]
|
||||
if not matches:
|
||||
print(f"Предупреждение: маска ничего не нашла: {pattern}", file=sys.stderr)
|
||||
for match in matches:
|
||||
add_candidate(match)
|
||||
else:
|
||||
path = Path(pattern)
|
||||
if path.exists():
|
||||
paths.append(path)
|
||||
else:
|
||||
raise FileNotFoundError(f"Не найден notebook: {pattern}")
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Не найден путь: {pattern}")
|
||||
add_candidate(path)
|
||||
|
||||
unique_paths: list[Path] = []
|
||||
seen: set[Path] = set()
|
||||
for path in paths:
|
||||
skipped: list[Path] = []
|
||||
|
||||
for path in candidates:
|
||||
resolved = path.resolve()
|
||||
if resolved not in seen:
|
||||
seen.add(resolved)
|
||||
if resolved in seen:
|
||||
continue
|
||||
seen.add(resolved)
|
||||
|
||||
if looks_like_data_notebook(path):
|
||||
unique_paths.append(path)
|
||||
else:
|
||||
skipped.append(path)
|
||||
|
||||
for path in sorted(skipped):
|
||||
print(f"Пропущен служебный notebook: {path}", file=sys.stderr)
|
||||
|
||||
if not unique_paths:
|
||||
raise FileNotFoundError(
|
||||
"Не найдено ни одного файла вида *_osc_Qw1.nb, *_pr_Qw2.nb и т. п."
|
||||
)
|
||||
|
||||
return unique_paths
|
||||
|
||||
|
||||
def report_missing_files(parsed_items) -> None:
|
||||
expected = {
|
||||
(model, qw_side)
|
||||
for model in MODEL_COLUMN_OFFSET
|
||||
for qw_side in (1, 2)
|
||||
}
|
||||
by_pair: dict[str, set[tuple[str, int]]] = {}
|
||||
for _, target, _, _ in parsed_items:
|
||||
by_pair.setdefault(target.pair, set()).add((target.model, target.qw_side))
|
||||
|
||||
for pair in sorted(by_pair, key=pair_sort_key):
|
||||
missing = expected - by_pair[pair]
|
||||
if missing:
|
||||
missing_text = ", ".join(
|
||||
f"{model}_Qw{qw_side}"
|
||||
for model, qw_side in sorted(
|
||||
missing, key=lambda item: (item[1], MODEL_SORT_ORDER[item[0]])
|
||||
)
|
||||
)
|
||||
print(
|
||||
f"Предупреждение: для диапазона {pair} отсутствуют: {missing_text}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--version",
|
||||
action="version",
|
||||
version=f"%(prog)s {SCRIPT_VERSION}",
|
||||
)
|
||||
parser.add_argument("--template", required=True, type=Path, help="Excel-шаблон")
|
||||
parser.add_argument(
|
||||
"--notebooks",
|
||||
required=True,
|
||||
nargs="+",
|
||||
help="Один или несколько .nb файлов; разрешены маски *.nb",
|
||||
help=(
|
||||
"Файлы, glob-маски или каталоги. Каталоги обходятся рекурсивно; "
|
||||
"например: --notebooks ."
|
||||
),
|
||||
)
|
||||
parser.add_argument("--output", required=True, type=Path, help="Итоговый .xlsx")
|
||||
parser.add_argument("--sheet", default="Лист1", help="Имя листа шаблона")
|
||||
parser.add_argument("--sheet", default="Лист1", help="Имя общего листа шаблона")
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Только разобрать notebook и показать найденные значения",
|
||||
help="Только разобрать notebook и показать найденные значения/позиции",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
print(
|
||||
f"[{Path(__file__).name} {SCRIPT_VERSION}] "
|
||||
"расширенная диагностика и новое округление включены",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
notebook_paths = expand_notebook_arguments(args.notebooks)
|
||||
parsed_items = []
|
||||
seen_targets: dict[tuple[str, str, int], Path] = {}
|
||||
notebook_errors: list[str] = []
|
||||
|
||||
for nb_path in notebook_paths:
|
||||
records = parse_records(nb_path)
|
||||
target = detect_target(nb_path)
|
||||
methods = extract_methods(records)
|
||||
metadata = extract_metadata(records)
|
||||
target_key = (target.pair, target.model, target.qw_side)
|
||||
if target_key in seen_targets:
|
||||
raise ValueError(
|
||||
f"Два файла претендуют на {target.pair}/{target.model}/Qw{target.qw_side}: "
|
||||
f"{seen_targets[target_key]} и {nb_path}"
|
||||
)
|
||||
seen_targets[target_key] = nb_path
|
||||
|
||||
try:
|
||||
records = parse_records(nb_path)
|
||||
# Контекст файла добавляется централизованно в обработчике ниже,
|
||||
# чтобы сообщение не дублировало строки «Файл» и «Позиция».
|
||||
methods = extract_methods(records)
|
||||
metadata = extract_metadata(records)
|
||||
except (OSError, ValueError) as exc:
|
||||
# Контекст добавляется здесь БЕЗУСЛОВНО, поэтому имя проблемного
|
||||
# файла выводится для любой ошибки разбора.
|
||||
try:
|
||||
shown_path = nb_path.resolve()
|
||||
except OSError:
|
||||
shown_path = nb_path
|
||||
|
||||
notebook_errors.append(
|
||||
"\n".join(
|
||||
(
|
||||
"=== ПРОБЛЕМНЫЙ NOTEBOOK ===",
|
||||
f"Файл: {shown_path}",
|
||||
(
|
||||
f"Позиция: диапазон {target.pair}, "
|
||||
f"{target.model}, Qw{target.qw_side}"
|
||||
),
|
||||
f"Причина: {exc}",
|
||||
)
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
parsed_items.append((nb_path, target, methods, metadata))
|
||||
|
||||
if notebook_errors:
|
||||
print(
|
||||
f"{nb_path.name}: pair={target.pair}, model={target.model}, "
|
||||
f"Ошибка разбора notebook-файлов ({len(notebook_errors)}):",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for index, message in enumerate(notebook_errors, start=1):
|
||||
indented = message.replace("\n", "\n ")
|
||||
print(f" {index}. {indented}", file=sys.stderr)
|
||||
print(
|
||||
"Исправьте или пересчитайте перечисленные notebook-файлы "
|
||||
"и запустите команду повторно.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
pairs = sorted({item[1].pair for item in parsed_items}, key=pair_sort_key)
|
||||
pair_to_index = {pair: index for index, pair in enumerate(pairs)}
|
||||
pair_to_start_row = {
|
||||
pair: data_start_row(index) for pair, index in pair_to_index.items()
|
||||
}
|
||||
|
||||
parsed_items.sort(
|
||||
key=lambda item: (
|
||||
item[1].pair_left,
|
||||
item[1].pair_right,
|
||||
item[1].qw_side,
|
||||
MODEL_SORT_ORDER[item[1].model],
|
||||
)
|
||||
)
|
||||
|
||||
print("Диапазоны на одном листе:")
|
||||
for pair in pairs:
|
||||
block_index = pair_to_index[pair]
|
||||
block_top = TEMPLATE_BLOCK_TOP_ROW + block_index * BLOCK_HEIGHT
|
||||
print(
|
||||
f" {pair}: блок строк {block_top}-{block_top + BLOCK_HEIGHT - 1}, "
|
||||
f"значения с строки {pair_to_start_row[pair]}"
|
||||
)
|
||||
|
||||
report_missing_files(parsed_items)
|
||||
|
||||
for nb_path, target, methods, _ in parsed_items:
|
||||
print(
|
||||
f"{nb_path}: pair={target.pair}, model={target.model}, "
|
||||
f"Qw{target.qw_side}"
|
||||
)
|
||||
for method_name in ("heavy", "new", "light"):
|
||||
|
|
@ -486,7 +866,7 @@ def main() -> int:
|
|||
if not args.template.exists():
|
||||
raise FileNotFoundError(f"Не найден шаблон: {args.template}")
|
||||
|
||||
# Открытие через бинарный поток позволяет использовать шаблон без расширения .xlsx.
|
||||
# Открытие через бинарный поток позволяет использовать шаблон без .xlsx.
|
||||
with args.template.open("rb") as template_file:
|
||||
workbook = load_workbook(template_file)
|
||||
|
||||
|
|
@ -497,15 +877,18 @@ def main() -> int:
|
|||
)
|
||||
worksheet = workbook[args.sheet]
|
||||
|
||||
actual_starts = prepare_sheet_blocks(worksheet, pairs)
|
||||
written_cells: dict[str, str] = {}
|
||||
|
||||
for nb_path, target, methods, metadata in parsed_items:
|
||||
report = write_notebook_to_sheet(
|
||||
worksheet=worksheet,
|
||||
start_row=actual_starts[target.pair],
|
||||
target=target,
|
||||
methods=methods,
|
||||
metadata=metadata,
|
||||
written_cells=written_cells,
|
||||
source_name=nb_path.name,
|
||||
source_name=str(nb_path),
|
||||
)
|
||||
print(f" Записано {len(report)} ячеек: " + ", ".join(report))
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue