#!/usr/bin/env python3 """Собирает итоговый ZIP-архив лабораторной работы. Запуск из каталога lab1: python scripts/package_submission.py Скрипт копирует только нужные файлы во временный каталог, формирует MANIFEST.txt и CHECKSUMS.sha256, затем упаковывает всё в dist/nlp_lab1_submission_YYYYMMDD.zip. Рядом кладётся .sha256 самого архива. """ from __future__ import annotations import argparse import hashlib import os import shutil import zipfile from datetime import date from pathlib import Path ROOT = Path(__file__).resolve().parents[1] DIST = ROOT / "dist" DEFAULT_NAME = f"nlp_lab1_submission_{date.today():%Y%m%d}" EXCLUDE_PREFIXES = ( "data/processed/", "features/", "results/", "nltk_data/", ".venv/", ".git/", "presentation/preview/", "report/build/", ".pytest_cache/", "dist/", "exports/nlp_lab1_results_", "exports/nlp_lab1_final_results_20261026_120000.tar.gz.sha256", ) EXCLUDE_NAMES = { "stardew_stylometry.bak", "START_HERE.md", "ARCHIVE_README.md", } LATEX_AUX_SUFFIXES = {".aux", ".log", ".nav", ".out", ".snm", ".toc"} KEY_FILES = [ "lab1/report/REPORT.pdf", "lab1/report/REPORT.docx", "lab1/report/REPORT.md", "lab1/presentation/stardew_stylometry.pdf", "lab1/presentation/stardew_stylometry.pptx", "lab1/presentation/latex/stardew_stylometry.pdf", "lab1/presentation/latex/stardew_stylometry.tex", "lab1/FINAL_RESULTS.csv", "lab1/data/dialogues.csv", "lab1/exports/nlp_lab1_final_results_20261026_120000.tar.gz", ] def sha256(path: Path) -> str: h = hashlib.sha256() with path.open("rb") as fh: for chunk in iter(lambda: fh.read(1024 * 1024), b""): h.update(chunk) return h.hexdigest() def should_copy(rel: Path) -> bool: posix = rel.as_posix() if rel.name in EXCLUDE_NAMES: return False if "__pycache__" in rel.parts or rel.suffix == ".pyc": return False if rel.suffix in LATEX_AUX_SUFFIXES: return False if any(posix == prefix.rstrip("/") or posix.startswith(prefix) for prefix in EXCLUDE_PREFIXES): return False return True def copy_tree(stage: Path) -> int: target_root = stage / "lab1" count = 0 for path in sorted(ROOT.rglob("*")): if not path.is_file(): continue rel = path.relative_to(ROOT) if not should_copy(rel): continue dst = target_root / rel dst.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(path, dst) count += 1 return count def write_manifest(stage: Path) -> int: files = sorted(p for p in stage.rglob("*") if p.is_file()) lines = [ "Архив: " + stage.name, f"Файлов в манифесте: {len(files)} (сам MANIFEST.txt не включён)", "", f"{'SHA-256':64} {'Размер':>12} Путь", "-" * 100, ] total = 0 for path in files: size = path.stat().st_size total += size rel = path.relative_to(stage).as_posix() lines.append(f"{sha256(path)} {size:>12} {rel}") lines.append("-" * 100) lines.append(f"Итого байт: {total}") (stage / "MANIFEST.txt").write_text("\n".join(lines) + "\n", encoding="utf-8") return len(files) def write_checksums(stage: Path) -> None: lines = [] for rel_name in KEY_FILES: path = stage / rel_name if path.exists(): lines.append(f"{sha256(path)} {rel_name}") else: print(f"ВНИМАНИЕ: нет ключевого файла {rel_name}") (stage / "CHECKSUMS.sha256").write_text("\n".join(lines) + "\n", encoding="utf-8") def zip_stage(stage: Path, archive: Path) -> None: archive.parent.mkdir(parents=True, exist_ok=True) with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as zf: for path in sorted(stage.rglob("*")): if not path.is_file(): continue rel = path.relative_to(stage).as_posix() arcname = f"{stage.name}/{rel}" info = zipfile.ZipInfo.from_file(path, arcname) info.compress_type = zipfile.ZIP_DEFLATED with path.open("rb") as src, zf.open(info, "w") as dst: shutil.copyfileobj(src, dst) def human(n: int) -> str: for unit in ("Б", "КБ", "МБ", "ГБ"): if n < 1024 or unit == "ГБ": return f"{n:.1f} {unit}" if unit != "Б" else f"{n} {unit}" n /= 1024 return f"{n} Б" def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--name", default=DEFAULT_NAME, help="имя каталога и ZIP") parser.add_argument("--keep-stage", action="store_true", help="не удалять промежуточный каталог dist/stage") args = parser.parse_args() name = args.name stage = DIST / "stage" / name if stage.exists(): shutil.rmtree(stage) stage.mkdir(parents=True, exist_ok=True) print(f"1/4. Копирование файлов в {stage} ...") copied = copy_tree(stage) shutil.copy2(ROOT / "START_HERE.md", stage / "START_HERE.md") shutil.copy2(ROOT / "ARCHIVE_README.md", stage / "ARCHIVE_README.md") print("2/4. Формирование CHECKSUMS.sha256 ...") write_checksums(stage) print("3/4. Формирование MANIFEST.txt ...") total = write_manifest(stage) archive = DIST / f"{name}.zip" print(f"4/4. Упаковка {archive} ...") zip_stage(stage, archive) digest = sha256(archive) (DIST / f"{name}.zip.sha256").write_text( f"{digest} {archive.name}\n", encoding="utf-8" ) print() print(f"Файлов в lab1: {copied}") print(f"Всего файлов в архиве: {total + 1}") print(f"Размер ZIP: {human(archive.stat().st_size)}") print(f"SHA-256 ZIP: {digest}") print(f"Архив: {archive}") print(f"Контрольная сумма: {archive}.sha256") if not args.keep_stage: shutil.rmtree(stage, ignore_errors=True) try: (DIST / "stage").rmdir() except OSError: pass return 0 if __name__ == "__main__": raise SystemExit(main())