modkeel 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
modkeel/__init__.py ADDED
@@ -0,0 +1,26 @@
1
+ """
2
+ Modkeel - Minecraft Mod Auto-Compiler
3
+
4
+ Automatically detects, compiles, and installs mods from GitHub repositories
5
+ for specific Minecraft versions and mod loaders.
6
+ """
7
+
8
+ from modkeel.constants import MODKEEL_VERSION
9
+ from modkeel.loaders import ( # noqa: F401
10
+ ALL_LOADERS,
11
+ KNOWN_LOADER_VERSIONS,
12
+ LOADER_PROFILES,
13
+ get_bridge_mods,
14
+ get_cross_loader_chain,
15
+ get_docker_server_type,
16
+ get_installer_url,
17
+ get_known_version,
18
+ get_maven_domain,
19
+ get_profile,
20
+ normalize_loader,
21
+ )
22
+
23
+ from modkeel.recommend import RecommendationEngine # noqa: F401
24
+ from modkeel.scanner import detect_mods_folder, scan_mods_folder # noqa: F401
25
+
26
+ __version__ = MODKEEL_VERSION
modkeel/build.py ADDED
@@ -0,0 +1,496 @@
1
+ """Build and compilation functions for Modkeel."""
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ import re
7
+ import subprocess
8
+ import zipfile
9
+ from pathlib import Path
10
+ from typing import List, Optional, Tuple
11
+
12
+ import toml
13
+
14
+ from modkeel.loaders import LOADER_PROFILES
15
+ from modkeel.models import FailureType
16
+ from modkeel.version import is_version_in_fabric_range, is_version_in_maven_range
17
+ from modkeel.constants import MODKEEL_HOME
18
+
19
+ logger = logging.getLogger("modkeel")
20
+
21
+
22
+ def classify_build_failure(
23
+ stderr: str, stdout: str
24
+ ) -> Tuple[FailureType, List[str]]:
25
+ """
26
+ Classify a Gradle build failure by parsing stderr/stdout.
27
+ Returns (failure_type, list_of_missing_dependency_coordinates).
28
+ """
29
+ combined = (stderr or "") + "\n" + (stdout or "")
30
+ missing_deps: List[str] = []
31
+
32
+ dep_patterns = [
33
+ r"Could not find\s+([\w.\-]+:[\w.\-]+:[\w.\-+]+)",
34
+ r"Could not resolve\s+([\w.\-]+:[\w.\-]+:[\w.\-+]+)",
35
+ r"Could not resolve all (?:files|dependencies) for configuration",
36
+ ]
37
+
38
+ is_dep_failure = False
39
+ for pattern in dep_patterns:
40
+ matches = re.findall(pattern, combined)
41
+ if matches:
42
+ is_dep_failure = True
43
+ if isinstance(matches[0], str) and ":" in matches[0]:
44
+ missing_deps.extend(m.rstrip(".") for m in matches)
45
+
46
+ if is_dep_failure:
47
+ seen = set()
48
+ unique_deps = []
49
+ for dep in missing_deps:
50
+ if dep not in seen:
51
+ seen.add(dep)
52
+ unique_deps.append(dep)
53
+ return FailureType.DEPENDENCY_RESOLUTION, unique_deps
54
+
55
+ return FailureType.BUILD_ERROR, []
56
+
57
+
58
+ _EXCLUDED_JAR_SUFFIXES = ('-sources.jar', '-dev.jar', '-javadoc.jar', '-slim.jar', '-api.jar')
59
+ _MOD_METADATA = ('META-INF/neoforge.mods.toml', 'META-INF/mods.toml', 'fabric.mod.json',
60
+ 'quilt.mod.json')
61
+
62
+
63
+ def _is_mod_jar(jar: Path) -> bool:
64
+ try:
65
+ with zipfile.ZipFile(jar) as z:
66
+ names = set(z.namelist())
67
+ except (zipfile.BadZipFile, OSError):
68
+ return False
69
+ return any(m in names for m in _MOD_METADATA)
70
+
71
+
72
+ def find_output_jars(repo_path: Path, max_depth: int = 3) -> List[Path]:
73
+ """Built JARs from the root and subprojects (multi-loader builds put them in
74
+ ``neoforge/build/libs`` and similar). Jars carrying mod metadata win; when none
75
+ do, fall back to every candidate so single-module builds keep working."""
76
+ candidates = []
77
+ for depth in range(max_depth + 1):
78
+ pattern = "/".join(["*"] * depth + ["build", "libs", "*.jar"])
79
+ for jar in repo_path.glob(pattern):
80
+ rel = jar.relative_to(repo_path).parts
81
+ if rel[0] == "buildSrc" or jar.name.endswith(_EXCLUDED_JAR_SUFFIXES):
82
+ continue
83
+ candidates.append(jar)
84
+ mod_jars = [j for j in candidates if _is_mod_jar(j)]
85
+ return mod_jars or candidates
86
+
87
+
88
+ # The lines that say why a build failed; Gradle's own tail only says that it did
89
+ _KEY_ERROR = re.compile(r"\berror:|\[ERROR\]|^\s*ERROR\b|Caused by:|cannot find symbol"
90
+ r"|What went wrong|OutOfMemoryError|Not enough memory", re.I)
91
+ _ANSI = re.compile(r"\x1b\[[0-9;]*m")
92
+
93
+
94
+ def key_error_lines(output: str, limit: int = 12) -> List[str]:
95
+ """First distinct error lines of a build's output, colour codes stripped."""
96
+ out: List[str] = []
97
+ lines = [ln.strip() for ln in _ANSI.sub("", output).splitlines()]
98
+ for i, line in enumerate(lines):
99
+ if not line or not _KEY_ERROR.search(line):
100
+ continue
101
+ if line.startswith("* What went wrong"):
102
+ # Gradle puts the cause on the next line
103
+ line = next((ln for ln in lines[i + 1:] if ln), line)
104
+ if line not in out:
105
+ out.append(line[:300])
106
+ if len(out) >= limit:
107
+ break
108
+ return out
109
+
110
+
111
+ GRADLE_DISTS = MODKEEL_HOME / "gradle"
112
+ _WRAPPER_URL = re.compile(r"^distributionUrl\s*=\s*(\S+)", re.M)
113
+ _LOADER_DIR_ORDER = ("fabric", "neoforge", "forge", "quilt")
114
+
115
+
116
+ def _gradle_script(root: Path) -> Path:
117
+ return root / ("gradlew.bat" if os.name == 'nt' else "gradlew")
118
+
119
+
120
+ def ensure_gradle_distribution(url: str) -> Optional[Path]:
121
+ """Download and unpack a Gradle distribution once; return its gradle executable."""
122
+ import requests # local: nothing else in build.py touches the network
123
+
124
+ name = url.rsplit("/", 1)[-1].removesuffix(".zip")
125
+ home = GRADLE_DISTS / name
126
+ exe_name = "gradle.bat" if os.name == 'nt' else "gradle"
127
+ found = list(home.glob(f"*/bin/{exe_name}")) if home.exists() else []
128
+ if found:
129
+ return found[0]
130
+ home.mkdir(parents=True, exist_ok=True)
131
+ archive = home / "dist.zip"
132
+ try:
133
+ with requests.get(url, stream=True, timeout=300) as r:
134
+ r.raise_for_status()
135
+ with open(archive, "wb") as f:
136
+ for chunk in r.iter_content(1 << 16):
137
+ f.write(chunk)
138
+ with zipfile.ZipFile(archive) as z:
139
+ z.extractall(home)
140
+ except Exception as e: # noqa: BLE001 - the caller reports a missing wrapper
141
+ logger.debug(f"Gradle distribution download failed ({url}): {e}")
142
+ return None
143
+ finally:
144
+ archive.unlink(missing_ok=True)
145
+ found = list(home.glob(f"*/bin/{exe_name}"))
146
+ if found and os.name != 'nt':
147
+ os.chmod(found[0], 0o755)
148
+ return found[0] if found else None
149
+
150
+
151
+ def resolve_gradle(repo_path: Path, loader: Optional[str] = None
152
+ ) -> Tuple[Optional[Path], Optional[Path]]:
153
+ """(build root, gradle executable) for a repo.
154
+
155
+ The wrapper script wins. A repo that ships only gradle-wrapper.properties gets the
156
+ distribution it names. Repos whose loaders are separate Gradle projects in subfolders
157
+ (``Fabric/``, ``NeoForge/``) build the folder of ``loader``, else the first known one.
158
+ """
159
+ script = _gradle_script(repo_path)
160
+ if script.exists():
161
+ if os.name != 'nt':
162
+ os.chmod(script, 0o755)
163
+ return repo_path, script
164
+ props = repo_path / "gradle" / "wrapper" / "gradle-wrapper.properties"
165
+ has_settings = any((repo_path / f).exists()
166
+ for f in ("settings.gradle", "settings.gradle.kts",
167
+ "build.gradle", "build.gradle.kts"))
168
+ if props.exists() and has_settings:
169
+ m = _WRAPPER_URL.search(props.read_text(encoding="utf-8", errors="replace"))
170
+ if m:
171
+ exe = ensure_gradle_distribution(m.group(1).replace("\\:", ":"))
172
+ if exe:
173
+ return repo_path, exe
174
+ subs = {d.name.lower(): d for d in repo_path.iterdir()
175
+ if d.is_dir() and (_gradle_script(d).exists()
176
+ or (d / "gradle" / "wrapper" / "gradle-wrapper.properties")
177
+ .exists())}
178
+ for name in ([loader.lower()] if loader else []) + list(_LOADER_DIR_ORDER):
179
+ if name in subs:
180
+ return resolve_gradle(subs[name])
181
+ return None, None
182
+
183
+
184
+ def loader_subproject(root: Path, loader: str) -> Optional[str]:
185
+ """Gradle path of a multi-loader repo's subproject for ``loader`` (``:Fabric``), if any."""
186
+ for d in root.iterdir():
187
+ if d.is_dir() and d.name.lower() == loader.lower() and \
188
+ any((d / f).exists() for f in ("build.gradle", "build.gradle.kts")):
189
+ return f":{d.name}"
190
+ return None
191
+
192
+
193
+ def build_tasks(root: Path, mc_version: Optional[str], loader: Optional[str] = None) -> List[str]:
194
+ """Tasks to build. Stonecutter builds every version it knows, and a multi-loader
195
+ build fails as a whole when one loader breaks: narrow to the target (and ``loader``)."""
196
+ sub = loader_subproject(root, loader) if loader else None
197
+ if sub:
198
+ return [f"{sub}:build"]
199
+ versions = root / "versions"
200
+ stonecutter = any((root / f).exists()
201
+ for f in ("stonecutter.gradle.kts", "stonecutter.gradle"))
202
+ if not (mc_version and stonecutter and versions.is_dir()):
203
+ return ["build"]
204
+ names = [d.name for d in versions.iterdir()
205
+ if d.is_dir() and (d.name == mc_version or d.name.startswith(mc_version + "-"))]
206
+ return [f":{n}:build" for n in sorted(names)] or ["build"]
207
+
208
+
209
+ def select_main_jar(jar_files: List[Path], mc_version: Optional[str] = None) -> Path:
210
+ """Fat jar, else the largest. Jars that validate for ``mc_version`` go first, so a
211
+ multi-version build does not hand back another version's jar."""
212
+ if mc_version:
213
+ jar_files = [j for j in jar_files if validate_jar(j, mc_version)[0]] or jar_files
214
+ fat_jars = [j for j in jar_files if j.name.endswith(('-all.jar', '-shadow.jar'))]
215
+ return max(fat_jars or jar_files, key=lambda j: j.stat().st_size)
216
+
217
+
218
+ def compile_mod(
219
+ repo_path: Path,
220
+ extra_gradle_args: Optional[List[str]] = None,
221
+ mc_version: Optional[str] = None,
222
+ loader: Optional[str] = None,
223
+ ) -> Tuple[bool, Optional[Path], str, FailureType, List[str]]:
224
+ """
225
+ Compile the mod using Gradle.
226
+ Returns (success, jar_path, message, failure_type, missing_deps)
227
+ """
228
+ print(" \U0001f528 Compiling...")
229
+
230
+ root, gradlew = resolve_gradle(repo_path, loader)
231
+ if not gradlew:
232
+ return (False, None, "Gradle wrapper not found",
233
+ FailureType.BUILD_ERROR, [])
234
+ repo_path = root
235
+
236
+ # Quick dependency resolution check
237
+ try:
238
+ print(" \U0001f50d Checking dependencies...")
239
+ dep_cmd = [
240
+ str(gradlew), "dependencies", "--configuration",
241
+ "compileClasspath", "--no-daemon"
242
+ ]
243
+ if extra_gradle_args:
244
+ dep_cmd.extend(extra_gradle_args)
245
+
246
+ dep_result = subprocess.run(
247
+ dep_cmd,
248
+ cwd=repo_path,
249
+ capture_output=True,
250
+ text=True,
251
+ timeout=120
252
+ )
253
+ if dep_result.returncode != 0:
254
+ fail_type, missing = classify_build_failure(
255
+ dep_result.stderr, dep_result.stdout
256
+ )
257
+ if fail_type == FailureType.DEPENDENCY_RESOLUTION:
258
+ print(f" \u274c Dependency check failed: {', '.join(missing[:3])}")
259
+ return (False, None,
260
+ f"Dependency check failed: {missing}",
261
+ fail_type, missing)
262
+ print(" \u26a0\ufe0f Dep check returned error but not dep-related, continuing build...")
263
+ except subprocess.TimeoutExpired:
264
+ print(" \u26a0\ufe0f Dep check timed out, continuing with full build...")
265
+ except Exception as e:
266
+ print(f" \u26a0\ufe0f Dep check error ({e}), continuing with full build...")
267
+
268
+ try:
269
+ cmd = [str(gradlew), *build_tasks(repo_path, mc_version, loader), "--no-daemon"]
270
+ if extra_gradle_args:
271
+ cmd.extend(extra_gradle_args)
272
+
273
+ result = subprocess.run(
274
+ cmd,
275
+ cwd=repo_path,
276
+ capture_output=True,
277
+ text=True,
278
+ timeout=600
279
+ )
280
+
281
+ if result.returncode != 0:
282
+ failure_type, missing_deps = classify_build_failure(
283
+ result.stderr, result.stdout
284
+ )
285
+
286
+ stderr_tail = '\n'.join(
287
+ result.stderr.split('\n')[-15:]
288
+ ) if result.stderr else ''
289
+ stdout_tail = '\n'.join(
290
+ result.stdout.split('\n')[-15:]
291
+ ) if result.stdout else ''
292
+ error_detail = f"Gradle build failed (exit code {result.returncode}):\n"
293
+ causes = key_error_lines(result.stdout + "\n" + result.stderr)
294
+ if causes:
295
+ error_detail += "--- errors ---\n" + "\n".join(causes) + "\n"
296
+ if stderr_tail.strip():
297
+ error_detail += f"--- stderr ---\n{stderr_tail}\n"
298
+ if stdout_tail.strip():
299
+ error_detail += f"--- stdout ---\n{stdout_tail}"
300
+
301
+ if missing_deps:
302
+ error_detail += (
303
+ "\n--- missing dependencies ---\n"
304
+ + "\n".join(f" {d}" for d in missing_deps)
305
+ )
306
+
307
+ return False, None, error_detail, failure_type, missing_deps
308
+
309
+ jar_files = find_output_jars(repo_path)
310
+
311
+ if not jar_files:
312
+ return (False, None, "No mod JAR found in any build/libs",
313
+ FailureType.BUILD_ERROR, [])
314
+
315
+ main_jar = select_main_jar(jar_files, mc_version)
316
+
317
+ return True, main_jar, "Compilation successful", FailureType.NONE, []
318
+
319
+ except subprocess.TimeoutExpired:
320
+ return (False, None, "Compilation timeout (>10 minutes)",
321
+ FailureType.TIMEOUT, [])
322
+ except Exception as e:
323
+ return (False, None, f"Compilation error: {e}",
324
+ FailureType.UNKNOWN, [])
325
+
326
+
327
+ def _validate_toml_jar(
328
+ jar: zipfile.ZipFile, toml_path: str, mc_version: str
329
+ ) -> Tuple[bool, Optional[str], Optional[str], str]:
330
+ """Validate a TOML-based mod JAR (NeoForge/Forge mods.toml)."""
331
+ with jar.open(toml_path) as f:
332
+ toml_content = f.read().decode('utf-8')
333
+ mod_info = toml.loads(toml_content)
334
+
335
+ if 'mods' in mod_info and len(mod_info['mods']) > 0:
336
+ first_mod = mod_info['mods'][0]
337
+ mod_name = first_mod.get('modId', 'unknown')
338
+ mod_version = first_mod.get('version', 'unknown')
339
+ else:
340
+ mod_name = 'unknown'
341
+ mod_version = 'unknown'
342
+
343
+ if 'dependencies' in mod_info:
344
+ for mod_id, dep_info in mod_info['dependencies'].items():
345
+ if isinstance(dep_info, list):
346
+ for dep in dep_info:
347
+ if dep.get('modId') == 'minecraft':
348
+ version_range = dep.get('versionRange', '')
349
+ if version_range and not is_version_in_maven_range(mc_version, version_range):
350
+ return False, mod_name, mod_version, f"JAR declares incompatible MC version: {version_range}"
351
+
352
+ return True, mod_name, mod_version, "JAR validation passed"
353
+
354
+
355
+ def _validate_json_jar(
356
+ jar: zipfile.ZipFile, json_path: str, mc_version: str
357
+ ) -> Tuple[bool, Optional[str], Optional[str], str]:
358
+ """Validate a JSON-based mod JAR (Fabric/Quilt)."""
359
+ with jar.open(json_path) as f:
360
+ data = json.loads(f.read().decode('utf-8'))
361
+
362
+ # Quilt schema: quilt_loader.id, quilt_loader.version
363
+ quilt_loader = data.get('quilt_loader', {})
364
+ if quilt_loader:
365
+ mod_name = quilt_loader.get('id', 'unknown')
366
+ mod_version_val = quilt_loader.get('version', 'unknown')
367
+ for dep in quilt_loader.get('depends', []):
368
+ if isinstance(dep, dict) and dep.get('id') == 'minecraft':
369
+ versions = dep.get('versions', '')
370
+ # A list means "any of these" in quilt.mod.json too
371
+ options = [versions] if isinstance(versions, str) else (
372
+ [v for v in versions if isinstance(v, str)]
373
+ if isinstance(versions, list) else [])
374
+ options = [v for v in options if v]
375
+ if options and not any(is_version_in_fabric_range(mc_version, v)
376
+ for v in options):
377
+ return False, mod_name, mod_version_val, f"JAR declares incompatible MC version: {versions}"
378
+ return True, mod_name, mod_version_val, "JAR validation passed"
379
+
380
+ # Fabric schema: id, version, depends.minecraft
381
+ mod_name = data.get('id', 'unknown')
382
+ mod_version_val = data.get('version', 'unknown')
383
+
384
+ depends = data.get('depends', {})
385
+ mc_range = depends.get('minecraft', '')
386
+ # A list means "any of these" in fabric.mod.json
387
+ alternatives = [mc_range] if isinstance(mc_range, str) else (
388
+ [r for r in mc_range if isinstance(r, str)] if isinstance(mc_range, list) else [])
389
+ alternatives = [r for r in alternatives if r]
390
+ if alternatives and not any(is_version_in_fabric_range(mc_version, r) for r in alternatives):
391
+ return False, mod_name, mod_version_val, f"JAR declares incompatible MC version: {mc_range}"
392
+
393
+ return True, mod_name, mod_version_val, "JAR validation passed"
394
+
395
+
396
+ def validate_jar(
397
+ jar_path: Path, mc_version: str, min_size: int = 10 * 1024
398
+ ) -> Tuple[bool, Optional[str], Optional[str], str]:
399
+ """
400
+ Validate the compiled JAR file.
401
+ Returns (is_valid, mod_name, mod_version, message)
402
+
403
+ min_size guards against picking a stub among a build's outputs (slim/API jars). A
404
+ published release has no such ambiguity, so its callers pass 0: real mods can be tiny
405
+ (Monsters in the Closet 1.0.3 is 10,238 bytes).
406
+ """
407
+ try:
408
+ with zipfile.ZipFile(jar_path, 'r') as jar:
409
+ jar_names = set(jar.namelist())
410
+
411
+ # Try each loader profile's metadata files
412
+ for loader_name, profile in LOADER_PROFILES.items():
413
+ fmt = profile["jar_metadata_format"]
414
+ for metadata_file in profile["jar_metadata_files"]:
415
+ if metadata_file in jar_names:
416
+ if fmt == "toml":
417
+ valid, name, ver, msg = _validate_toml_jar(
418
+ jar, metadata_file, mc_version
419
+ )
420
+ else:
421
+ valid, name, ver, msg = _validate_json_jar(
422
+ jar, metadata_file, mc_version
423
+ )
424
+ if not valid:
425
+ return valid, name, ver, msg
426
+ if jar_path.stat().st_size < min_size:
427
+ return False, name, ver, "JAR file suspiciously small (<10KB)"
428
+ return True, name, ver, msg
429
+
430
+ return False, None, None, "No recognized mod metadata found in JAR"
431
+
432
+ except zipfile.BadZipFile:
433
+ return False, None, None, "Invalid JAR file (corrupted)"
434
+ except Exception as e:
435
+ return False, None, None, f"JAR validation error: {e}"
436
+
437
+
438
+ def create_maven_local_init_script(temp_dir: str) -> Path:
439
+ """Create a Gradle init script that injects mavenLocal()."""
440
+ init_script = Path(temp_dir) / "maven-local-init.gradle"
441
+ init_script.write_text(
442
+ "allprojects {\n"
443
+ " repositories {\n"
444
+ " mavenLocal()\n"
445
+ " }\n"
446
+ "}\n",
447
+ encoding="utf-8"
448
+ )
449
+ return init_script
450
+
451
+
452
+ def publish_to_maven_local(
453
+ repo_path: Path,
454
+ extra_gradle_args: Optional[List[str]] = None
455
+ ) -> bool:
456
+ """Run publishToMavenLocal on a successfully compiled repo."""
457
+ root, gradlew = resolve_gradle(repo_path)
458
+ if not gradlew:
459
+ return False
460
+ repo_path = root
461
+
462
+ # Release builds often GPG-sign their publications; a local publish needs no signature
463
+ # and would otherwise fail with "no configured signatory".
464
+ no_signing = repo_path / ".modkeel-no-signing.gradle"
465
+ no_signing.write_text(
466
+ "allprojects {\n"
467
+ " tasks.withType(Sign).configureEach { enabled = false }\n"
468
+ "}\n",
469
+ encoding="utf-8"
470
+ )
471
+
472
+ cmd = [str(gradlew), "publishToMavenLocal", "--no-daemon", "-x", "test",
473
+ "--init-script", str(no_signing)]
474
+ if extra_gradle_args:
475
+ cmd.extend(extra_gradle_args)
476
+
477
+ try:
478
+ result = subprocess.run(
479
+ cmd,
480
+ cwd=repo_path,
481
+ capture_output=True,
482
+ text=True,
483
+ timeout=600
484
+ )
485
+ if result.returncode == 0:
486
+ print(" \U0001f4e4 Published to Maven Local (~/.m2/repository/)")
487
+ return True
488
+ else:
489
+ logger.debug(
490
+ "publishToMavenLocal failed (task may not exist): %s",
491
+ result.stderr[:200] if result.stderr else ""
492
+ )
493
+ return False
494
+ except (subprocess.TimeoutExpired, Exception) as e:
495
+ logger.debug("publishToMavenLocal error: %s", e)
496
+ return False