205 lines
6.7 KiB
Python
205 lines
6.7 KiB
Python
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
|
|
EXCLUDE_DIRS = {".git", ".gitea", ".update_tmp", "dist", "tools", "__pycache__"}
|
|
EXCLUDE_FILES = {".gitignore", "log.txt"}
|
|
|
|
|
|
def sha256_file(path: Path) -> str:
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as file:
|
|
for chunk in iter(lambda: file.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def should_include(path: Path, root: Path) -> bool:
|
|
relative = path.relative_to(root)
|
|
return (
|
|
path.is_file()
|
|
and relative.name not in EXCLUDE_FILES
|
|
and not any(part in EXCLUDE_DIRS for part in relative.parts)
|
|
)
|
|
|
|
|
|
def scan_files(root: Path) -> list[Path]:
|
|
return sorted(
|
|
(path for path in root.rglob("*") if should_include(path, root)),
|
|
key=lambda path: str(path.relative_to(root)).replace("\\", "/"),
|
|
)
|
|
|
|
|
|
def normalize_path(path: str) -> str:
|
|
return path.replace("\\", "/").strip("/")
|
|
|
|
|
|
def validate_relative_paths(paths: list[str], label: str) -> None:
|
|
unsafe = [
|
|
path for path in paths
|
|
if not path
|
|
or any(part in {"", ".", ".."} for part in path.split("/"))
|
|
or any(":" in part for part in path.split("/"))
|
|
]
|
|
if unsafe:
|
|
raise ValueError(f"{label} contains unsafe paths: {', '.join(unsafe)}")
|
|
|
|
|
|
def load_config(root: Path, config_path: str) -> dict:
|
|
path = Path(config_path)
|
|
if not path.is_absolute():
|
|
path = root / path
|
|
|
|
if not path.exists():
|
|
return {
|
|
"core": [],
|
|
"remove": [],
|
|
"removeDirectories": [],
|
|
}
|
|
|
|
with path.open("r", encoding="utf-8") as file:
|
|
config = json.load(file)
|
|
|
|
return {
|
|
"core": [normalize_path(item) for item in config.get("core", [])],
|
|
"remove": [normalize_path(item) for item in config.get("remove", [])],
|
|
"removeDirectories": [normalize_path(item) for item in config.get("removeDirectories", [])],
|
|
}
|
|
|
|
|
|
def preserve_removals(config: dict, output: Path) -> dict:
|
|
"""合并现有清单中的删除项,避免重新生成时覆盖人工维护的内容。"""
|
|
if not output.is_file():
|
|
return config
|
|
|
|
with output.open("r", encoding="utf-8") as file:
|
|
previous = json.load(file)
|
|
|
|
for key in ("remove", "removeDirectories"):
|
|
existing = [normalize_path(item) for item in previous.get(key, [])]
|
|
config[key] = list(dict.fromkeys([*existing, *config[key]]))
|
|
|
|
return config
|
|
|
|
|
|
def validate_config_files(root: Path, files: list[str], label: str) -> None:
|
|
missing = [file for file in files if not (root / file).is_file()]
|
|
if missing:
|
|
raise FileNotFoundError(f"{label} contains missing files: {', '.join(missing)}")
|
|
|
|
|
|
def set_nested_file(tree: dict, relative_path: str, info: dict) -> None:
|
|
parts = relative_path.split("/")
|
|
node = tree
|
|
|
|
for folder in parts[:-1]:
|
|
node = node.setdefault(folder, {
|
|
"type": "directory",
|
|
"children": {},
|
|
})["children"]
|
|
|
|
node[parts[-1]] = {
|
|
"type": "file",
|
|
**info,
|
|
}
|
|
|
|
|
|
def build_manifest(root: Path, version: str, config: dict) -> dict:
|
|
flat_files = {}
|
|
tree = {
|
|
"type": "directory",
|
|
"name": root.name,
|
|
"children": {},
|
|
}
|
|
core_files = config["core"]
|
|
core_file_set = set(core_files)
|
|
remove_files = config["remove"]
|
|
remove_directories = config["removeDirectories"]
|
|
validate_config_files(root, core_files, "core")
|
|
validate_relative_paths(remove_files, "remove")
|
|
validate_relative_paths(remove_directories, "removeDirectories")
|
|
|
|
for path in scan_files(root):
|
|
relative_path = str(path.relative_to(root)).replace("\\", "/")
|
|
info = {
|
|
"size": path.stat().st_size,
|
|
"sha256": sha256_file(path),
|
|
}
|
|
|
|
flat_files[relative_path] = info
|
|
set_nested_file(tree["children"], relative_path, info)
|
|
|
|
remove_file_conflicts = [file for file in remove_files if file in flat_files]
|
|
if remove_file_conflicts:
|
|
raise ValueError(f"remove still contains current files: {', '.join(remove_file_conflicts)}")
|
|
|
|
remove_directory_conflicts = [
|
|
directory for directory in remove_directories
|
|
if any(file == directory or file.startswith(f"{directory}/") for file in flat_files)
|
|
]
|
|
if remove_directory_conflicts:
|
|
raise ValueError(
|
|
"removeDirectories still contain current files: "
|
|
+ ", ".join(remove_directory_conflicts)
|
|
)
|
|
|
|
folder_digest = hashlib.sha256()
|
|
for relative_path in sorted(flat_files):
|
|
info = flat_files[relative_path]
|
|
folder_digest.update(relative_path.encode("utf-8"))
|
|
folder_digest.update(str(info["size"]).encode("ascii"))
|
|
folder_digest.update(info["sha256"].encode("ascii"))
|
|
|
|
return {
|
|
"version": version,
|
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
"algorithm": "sha256",
|
|
"file_count": len(flat_files),
|
|
"folder_sha256": folder_digest.hexdigest(),
|
|
"core": core_files,
|
|
"assets": [file for file in sorted(flat_files) if file not in core_file_set],
|
|
"remove": remove_files,
|
|
"removeDirectories": remove_directories,
|
|
"files": flat_files,
|
|
"tree": tree,
|
|
}
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description="Generate shuying manifest.json")
|
|
parser.add_argument("--root", default=".", help="extension root")
|
|
parser.add_argument("--version", help="version, for example 2.0.0.4")
|
|
parser.add_argument("--out", default="dist/manifest.json", help="output json path")
|
|
parser.add_argument("--config", default="tools/manifest_config.json", help="manifest config path")
|
|
args = parser.parse_args()
|
|
|
|
root = Path(args.root).resolve()
|
|
version = args.version or input("Input version, for example 2.0.0.4: ").strip()
|
|
|
|
if not version:
|
|
raise ValueError("Version cannot be empty")
|
|
|
|
output = (root / args.out).resolve()
|
|
config = preserve_removals(load_config(root, args.config), output)
|
|
manifest = build_manifest(root, version, config)
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
output.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
|
|
print(f"version: {version}")
|
|
print(f"file_count: {manifest['file_count']}")
|
|
print(f"core_count: {len(manifest['core'])}")
|
|
print(f"asset_count: {len(manifest['assets'])}")
|
|
print(f"remove_count: {len(manifest['remove'])}")
|
|
print(f"remove_directory_count: {len(manifest['removeDirectories'])}")
|
|
print(f"folder_sha256: {manifest['folder_sha256']}")
|
|
print(f"generated: {output}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|