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,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