Files
packwiz-to-sms/packwiz_to_sms.py
T
claude-timemachine 8651b076d3 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.
2026-06-02 11:44:20 +02:00

292 lines
9.2 KiB
Python

#!/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())