initial: packwiz → simple-mod-sync manifest converter
Reads packwiz pack (pack.toml + index.toml + per-file .pw.toml) and emits a simple-mod-sync sync_version=3 manifest. Drops server-only mods, skips CurseForge metadata-mode entries with a warning, maps content type from the parent directory of each metafile. Optional --bundle-non-mods zips config/, options.txt etc into one archive served as a 'packed' entry — covers the gap where simple-mod-sync only ships zip-extractable content for non-mods. 15 tests, includes integration against upstream packwiz-example-pack.
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Convert a packwiz pack into a simple-mod-sync manifest.
|
||||
|
||||
Reads a packwiz pack rooted at PACK_DIR and emits a simple-mod-sync
|
||||
sync_version=3 manifest to stdout (or --output).
|
||||
|
||||
Mods, resourcepacks, shaders and datapacks are emitted with their direct
|
||||
download URL pulled from each per-file packwiz metafile (.pw.toml).
|
||||
|
||||
Server-only mods are dropped. Client-only and both-side mods are kept.
|
||||
|
||||
CurseForge mods that lack a direct download URL (mode=metadata:curseforge)
|
||||
are skipped with a warning; the operator can either swap to Modrinth or
|
||||
pre-resolve URLs with `packwiz cf reexport`.
|
||||
|
||||
Arbitrary files (config/, options.txt, servers.dat, ...) are not emitted by
|
||||
default since simple-mod-sync's `packed`/`config` types expect a zip.
|
||||
Pass --bundle-non-mods PATH to write a zip of all non-metafile files; a
|
||||
single `packed` entry pointing at <base-url>/<bundle-name> will be appended
|
||||
to the manifest. Operator is responsible for hosting that zip.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import tomllib
|
||||
import zipfile
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
# --- packwiz model --------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PackwizMetafile:
|
||||
"""One mods/foo.pw.toml file (mod or resourcepack/shader/datapack)."""
|
||||
|
||||
path: Path # e.g. "mods/sodium.pw.toml"
|
||||
name: str
|
||||
filename: str
|
||||
side: str # "client", "server", "both"
|
||||
url: str | None # None when packwiz can't resolve (CF metadata mode)
|
||||
hash_value: str | None
|
||||
hash_format: str | None
|
||||
|
||||
|
||||
def _load_pack_metafile(pack_root: Path, rel_path: str) -> PackwizMetafile:
|
||||
abs_path = pack_root / rel_path
|
||||
data = tomllib.loads(abs_path.read_text(encoding="utf-8"))
|
||||
|
||||
download = data.get("download", {})
|
||||
mode = download.get("mode", "url")
|
||||
url = download.get("url") if mode in ("url", "") else None
|
||||
|
||||
return PackwizMetafile(
|
||||
path=Path(rel_path),
|
||||
name=data.get("name", abs_path.stem),
|
||||
filename=data.get("filename", ""),
|
||||
side=data.get("side", "both"),
|
||||
url=url,
|
||||
hash_value=download.get("hash"),
|
||||
hash_format=download.get("hash-format"),
|
||||
)
|
||||
|
||||
|
||||
def _load_index(pack_root: Path) -> dict[str, Any]:
|
||||
return tomllib.loads((pack_root / "index.toml").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _load_pack(pack_root: Path) -> dict[str, Any]:
|
||||
return tomllib.loads((pack_root / "pack.toml").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
# --- simple-mod-sync model ------------------------------------------------
|
||||
|
||||
|
||||
_DIR_TO_TYPE = {
|
||||
"mods": "mod",
|
||||
"resourcepacks": "resourcepack",
|
||||
"shaderpacks": "shader",
|
||||
}
|
||||
|
||||
|
||||
def _content_type_for(rel_path: Path) -> str | None:
|
||||
"""Map mods/foo.pw.toml -> "mod", resourcepacks/foo.pw.toml -> "resourcepack" etc.
|
||||
|
||||
`datapack` is intentionally not auto-mapped: packwiz puts datapacks under
|
||||
a world directory which varies. Operator should set type manually or
|
||||
place datapacks in a `datapacks/` top-level dir (uncommon).
|
||||
"""
|
||||
if rel_path.parent.name == "datapacks":
|
||||
return "datapack"
|
||||
return _DIR_TO_TYPE.get(rel_path.parts[0]) if rel_path.parts else None
|
||||
|
||||
|
||||
def _make_entry(meta: PackwizMetafile, content_type: str) -> dict[str, Any]:
|
||||
version = meta.filename or "unknown"
|
||||
# packwiz filenames carry the version (e.g. sodium-0.6.5+1.21.1.jar).
|
||||
# If we can extract a cleaner version, do it; otherwise keep the filename.
|
||||
if meta.filename:
|
||||
m = re.search(r"[-_]([\w.+]+?)\.(?:jar|zip)$", meta.filename)
|
||||
if m:
|
||||
version = m.group(1)
|
||||
return {
|
||||
"url": meta.url,
|
||||
"name": meta.name,
|
||||
"version": version,
|
||||
"type": content_type,
|
||||
}
|
||||
|
||||
|
||||
# --- conversion driver ----------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConvertResult:
|
||||
manifest: dict[str, Any]
|
||||
skipped_server_only: list[str]
|
||||
skipped_no_url: list[str]
|
||||
skipped_unknown_type: list[str]
|
||||
non_mod_files: list[Path]
|
||||
|
||||
|
||||
def convert(
|
||||
pack_root: Path,
|
||||
*,
|
||||
include_client_only: bool = True,
|
||||
include_both: bool = True,
|
||||
) -> ConvertResult:
|
||||
pack = _load_pack(pack_root)
|
||||
index = _load_index(pack_root)
|
||||
|
||||
sync: list[dict[str, Any]] = []
|
||||
skipped_server_only: list[str] = []
|
||||
skipped_no_url: list[str] = []
|
||||
skipped_unknown_type: list[str] = []
|
||||
non_mod_files: list[Path] = []
|
||||
|
||||
for f in index.get("files", []):
|
||||
rel_path = Path(f["file"])
|
||||
if not f.get("metafile"):
|
||||
non_mod_files.append(rel_path)
|
||||
continue
|
||||
|
||||
meta = _load_pack_metafile(pack_root, f["file"])
|
||||
if meta.side == "server":
|
||||
skipped_server_only.append(meta.name)
|
||||
continue
|
||||
if meta.side == "client" and not include_client_only:
|
||||
continue
|
||||
if meta.side == "both" and not include_both:
|
||||
continue
|
||||
|
||||
content_type = _content_type_for(meta.path)
|
||||
if content_type is None:
|
||||
skipped_unknown_type.append(str(meta.path))
|
||||
continue
|
||||
if meta.url is None:
|
||||
skipped_no_url.append(meta.name)
|
||||
continue
|
||||
|
||||
sync.append(_make_entry(meta, content_type))
|
||||
|
||||
manifest = {
|
||||
"sync_version": 3,
|
||||
"_generator": {
|
||||
"tool": "packwiz-to-sms",
|
||||
"pack_name": pack.get("name", ""),
|
||||
"pack_version": pack.get("version", ""),
|
||||
},
|
||||
"sync": sync,
|
||||
}
|
||||
|
||||
return ConvertResult(
|
||||
manifest=manifest,
|
||||
skipped_server_only=skipped_server_only,
|
||||
skipped_no_url=skipped_no_url,
|
||||
skipped_unknown_type=skipped_unknown_type,
|
||||
non_mod_files=non_mod_files,
|
||||
)
|
||||
|
||||
|
||||
def bundle_non_mods(
|
||||
pack_root: Path,
|
||||
non_mod_files: list[Path],
|
||||
out_zip: Path,
|
||||
) -> None:
|
||||
"""Zip non-mod files (config/, options.txt, etc.) into one archive
|
||||
suitable for shipping as a simple-mod-sync `packed` entry with
|
||||
`directory: "."`."""
|
||||
out_zip.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zipfile.ZipFile(out_zip, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for rel in non_mod_files:
|
||||
abs_path = pack_root / rel
|
||||
if not abs_path.is_file():
|
||||
continue
|
||||
zf.write(abs_path, arcname=str(rel))
|
||||
|
||||
|
||||
def add_bundle_entry(
|
||||
manifest: dict[str, Any],
|
||||
bundle_url: str,
|
||||
bundle_version: str,
|
||||
) -> None:
|
||||
manifest["sync"].append(
|
||||
{
|
||||
"url": bundle_url,
|
||||
"name": "Pack overrides",
|
||||
"version": bundle_version,
|
||||
"type": "packed",
|
||||
"directory": ".",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# --- CLI -------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_arg_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(
|
||||
prog="packwiz-to-sms",
|
||||
description="Convert a packwiz pack to a simple-mod-sync manifest.",
|
||||
)
|
||||
p.add_argument("pack_root", type=Path, help="Path to packwiz pack root (contains pack.toml)")
|
||||
p.add_argument(
|
||||
"-o", "--output", type=Path, default=None,
|
||||
help="Write manifest to this file (default stdout).",
|
||||
)
|
||||
p.add_argument(
|
||||
"--bundle-non-mods", type=Path, default=None, metavar="ZIP_PATH",
|
||||
help="Zip all non-mod files into this zip and add a 'packed' entry pointing at --bundle-url.",
|
||||
)
|
||||
p.add_argument(
|
||||
"--bundle-url", type=str, default=None,
|
||||
help="URL where the non-mod bundle will be hosted. Required with --bundle-non-mods.",
|
||||
)
|
||||
p.add_argument(
|
||||
"--include-server-only", action="store_true",
|
||||
help="Include mods marked side=server (off by default; only useful for mod-server distribution).",
|
||||
)
|
||||
p.add_argument("--quiet", action="store_true", help="Suppress skip warnings on stderr.")
|
||||
return p
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = _build_arg_parser().parse_args(argv)
|
||||
|
||||
if not (args.pack_root / "pack.toml").is_file():
|
||||
print(f"error: {args.pack_root} does not contain pack.toml", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
result = convert(args.pack_root)
|
||||
|
||||
if args.bundle_non_mods:
|
||||
if not args.bundle_url:
|
||||
print("error: --bundle-non-mods requires --bundle-url", file=sys.stderr)
|
||||
return 2
|
||||
bundle_non_mods(args.pack_root, result.non_mod_files, args.bundle_non_mods)
|
||||
pack = _load_pack(args.pack_root)
|
||||
add_bundle_entry(result.manifest, args.bundle_url, pack.get("version", "1"))
|
||||
|
||||
if not args.quiet:
|
||||
for n in result.skipped_server_only:
|
||||
print(f"skip (server-only): {n}", file=sys.stderr)
|
||||
for n in result.skipped_no_url:
|
||||
print(f"skip (no resolvable url, likely CurseForge metadata mode): {n}", file=sys.stderr)
|
||||
for n in result.skipped_unknown_type:
|
||||
print(f"skip (unknown content type for path): {n}", file=sys.stderr)
|
||||
if result.non_mod_files and not args.bundle_non_mods:
|
||||
print(
|
||||
f"note: {len(result.non_mod_files)} non-mod file(s) in pack "
|
||||
f"(config/, options.txt, ...) — pass --bundle-non-mods to ship them",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
payload = json.dumps(result.manifest, indent=2) + "\n"
|
||||
if args.output:
|
||||
args.output.write_text(payload, encoding="utf-8")
|
||||
else:
|
||||
sys.stdout.write(payload)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user