Files

317 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import csv
import json
import os
import shutil
from dataclasses import asdict, dataclass
from datetime import datetime
from pathlib import Path
from typing import Callable, Iterable
LT1_ARCHIVE_SUFFIXES = (".tar.gz", ".tgz", ".tar", ".zip")
LT1_ORBIT_SUFFIXES = (".txt",)
ProgressCallback = Callable[[str], None]
def now_text() -> str:
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def stamp_text() -> str:
return datetime.now().strftime("%Y%m%d_%H%M%S")
def normalize_path(value: str) -> Path:
return Path(value.strip().strip('"')).expanduser()
def parse_path_list(value: str) -> list[Path]:
paths: list[Path] = []
for line in str(value or "").replace(";", "\n").splitlines():
text = line.strip().strip('"')
if text:
paths.append(normalize_path(text))
return paths
def safe_mkdir(path: Path) -> None:
path.mkdir(parents=True, exist_ok=True)
def is_asset_file(path: Path) -> bool:
lower = path.name.lower()
if lower.startswith("lt1") and lower.endswith(LT1_ARCHIVE_SUFFIXES):
return True
if lower.startswith("lt1") and lower.endswith(LT1_ORBIT_SUFFIXES):
return True
return False
def classify_asset(path: Path) -> str:
lower = path.name.lower()
if lower.endswith(LT1_ARCHIVE_SUFFIXES):
return "lt1_archive"
if lower.endswith(LT1_ORBIT_SUFFIXES):
return "lt1_orbit"
return "unknown"
@dataclass(frozen=True)
class AssetRecord:
kind: str
name: str
path: str
size: int
mtime: float
@dataclass(frozen=True)
class CopyRecord:
name: str
kind: str
source_path: str
target_path: str
size: int
action: str
reason: str
class FileLogger:
def __init__(self, path: Path, ui_log: ProgressCallback | None = None) -> None:
self.path = path
self.ui_log = ui_log
safe_mkdir(path.parent)
def __call__(self, message: str) -> None:
line = f"{now_text()} {message}"
with self.path.open("a", encoding="utf-8") as handle:
handle.write(line + "\n")
if self.ui_log:
self.ui_log(line)
def scan_assets(paths: Iterable[Path], *, log: ProgressCallback | None = None) -> list[AssetRecord]:
records: list[AssetRecord] = []
for root in paths:
if not root.exists():
if log:
log(f"路径不存在,跳过:{root}")
continue
if not root.is_dir():
if log:
log(f"不是目录,跳过:{root}")
continue
count = 0
for entry in root.iterdir():
if not entry.is_file() or not is_asset_file(entry):
continue
stat = entry.stat()
records.append(
AssetRecord(
kind=classify_asset(entry),
name=entry.name,
path=str(entry),
size=stat.st_size,
mtime=stat.st_mtime,
)
)
count += 1
if log:
log(f"扫描完成:{root},资产 {count} 个")
records.sort(key=lambda item: (item.kind, item.name.lower(), item.path.lower()))
return records
def asset_key(record: AssetRecord) -> tuple[str, str]:
return record.kind, record.name.lower()
def build_asset_index(records: Iterable[AssetRecord]) -> dict[tuple[str, str], AssetRecord]:
index: dict[tuple[str, str], AssetRecord] = {}
for record in records:
key = asset_key(record)
if key not in index:
index[key] = record
return index
def read_inventory(path: Path) -> list[AssetRecord]:
payload = json.loads(path.read_text(encoding="utf-8"))
records: list[AssetRecord] = []
for item in payload.get("assets", payload.get("files", [])):
try:
records.append(
AssetRecord(
kind=str(item.get("kind") or ""),
name=str(item.get("name") or ""),
path=str(item.get("path") or ""),
size=int(item.get("size") or 0),
mtime=float(item.get("mtime") or 0),
)
)
except (TypeError, ValueError):
continue
return [item for item in records if item.kind and item.name]
def write_inventory(path: Path, records: list[AssetRecord], roots: list[str]) -> None:
safe_mkdir(path.parent)
payload = {
"schema": "lt1_asset_inventory.v2",
"generated_at": now_text(),
"roots": roots,
"asset_count": len(records),
"assets": [asdict(item) for item in records],
}
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
def write_csv(path: Path, records: list[CopyRecord]) -> None:
safe_mkdir(path.parent)
with path.open("w", newline="", encoding="utf-8-sig") as handle:
writer = csv.DictWriter(
handle,
fieldnames=["name", "kind", "source_path", "target_path", "size", "action", "reason"],
)
writer.writeheader()
for record in records:
writer.writerow(asdict(record))
def choose_target_root(target_roots: list[Path], filename: str, size: int) -> tuple[Path | None, str]:
for root in target_roots:
dest = root / filename
if dest.exists():
try:
if dest.stat().st_size == size:
return None, f"目标已存在且大小一致:{dest}"
return None, f"目标已存在但大小不同:{dest}"
except OSError:
return None, f"无法读取目标文件状态:{dest}"
for root in target_roots:
try:
safe_mkdir(root)
free_bytes = shutil.disk_usage(root).free
if free_bytes > size:
return root, "选择第一个空间足够的目标路径"
except OSError:
continue
return None, "没有空间足够的目标路径"
def copy_file_atomic(source: Path, target: Path, *, move: bool = False) -> None:
safe_mkdir(target.parent)
part = target.with_name(target.name + ".part")
if part.exists():
part.unlink()
if target.exists():
raise FileExistsError(str(target))
if move:
shutil.copy2(source, part)
if part.stat().st_size != source.stat().st_size:
raise OSError(f"复制后大小不一致:{source} -> {target}")
os.replace(part, target)
source.unlink()
else:
shutil.copy2(source, part)
if part.stat().st_size != source.stat().st_size:
raise OSError(f"复制后大小不一致:{source} -> {target}")
os.replace(part, target)
def copy_unc_missing_assets(
unc_roots: list[Path],
server_inventory_json: Path,
target_roots: list[Path],
report_dir: Path,
*,
execute: bool,
log: ProgressCallback | None = None,
) -> list[CopyRecord]:
server_assets = read_inventory(server_inventory_json)
server_index = build_asset_index(server_assets)
source_assets = scan_assets(unc_roots, log=log)
report: list[CopyRecord] = []
for asset in source_assets:
server_asset = server_index.get(asset_key(asset))
if server_asset and server_asset.size == asset.size:
report.append(
CopyRecord(asset.name, asset.kind, asset.path, server_asset.path, asset.size, "skip", "服务器清单已有且大小一致")
)
continue
if server_asset and server_asset.size != asset.size:
report.append(
CopyRecord(asset.name, asset.kind, asset.path, server_asset.path, asset.size, "conflict", "服务器清单有同名资产但大小不同")
)
continue
target_root, reason = choose_target_root(target_roots, asset.name, asset.size)
if target_root is None:
report.append(CopyRecord(asset.name, asset.kind, asset.path, "", asset.size, "skip", reason))
continue
target_path = target_root / asset.name
if not execute:
report.append(CopyRecord(asset.name, asset.kind, asset.path, str(target_path), asset.size, "planned", reason))
continue
try:
copy_file_atomic(Path(asset.path), target_path)
report.append(CopyRecord(asset.name, asset.kind, asset.path, str(target_path), asset.size, "copied", reason))
if log:
log(f"已复制:{asset.name} -> {target_path}")
except Exception as exc:
report.append(CopyRecord(asset.name, asset.kind, asset.path, str(target_path), asset.size, "failed", str(exc)))
if log:
log(f"复制失败:{asset.name}{exc}")
write_csv(report_dir / f"unc_copy_report_{stamp_text()}.csv", report)
return report
def import_disk_assets_to_server(
disk_roots: list[Path],
server_asset_roots: list[Path],
report_dir: Path,
*,
execute: bool,
move: bool,
log: ProgressCallback | None = None,
) -> list[CopyRecord]:
source_assets = scan_assets(disk_roots, log=log)
server_assets = scan_assets(server_asset_roots, log=log)
server_index = build_asset_index(server_assets)
report: list[CopyRecord] = []
for asset in source_assets:
existing = server_index.get(asset_key(asset))
if existing and existing.size == asset.size:
report.append(CopyRecord(asset.name, asset.kind, asset.path, existing.path, asset.size, "skip", "服务器已存在且大小一致"))
continue
if existing and existing.size != asset.size:
report.append(CopyRecord(asset.name, asset.kind, asset.path, existing.path, asset.size, "conflict", "服务器有同名资产但大小不同"))
continue
target_root, reason = choose_target_root(server_asset_roots, asset.name, asset.size)
if target_root is None:
report.append(CopyRecord(asset.name, asset.kind, asset.path, "", asset.size, "skip", reason))
continue
target_path = target_root / asset.name
if not execute:
report.append(CopyRecord(asset.name, asset.kind, asset.path, str(target_path), asset.size, "planned", reason))
continue
try:
copy_file_atomic(Path(asset.path), target_path, move=move)
action = "moved" if move else "copied"
report.append(CopyRecord(asset.name, asset.kind, asset.path, str(target_path), asset.size, action, reason))
if log:
log(f"已{('剪切' if move else '复制')}{asset.name} -> {target_path}")
except Exception as exc:
report.append(CopyRecord(asset.name, asset.kind, asset.path, str(target_path), asset.size, "failed", str(exc)))
if log:
log(f"导入失败:{asset.name}{exc}")
write_csv(report_dir / f"disk_import_report_{stamp_text()}.csv", report)
return report