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,7 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache/
|
||||||
|
.venv/
|
||||||
|
*.egg-info/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Timemachine
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# packwiz-to-sms
|
||||||
|
|
||||||
|
Convert a [packwiz](https://github.com/packwiz/packwiz) pack to a [simple-mod-sync](https://github.com/oxydien/simple-mod-sync) manifest (`sync_version: 3`).
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Use **packwiz** to author the modpack (Modrinth/CurseForge integration, git-friendly TOML, optional/side-aware mods, `.mrpack` export for free), and **simple-mod-sync** for delivery to clients that can't or won't use Prism/MMC pre-launch hooks (vanilla launcher, TLauncher, cracked players).
|
||||||
|
|
||||||
|
One source of truth, two distribution channels — `.mrpack` export and simple-mod-sync manifest are both produced from the same packwiz repo.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
Requires Python 3.11+ (uses `tomllib`). Zero runtime deps.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.timemachine.center/Timemachine/packwiz-to-sms.git
|
||||||
|
cd packwiz-to-sms
|
||||||
|
python3 packwiz_to_sms.py --help
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Minimal — emit manifest to stdout
|
||||||
|
python3 packwiz_to_sms.py /path/to/packwiz/pack
|
||||||
|
|
||||||
|
# Write to file
|
||||||
|
python3 packwiz_to_sms.py /path/to/packwiz/pack -o manifest.json
|
||||||
|
|
||||||
|
# Bundle non-mod files (config/, options.txt, servers.dat) into a zip
|
||||||
|
# and add a 'packed' entry pointing at where you'll host it
|
||||||
|
python3 packwiz_to_sms.py /path/to/packwiz/pack \
|
||||||
|
-o manifest.json \
|
||||||
|
--bundle-non-mods overrides.zip \
|
||||||
|
--bundle-url https://packs.example.com/overrides.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
## What gets emitted
|
||||||
|
|
||||||
|
| Packwiz path | Becomes simple-mod-sync `type` |
|
||||||
|
|---|---|
|
||||||
|
| `mods/*.pw.toml` (side=client or both) | `mod` |
|
||||||
|
| `mods/*.pw.toml` (side=server) | dropped |
|
||||||
|
| `resourcepacks/*.pw.toml` | `resourcepack` |
|
||||||
|
| `shaderpacks/*.pw.toml` | `shader` |
|
||||||
|
| `**/datapacks/*.pw.toml` | `datapack` |
|
||||||
|
| Any non-metafile (e.g. `options.txt`, `config/*`) | bundled into zip via `--bundle-non-mods`, emitted as one `packed` entry pointing at `directory: "."` |
|
||||||
|
|
||||||
|
CurseForge mods that use `mode = "metadata:curseforge"` (no direct URL) are skipped with a warning. Either switch to Modrinth equivalents or run `packwiz cf reexport` first to inline resolved URLs.
|
||||||
|
|
||||||
|
## What's not handled
|
||||||
|
|
||||||
|
- **Optional mods** — simple-mod-sync has no per-client toggle UI. All non-server mods are emitted unconditionally. Ship two manifests (with/without optional) if you need this.
|
||||||
|
- **Mod removal** — simple-mod-sync's `modify`/`remove` is not auto-populated. Convert by hand if you're dropping mods.
|
||||||
|
- **Rename / regex transforms** — packwiz has no equivalent concept, so we don't generate `modify.rename` entries.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
1. Reads `pack.toml` + `index.toml`.
|
||||||
|
2. For each entry marked `metafile = true`: reads the `.pw.toml`, pulls `download.url`, picks the simple-mod-sync `type` from the parent directory.
|
||||||
|
3. Drops `side = "server"`.
|
||||||
|
4. Drops entries where `download.url` is missing (CF metadata mode).
|
||||||
|
5. Optionally zips non-metafile files into a single archive for `packed` distribution.
|
||||||
|
|
||||||
|
Output schema matches `sync_version: 3` exactly (see [simple-mod-sync DOCS.md](https://github.com/oxydien/simple-mod-sync/blob/main/DOCS.md)).
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m pytest tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
Covers conversion logic + CLI + bundle pipeline + a network integration test that converts the official [`packwiz-example-pack`](https://github.com/packwiz/packwiz-example-pack) (auto-skipped if offline).
|
||||||
|
|
||||||
|
## Upstream contribution
|
||||||
|
|
||||||
|
This tool fits the model used by other [simple-mod-sync translators](https://github.com/oxydien/simple-mod-sync/tree/main/translators). It can be PR'd upstream as `translators/packwiz.py`.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT.
|
||||||
@@ -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())
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
voice_chat_volume=0.8
|
||||||
|
microphone_amplification=1.0
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
hash-format = "sha256"
|
||||||
|
|
||||||
|
[[files]]
|
||||||
|
file = "mods/sodium.pw.toml"
|
||||||
|
hash = "0000000000000000000000000000000000000000000000000000000000000000"
|
||||||
|
metafile = true
|
||||||
|
|
||||||
|
[[files]]
|
||||||
|
file = "mods/fabric-api.pw.toml"
|
||||||
|
hash = "0000000000000000000000000000000000000000000000000000000000000000"
|
||||||
|
metafile = true
|
||||||
|
|
||||||
|
[[files]]
|
||||||
|
file = "mods/server-side-tool.pw.toml"
|
||||||
|
hash = "0000000000000000000000000000000000000000000000000000000000000000"
|
||||||
|
metafile = true
|
||||||
|
|
||||||
|
[[files]]
|
||||||
|
file = "mods/cf-only-mod.pw.toml"
|
||||||
|
hash = "0000000000000000000000000000000000000000000000000000000000000000"
|
||||||
|
metafile = true
|
||||||
|
|
||||||
|
[[files]]
|
||||||
|
file = "resourcepacks/branding.pw.toml"
|
||||||
|
hash = "0000000000000000000000000000000000000000000000000000000000000000"
|
||||||
|
metafile = true
|
||||||
|
|
||||||
|
[[files]]
|
||||||
|
file = "shaderpacks/iris.pw.toml"
|
||||||
|
hash = "0000000000000000000000000000000000000000000000000000000000000000"
|
||||||
|
metafile = true
|
||||||
|
|
||||||
|
[[files]]
|
||||||
|
file = "config/voicechat-client.properties"
|
||||||
|
hash = "1111111111111111111111111111111111111111111111111111111111111111"
|
||||||
|
|
||||||
|
[[files]]
|
||||||
|
file = "options.txt"
|
||||||
|
hash = "2222222222222222222222222222222222222222222222222222222222222222"
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
name = "CF Only Mod"
|
||||||
|
filename = "cfmod-1.0.jar"
|
||||||
|
side = "both"
|
||||||
|
|
||||||
|
[download]
|
||||||
|
hash-format = "murmur2"
|
||||||
|
hash = "1234567"
|
||||||
|
mode = "metadata:curseforge"
|
||||||
|
|
||||||
|
[update]
|
||||||
|
[update.curseforge]
|
||||||
|
project-id = 12345
|
||||||
|
file-id = 67890
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
name = "Fabric API"
|
||||||
|
filename = "fabric-api-0.110.5+1.21.1.jar"
|
||||||
|
side = "both"
|
||||||
|
|
||||||
|
[download]
|
||||||
|
url = "https://cdn.modrinth.com/data/P7dR8mSH/versions/4OZL6q6h/fabric-api-0.110.5%2B1.21.1.jar"
|
||||||
|
hash-format = "sha1"
|
||||||
|
hash = "1234560000000000000000000000000000000000"
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
name = "Server Side Tool"
|
||||||
|
filename = "spark-1.10.55-fabric.jar"
|
||||||
|
side = "server"
|
||||||
|
|
||||||
|
[download]
|
||||||
|
url = "https://example.com/spark.jar"
|
||||||
|
hash-format = "sha1"
|
||||||
|
hash = "9999990000000000000000000000000000000000"
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
name = "Sodium"
|
||||||
|
filename = "sodium-fabric-0.6.5+mc1.21.1.jar"
|
||||||
|
side = "client"
|
||||||
|
|
||||||
|
[download]
|
||||||
|
url = "https://cdn.modrinth.com/data/AANobbMI/versions/EoNKHoLH/sodium-fabric-0.6.5%2Bmc1.21.1.jar"
|
||||||
|
hash-format = "sha1"
|
||||||
|
hash = "abcdef0000000000000000000000000000000000"
|
||||||
|
|
||||||
|
[update]
|
||||||
|
[update.modrinth]
|
||||||
|
mod-id = "AANobbMI"
|
||||||
|
version = "EoNKHoLH"
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fov:90
|
||||||
|
renderDistance:12
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
name = "Test Pack"
|
||||||
|
author = "tester"
|
||||||
|
version = "0.1.0"
|
||||||
|
pack-format = "packwiz:1.1.0"
|
||||||
|
|
||||||
|
[index]
|
||||||
|
file = "index.toml"
|
||||||
|
hash-format = "sha256"
|
||||||
|
hash = "0000000000000000000000000000000000000000000000000000000000000000"
|
||||||
|
|
||||||
|
[versions]
|
||||||
|
minecraft = "1.21.1"
|
||||||
|
fabric = "0.16.5"
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
name = "Server Branding"
|
||||||
|
filename = "automc-branding-0.3.zip"
|
||||||
|
side = "client"
|
||||||
|
|
||||||
|
[download]
|
||||||
|
url = "https://example.com/automc-branding-0.3.zip"
|
||||||
|
hash-format = "sha256"
|
||||||
|
hash = "aaaa000000000000000000000000000000000000000000000000000000000000"
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
name = "Complementary Shaders"
|
||||||
|
filename = "complementary-r5.5.zip"
|
||||||
|
side = "client"
|
||||||
|
|
||||||
|
[download]
|
||||||
|
url = "https://example.com/complementary-r5.5.zip"
|
||||||
|
hash-format = "sha256"
|
||||||
|
hash = "bbbb000000000000000000000000000000000000000000000000000000000000"
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
"""Unit tests for packwiz_to_sms.convert + CLI."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
FIXTURE = Path(__file__).resolve().parent / "fixture-pack"
|
||||||
|
SCRIPT = ROOT / "packwiz_to_sms.py"
|
||||||
|
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
import packwiz_to_sms as p2s # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _by_name(sync: list[dict]) -> dict[str, dict]:
|
||||||
|
return {e["name"]: e for e in sync}
|
||||||
|
|
||||||
|
|
||||||
|
def test_convert_emits_client_and_both_only() -> None:
|
||||||
|
r = p2s.convert(FIXTURE)
|
||||||
|
names = {e["name"] for e in r.manifest["sync"]}
|
||||||
|
# client + both kept
|
||||||
|
assert "Sodium" in names
|
||||||
|
assert "Fabric API" in names
|
||||||
|
# server-only dropped
|
||||||
|
assert "Server Side Tool" not in names
|
||||||
|
# CurseForge metadata-mode dropped (no URL)
|
||||||
|
assert "CF Only Mod" not in names
|
||||||
|
# resourcepack + shader kept
|
||||||
|
assert "Server Branding" in names
|
||||||
|
assert "Complementary Shaders" in names
|
||||||
|
|
||||||
|
|
||||||
|
def test_convert_skip_reasons() -> None:
|
||||||
|
r = p2s.convert(FIXTURE)
|
||||||
|
assert "Server Side Tool" in r.skipped_server_only
|
||||||
|
assert "CF Only Mod" in r.skipped_no_url
|
||||||
|
assert r.skipped_unknown_type == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_convert_content_types() -> None:
|
||||||
|
r = p2s.convert(FIXTURE)
|
||||||
|
entries = _by_name(r.manifest["sync"])
|
||||||
|
assert entries["Sodium"]["type"] == "mod"
|
||||||
|
assert entries["Fabric API"]["type"] == "mod"
|
||||||
|
assert entries["Server Branding"]["type"] == "resourcepack"
|
||||||
|
assert entries["Complementary Shaders"]["type"] == "shader"
|
||||||
|
|
||||||
|
|
||||||
|
def test_convert_url_preserved_verbatim() -> None:
|
||||||
|
r = p2s.convert(FIXTURE)
|
||||||
|
sodium = _by_name(r.manifest["sync"])["Sodium"]
|
||||||
|
assert sodium["url"] == (
|
||||||
|
"https://cdn.modrinth.com/data/AANobbMI/versions/EoNKHoLH/"
|
||||||
|
"sodium-fabric-0.6.5%2Bmc1.21.1.jar"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_convert_version_extracted_from_filename() -> None:
|
||||||
|
r = p2s.convert(FIXTURE)
|
||||||
|
entries = _by_name(r.manifest["sync"])
|
||||||
|
assert entries["Sodium"]["version"] == "0.6.5+mc1.21.1"
|
||||||
|
assert entries["Fabric API"]["version"] == "0.110.5+1.21.1"
|
||||||
|
# Zip artifacts (resourcepacks, shaders) — same extractor handles them.
|
||||||
|
assert entries["Server Branding"]["version"] == "0.3"
|
||||||
|
assert entries["Complementary Shaders"]["version"] == "r5.5"
|
||||||
|
|
||||||
|
|
||||||
|
def test_convert_lists_non_mod_files() -> None:
|
||||||
|
r = p2s.convert(FIXTURE)
|
||||||
|
files = {str(p) for p in r.non_mod_files}
|
||||||
|
assert "config/voicechat-client.properties" in files
|
||||||
|
assert "options.txt" in files
|
||||||
|
|
||||||
|
|
||||||
|
def test_convert_top_level_shape() -> None:
|
||||||
|
r = p2s.convert(FIXTURE)
|
||||||
|
assert r.manifest["sync_version"] == 3
|
||||||
|
assert isinstance(r.manifest["sync"], list)
|
||||||
|
assert r.manifest["_generator"]["tool"] == "packwiz-to-sms"
|
||||||
|
assert r.manifest["_generator"]["pack_name"] == "Test Pack"
|
||||||
|
assert r.manifest["_generator"]["pack_version"] == "0.1.0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bundle_non_mods(tmp_path: Path) -> None:
|
||||||
|
r = p2s.convert(FIXTURE)
|
||||||
|
out = tmp_path / "overrides.zip"
|
||||||
|
p2s.bundle_non_mods(FIXTURE, r.non_mod_files, out)
|
||||||
|
with zipfile.ZipFile(out) as zf:
|
||||||
|
names = set(zf.namelist())
|
||||||
|
assert "config/voicechat-client.properties" in names
|
||||||
|
assert "options.txt" in names
|
||||||
|
|
||||||
|
|
||||||
|
def test_bundle_entry_appended() -> None:
|
||||||
|
r = p2s.convert(FIXTURE)
|
||||||
|
n_before = len(r.manifest["sync"])
|
||||||
|
p2s.add_bundle_entry(r.manifest, "https://example.com/overrides.zip", "0.1.0")
|
||||||
|
assert len(r.manifest["sync"]) == n_before + 1
|
||||||
|
bundle = r.manifest["sync"][-1]
|
||||||
|
assert bundle["type"] == "packed"
|
||||||
|
assert bundle["directory"] == "."
|
||||||
|
assert bundle["url"] == "https://example.com/overrides.zip"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_writes_to_stdout(tmp_path: Path) -> None:
|
||||||
|
proc = subprocess.run(
|
||||||
|
[sys.executable, str(SCRIPT), str(FIXTURE), "--quiet"],
|
||||||
|
capture_output=True, text=True, check=True,
|
||||||
|
)
|
||||||
|
payload = json.loads(proc.stdout)
|
||||||
|
assert payload["sync_version"] == 3
|
||||||
|
assert any(e["name"] == "Sodium" for e in payload["sync"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_writes_to_output_flag(tmp_path: Path) -> None:
|
||||||
|
out = tmp_path / "manifest.json"
|
||||||
|
subprocess.run(
|
||||||
|
[sys.executable, str(SCRIPT), str(FIXTURE), "--quiet", "-o", str(out)],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
payload = json.loads(out.read_text())
|
||||||
|
assert payload["sync_version"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_bundle_requires_url(tmp_path: Path) -> None:
|
||||||
|
proc = subprocess.run(
|
||||||
|
[sys.executable, str(SCRIPT), str(FIXTURE),
|
||||||
|
"--bundle-non-mods", str(tmp_path / "x.zip"), "--quiet"],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
assert proc.returncode == 2
|
||||||
|
assert "--bundle-url" in proc.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_full_bundle_pipeline(tmp_path: Path) -> None:
|
||||||
|
bundle = tmp_path / "overrides.zip"
|
||||||
|
out = tmp_path / "manifest.json"
|
||||||
|
subprocess.run(
|
||||||
|
[sys.executable, str(SCRIPT), str(FIXTURE), "--quiet",
|
||||||
|
"-o", str(out),
|
||||||
|
"--bundle-non-mods", str(bundle),
|
||||||
|
"--bundle-url", "https://example.com/overrides.zip"],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
payload = json.loads(out.read_text())
|
||||||
|
bundle_entry = next(
|
||||||
|
(e for e in payload["sync"] if e.get("type") == "packed"), None
|
||||||
|
)
|
||||||
|
assert bundle_entry is not None
|
||||||
|
assert bundle_entry["url"] == "https://example.com/overrides.zip"
|
||||||
|
assert bundle.is_file()
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_missing_pack_toml(tmp_path: Path) -> None:
|
||||||
|
proc = subprocess.run(
|
||||||
|
[sys.executable, str(SCRIPT), str(tmp_path), "--quiet"],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
assert proc.returncode == 2
|
||||||
|
assert "pack.toml" in proc.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_convert_against_real_packwiz_example_pack(tmp_path: Path) -> None:
|
||||||
|
"""Integration: clone the upstream packwiz-example-pack and convert it.
|
||||||
|
|
||||||
|
Skipped if no network / git unavailable. Verifies the tool works on
|
||||||
|
a real pack, not just our hand-rolled fixture.
|
||||||
|
"""
|
||||||
|
pack_root = tmp_path / "example-pack"
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["git", "clone", "--depth", "1", "--branch", "v1",
|
||||||
|
"https://github.com/packwiz/packwiz-example-pack.git",
|
||||||
|
str(pack_root)],
|
||||||
|
check=True, capture_output=True, timeout=30,
|
||||||
|
)
|
||||||
|
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):
|
||||||
|
pytest.skip("network/git unavailable")
|
||||||
|
|
||||||
|
r = p2s.convert(pack_root)
|
||||||
|
assert r.manifest["sync_version"] == 3
|
||||||
|
# Example pack has at least two mods we recognize
|
||||||
|
names = {e["name"] for e in r.manifest["sync"]}
|
||||||
|
assert any("Borderless" in n for n in names)
|
||||||
|
# All entries have a URL
|
||||||
|
assert all(e.get("url") for e in r.manifest["sync"])
|
||||||
Reference in New Issue
Block a user