Step-1.5-music-api / resolver.py
ZeroClawAgent's picture
Upload 10 files
d984ed3 verified
Raw
History Blame Contribute Delete
26.1 kB
"""
Version resolver: turns "latest" (or a specific version string) into a
fully-resolved Fabric build configuration by consulting the Mojang manifest,
Fabric Meta, and Modrinth APIs.
The most subtle part of this module is the **snapshot fallback logic** in
`VersionResolver.resolve_version`. See its docstring for the rationale.
"""
from __future__ import annotations
import asyncio
import logging
import re
from datetime import datetime
from typing import Any, Optional
import httpx
from models import FabricConfig, MinecraftVersion
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
MOJANG_MANIFEST_URL = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"
FABRIC_YARN_URL = "https://meta.fabricmc.net/v2/versions/yarn/{mc_version}"
FABRIC_LOADER_URL = "https://meta.fabricmc.net/v2/versions/loader"
MODRINTH_FABRIC_API_URL = "https://api.modrinth.com/v2/project/fabric-api/version"
# These defaults are not currently fetched from a remote source. They reflect
# the toolchain versions that work across the MC 1.21 / 26.x range. Override
# them on the returned FabricConfig if your project requires different values.
DEFAULT_LOOM_VERSION = "1.7-SNAPSHOT"
DEFAULT_GRADLE_VERSION = "8.8"
# Lowest Minecraft version the oracle will consider for "latest" resolution.
MINIMUM_MC_VERSION_ID = "1.21"
# Cache TTLs
MANIFEST_CACHE_TTL_SECONDS = 600 # 10 minutes β€” manifest doesn't change often
# Safety caps so a runaway loop never hammers the upstream APIs.
MAX_SNAPSHOTS_TO_PROBE = 20
MAX_RELEASES_TO_PROBE = 10
# ---------------------------------------------------------------------------
# Version string parsing
# ---------------------------------------------------------------------------
# Match the space form "26.3 Snapshot 2" OR the dash form "26.3-snapshot-2"
# (the dash form is what Mojang's manifest actually publishes).
_SNAPSHOT_RE = re.compile(
r"^\s*(\d+)\.(\d+)(?:\.(\d+))?\s*[-\s]+[Ss]napshot\s*[-\s]*(\d+)\s*$"
)
# Pre-release and release candidate forms: "26.2-pre-6", "26.2-rc-1".
# Treated as snapshots for sorting (below the corresponding release).
_PRE_RE = re.compile(
r"^\s*(\d+)\.(\d+)(?:\.(\d+))?\s*[-\s]+(?:pre|rc)\s*[-\s]*(\d+)\s*$"
)
_RELEASE_RE = re.compile(r"^\s*(\d+)\.(\d+)(?:\.(\d+))?\s*$")
# Legacy weekly-snapshot format like "23w13a", "25w46a". Used by Mojang for
# older snapshots. We need to recognize these so we can correctly *exclude*
# pre-1.21 ones from the "latest" candidate pool.
_WEEKLY_SNAPSHOT_RE = re.compile(r"^\s*(\d{2})w(\d{1,2})[a-z]\s*$")
def parse_mc_version(version_id: str) -> tuple[int, ...]:
"""
Parse a Minecraft version string into a comparable tuple.
Minecraft recently switched from `1.21.x` style versioning to year-based
`26.x` versioning. A standard semver parser would treat `1.21.4` as
*newer* than `26.2` because semver would parse `1` and `26` as the major
version. We instead want `26.2` to be newer, because year 2026 follows
year 2024 (when `1.21` shipped).
The trick: keep the numeric components in tuple order. Python's tuple
comparison walks left-to-right, so `(26, 2, 0, ...)` > `(1, 21, 0, ...)`
because `26 > 1`. No special "year offset" is needed.
Snapshots are sorted *below* their corresponding release by appending a
type marker (`1` for snapshot, `2` for release) followed by the snapshot
number for snapshots. This means:
`26.3 Snapshot 2` < `26.3 Snapshot 10` < `26.3` (release)
Supports three input forms (all observed in Mojang's manifest):
- "26.3 Snapshot 2" (space form, user-facing)
- "26.3-snapshot-2" (dash form, manifest id)
- "26.2-pre-6" (dash form, pre-release)
- "26.2-rc-1" (dash form, release candidate)
Returns a tuple of ints. Unparseable strings return (0,) so they always
sort below any real version β€” this is intentional, because the resolver's
`is_minimum_version` filter then excludes them from the "latest" pool.
"""
snap = _SNAPSHOT_RE.match(version_id)
if snap:
major = int(snap.group(1))
minor = int(snap.group(2))
patch = int(snap.group(3)) if snap.group(3) else 0
snap_num = int(snap.group(4))
# type marker: 1 = snapshot (lower than release's 2)
return (major, minor, patch, 1, snap_num)
pre = _PRE_RE.match(version_id)
if pre:
major = int(pre.group(1))
minor = int(pre.group(2))
patch = int(pre.group(3)) if pre.group(3) else 0
pre_num = int(pre.group(4))
# Treat pre-releases as snapshots for ordering purposes.
return (major, minor, patch, 1, pre_num)
rel = _RELEASE_RE.match(version_id)
if rel:
major = int(rel.group(1))
minor = int(rel.group(2))
patch = int(rel.group(3)) if rel.group(3) else 0
# type marker: 2 = release
return (major, minor, patch, 2)
# Legacy weekly snapshots like "23w13a", "25w46a". Map them onto the
# (year, week) tuple. Year 23 == 2023, week 13. We use the same 4-tuple
# shape with a sentinel marker so they sort *above* 1.21 if year>=24 but
# below 1.21 otherwise. (24wXXa corresponds to MC 1.21 snapshots.)
weekly = _WEEKLY_SNAPSHOT_RE.match(version_id)
if weekly:
year_short = int(weekly.group(1))
week = int(weekly.group(2))
# Weekly snapshots sort *below* the corresponding release: use type
# marker 1. Major version = full year (e.g. 23 -> 2023) so 23w13a
# (2023) sorts below 1.21 (which is (1, 21, 0, 2)).
return (2000 + year_short, week, 0, 1)
# Truly unparseable: return (0,) so it sorts below everything.
return (0,)
def is_minimum_version(version_id: str, minimum: str = MINIMUM_MC_VERSION_ID) -> bool:
"""
True iff `version_id` >= `minimum` under our custom parser.
For minimum "1.21", this rejects:
- Legacy weekly snapshots from before 1.21's release (mid-2024).
Weekly snapshots live in a separate "epoch" (year + week) that
doesn't tuple-compare cleanly against `1.x` / `26.x` versions, so
we apply a separate cutoff: weekly snapshots at or after week 24
of 2024 (when 1.21 dropped) are considered "above 1.21".
- Truly unparseable strings (which return (0,)).
"""
# Special case: legacy weekly snapshots (YYwNNa format). 1.21 was
# released on 2024-06-13 (ISO week 24). Snapshots at or after that
# point are experimental snapshots for 1.21.x+ content.
weekly = _WEEKLY_SNAPSHOT_RE.match(version_id)
if weekly:
year_short = int(weekly.group(1))
week = int(weekly.group(2))
return (year_short, week) >= (24, 24)
try:
return parse_mc_version(version_id) >= parse_mc_version(minimum)
except Exception:
return False
# Minecraft 26.1 (released March 2026) is the first release where Mojang
# shipped the Java jar *unobfuscated*, with parameter names and (limited)
# Javadoc-style comments included. As a consequence, the Fabric Project
# officially retired Yarn mappings β€” Yarn builds simply don't exist for
# 26.1+. Mods targeting 26.1+ use Mojang's official mappings directly via
# Loom's `loom.officialMojangMappings()` call in build.gradle, and the
# `gradle.properties` file does NOT carry a `yarn_mappings` line.
#
# Source: https://docs.fabricmc.net/develop/porting/mappings
# "Minecraft 26.1 is unobfuscated and includes parameter names,
# so there is no need for any obfuscation mappings."
UNOBFUSCATED_MINIMUM = "26.1"
def is_unobfuscated(version_id: str) -> bool:
"""
True iff `version_id` is an unobfuscated Minecraft release (>= 26.1).
For these versions:
- Yarn mappings do NOT exist on Fabric Meta and never will.
- The Fabric ecosystem (Yarn, Loader, Fabric API) still works β€” only
the mappings layer is replaced by Mojang's official Mojmap.
- Loom reads Mojmap via `loom.officialMojangMappings()` in build.gradle
instead of `mappings "net.fabricmc:yarn:..."`.
The resolver uses this to decide whether Yarn is a hard requirement
when probing for "latest" support.
"""
# Weekly snapshots from 2025+ (25wXXa) are 1.21.x dev cycle, still
# obfuscated. Only year-based 26.1+ versions are unobfuscated.
if _WEEKLY_SNAPSHOT_RE.match(version_id):
return False
try:
return parse_mc_version(version_id) >= parse_mc_version(UNOBFUSCATED_MINIMUM)
except Exception:
return False
# ---------------------------------------------------------------------------
# Resolver
# ---------------------------------------------------------------------------
class VersionResolver:
"""
Resolves the optimal Fabric configuration for a Minecraft version.
## Snapshot fallback logic
When `target_version` is `"latest"` (or omitted), the resolver must be
*intelligent* about which version it picks. Minecraft snapshots are
released frequently by Mojang, but the Fabric ecosystem (Fabric API mod
for all versions; Yarn mappings only for *obfuscated* versions) often
lags by days. If we blindly returned the newest snapshot, the AI might
generate a `gradle.properties` that references Fabric API versions that
*do not exist yet*, and the user's mod would fail to build.
The resolver therefore:
1. Fetches the Mojang manifest and keeps only versions >= `1.21`.
2. Sorts them by `releaseTime` descending (newest first).
3. Iterates the newest snapshots, one by one. For each, it asks
`has_fabric_support(mc_version)`, which:
- For OBFUSCATED versions (1.21.x and earlier): requires BOTH
Yarn mappings AND a Fabric API build.
- For UNOBFUSCATED versions (26.1+, per Mojang's Oct 2025
announcement and Fabric's deprecation of Yarn): requires ONLY
a Fabric API build. Yarn doesn't exist for these versions
and is not needed β€” mods use Mojang's official Mojmap via
Loom's `loom.officialMojangMappings()`.
If support is confirmed, that snapshot is returned immediately.
4. If none of the probed snapshots have support, the resolver repeats
the same check against the newest stable releases.
5. If even releases have no support (extremely unlikely β€” would
indicate a Fabric ecosystem outage), it returns the newest release
as a last-resort so the tool can still produce a useful error.
Iteration is *sequential* not parallel. This is intentional: the typical
case is that the 1st or 2nd snapshot already has support, so we make at
most 2-3 API round-trips total. Parallel probing would always make N
round-trips and risk rate-limiting on Modrinth.
See: https://docs.fabricmc.net/develop/porting/mappings
"""
def __init__(self, http_client: Optional[httpx.AsyncClient] = None) -> None:
# A shared client gives us connection pooling and HTTP/2 multiplexing.
self._client: httpx.AsyncClient = http_client or httpx.AsyncClient(
timeout=httpx.Timeout(30.0),
headers={"User-Agent": "fabric-config-oracle/1.0"},
)
# In-process caches. They live for the lifetime of the resolver
# (i.e. for the lifetime of the MCP server process). The manifest
# cache additionally has a TTL; the per-version caches do not, but
# they're small (one entry per probed MC version) and never grow
# unboundedly because we cap probes via MAX_*_TO_PROBE.
self._manifest_cache: Optional[dict[str, Any]] = None
self._manifest_cache_time: Optional[datetime] = None
self._loader_cache: Optional[list[dict[str, Any]]] = None
self._yarn_cache: dict[str, Optional[dict[str, Any]]] = {}
self._fabric_api_cache: dict[str, Optional[dict[str, Any]]] = {}
# -- lifecycle --------------------------------------------------------
async def aclose(self) -> None:
"""Close the underlying HTTP client. Safe to call multiple times."""
await self._client.aclose()
async def __aenter__(self) -> "VersionResolver":
return self
async def __aexit__(self, *_: object) -> None:
await self.aclose()
# -- low-level HTTP ---------------------------------------------------
async def _fetch_json(
self,
url: str,
params: Optional[dict[str, Any]] = None,
) -> Any:
"""
Fetch JSON from `url` with graceful error handling.
Returns:
- The parsed JSON value on success.
- `None` on 404 (treated as "no data available").
- `None` on any other error after logging.
429 (rate limited) responses trigger a single backoff-and-retry.
"""
try:
response = await self._client.get(url, params=params)
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as exc:
status = exc.response.status_code
if status == 404:
return None
if status == 429:
logger.warning("Rate limited by %s; backing off 2s and retrying once.", url)
await asyncio.sleep(2.0)
try:
retry = await self._client.get(url, params=params)
retry.raise_for_status()
return retry.json()
except Exception as retry_exc: # noqa: BLE001
logger.error("Retry of %s failed: %s", url, retry_exc)
return None
logger.error("HTTP %d from %s: %s", status, url, exc)
return None
except (httpx.RequestError, ValueError) as exc:
logger.error("Error fetching %s: %s", url, exc)
return None
# -- API wrappers -----------------------------------------------------
async def fetch_mojang_manifest(self) -> list[MinecraftVersion]:
"""Fetch and parse the Mojang manifest, filtered to versions >= 1.21."""
now = datetime.now()
if (
self._manifest_cache is not None
and self._manifest_cache_time is not None
and (now - self._manifest_cache_time).total_seconds() < MANIFEST_CACHE_TTL_SECONDS
):
return self._parse_manifest(self._manifest_cache)
data = await self._fetch_json(MOJANG_MANIFEST_URL)
if data is None:
if self._manifest_cache is not None:
logger.warning("Mojang manifest fetch failed; using stale cache.")
return self._parse_manifest(self._manifest_cache)
logger.error("Mojang manifest unavailable and no cache to fall back on.")
return []
self._manifest_cache = data
self._manifest_cache_time = now
return self._parse_manifest(data)
def _parse_manifest(self, data: dict[str, Any]) -> list[MinecraftVersion]:
"""Parse raw manifest JSON into a list of MinecraftVersion objects."""
out: list[MinecraftVersion] = []
for v in data.get("versions", []):
vid = v.get("id", "")
vtype = v.get("type", "")
# Only release/snapshot types are relevant to us. Mojang also
# exposes 'old_beta' / 'old_alpha' which we deliberately skip.
if vtype not in ("release", "snapshot"):
continue
if not is_minimum_version(vid):
continue
try:
rt_raw = v["releaseTime"]
rt = datetime.fromisoformat(rt_raw.replace("Z", "+00:00"))
except (KeyError, ValueError):
continue
out.append(
MinecraftVersion(
id=vid,
type=vtype, # type: ignore[arg-type]
release_time=rt,
url=v.get("url"),
)
)
return out
async def fetch_yarn(self, mc_version: str) -> Optional[dict[str, Any]]:
"""Fetch the newest Yarn mappings build for `mc_version`, or None."""
if mc_version in self._yarn_cache:
return self._yarn_cache[mc_version]
data = await self._fetch_json(FABRIC_YARN_URL.format(mc_version=mc_version))
if not isinstance(data, list) or len(data) == 0:
self._yarn_cache[mc_version] = None
return None
# Fabric Meta returns Yarn builds newest-first, but sort defensively.
data.sort(key=lambda y: y.get("build", 0), reverse=True)
yarn = data[0]
self._yarn_cache[mc_version] = yarn
return yarn
async def fetch_loader(self) -> Optional[dict[str, Any]]:
"""Fetch the newest stable Fabric Loader version."""
if self._loader_cache is not None:
return self._loader_cache[0] if self._loader_cache else None
data = await self._fetch_json(FABRIC_LOADER_URL)
if not isinstance(data, list) or len(data) == 0:
return None
self._loader_cache = data
# First entry is the latest stable loader per Fabric Meta convention.
return data[0]
async def fetch_fabric_api(self, mc_version: str) -> Optional[dict[str, Any]]:
"""Fetch the newest Fabric API mod build for `mc_version` from Modrinth."""
if mc_version in self._fabric_api_cache:
return self._fabric_api_cache[mc_version]
# Modrinth expects JSON-encoded array query params, e.g.
# ?game_versions=["1.21.4"]&loaders=["fabric"]
params = {
"game_versions": f'["{mc_version}"]',
"loaders": '["fabric"]',
}
data = await self._fetch_json(MODRINTH_FABRIC_API_URL, params=params)
if not isinstance(data, list) or len(data) == 0:
self._fabric_api_cache[mc_version] = None
return None
data.sort(key=lambda v: v.get("date_published", ""), reverse=True)
fabric_api = data[0]
self._fabric_api_cache[mc_version] = fabric_api
return fabric_api
async def has_fabric_support(self, mc_version: str) -> bool:
"""
True iff `mc_version` has the Fabric dependencies required to build
a working mod.
## Unobfuscated versions (26.1+)
As of Minecraft 26.1 (March 2026), Mojang ships Java jars
unobfuscated, so Yarn mappings are deprecated and no longer
published. Mods targeting 26.1+ use Mojang's official Mojmap
directly via Loom's `loom.officialMojangMappings()`. For these
versions, the only Fabric dependency we need to verify is Fabric
API on Modrinth.
## Obfuscated versions (<= 1.21.11)
For these, BOTH Yarn AND Fabric API must exist, since a mod
referencing obfuscated class names cannot compile without Yarn
(or another mapping set) to translate them.
See: https://docs.fabricmc.net/develop/porting/mappings
"""
if is_unobfuscated(mc_version):
# Only Fabric API required; Yarn doesn't exist for 26.1+.
fabric_api = await self.fetch_fabric_api(mc_version)
return fabric_api is not None
# Obfuscated path: Yarn + Fabric API both required.
yarn, fabric_api = await asyncio.gather(
self.fetch_yarn(mc_version),
self.fetch_fabric_api(mc_version),
)
return yarn is not None and fabric_api is not None
# -- resolution -------------------------------------------------------
async def resolve_version(
self,
target_version: str = "latest",
) -> Optional[MinecraftVersion]:
"""
Resolve a target Minecraft version, applying fallback logic for "latest".
See the `VersionResolver` class docstring for the full snapshot
fallback rationale.
Args:
target_version: Either "latest" (default) or a specific version
string like "1.21.4", "26.2", or "26.3 Snapshot 2".
Returns:
A `MinecraftVersion` if resolution succeeded, else `None`.
For specific (non-"latest") requests, the version is looked up in the
manifest without a support check. If it isn't in the manifest, `None`
is returned and the caller may still attempt to fetch dependencies for
it β€” the per-API wrappers will return empty results gracefully.
"""
versions = await self.fetch_mojang_manifest()
if not versions:
return None
# Direct (specific-version) request β€” no fallback logic.
if target_version and target_version.lower() != "latest":
for v in versions:
if v.id == target_version:
return v
# Case-insensitive fallback (some users mistype casing).
for v in versions:
if v.id.lower() == target_version.lower():
return v
# Not in manifest. Return None so the caller can still try to
# fetch dependencies directly β€” Fabric Meta / Modrinth might
# know about it even if Mojang's manifest hasn't updated yet.
return None
# "latest" β€” apply snapshot-first, release-fallback resolution.
versions_sorted = sorted(versions, key=lambda v: v.release_time, reverse=True)
# Step 3: probe newest snapshots.
snapshots = [v for v in versions_sorted if v.type == "snapshot"]
for snap in snapshots[:MAX_SNAPSHOTS_TO_PROBE]:
if await self.has_fabric_support(snap.id):
return snap
# Step 4: fall back to newest stable releases.
releases = [v for v in versions_sorted if v.type == "release"]
for rel in releases[:MAX_RELEASES_TO_PROBE]:
if await self.has_fabric_support(rel.id):
return rel
# Step 5: last resort β€” return the newest release even without
# confirmed support, so we can surface a meaningful error message
# via FabricConfig.message.
return releases[0] if releases else (snapshots[0] if snapshots else None)
async def resolve_config(self, target_version: str = "latest") -> FabricConfig:
"""
Resolve the full Fabric configuration for a target MC version.
This is the high-level entry point used by the MCP tool. It first
resolves the version (applying fallback logic if "latest"), then
fetches Yarn, Loader, and Fabric API versions concurrently.
The returned `FabricConfig.message` field always describes the
outcome β€” success, partial success, or failure β€” in human-readable
form so the AI caller can react accordingly.
"""
mc_version = await self.resolve_version(target_version)
if mc_version is None:
return FabricConfig(
minecraft_version=target_version,
version_type="release",
yarn_mappings="",
loader_version="",
fabric_api_version="",
loom_version=DEFAULT_LOOM_VERSION,
gradle_wrapper_version=DEFAULT_GRADLE_VERSION,
message=(
f"Could not resolve Minecraft version '{target_version}'. "
"It may not exist in the Mojang manifest, or the manifest "
"could not be fetched."
),
)
# For unobfuscated versions (26.1+), Yarn does not exist and is not
# needed β€” mods use Mojang's official Mojmap directly via Loom. Skip
# the Yarn fetch entirely to avoid a wasted 404 round-trip.
unobf = is_unobfuscated(mc_version.id)
if unobf:
loader, fabric_api = await asyncio.gather(
self.fetch_loader(),
self.fetch_fabric_api(mc_version.id),
)
yarn = None
else:
yarn, loader, fabric_api = await asyncio.gather(
self.fetch_yarn(mc_version.id),
self.fetch_loader(),
self.fetch_fabric_api(mc_version.id),
)
yarn_version = (yarn or {}).get("version", "")
loader_version = (loader or {}).get("version", "")
# Modrinth's `version_number` is the human-facing string
# (e.g. "0.100.0+1.21.4"). `id` is the URL-safe UUID.
fabric_api_version = (fabric_api or {}).get("version_number", "")
# Build the missing-deps list. For unobfuscated versions, Yarn is
# intentionally absent β€” it's not "missing", it's deprecated.
missing: list[str] = []
if not unobf and not yarn_version:
missing.append("Yarn mappings")
if not loader_version:
missing.append("Fabric Loader")
if not fabric_api_version:
missing.append("Fabric API")
if missing:
message = (
f"Configuration partially resolved for MC {mc_version.id}. "
f"Missing: {', '.join(missing)}."
)
elif unobf:
message = (
f"Configuration resolved successfully for MC {mc_version.id}. "
"Yarn mappings are not required (Minecraft 26.1+ ships "
"unobfuscated; mods use Mojang official Mojmap via Loom's "
"`loom.officialMojangMappings()` in build.gradle)."
)
else:
message = "Configuration resolved successfully."
return FabricConfig(
minecraft_version=mc_version.id,
version_type=mc_version.type,
yarn_mappings=yarn_version,
loader_version=loader_version,
fabric_api_version=fabric_api_version,
loom_version=DEFAULT_LOOM_VERSION,
gradle_wrapper_version=DEFAULT_GRADLE_VERSION,
message=message,
)