Heavy computation is completed.
This commit is contained in:
parent
3585c75b2a
commit
eeb9c31455
30 changed files with 193638 additions and 105489 deletions
Binary file not shown.
BIN
Orekhov.xlsx
BIN
Orekhov.xlsx
Binary file not shown.
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
BIN
Pu240_798-811_TitovAA/result_table.xlsx
Normal file
BIN
Pu240_798-811_TitovAA/result_table.xlsx
Normal file
Binary file not shown.
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