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