Skip to content

Commit 19b2ee8

Browse files
committed
feat: add NeoForge version tracking
Add a custom parser that fetches versions from the NeoForge Maven API, with fallback to the CreeperHost mirror. Handles both the old (<26) and new (>=26) version-to-MC-version mapping schemes.
1 parent 29f7789 commit 19b2ee8

6 files changed

Lines changed: 238 additions & 1 deletion

File tree

mod_polling/mods.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,15 @@
3434
"slim": true
3535
}
3636
},
37+
"NeoForge": {
38+
"parser": "neoforge",
39+
"category": "forge",
40+
"active": true,
41+
"neoforge": {
42+
"url": "https://maven.neoforged.net/api/maven/versions/releases/net%2Fneoforged%2Fneoforge",
43+
"fallback_url": "https://maven.creeperhost.net/api/maven/versions/releases/net%2Fneoforged%2Fneoforge"
44+
}
45+
},
3746
"OpenComputers": {
3847
"parser": "cfwidget",
3948
"active": true,

mod_polling/poller.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,69 @@ async def check_buildcraft(self, mod):
409409

410410
return {"mc": mc, "version": version}
411411

412+
async def check_neoforge(self, mod):
413+
url = self.mods[mod]["neoforge"]["url"]
414+
fallback_url = self.mods[mod]["neoforge"].get("fallback_url")
415+
416+
try:
417+
jsonres = await self.fetch_json(url)
418+
except Exception:
419+
if not fallback_url:
420+
raise
421+
jsonres = await self.fetch_json(fallback_url)
422+
423+
result = {}
424+
425+
# Iterate newest first (API returns oldest first)
426+
for neoforge_version in reversed(jsonres.get("versions", [])):
427+
# Skip 0.x joke/test versions
428+
if neoforge_version.startswith("0"):
429+
continue
430+
431+
# Skip alpha versions
432+
if "-alpha" in neoforge_version:
433+
continue
434+
435+
# Skip snapshot builds (+ denotes MC snapshot identifier)
436+
if "+" in neoforge_version:
437+
continue
438+
439+
mc_version = self._neoforge_mc_version(neoforge_version)
440+
is_beta = "-beta" in neoforge_version
441+
442+
if mc_version not in result:
443+
result[mc_version] = {}
444+
445+
mc_entry = result[mc_version]
446+
447+
if is_beta:
448+
# Only store beta as dev if no same-or-newer stable exists
449+
if "dev" not in mc_entry and "version" not in mc_entry:
450+
mc_entry["dev"] = neoforge_version
451+
else:
452+
if "version" not in mc_entry:
453+
mc_entry["version"] = neoforge_version
454+
455+
return result
456+
457+
@staticmethod
458+
def _neoforge_mc_version(neoforge_version):
459+
"""Derive the Minecraft version from a NeoForge version string.
460+
461+
<26: prepend "1." to first two parts (e.g. 21.1.222 -> 1.21.1)
462+
>=26: first three parts, drop trailing .0 (e.g. 26.1.1.0-beta -> 26.1.1)
463+
"""
464+
base = neoforge_version.split("-")[0]
465+
parts = base.split(".")
466+
467+
if int(parts[0]) >= 26:
468+
mc = parts[0] + "." + parts[1]
469+
if parts[2] != "0":
470+
mc += "." + parts[2]
471+
return mc
472+
else:
473+
return "1." + parts[0] + "." + parts[1]
474+
412475
def is_version_valid(self, version):
413476
return all(not regex.search(version) for regex in self.invalid_versions)
414477

plugins/nemp.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -745,6 +745,8 @@ async def cmd_url(self, router, name, params, channel, userdata, rank):
745745
url = mod["forgejson"]["url"]
746746
elif func == "html":
747747
url = mod["html"]["url"]
748+
elif func == "neoforge":
749+
url = mod["neoforge"]["url"]
748750
else:
749751
url = None
750752

scripts/test_regexes.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,34 @@ async def check_custom_dead(session, mod_name, mod_data, compiled_regex, *, use_
382382
return DEAD, "Custom parser endpoint (likely dead)", None, [], []
383383

384384

385+
async def check_neoforge(session, mod_name, mod_data, compiled_regex, *, use_cache=False, all_files=False):
386+
url = mod_data["neoforge"]["url"]
387+
fallback_url = mod_data["neoforge"].get("fallback_url")
388+
389+
data = None
390+
for attempt_url in [url, fallback_url]:
391+
if not attempt_url:
392+
continue
393+
try:
394+
async with session.get(attempt_url) as resp:
395+
if resp.status >= 400:
396+
continue
397+
data = await resp.json(content_type=None)
398+
break
399+
except Exception:
400+
continue
401+
402+
if data is None:
403+
return DEAD, "Both primary and fallback URLs failed", None, [], []
404+
405+
versions = data.get("versions", [])
406+
if not versions:
407+
return DEAD, "Empty versions list", None, [], []
408+
409+
samples = versions[-5:]
410+
return PASS, f"Found {len(versions)} NeoForge versions", None, samples, []
411+
412+
385413
PARSER_MAP = {
386414
"cfwidget": check_curse,
387415
"jenkins": check_jenkins,
@@ -390,6 +418,7 @@ async def check_custom_dead(session, mod_name, mod_data, compiled_regex, *, use_
390418
"mcforge_v2": check_mcforge2,
391419
"html": check_html,
392420
"buildcraft": check_buildcraft,
421+
"neoforge": check_neoforge,
393422
}
394423

395424

tests/test_mods_json.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,14 @@ def test_forgejson_parser(self):
5151

5252
assert "forgejson" in mod_info, msg
5353
assert "url" in mod_info["forgejson"], msg
54+
55+
def test_neoforge_parser(self):
56+
for mod, mod_info in self.mods.items():
57+
if mod_info["parser"] != "neoforge":
58+
continue
59+
60+
msg = f"Mod {mod!r} has missing NeoForge parser information"
61+
62+
assert "neoforge" in mod_info, msg
63+
assert "url" in mod_info["neoforge"], msg
64+
assert "fallback_url" in mod_info["neoforge"], msg

tests/test_poller_parsers.py

Lines changed: 124 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import pytest
44

5-
from mod_polling.poller import NEMPException
5+
from mod_polling.poller import ModPoller, NEMPException
66

77

88
class TestCheckMCForge2:
@@ -270,3 +270,126 @@ async def test_buildcraft(self, mod_poller):
270270
result = await mod_poller.check_buildcraft("BuildCraft")
271271
assert result["mc"] == "1.12.2"
272272
assert result["version"] == "7.99.24"
273+
274+
275+
class TestCheckNeoForge:
276+
NEOFORGE_MOD = {
277+
"parser": "neoforge",
278+
"neoforge": {
279+
"url": "https://maven.neoforged.net/api/maven/versions/releases/net%2Fneoforged%2Fneoforge",
280+
"fallback_url": "https://maven.creeperhost.net/api/maven/versions/releases/net%2Fneoforged%2Fneoforge",
281+
},
282+
}
283+
284+
async def test_multi_mc_versions(self, mod_poller):
285+
"""Both <26 and >=26 version schemes resolve correctly."""
286+
mod_poller.mods["NeoForge"] = self.NEOFORGE_MOD
287+
mod_poller.fetch_json = AsyncMock(
288+
return_value={
289+
"versions": [
290+
"20.2.3-beta",
291+
"20.2.86",
292+
"21.1.0-beta",
293+
"21.1.222",
294+
"26.1.0.1-beta",
295+
"26.1.1.0-beta",
296+
]
297+
}
298+
)
299+
result = await mod_poller.check_neoforge("NeoForge")
300+
assert result["1.20.2"]["version"] == "20.2.86"
301+
assert result["1.21.1"]["version"] == "21.1.222"
302+
assert result["26.1"]["dev"] == "26.1.0.1-beta"
303+
assert result["26.1.1"]["dev"] == "26.1.1.0-beta"
304+
305+
async def test_beta_only(self, mod_poller):
306+
"""MC version with only beta releases gets dev, no version."""
307+
mod_poller.mods["NeoForge"] = self.NEOFORGE_MOD
308+
mod_poller.fetch_json = AsyncMock(
309+
return_value={"versions": ["26.1.1.0-beta"]}
310+
)
311+
result = await mod_poller.check_neoforge("NeoForge")
312+
assert result["26.1.1"] == {"dev": "26.1.1.0-beta"}
313+
314+
async def test_stable_suppresses_older_beta(self, mod_poller):
315+
"""When a stable release exists, older betas are not stored as dev."""
316+
mod_poller.mods["NeoForge"] = self.NEOFORGE_MOD
317+
mod_poller.fetch_json = AsyncMock(
318+
return_value={
319+
"versions": [
320+
"21.1.0-beta",
321+
"21.1.1-beta",
322+
"21.1.50",
323+
"21.1.100",
324+
]
325+
}
326+
)
327+
result = await mod_poller.check_neoforge("NeoForge")
328+
assert result["1.21.1"]["version"] == "21.1.100"
329+
assert "dev" not in result["1.21.1"]
330+
331+
async def test_skips_alpha_zero_and_snapshot(self, mod_poller):
332+
"""Alpha, 0.x, and + (snapshot) versions are excluded."""
333+
mod_poller.mods["NeoForge"] = self.NEOFORGE_MOD
334+
mod_poller.fetch_json = AsyncMock(
335+
return_value={
336+
"versions": [
337+
"0.25w14craftmine.3-beta",
338+
"21.1.0-alpha.1",
339+
"21.1.5+snapshot-1",
340+
"21.1.100",
341+
]
342+
}
343+
)
344+
result = await mod_poller.check_neoforge("NeoForge")
345+
assert "0.25" not in result
346+
assert result == {"1.21.1": {"version": "21.1.100"}}
347+
348+
async def test_fallback_on_primary_failure(self, mod_poller):
349+
"""Falls back to CreeperHost mirror when primary fails."""
350+
mod_poller.mods["NeoForge"] = self.NEOFORGE_MOD
351+
mod_poller.fetch_json = AsyncMock(
352+
side_effect=[
353+
Exception("primary down"),
354+
{"versions": ["21.1.50"]},
355+
]
356+
)
357+
result = await mod_poller.check_neoforge("NeoForge")
358+
assert result["1.21.1"]["version"] == "21.1.50"
359+
assert mod_poller.fetch_json.call_count == 2
360+
361+
async def test_fallback_not_attempted_without_url(self, mod_poller):
362+
"""Without fallback_url, primary failure propagates."""
363+
mod_poller.mods["NeoForge"] = {
364+
"parser": "neoforge",
365+
"neoforge": {"url": "https://maven.neoforged.net/..."},
366+
}
367+
mod_poller.fetch_json = AsyncMock(side_effect=Exception("primary down"))
368+
with pytest.raises(Exception, match="primary down"):
369+
await mod_poller.check_neoforge("NeoForge")
370+
371+
async def test_empty_versions(self, mod_poller):
372+
"""Empty versions list returns empty result."""
373+
mod_poller.mods["NeoForge"] = self.NEOFORGE_MOD
374+
mod_poller.fetch_json = AsyncMock(return_value={"versions": []})
375+
result = await mod_poller.check_neoforge("NeoForge")
376+
assert result == {}
377+
378+
379+
class TestNeoForgeMcVersion:
380+
"""Unit tests for the MC version derivation helper."""
381+
382+
def test_old_scheme(self):
383+
assert ModPoller._neoforge_mc_version("21.1.222") == "1.21.1"
384+
385+
def test_old_scheme_beta(self):
386+
assert ModPoller._neoforge_mc_version("20.2.3-beta") == "1.20.2"
387+
388+
def test_new_scheme_minor_zero(self):
389+
assert ModPoller._neoforge_mc_version("26.1.0.19-beta") == "26.1"
390+
391+
def test_new_scheme_minor_nonzero(self):
392+
assert ModPoller._neoforge_mc_version("26.1.1.0-beta") == "26.1.1"
393+
394+
def test_new_scheme_stable(self):
395+
assert ModPoller._neoforge_mc_version("26.1.1.5") == "26.1.1"

0 commit comments

Comments
 (0)